Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions crates/compositor/src/compositor_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<HashMap<usize, (ID3D11Texture2D, ID3D11ShaderResourceView, ID3D11ShaderResourceView)>>,
/// 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<std::collections::HashSet<usize>>,
/// The shader's `Source` cbuffer (b1), one immutable buffer per range: `[0]` limited,
/// `[1]` full.
range_cb: [ID3D11Buffer; 2],
live_params: RefCell<LiveParams>,
/// Scène pilotée par l'app (contrat) : quand présente, remplace le layout fixture de
/// `timeline()`. Voir `scene.rs` / `SceneDescription` (TS).
Expand Down Expand Up @@ -715,6 +722,20 @@ impl Compositor {
};
let mut shader_fx_params_cb: Option<ID3D11Buffer> = None;
dev.CreateBuffer(&fx_params_bd, None, Some(&mut shader_fx_params_cb))?;
let range_buffer = |full: f32| -> Result<ID3D11Buffer> {
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<ID3D11Buffer> = 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();
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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))
}

Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -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
Expand Down
108 changes: 89 additions & 19 deletions crates/compositor/src/cpu_frames_windows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -245,3 +273,45 @@ impl Drop for CpuFrames {
}
}
}

#[cfg(test)]
mod tests {
use super::*;

/// 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;
(*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);
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 ; 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 {
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, 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));
}
}
}
14 changes: 14 additions & 0 deletions crates/compositor/src/shaders.hlsl
Original file line number Diff line number Diff line change
Expand Up @@ -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<float> texY : register(t0);
Texture2D<float2> texUV : register(t1);
Texture2D<float4> texImg : register(t2); // wallpaper image RGBA (fond, mode 6) ; mode 18 : l'écran cadré isolé
Expand Down Expand Up @@ -85,6 +91,14 @@ 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;
// 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);
}

Expand Down
6 changes: 6 additions & 0 deletions crates/compositor/tests/common/nv12.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
58 changes: 58 additions & 0 deletions crates/compositor/tests/full_range_render.rs
Original file line number Diff line number Diff line change
@@ -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");
}
Loading