diff --git a/crates/compositor/src/compositor_macos.rs b/crates/compositor/src/compositor_macos.rs index c1072cbf1..577ea4e76 100644 --- a/crates/compositor/src/compositor_macos.rs +++ b/crates/compositor/src/compositor_macos.rs @@ -99,8 +99,15 @@ 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"); +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 /// retenir la texture donne un `id` mort. Et ne jamais le relâcher — @@ -198,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 { @@ -1156,6 +1185,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 +1201,18 @@ 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 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) + } + _ => (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)?; + if matches!(pixel_format, CV_420_8_BIPLANAR_FULL | CV_420_10_BIPLANAR_FULL) { + return Ok((y, full_range_view(&uv))); + } Ok((y, uv)) } @@ -3816,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; @@ -3829,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 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 new file mode 100644 index 000000000..205cabada --- /dev/null +++ b/crates/compositor/tests/hevc10_decode.rs @@ -0,0 +1,109 @@ +//! 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, 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}}], + "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}} + }}"## + ) +} + +/// La frame 10 de `source`, rendue seule sur un fond uni, sans effet. +fn render_frame_10(source: &str) -> RenderedFrame { + let source = source.to_owned(); + 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 mut frames = + unsafe { render_frames(&clips, &gpu, &comp, &cfg, 30, &[10]) }.expect("render_frames"); + 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 (w, h) = (frame.width, frame.height); + 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); + 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); +}