From d5b22ed8c6f86072980bab8b5ce608edf22f1583 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 11 Nov 2026 11:41:46 +0100 Subject: [PATCH 1/2] fix(compositor): read full-range video as full range on Windows Imported footage tagged AVCOL_RANGE_JPEG was drawn as limited range, so its shadows and highlights clipped in preview and export. - CpuFrames: set swscale's source range from the frame (full in, limited out) before init, so yuv420p/yuv420p10le full-range frames are normalised; yuvj* keeps working. - D3D11VA: nv12_srvs records the frame's range per Y view, draw_video binds a tiny immutable cbuffer (b1), and sample_yuv expands full range to limited behind a uniform branch. Limited input keeps its exact math. Refs #1531 --- crates/compositor/src/compositor_windows.rs | 50 ++++++++++ crates/compositor/src/cpu_frames_windows.rs | 104 ++++++++++++++++---- crates/compositor/src/shaders.hlsl | 13 +++ 3 files changed, 148 insertions(+), 19 deletions(-) diff --git a/crates/compositor/src/compositor_windows.rs b/crates/compositor/src/compositor_windows.rs index aca4c6dad9..2e8718373a 100644 --- a/crates/compositor/src/compositor_windows.rs +++ b/crates/compositor/src/compositor_windows.rs @@ -202,6 +202,13 @@ pub struct Compositor { /// the copy's size and format on every hit, so a new texture landing on an old address /// is never handed a copy sized for the old one. `clear_srv_cache` frees stale entries. srv_cache: RefCell>, + /// The Y views (`srv_cache`) whose last frame was full range (`AVCOL_RANGE_JPEG`): + /// `draw_video` binds `range_cb[1]` for them, so `sample_yuv` expands them to limited + /// range first (#1531). Software frames never land here: `CpuFrames` normalises them. + full_range_srvs: RefCell>, + /// The shader's `Source` cbuffer (b1), one immutable buffer per range: `[0]` limited, + /// `[1]` full. + range_cb: [ID3D11Buffer; 2], live_params: RefCell, /// Scène pilotée par l'app (contrat) : quand présente, remplace le layout fixture de /// `timeline()`. Voir `scene.rs` / `SceneDescription` (TS). @@ -715,6 +722,20 @@ impl Compositor { }; let mut shader_fx_params_cb: Option = None; dev.CreateBuffer(&fx_params_bd, None, Some(&mut shader_fx_params_cb))?; + let range_buffer = |full: f32| -> Result { + let data = [full, 0.0, 0.0, 0.0]; + let desc = D3D11_BUFFER_DESC { + ByteWidth: std::mem::size_of_val(&data) as u32, + Usage: D3D11_USAGE_IMMUTABLE, + BindFlags: D3D11_BIND_CONSTANT_BUFFER.0 as u32, + ..Default::default() + }; + let init = D3D11_SUBRESOURCE_DATA { pSysMem: data.as_ptr().cast(), ..Default::default() }; + let mut buf: Option = None; + dev.CreateBuffer(&desc, Some(&init), Some(&mut buf))?; + Ok(buf.unwrap()) + }; + let range_cb = [range_buffer(0.0)?, range_buffer(1.0)?]; // --- blend alpha prémultiplié --- let mut bl = D3D11_BLEND_DESC::default(); @@ -856,6 +877,8 @@ impl Compositor { shader_fx: RefCell::new(HashMap::new()), shader_fx_tick: Cell::new(0), shader_fx_cb: shader_fx_cb.unwrap(), + full_range_srvs: RefCell::new(std::collections::HashSet::new()), + range_cb, shader_fx_params_cb: shader_fx_params_cb.unwrap(), shader_fx_blocking: Cell::new(true), text_layer: RefCell::new(None), @@ -1211,6 +1234,12 @@ impl Compositor { }; self.ctx.CopySubresourceRegion(&dst, 0, 0, 0, 0, &src, slice, None); + let y_key = y.as_raw() as usize; + if (*frame).color_range == crate::ffi::AVColorRange::AVCOL_RANGE_JPEG { + self.full_range_srvs.borrow_mut().insert(y_key); + } else { + self.full_range_srvs.borrow_mut().remove(&y_key); + } Ok((y, uv)) } @@ -1506,6 +1535,8 @@ impl Compositor { ) { self.ctx .PSSetShaderResources(0, Some(&[Some(srv_y.clone()), Some(srv_uv.clone())])); + let full = self.full_range_srvs.borrow().contains(&(srv_y.as_raw() as usize)); + self.ctx.PSSetConstantBuffers(1, Some(&[Some(self.range_cb[full as usize].clone())])); self.draw_layer(cb); } @@ -3788,6 +3819,7 @@ impl Compositor { /// (p.ex. après un export) pour ne pas retenir indéfiniment des textures de pool. pub fn clear_srv_cache(&self) { self.srv_cache.borrow_mut().clear(); + self.full_range_srvs.borrow_mut().clear(); } } @@ -3904,6 +3936,24 @@ mod tests { } } + /// #1531 : une surface pleine plage (D3D11VA, `yuvj420p`) marque sa vue Y pour que + /// `draw_video` lie `range_cb[1]` ; la même texture redevenue limitée la démarque. + #[test] + fn full_range_frames_mark_their_y_view() { + let Ok(gpu) = crate::d3d::Gpu::create_auto(false) else { + eprintln!("pas de device D3D11 — test sauté"); + return; + }; + let comp = Compositor::new_sized(&gpu, 320, 180).expect("compositeur"); + let (mut frame, _tex) = nv12_frame(&gpu, 64, 64); + frame.color_range = crate::ffi::AVColorRange::AVCOL_RANGE_JPEG; + let (y, _) = unsafe { comp.nv12_srvs(&*frame) }.expect("source pleine"); + assert!(comp.full_range_srvs.borrow().contains(&(y.as_raw() as usize))); + frame.color_range = crate::ffi::AVColorRange::AVCOL_RANGE_MPEG; + let (y, _) = unsafe { comp.nv12_srvs(&*frame) }.expect("source limitée"); + assert!(!comp.full_range_srvs.borrow().contains(&(y.as_raw() as usize))); + } + /// Un décodeur remplacé sans `clear_srv_cache` (changement de résolution en cours de flux, /// `CpuFrames::ensure_tex`) peut donner à une texture NEUVE l'adresse d'une ancienne. La /// copie privée gardée pour l'ancienne est alors trop petite, `CopySubresourceRegion` la diff --git a/crates/compositor/src/cpu_frames_windows.rs b/crates/compositor/src/cpu_frames_windows.rs index b5a240bbec..673881f2cc 100644 --- a/crates/compositor/src/cpu_frames_windows.rs +++ b/crates/compositor/src/cpu_frames_windows.rs @@ -34,10 +34,10 @@ pub(crate) struct CpuFrames { dev: d3d11::ID3D11Device, ctx: d3d11::ID3D11DeviceContext, sws: *mut SwsContext, - /// `(w, h, format source)` du contexte swscale courant. Un flux qui change de - /// résolution en cours de route (rare mais légal) le reconstruit au lieu de + /// `(w, h, format source, plage pleine)` du contexte swscale courant. Un flux qui change + /// de résolution en cours de route (rare mais légal) le reconstruit au lieu de /// convertir de travers. - sws_key: (i32, i32, i32), + sws_key: (i32, i32, i32, bool), /// NV12 en mémoire système : la cible de swscale, la source de l'upload. nv12: *mut AVFrame, /// La texture NV12 échantillonnée par les shaders. UNE seule, réécrite à chaque @@ -65,7 +65,7 @@ impl CpuFrames { dev: gpu.device.clone(), ctx: gpu.context.clone(), sws: ptr::null_mut(), - sws_key: (0, 0, -1), + sws_key: (0, 0, -1, false), nv12, tex: None, tex_dims: (0, 0), @@ -81,7 +81,8 @@ impl CpuFrames { if w <= 0 || h <= 0 { bail!("frame décodée sans dimensions ({w}x{h})"); } - self.ensure_sws(w, h, (*src).format)?; + let full = (*src).color_range == AVColorRange::AVCOL_RANGE_JPEG; + self.ensure_sws(w, h, (*src).format, full)?; self.ensure_nv12(w, h)?; // Les plans NV12 de destination sont ceux de `self.nv12` : swscale écrit @@ -109,26 +110,53 @@ impl CpuFrames { Ok(self.present) } - unsafe fn ensure_sws(&mut self, w: i32, h: i32, src_fmt: i32) -> Result<()> { - let key = (w, h, src_fmt); + /// `full` : la frame est en plage pleine (`AVCOL_RANGE_JPEG`). swscale ne le déduit que + /// des formats `yuvj*` ; un `yuv420p` étiqueté plein serait copié tel quel, et le shader, + /// qui lit en plage limitée, écraserait ses ombres et ses hautes lumières (#1531). On le + /// lui dit donc : source pleine, destination limitée. + unsafe fn ensure_sws(&mut self, w: i32, h: i32, src_fmt: i32, full: bool) -> Result<()> { + let key = (w, h, src_fmt, full); if self.sws_key == key && !self.sws.is_null() { return Ok(()); } if !self.sws.is_null() { sws_freeContext(self.sws); + self.sws = ptr::null_mut(); } - self.sws = sws_getContext( - w, - h, - src_fmt as AVPixelFormat::Type, - w, - h, - AVPixelFormat::AV_PIX_FMT_NV12, - SWS_POINT, - ptr::null_mut(), - ptr::null_mut(), - ptr::null(), - ); + self.sws = if full { + // La plage se pose AVANT `sws_init_context` : c'est elle qui choisit le + // convertisseur, et le raccourci yuv420p → NV12 copie sans la regarder. + let c = sws_alloc_context(); + if !c.is_null() { + (*c).src_w = w; + (*c).src_h = h; + (*c).src_format = src_fmt; + (*c).dst_w = w; + (*c).dst_h = h; + (*c).dst_format = AVPixelFormat::AV_PIX_FMT_NV12 as i32; + (*c).flags = SWS_POINT as u32; + (*c).src_range = 1; + (*c).dst_range = 0; + if sws_init_context(c, ptr::null_mut(), ptr::null_mut()) < 0 { + sws_freeContext(c); + bail!("sws_init_context plage pleine {w}x{h} fmt {src_fmt} → NV12"); + } + } + c + } else { + sws_getContext( + w, + h, + src_fmt as AVPixelFormat::Type, + w, + h, + AVPixelFormat::AV_PIX_FMT_NV12, + SWS_POINT, + ptr::null_mut(), + ptr::null_mut(), + ptr::null(), + ) + }; if self.sws.is_null() { bail!("sws_getContext {w}x{h} fmt {src_fmt} → NV12"); } @@ -245,3 +273,41 @@ impl Drop for CpuFrames { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// La luma NV12 que `present` produit d'un yuv420p uni de valeur `y`, étiqueté `range`. + unsafe fn nv12_luma(frames: &mut CpuFrames, y: u8, range: AVColorRange::Type) -> u8 { + let mut src = av_frame_alloc(); + (*src).width = 16; + (*src).height = 16; + (*src).format = AVPixelFormat::AV_PIX_FMT_YUV420P as i32; + (*src).color_range = range; + assert!(av_frame_get_buffer(src, 32) >= 0); + ptr::write_bytes((*src).data[0], y, (*src).linesize[0] as usize * 16); + ptr::write_bytes((*src).data[1], 128, (*src).linesize[1] as usize * 8); + ptr::write_bytes((*src).data[2], 128, (*src).linesize[2] as usize * 8); + frames.present(src).expect("present"); + av_frame_free(&mut src); + *(*frames.nv12).data[0].add(16 * 8 + 8) + } + + /// #1531 : un yuv420p plein est ramené en plage limitée, un limité passe tel quel. + #[test] + fn full_range_yuv_is_normalised_to_limited() { + let Ok(gpu) = crate::d3d::Gpu::create_auto(false) else { + eprintln!("pas de device D3D11 — test sauté"); + return; + }; + let mut frames = CpuFrames::new(&gpu).expect("CpuFrames"); + unsafe { + use AVColorRange::*; + assert_eq!(nv12_luma(&mut frames, 0, AVCOL_RANGE_JPEG), 16); + assert_eq!(nv12_luma(&mut frames, 255, AVCOL_RANGE_JPEG), 235); + assert_eq!(nv12_luma(&mut frames, 0, AVCOL_RANGE_UNSPECIFIED), 0); + assert_eq!(nv12_luma(&mut frames, 255, AVCOL_RANGE_MPEG), 255); + } + } +} diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index 8478823035..5f55f960d7 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -49,6 +49,12 @@ VSOut vs_main(uint vid : SV_VertexID) return o; } +// Plage de la source NV12 liée en t0/t1 : 1 = pleine, 0 = limitée (`Compositor::draw_video`). +cbuffer Source : register(b1) +{ + float full_range; +}; + Texture2D texY : register(t0); Texture2D texUV : register(t1); Texture2D texImg : register(t2); // wallpaper image RGBA (fond, mode 6) ; mode 18 : l'écran cadré isolé @@ -85,6 +91,13 @@ float3 sample_yuv(float2 uv) { float y = texY.Sample(samp, uv); float2 cbcr = texUV.Sample(samp, uv); + // Plage pleine (vidéo importée, #1531) : ramenée en limitée avant la matrice. Branche + // uniforme : une source limitée garde exactement son calcul d'avant. + [branch] if (full_range > 0.5) + { + y = y * (219.0 / 255.0) + 16.0 / 255.0; + cbcr = cbcr * (224.0 / 255.0) + 16.0 / 255.0; + } return yuv709_limited(y, cbcr); } From abcbeb81168ff9f8835146e511cf51842ef85f7e Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Fri, 13 Nov 2026 13:43:59 +0100 Subject: [PATCH 2/2] fix(compositor): keep neutral chroma when expanding full range on Windows The shader mapped full-range chroma with an offset of 16, so neutral 128 came out at 128.44 and full-range grey rendered tinted (129, 128, 129). Scale chroma around 128 instead. The CPU path goes through swscale, which already centres chroma; its test now asserts that too. A render test draws a full-range (128, 128, 128) NV12 frame through the D3D11 shader and checks it comes out neutral grey at 128. Refs #1531 --- crates/compositor/src/cpu_frames_windows.rs | 20 ++++--- crates/compositor/src/shaders.hlsl | 3 +- crates/compositor/tests/common/nv12.rs | 6 ++ crates/compositor/tests/full_range_render.rs | 58 ++++++++++++++++++++ 4 files changed, 78 insertions(+), 9 deletions(-) create mode 100644 crates/compositor/tests/full_range_render.rs diff --git a/crates/compositor/src/cpu_frames_windows.rs b/crates/compositor/src/cpu_frames_windows.rs index 673881f2cc..cd96e12c61 100644 --- a/crates/compositor/src/cpu_frames_windows.rs +++ b/crates/compositor/src/cpu_frames_windows.rs @@ -278,8 +278,9 @@ impl Drop for CpuFrames { mod tests { use super::*; - /// La luma NV12 que `present` produit d'un yuv420p uni de valeur `y`, étiqueté `range`. - unsafe fn nv12_luma(frames: &mut CpuFrames, y: u8, range: AVColorRange::Type) -> u8 { + /// La luma et la chroma (Cb, Cr) NV12 que `present` produit d'un yuv420p uni de luma `y` + /// et de chroma neutre, étiqueté `range`. + unsafe fn nv12_luma(frames: &mut CpuFrames, y: u8, range: AVColorRange::Type) -> (u8, u8, u8) { let mut src = av_frame_alloc(); (*src).width = 16; (*src).height = 16; @@ -291,10 +292,13 @@ mod tests { ptr::write_bytes((*src).data[2], 128, (*src).linesize[2] as usize * 8); frames.present(src).expect("present"); av_frame_free(&mut src); - *(*frames.nv12).data[0].add(16 * 8 + 8) + let n = &*frames.nv12; + let uv = n.data[1].add(n.linesize[1] as usize * 4 + 8); + (*n.data[0].add(n.linesize[0] as usize * 8 + 8), *uv, *uv.add(1)) } - /// #1531 : un yuv420p plein est ramené en plage limitée, un limité passe tel quel. + /// #1531 : un yuv420p plein est ramené en plage limitée, un limité passe tel quel ; la + /// chroma neutre reste à 128 (un gris plein ne se teinte pas). #[test] fn full_range_yuv_is_normalised_to_limited() { let Ok(gpu) = crate::d3d::Gpu::create_auto(false) else { @@ -304,10 +308,10 @@ mod tests { let mut frames = CpuFrames::new(&gpu).expect("CpuFrames"); unsafe { use AVColorRange::*; - assert_eq!(nv12_luma(&mut frames, 0, AVCOL_RANGE_JPEG), 16); - assert_eq!(nv12_luma(&mut frames, 255, AVCOL_RANGE_JPEG), 235); - assert_eq!(nv12_luma(&mut frames, 0, AVCOL_RANGE_UNSPECIFIED), 0); - assert_eq!(nv12_luma(&mut frames, 255, AVCOL_RANGE_MPEG), 255); + assert_eq!(nv12_luma(&mut frames, 0, AVCOL_RANGE_JPEG), (16, 128, 128)); + assert_eq!(nv12_luma(&mut frames, 255, AVCOL_RANGE_JPEG), (235, 128, 128)); + assert_eq!(nv12_luma(&mut frames, 0, AVCOL_RANGE_UNSPECIFIED), (0, 128, 128)); + assert_eq!(nv12_luma(&mut frames, 255, AVCOL_RANGE_MPEG), (255, 128, 128)); } } } diff --git a/crates/compositor/src/shaders.hlsl b/crates/compositor/src/shaders.hlsl index 5f55f960d7..eab13c8d96 100644 --- a/crates/compositor/src/shaders.hlsl +++ b/crates/compositor/src/shaders.hlsl @@ -96,7 +96,8 @@ float3 sample_yuv(float2 uv) [branch] if (full_range > 0.5) { y = y * (219.0 / 255.0) + 16.0 / 255.0; - cbcr = cbcr * (224.0 / 255.0) + 16.0 / 255.0; + // Autour du neutre 128 : un gris plein reste gris (sans ça, 128 → 128,44). + cbcr = (cbcr - 128.0 / 255.0) * (224.0 / 255.0) + 128.0 / 255.0; } return yuv709_limited(y, cbcr); } diff --git a/crates/compositor/tests/common/nv12.rs b/crates/compositor/tests/common/nv12.rs index 66353350c8..79b39f09d5 100644 --- a/crates/compositor/tests/common/nv12.rs +++ b/crates/compositor/tests/common/nv12.rs @@ -72,6 +72,12 @@ impl Nv12Frame { } } + /// Étiquette la frame en plage pleine (`AVCOL_RANGE_JPEG`), comme un décodeur `yuvj*`. + pub fn full_range(mut self) -> Nv12Frame { + self.frame.color_range = openscreen_compositor::ffi::AVColorRange::AVCOL_RANGE_JPEG; + self + } + pub fn as_ptr(&self) -> *const AVFrame { &*self.frame as *const AVFrame } diff --git a/crates/compositor/tests/full_range_render.rs b/crates/compositor/tests/full_range_render.rs new file mode 100644 index 0000000000..174aa2af5a --- /dev/null +++ b/crates/compositor/tests/full_range_render.rs @@ -0,0 +1,58 @@ +//! #1531 : une vidéo en plage pleine passe par le vrai shader D3D11 (`sample_yuv`) sans dériver. +//! +//! Frame NV12 synthétique et unie, étiquetée pleine : (128, 128, 128) doit sortir gris neutre +//! à 128. Une chroma ramenée en limitée hors du centre (128 → 128,44) teintait ce gris. +//! Sans adaptateur matériel, le test se saute. + +// Windows seulement : le chemin D3D11 de ce harnais n'existe que là. +#![cfg(windows)] + +use openscreen_compositor::compositor::Compositor; +use openscreen_compositor::config::Cfg; +use openscreen_compositor::frame_geometry::live_params_from_scene; +use openscreen_compositor::scene::Scene; + +mod common; +use common::{gpu, Nv12Frame}; + +const OUT: (u32, u32) = (320, 180); + +#[test] +fn full_range_grey_stays_neutral_grey() { + let Some(gpu) = gpu() else { return }; + let comp = Compositor::new_sized(&gpu, OUT.0, OUT.1).expect("compositor"); + let screen = Nv12Frame::new(&gpu, (64, 64), |_, _| 128, |_, _| [128, 128]).full_range(); + let scene = Scene::from_json( + r##"{"clips":[{"screenPath":"/s.mp4","webcamPath":"","sourceStartSec":0,"sourceEndSec":10,"webcamOffsetSec":0,"hasAudio":false}], + "layout":{"preset":"no-webcam","webcamSize":1,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}, + "effects":{"padding":0,"blur":false,"shadow":0,"roundnessFrac":0,"motionBlur":0}, + "background":{"kind":"color","color":"#000000"}, + "zoomRegions":[],"annotations":[], + "cursor":{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"none"}, + "cropByClip":[null], + "output":{"width":320,"height":180,"fps":30}}"##, + ) + .expect("scène valide"); + let mut live = live_params_from_scene(&scene); + live.has_webcam = false; + comp.set_live_params(live); + comp.set_has_webcam(false); + comp.set_scene(Some(scene)); + comp.clear_cursor(); + let mut cfg = Cfg::c8(); + cfg.bg_blur = 0.0; + cfg.zoom = false; + cfg.layout_anim = false; + cfg.cursor = false; + cfg.mblur_n = 1; + cfg.shadow = false; + let (w, _, rgba) = unsafe { + comp.compose_frame(screen.as_ptr(), screen.as_ptr(), 0.0, &cfg).expect("compose_frame"); + comp.readback_direct().expect("readback") + }; + let i = (((OUT.1 / 2) * w + OUT.0 / 2) * 4) as usize; + let [r, g, b] = [rgba[i], rgba[i + 1], rgba[i + 2]]; + println!("centre : ({r}, {g}, {b})"); + assert!(r == g && g == b, "gris teinté : ({r}, {g}, {b})"); + assert!(r.abs_diff(128) <= 1, "gris {r} au lieu de 128"); +}