diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 577ea4e76..f1b8cb79a 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -29,7 +29,7 @@ use crate::config::Cfg; use crate::d3d::Gpu; -use crate::ffi::AVFrame; +use crate::ffi::{AVColorSpace, AVFrame}; // Le constant buffer est le MÊME struct des deux côtés — cf. `frame_geometry`. // Constant buffer, params runtime et constantes de sortie : une seule définition pour // les deux backends — cf. `frame_geometry`, qui documente les divergences que @@ -205,25 +205,47 @@ impl CVMetalTextureCache { } } -/// Vue du plan CbCr dont le canal bleu vaut 1 : c'est ainsi que `sample_yuv` (`shaders.metal`) -/// sait qu'une frame est en pleine plage (`420f`, `xf20`). Un `RG` lu sans swizzle rend 0 en -/// bleu, donc la plage limitée reste le cas par défaut, sans rien lier de plus à chaque draw. -fn full_range_view(uv: &metal::TextureRef) -> metal::Texture { +// `MTLTextureSwizzle` ; absent des bindings `metal` 0.29. +const SWZ_ZERO: u8 = 0; +const SWZ_ONE: u8 = 1; +const SWZ_RED: u8 = 2; +const SWZ_GREEN: u8 = 3; +const SWZ_ALPHA: u8 = 5; + +/// Vue de `tex` qui rend `swizzle` (rouge, vert, bleu, alpha). C'est ainsi que `sample_yuv` +/// (`shaders.metal`) lit le format d'une frame sans rien lier de plus à chaque draw : +/// - bleu à 1 sur le plan CbCr : pleine plage (`420f`, `xf20`) ; +/// - vert ou bleu à 1 sur le plan Y : matrice BT.601 ou BT.2020. +/// +/// Sans vue, `R` et `RG` rendent 0 sur les canaux libres : plage limitée et BT.709 par défaut. +fn swizzled_view(tex: &metal::TextureRef, swizzle: [u8; 4]) -> metal::Texture { use objc::{msg_send, sel, sel_impl}; - // `MTLTextureSwizzleChannels` : rouge, vert, un, alpha ; absent des bindings `metal` 0.29. #[repr(C)] struct Swizzle([u8; 4]); - const RED: u8 = 2; - const GREEN: u8 = 3; - const ONE: u8 = 1; - const ALPHA: u8 = 5; let one = metal::NSRange::new(0, 1); unsafe { - msg_send![uv, newTextureViewWithPixelFormat:uv.pixel_format() - textureType:uv.texture_type() - levels:one - slices:one - swizzle:Swizzle([RED, GREEN, ONE, ALPHA])] + msg_send![tex, newTextureViewWithPixelFormat:tex.pixel_format() + textureType:tex.texture_type() + levels:one + slices:one + swizzle:Swizzle(swizzle)] + } +} + +/// Le swizzle du plan Y qui porte la matrice de la frame, ou `None` pour BT.709 (aussi le +/// défaut d'un flux non tagué). La matrice vient de `AVFrame::colorspace`, que libavcodec lit +/// dans le VUI : VideoToolbox ne pose pas `kCVImageBufferYCbCrMatrixKey` sur ses buffers. +/// BT.2020 CL n'est pas une simple matrice : il garde le défaut plutôt qu'un rendu NCL faux. +/// +/// Alpha à 0 : échantillons 10 bits (P010), que `yuv_matrix` convertit avec les bornes 10 bits. +fn matrix_swizzle(colorspace: AVColorSpace::Type, ten_bit: bool) -> Option<[u8; 4]> { + let depth = if ten_bit { SWZ_ZERO } else { SWZ_ONE }; + match colorspace { + AVColorSpace::AVCOL_SPC_BT470BG | AVColorSpace::AVCOL_SPC_SMPTE170M => { + Some([SWZ_RED, SWZ_ONE, SWZ_ZERO, depth]) + } + AVColorSpace::AVCOL_SPC_BT2020_NCL => Some([SWZ_RED, SWZ_ZERO, SWZ_ONE, depth]), + _ => None, } } @@ -1208,10 +1230,14 @@ impl Compositor { } _ => (metal::MTLPixelFormat::R8Unorm, metal::MTLPixelFormat::RG8Unorm), }; - let y = cache.make_texture_from_pixel_buffer(pb, 0, y_fmt)?; - let uv = cache.make_texture_from_pixel_buffer(pb, 1, uv_fmt)?; + let mut y = cache.make_texture_from_pixel_buffer(pb, 0, y_fmt)?; + let mut uv = cache.make_texture_from_pixel_buffer(pb, 1, uv_fmt)?; + let ten_bit = matches!(pixel_format, CV_420_10_BIPLANAR_VIDEO | CV_420_10_BIPLANAR_FULL); + if let Some(swizzle) = matrix_swizzle((*frame).colorspace, ten_bit) { + y = swizzled_view(&y, swizzle); + } if matches!(pixel_format, CV_420_8_BIPLANAR_FULL | CV_420_10_BIPLANAR_FULL) { - return Ok((y, full_range_view(&uv))); + uv = swizzled_view(&uv, [SWZ_RED, SWZ_GREEN, SWZ_ONE, SWZ_ALPHA]); } Ok((y, uv)) } @@ -5428,4 +5454,19 @@ mod tests { crate::shader_fx::compile_metal(&gpu.device, &code, ¶ms).expect("compile"); eprintln!("recompilation d'une source déjà vue : {:.1} ms", t0.elapsed().as_secs_f64() * 1000.0); } + + #[test] + fn matrix_swizzle_marks_matrix_and_depth() { + use AVColorSpace::*; + assert_eq!( + matrix_swizzle(AVCOL_SPC_SMPTE170M, false), + Some([SWZ_RED, SWZ_ONE, SWZ_ZERO, SWZ_ONE]) + ); + assert_eq!( + matrix_swizzle(AVCOL_SPC_BT2020_NCL, true), + Some([SWZ_RED, SWZ_ZERO, SWZ_ONE, SWZ_ZERO]) + ); + assert_eq!(matrix_swizzle(AVCOL_SPC_BT709, false), None); + assert_eq!(matrix_swizzle(AVCOL_SPC_BT2020_CL, true), None); + } } diff --git a/crates/compositor/src/mac_frames.rs b/crates/compositor/src/mac_frames.rs index 1dcd0dda7..68459b667 100644 --- a/crates/compositor/src/mac_frames.rs +++ b/crates/compositor/src/mac_frames.rs @@ -352,6 +352,8 @@ impl CpuFrames { // reporter depuis la source, sinon toute la timeline se croit à t=0. (*self.present).pts = (*src).pts; (*self.present).best_effort_timestamp = (*src).best_effort_timestamp; + // sws garde la matrice YCbCr de la source : `nv12_srvs` la relit ici. + (*self.present).colorspace = (*src).colorspace; Ok(()) } diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal index 6b68122bf..06ee2d87b 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -153,6 +153,30 @@ inline float3 yuv709_full(float y, float2 cbcr) return clamp(rgb, 0.0, 1.0); } +// BT.601 / BT.2020 -> RGB. `m` : coefficients Cr->R, Cb->G, Cr->G, Cb->B. BT.709 garde ses +// deux fonctions à constantes littérales, pour rendre ses frames à l'octet près comme avant. +// `ten_bit` : un échantillon P010 lu en `R16Unorm` vaut code10 * 64 / 65535, pas code8 / 255 ; +// on revient au code 10 bits pour appliquer ses bornes (64..940, chroma 512, pleine 0..1023). +inline float3 yuv_matrix(float y, float2 cbcr, float4 m, bool full, bool ten_bit) +{ + float Yf, Cb, Cr; + if (ten_bit) { + float code = 65535.0 / 64.0; + Yf = full ? y * code / 1023.0 : (y * code - 64.0) / 876.0; + Cb = full ? (cbcr.x * code - 512.0) / 1023.0 : (cbcr.x * code - 512.0) / 896.0; + Cr = full ? (cbcr.y * code - 512.0) / 1023.0 : (cbcr.y * code - 512.0) / 896.0; + } else { + Yf = full ? y : (y * 255.0 - 16.0) / 219.0; + Cb = full ? cbcr.x - 128.0 / 255.0 : (cbcr.x * 255.0 - 128.0) / 224.0; + Cr = full ? cbcr.y - 128.0 / 255.0 : (cbcr.y * 255.0 - 128.0) / 224.0; + } + float3 rgb; + rgb.r = Yf + m.x * Cr; + rgb.g = Yf - m.y * Cb - m.z * Cr; + rgb.b = Yf + m.w * Cb; + return clamp(rgb, 0.0, 1.0); +} + // `texture2d::sample` rend TOUJOURS un `float4` en MSL, là où le HLSL // `Texture2D` rend un scalaire : d'où les `.r` / `.rg` que le port d'origine // n'avait pas (et qui ne compilaient pas). @@ -160,10 +184,18 @@ inline float3 sample_yuv(float2 uv, texture2d texY, texture2d texUV) { - float y = texY.sample(samp, uv).r; + float4 y = texY.sample(samp, uv); float4 c = texUV.sample(samp, uv); - // Bleu à 1 : vue pleine plage posée par `full_range_view` (420f, xf20). - return c.b > 0.5 ? yuv709_full(y, c.rg) : yuv709_limited(y, c.rg); + // Bleu à 1 sur la vue CbCr : pleine plage (420f, xf20). + bool full = c.b > 0.5; + // Vert ou bleu à 1 sur la vue Y : matrice BT.601 ou BT.2020 posée par `nv12_srvs` ; + // alpha à 0 sur cette vue : échantillons 10 bits. + bool ten_bit = y.a < 0.5; + if (y.g > 0.5) + return yuv_matrix(y.r, c.rg, float4(1.402, 0.344136, 0.714136, 1.772), full, ten_bit); + if (y.b > 0.5) + return yuv_matrix(y.r, c.rg, float4(1.4746, 0.164553, 0.571353, 1.8814), full, ten_bit); + return full ? yuv709_full(y.r, c.rg) : yuv709_limited(y.r, c.rg); } // SDF segment à bouts ronds — la primitive des flèches d'annotation. diff --git a/crates/compositor/tests/hevc10_decode.rs b/crates/compositor/tests/hevc10_decode.rs index 205cabada..56ca01f93 100644 --- a/crates/compositor/tests/hevc10_decode.rs +++ b/crates/compositor/tests/hevc10_decode.rs @@ -107,3 +107,22 @@ fn a_full_range_frame_keeps_its_levels() { let frame = render_frame_10(&source.to_string_lossy()); assert_halves(&frame, [0x20; 3], [0xe0; 3], 6); } + +/// Une frame BT.601 n'est pas relue en BT.709 : la matrice vient de `AVFrame::colorspace` +/// (le VUI du flux). Sans le correctif, le vert pur tombe à ~216. +/// +/// ```sh +/// ffmpeg -f lavfi -i "color=c=0x00ff00:size=640x360:rate=30:duration=1,drawbox=x=320:y=0:w=320:h=360:color=0x0000ff:t=fill" \ +/// -vf scale=out_color_matrix=bt601,format=nv12 -colorspace smpte170m -color_primaries smpte170m -color_trc smpte170m \ +/// -c:v h264_videotoolbox split601.mov +/// OPENSCREEN_BT601_SOURCE=$PWD/split601.mov cargo test -p openscreen-compositor --test hevc10_decode +/// ``` +#[test] +fn a_bt601_frame_keeps_its_hues() { + let Some(source) = std::env::var_os("OPENSCREEN_BT601_SOURCE") else { + eprintln!("OPENSCREEN_BT601_SOURCE absent — test sauté"); + return; + }; + let frame = render_frame_10(&source.to_string_lossy()); + assert_halves(&frame, [0, 255, 0], [0, 0, 255], 12); +}