From 2de53fa7292627dba9fd1047bcf99dbd9cb9d030 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 28 Oct 2026 09:03:37 +0100 Subject: [PATCH 1/4] fix(compositor): read 10-bit VideoToolbox frames as 16-bit planes on macOS VideoToolbox decodes HEVC Main 10 into a P010 CVPixelBuffer ('x420'), but nv12_srvs always wrapped its planes as R8Unorm/RG8Unorm, so each 16-bit sample became two texels and iPhone footage rendered as stripes. Pick R16Unorm/RG16Unorm for 10-bit buffers; the shader reads the same normalised values. Refs #1390 --- crates/compositor/src/compositor_macos.rs | 21 +++++- crates/compositor/tests/hevc10_decode.rs | 82 +++++++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) create mode 100644 crates/compositor/tests/hevc10_decode.rs diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index c1072cbf1..e190ec385 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -99,8 +99,14 @@ extern "C" { fn CVPixelBufferGetHeightOfPlane(p: *mut std::ffi::c_void, plane_index: usize) -> usize; fn CVPixelBufferGetWidth(p: *mut std::ffi::c_void) -> usize; fn CVPixelBufferGetHeight(p: *mut std::ffi::c_void) -> usize; + fn CVPixelBufferGetPixelFormatType(p: *mut std::ffi::c_void) -> u32; } +/// `kCVPixelFormatType_420YpCbCr10BiPlanarVideoRange` ('x420') et sa variante pleine +/// excursion ('xf20') : le P010 que VideoToolbox sort pour un flux 10 bits (HEVC Main 10). +const CV_420_10_BIPLANAR_VIDEO: u32 = u32::from_be_bytes(*b"x420"); +const CV_420_10_BIPLANAR_FULL: u32 = u32::from_be_bytes(*b"xf20"); + /// `retain` ObjC sur un `id`. `CVMetalTextureGetTexture` rend une référence /// *empruntée* au `CVMetalTextureRef` qui la porte : relâcher ce dernier sans /// retenir la texture donne un `id` mort. Et ne jamais le relâcher — @@ -1156,6 +1162,11 @@ impl Compositor { /// Crée les `MTLTexture` Y (`R8Unorm`) et UV (`RG8Unorm`) de la frame. Zéro copie : /// les textures Metal partagent l'IOSurface du `CVPixelBuffer`. API symétrique de /// `compositor_windows::nv12_srvs`. + /// + /// Un flux 10 bits arrive en P010 : 16 bits par échantillon, valeur dans les bits de + /// poids fort. En `R16Unorm`/`RG16Unorm` le shader lit la même valeur normalisée qu'en + /// NV12 ; en `R8Unorm` chaque échantillon devenait deux texels, d'où l'image en bandes + /// d'une vidéo iPhone (#1390). pub unsafe fn nv12_srvs( &self, frame: *const AVFrame, @@ -1167,8 +1178,14 @@ impl Compositor { ) })?; let cache = &self.metal_texture_cache; - let y = cache.make_texture_from_pixel_buffer(pb, 0, metal::MTLPixelFormat::R8Unorm)?; - let uv = cache.make_texture_from_pixel_buffer(pb, 1, metal::MTLPixelFormat::RG8Unorm)?; + let (y_fmt, uv_fmt) = match CVPixelBufferGetPixelFormatType(pb) { + CV_420_10_BIPLANAR_VIDEO | CV_420_10_BIPLANAR_FULL => { + (metal::MTLPixelFormat::R16Unorm, metal::MTLPixelFormat::RG16Unorm) + } + _ => (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)?; Ok((y, uv)) } diff --git a/crates/compositor/tests/hevc10_decode.rs b/crates/compositor/tests/hevc10_decode.rs new file mode 100644 index 000000000..ae743d76f --- /dev/null +++ b/crates/compositor/tests/hevc10_decode.rs @@ -0,0 +1,82 @@ +//! Une vidéo HEVC Main 10 (iPhone, #1390) se rend avec ses couleurs, pas en bandes. +//! +//! VideoToolbox sort un `CVPixelBuffer` P010 (16 bits par échantillon) pour un flux 10 bits. +//! Lu comme du NV12 8 bits, chaque échantillon devient deux texels : l'image se couvre de +//! bandes jaunes et vertes. La mire est coupée en deux, rouge à gauche, bleu à droite ; +//! chaque moitié doit garder sa couleur. +//! +//! La CI ne le joue pas : il faut le GPU et un encodeur HEVC 10 bits. +//! +//! ```sh +//! ffmpeg -f lavfi -i "color=c=red:size=640x360:rate=30:duration=1,drawbox=x=320:y=0:w=320:h=360:color=blue:t=fill" \ +//! -vf format=p010le -c:v hevc_videotoolbox -profile:v main10 -tag:v hvc1 split10.mov +//! OPENSCREEN_HEVC10_SOURCE=$PWD/split10.mov cargo test -p openscreen-compositor --test hevc10_decode +//! ``` +#![cfg(target_os = "macos")] + +use openscreen_compositor::compositor::{live_params_from_scene, Compositor}; +use openscreen_compositor::config; +use openscreen_compositor::d3d::Gpu; +use openscreen_compositor::pipeline::ClipSource; +use openscreen_compositor::scene::Scene; +use openscreen_compositor::timeline_walk::render_frames; + +fn scene_json(source: &str) -> String { + format!( + r##"{{ + "clips": [{{"screenPath":"{source}","webcamPath":"","sourceStartSec":0,"sourceEndSec":1,"webcamOffsetSec":0,"hasAudio":false}}], + "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}}, + "effects": {{"padding":0,"blur":false,"shadow":0.0,"roundnessFrac":0,"motionBlur":0.0}}, + "background": {{"kind":"color","color":"#303030"}}, + "zoomRegions": [], + "cursor": {{"show":false,"size":1,"smoothing":0,"motionBlur":0,"clickBounce":0,"clipToBounds":false,"theme":"default"}}, + "cropByClip": [null], + "output": {{"width":640,"height":360,"fps":null}} + }}"## + ) +} + +#[test] +fn a_10_bit_hevc_frame_keeps_its_colours() { + let Some(source) = std::env::var_os("OPENSCREEN_HEVC10_SOURCE") else { + eprintln!("OPENSCREEN_HEVC10_SOURCE absent — test sauté"); + return; + }; + let source = source.to_string_lossy().replace('\\', "/"); + let gpu = Gpu::create_auto(false).expect("device"); + let clips = [ClipSource { + screen: source.clone(), + webcam: String::new(), + source_start_sec: 0.0, + source_end_sec: 1.0, + webcam_offset_sec: 0.0, + has_audio: false, + }]; + let mut cfg = config::all().pop().expect("C8"); + cfg.zoom = false; + cfg.layout_anim = false; + cfg.mblur_n = 1; + cfg.bg_blur = 0.0; + cfg.cursor = false; + let scene = Scene::from_json(&scene_json(&source)).expect("scène"); + let comp = Compositor::new_sized(&gpu, 640, 360).expect("compositeur"); + comp.set_live_params(live_params_from_scene(&scene)); + comp.set_scene(Some(scene)); + let frames = + unsafe { render_frames(&clips, &gpu, &comp, &cfg, 30, &[10]) }.expect("render_frames"); + let frame = &frames[0]; + + let px = |x: u32, y: u32| { + let i = ((y * frame.width + x) * 4) as usize; + [frame.rgba[i], frame.rgba[i + 1], frame.rgba[i + 2]] + }; + let close = + |got: [u8; 3], want: [u8; 3]| got.iter().zip(want).all(|(&g, w)| g.abs_diff(w) <= 40); + let (w, h) = (frame.width, frame.height); + for (x, want) in [(w / 4, [255, 0, 0]), (3 * w / 4, [0, 0, 255])] { + for y in [h / 4, h / 2, 3 * h / 4] { + let got = px(x, y); + assert!(close(got, want), "({x},{y}) : {got:?}, attendu ≈ {want:?}"); + } + } +} From c49cafa519c553023ee8c061c71e17d1caa49b83 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 28 Oct 2026 19:49:57 +0100 Subject: [PATCH 2/4] fix(compositor): convert full-range VideoToolbox frames with full-range BT.709 on macOS 420f and xf20 buffers went through the limited-range conversion, lifting blacks and crushing whites. nv12_srvs now hands the shader a CbCr view whose blue channel is 1 for those formats, and sample_yuv picks the conversion from it. The test now escapes the source path and checks a full-range clip. --- crates/compositor/src/compositor_macos.rs | 29 +++++++++++- crates/compositor/src/shaders.metal | 17 ++++++- crates/compositor/tests/hevc10_decode.rs | 56 +++++++++++++++++------ 3 files changed, 84 insertions(+), 18 deletions(-) diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index e190ec385..92dc08a87 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -106,6 +106,7 @@ extern "C" { /// excursion ('xf20') : le P010 que VideoToolbox sort pour un flux 10 bits (HEVC Main 10). const CV_420_10_BIPLANAR_VIDEO: u32 = u32::from_be_bytes(*b"x420"); const CV_420_10_BIPLANAR_FULL: u32 = u32::from_be_bytes(*b"xf20"); +const CV_420_8_BIPLANAR_FULL: u32 = u32::from_be_bytes(*b"420f"); /// `retain` ObjC sur un `id`. `CVMetalTextureGetTexture` rend une référence /// *empruntée* au `CVMetalTextureRef` qui la porte : relâcher ce dernier sans @@ -204,6 +205,28 @@ 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 { + 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])] + } +} + impl Drop for CVMetalTextureCache { fn drop(&mut self) { unsafe { @@ -1178,7 +1201,8 @@ impl Compositor { ) })?; let cache = &self.metal_texture_cache; - let (y_fmt, uv_fmt) = match CVPixelBufferGetPixelFormatType(pb) { + let pixel_format = CVPixelBufferGetPixelFormatType(pb); + let (y_fmt, uv_fmt) = match pixel_format { CV_420_10_BIPLANAR_VIDEO | CV_420_10_BIPLANAR_FULL => { (metal::MTLPixelFormat::R16Unorm, metal::MTLPixelFormat::RG16Unorm) } @@ -1186,6 +1210,9 @@ impl Compositor { }; let y = cache.make_texture_from_pixel_buffer(pb, 0, y_fmt)?; let uv = cache.make_texture_from_pixel_buffer(pb, 1, uv_fmt)?; + if matches!(pixel_format, CV_420_8_BIPLANAR_FULL | CV_420_10_BIPLANAR_FULL) { + return Ok((y, full_range_view(&uv))); + } Ok((y, uv)) } diff --git a/crates/compositor/src/shaders.metal b/crates/compositor/src/shaders.metal index 2a469a504..6b68122bf 100644 --- a/crates/compositor/src/shaders.metal +++ b/crates/compositor/src/shaders.metal @@ -141,6 +141,18 @@ inline float3 yuv709_limited(float y, float2 cbcr) return clamp(rgb, 0.0, 1.0); } +// BT.709 pleine plage -> RGB : Y sur 0..255, CbCr centrés sur 128 et étalés sur 255. +inline float3 yuv709_full(float y, float2 cbcr) +{ + float Cb = cbcr.x - 128.0 / 255.0; + float Cr = cbcr.y - 128.0 / 255.0; + float3 rgb; + rgb.r = y + 1.5748 * Cr; + rgb.g = y - 0.1873 * Cb - 0.4681 * Cr; + rgb.b = y + 1.8556 * 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). @@ -149,8 +161,9 @@ inline float3 sample_yuv(float2 uv, texture2d texUV) { float y = texY.sample(samp, uv).r; - float2 cbcr = texUV.sample(samp, uv).rg; - return yuv709_limited(y, cbcr); + 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); } // 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 ae743d76f..33dd9edad 100644 --- a/crates/compositor/tests/hevc10_decode.rs +++ b/crates/compositor/tests/hevc10_decode.rs @@ -19,12 +19,13 @@ use openscreen_compositor::config; use openscreen_compositor::d3d::Gpu; use openscreen_compositor::pipeline::ClipSource; use openscreen_compositor::scene::Scene; -use openscreen_compositor::timeline_walk::render_frames; +use openscreen_compositor::timeline_walk::{render_frames, RenderedFrame}; fn scene_json(source: &str) -> String { + let source = serde_json::to_string(source).expect("chemin JSON"); format!( r##"{{ - "clips": [{{"screenPath":"{source}","webcamPath":"","sourceStartSec":0,"sourceEndSec":1,"webcamOffsetSec":0,"hasAudio":false}}], + "clips": [{{"screenPath":{source},"webcamPath":"","sourceStartSec":0,"sourceEndSec":1,"webcamOffsetSec":0,"hasAudio":false}}], "layout": {{"preset":"no-webcam","webcamSize":1.0,"webcamShape":"rounded","webcamMirror":false,"webcamPosition":null,"webcamReactiveZoom":false}}, "effects": {{"padding":0,"blur":false,"shadow":0.0,"roundnessFrac":0,"motionBlur":0.0}}, "background": {{"kind":"color","color":"#303030"}}, @@ -36,13 +37,9 @@ fn scene_json(source: &str) -> String { ) } -#[test] -fn a_10_bit_hevc_frame_keeps_its_colours() { - let Some(source) = std::env::var_os("OPENSCREEN_HEVC10_SOURCE") else { - eprintln!("OPENSCREEN_HEVC10_SOURCE absent — test sauté"); - return; - }; - let source = source.to_string_lossy().replace('\\', "/"); +/// La frame 10 de `source`, rendue seule sur un fond uni, sans effet. +fn render_frame_10(source: &str) -> RenderedFrame { + let source = source.replace('\\', "/"); let gpu = Gpu::create_auto(false).expect("device"); let clips = [ClipSource { screen: source.clone(), @@ -62,21 +59,50 @@ fn a_10_bit_hevc_frame_keeps_its_colours() { let comp = Compositor::new_sized(&gpu, 640, 360).expect("compositeur"); comp.set_live_params(live_params_from_scene(&scene)); comp.set_scene(Some(scene)); - let frames = + let mut frames = unsafe { render_frames(&clips, &gpu, &comp, &cfg, 30, &[10]) }.expect("render_frames"); - let frame = &frames[0]; + frames.swap_remove(0) +} +/// Chaque moitié de la mire garde `left` / `right`, à `tol` près par canal. +fn assert_halves(frame: &RenderedFrame, left: [u8; 3], right: [u8; 3], tol: u8) { let px = |x: u32, y: u32| { let i = ((y * frame.width + x) * 4) as usize; [frame.rgba[i], frame.rgba[i + 1], frame.rgba[i + 2]] }; - let close = - |got: [u8; 3], want: [u8; 3]| got.iter().zip(want).all(|(&g, w)| g.abs_diff(w) <= 40); let (w, h) = (frame.width, frame.height); - for (x, want) in [(w / 4, [255, 0, 0]), (3 * w / 4, [0, 0, 255])] { + for (x, want) in [(w / 4, left), (3 * w / 4, right)] { for y in [h / 4, h / 2, 3 * h / 4] { let got = px(x, y); - assert!(close(got, want), "({x},{y}) : {got:?}, attendu ≈ {want:?}"); + let close = got.iter().zip(want).all(|(&g, w)| g.abs_diff(w) <= tol); + assert!(close, "({x},{y}) : {got:?}, attendu ≈ {want:?}"); } } } + +#[test] +fn a_10_bit_hevc_frame_keeps_its_colours() { + let Some(source) = std::env::var_os("OPENSCREEN_HEVC10_SOURCE") else { + eprintln!("OPENSCREEN_HEVC10_SOURCE absent — test sauté"); + return; + }; + let frame = render_frame_10(&source.to_string_lossy()); + assert_halves(&frame, [255, 0, 0], [0, 0, 255], 40); +} + +/// Une frame pleine plage (`420f`, `xf20`) n'est pas relue comme limitée : sans le +/// correctif, le gris sombre tombe à ~19 et le gris clair monte à ~242. +/// +/// ```sh +/// ffmpeg -f lavfi -i "color=c=0x202020:size=640x360:rate=30:duration=1,drawbox=x=320:y=0:w=320:h=360:color=0xe0e0e0:t=fill" /// -vf scale=out_range=full,format=p010le -color_range pc -c:v hevc_videotoolbox -profile:v main10 -tag:v hvc1 grey10full.mov +/// OPENSCREEN_FULL_RANGE_SOURCE=$PWD/grey10full.mov cargo test -p openscreen-compositor --test hevc10_decode +/// ``` +#[test] +fn a_full_range_frame_keeps_its_levels() { + let Some(source) = std::env::var_os("OPENSCREEN_FULL_RANGE_SOURCE") else { + eprintln!("OPENSCREEN_FULL_RANGE_SOURCE absent — test sauté"); + return; + }; + let frame = render_frame_10(&source.to_string_lossy()); + assert_halves(&frame, [0x20; 3], [0xe0; 3], 6); +} From 6bfaa4da02942a2f92ad37d01d5f6bd54890a770 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 28 Oct 2026 20:15:43 +0100 Subject: [PATCH 3/4] docs: fix the documented ffmpeg command in the HEVC test --- crates/compositor/tests/hevc10_decode.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/compositor/tests/hevc10_decode.rs b/crates/compositor/tests/hevc10_decode.rs index 33dd9edad..e0099fc2a 100644 --- a/crates/compositor/tests/hevc10_decode.rs +++ b/crates/compositor/tests/hevc10_decode.rs @@ -94,7 +94,8 @@ fn a_10_bit_hevc_frame_keeps_its_colours() { /// correctif, le gris sombre tombe à ~19 et le gris clair monte à ~242. /// /// ```sh -/// ffmpeg -f lavfi -i "color=c=0x202020:size=640x360:rate=30:duration=1,drawbox=x=320:y=0:w=320:h=360:color=0xe0e0e0:t=fill" /// -vf scale=out_range=full,format=p010le -color_range pc -c:v hevc_videotoolbox -profile:v main10 -tag:v hvc1 grey10full.mov +/// ffmpeg -f lavfi -i "color=c=0x202020:size=640x360:rate=30:duration=1,drawbox=x=320:y=0:w=320:h=360:color=0xe0e0e0:t=fill" \ +/// -vf scale=out_range=full,format=p010le -color_range pc -c:v hevc_videotoolbox -profile:v main10 -tag:v hvc1 grey10full.mov /// OPENSCREEN_FULL_RANGE_SOURCE=$PWD/grey10full.mov cargo test -p openscreen-compositor --test hevc10_decode /// ``` #[test] From 3f71f8382e6452923494db33137bfd7e566f5df7 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 28 Oct 2026 20:31:09 +0100 Subject: [PATCH 4/4] test(compositor): check the P010 and full-range views without a media file Address review on #1408: build each VideoToolbox biplanar format in process and assert the plane formats and full-range flag nv12_srvs picks, so CI covers the path. Also pass the opt-in source path through unchanged. --- crates/compositor/src/compositor_macos.rs | 42 +++++++++++++++++++++++ crates/compositor/src/mac_frames.rs | 22 +++++++++--- crates/compositor/tests/hevc10_decode.rs | 2 +- 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index 92dc08a87..577ea4e76 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -3860,6 +3860,10 @@ mod tests { fn from_planes(w: u32, h: u32, y: &[u8], uv: &[u8]) -> FakeFrame { let pb = crate::mac_frames::nv12_pixel_buffer_from_planes(w, h, y, uv) .expect("CVPixelBuffer NV12"); + FakeFrame::from_pixel_buffer(w, h, pb) + } + + fn from_pixel_buffer(w: u32, h: u32, pb: crate::mac_frames::CVPixelBufferRef) -> FakeFrame { let mut frame: Box = Box::new(unsafe { std::mem::zeroed() }); frame.format = crate::ffi::AVPixelFormat::AV_PIX_FMT_VIDEOTOOLBOX as i32; frame.data[3] = pb.as_ptr() as *mut u8; @@ -3873,6 +3877,44 @@ mod tests { } } + /// Les vues que `nv12_srvs` tire de chaque format biplanaire de VideoToolbox, sans + /// décoder ni fichier média. Un flux 10 bits (`x420`, `xf20`) se lit en plans 16 bits, + /// sinon l'image part en bandes (#1390). Une frame pleine plage (`420f`, `xf20`) porte un + /// 1 dans le bleu de sa vue CbCr : c'est le drapeau que `sample_yuv` lit. + #[test] + fn nv12_srvs_reads_bit_depth_and_range_from_the_pixel_format() { + use metal::MTLPixelFormat::{R16Unorm, R8Unorm, RG16Unorm, RG8Unorm}; + use objc::{msg_send, sel, sel_impl}; + #[repr(C)] + struct Swizzle([u8; 4]); + const SWIZZLE_ONE: u8 = 1; + + let Ok(gpu) = crate::d3d::Gpu::create(false) else { + eprintln!("pas de device Metal — test sauté"); + return; + }; + let comp = super::Compositor::new_sized(&gpu, 64, 64).expect("Compositor::new_sized"); + for (fourcc, y_fmt, uv_fmt, full_range) in [ + (b"420v", R8Unorm, RG8Unorm, false), + (b"420f", R8Unorm, RG8Unorm, true), + (b"x420", R16Unorm, RG16Unorm, false), + (b"xf20", R16Unorm, RG16Unorm, true), + ] { + let name = std::str::from_utf8(fourcc).unwrap(); + let pb = unsafe { + crate::mac_frames::create_nv12_pixel_buffer(64, 64, u32::from_be_bytes(*fourcc)) + } + .unwrap_or_else(|e| panic!("{name} : {e}")); + let frame = FakeFrame::from_pixel_buffer(64, 64, pb); + let (y, uv) = unsafe { comp.nv12_srvs(frame.as_ptr()) } + .unwrap_or_else(|e| panic!("{name} : {e}")); + assert_eq!(y.pixel_format(), y_fmt, "{name} : plan Y"); + assert_eq!(uv.pixel_format(), uv_fmt, "{name} : plan CbCr"); + let swizzle: Swizzle = unsafe { msg_send![uv, swizzle] }; + assert_eq!(swizzle.0[2] == SWIZZLE_ONE, full_range, "{name} : drapeau pleine plage"); + } + } + /// Scène PiP minimale. `effect` est le JSON de `webcamEffect` (`"null"` pour aucun). /// /// `effects.shadow` vaut 0 À DESSEIN : ce curseur ne pilote plus que l'ombre de l'écran, diff --git a/crates/compositor/src/mac_frames.rs b/crates/compositor/src/mac_frames.rs index 0d04b3da5..1dcd0dda7 100644 --- a/crates/compositor/src/mac_frames.rs +++ b/crates/compositor/src/mac_frames.rs @@ -105,11 +105,17 @@ extern "C" { } /// Crée un `CVPixelBufferRef` NV12 IOSurface-backed, dimensions paires `(w, h)`. +/// `format` est le fourcc CoreVideo : `420v` partout, sauf les tests qui fabriquent les +/// autres formats biplanaires que VideoToolbox sort (`420f`, `x420`, `xf20`). /// /// NV12 impose des dimensions paires : on arrondit AU-DESSUS pour le buffer et on /// laisse `present.width/height` aux dimensions visibles — c'est le même écart /// texture/visible que produit l'alignement macrobloc de D3D11VA (1080 → 1088). -unsafe fn create_nv12_pixel_buffer(w: usize, h: usize) -> Result { +pub(crate) unsafe fn create_nv12_pixel_buffer( + w: usize, + h: usize, + format: u32, +) -> Result { // `{ IOSurfaceProperties: {}, MetalCompatibility: true }` — un dictionnaire // IOSurface vide suffit à demander le backing, `MetalCompatibility` fait valider // par CoreVideo que le résultat est utilisable depuis Metal. @@ -130,7 +136,7 @@ unsafe fn create_nv12_pixel_buffer(w: usize, h: usize) -> Result String { /// La frame 10 de `source`, rendue seule sur un fond uni, sans effet. fn render_frame_10(source: &str) -> RenderedFrame { - let source = source.replace('\\', "/"); + let source = source.to_owned(); let gpu = Gpu::create_auto(false).expect("device"); let clips = [ClipSource { screen: source.clone(),