Skip to content
Merged
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
90 changes: 88 additions & 2 deletions crates/compositor/src/compositor_macos.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<MTLTexture>` mort. Et ne jamais le relâcher —
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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))
}

Expand Down Expand Up @@ -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<AVFrame> = 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;
Expand All @@ -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,
Expand Down
22 changes: 18 additions & 4 deletions crates/compositor/src/mac_frames.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CVPixelBufferRef> {
pub(crate) unsafe fn create_nv12_pixel_buffer(
w: usize,
h: usize,
format: u32,
) -> Result<CVPixelBufferRef> {
// `{ IOSurfaceProperties: {}, MetalCompatibility: true }` — un dictionnaire
// IOSurface vide suffit à demander le backing, `MetalCompatibility` fait valider
// par CoreVideo que le résultat est utilisable depuis Metal.
Expand All @@ -130,7 +136,7 @@ unsafe fn create_nv12_pixel_buffer(w: usize, h: usize) -> Result<CVPixelBufferRe
ptr::null(), // default allocator
w,
h,
K_CV_PIXEL_FORMAT_TYPE_420_Y_P_C_B_CR_8_BI_PLANAR_VIDEO_RANGE,
format,
attributes.as_concrete_TypeRef() as *const std::ffi::c_void,
&mut pixel_buffer,
);
Expand Down Expand Up @@ -256,7 +262,11 @@ impl CpuFrames {
if self.pixel_buffer.is_some() && self.pixel_buffer_dims == dims {
return Ok(());
}
let pb = create_nv12_pixel_buffer(dims.0 as usize, dims.1 as usize)?;
let pb = create_nv12_pixel_buffer(
dims.0 as usize,
dims.1 as usize,
K_CV_PIXEL_FORMAT_TYPE_420_Y_P_C_B_CR_8_BI_PLANAR_VIDEO_RANGE,
)?;
self.pixel_buffer = Some(pb);
self.pixel_buffer_dims = dims;
Ok(())
Expand Down Expand Up @@ -401,7 +411,11 @@ pub(crate) fn nv12_pixel_buffer_from_planes(
);
}
unsafe {
let pb = create_nv12_pixel_buffer(w, h)?;
let pb = create_nv12_pixel_buffer(
w,
h,
K_CV_PIXEL_FORMAT_TYPE_420_Y_P_C_B_CR_8_BI_PLANAR_VIDEO_RANGE,
)?;
if CVPixelBufferLockBaseAddress(pb.as_ptr(), 0) != 0 {
bail!("CVPixelBufferLockBaseAddress (fixture NV12)");
}
Expand Down
17 changes: 15 additions & 2 deletions crates/compositor/src/shaders.metal
Original file line number Diff line number Diff line change
Expand Up @@ -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<float>::sample` rend TOUJOURS un `float4` en MSL, là où le HLSL
// `Texture2D<float>` rend un scalaire : d'où les `.r` / `.rg` que le port d'origine
// n'avait pas (et qui ne compilaient pas).
Expand All @@ -149,8 +161,9 @@ inline float3 sample_yuv(float2 uv,
texture2d<float, access::sample> 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.
Expand Down
109 changes: 109 additions & 0 deletions crates/compositor/tests/hevc10_decode.rs
Original file line number Diff line number Diff line change
@@ -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}},
Comment thread
EtienneLescot marked this conversation as resolved.
"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);
}
Loading