From d6549cd268f3315b7164db527f411e73cd416496 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Sun, 1 Nov 2026 09:34:57 +0100 Subject: [PATCH] fix(export): tag the MP4's colour matrix, primaries, transfer and range No export encoder set them, so the file carried no colr atom and no VUI colour description, and each player guessed. macOS and Windows encode BT.709 limited; a reader that assumes BT.601 for an untagged file, ffmpeg among them, shifted saturated colours by up to 22 levels on a macOS export. Linux encodes BT.601 limited and was misread the other way by players that assume BT.709 for HD. Each encoder now declares the matrix of its own RGB to YUV conversion, with BT.709 primaries and transfer for the sRGB the compositor renders, before avcodec_open2. --- crates/compositor/src/ffi.rs | 14 +++++++++ crates/compositor/src/pipeline_linux.rs | 36 +++++++++++++++++++++++ crates/compositor/src/pipeline_macos.rs | 2 ++ crates/compositor/src/pipeline_windows.rs | 36 +++++++++++++++++++++++ 4 files changed, 88 insertions(+) diff --git a/crates/compositor/src/ffi.rs b/crates/compositor/src/ffi.rs index c0f8856ac..670b6ba55 100644 --- a/crates/compositor/src/ffi.rs +++ b/crates/compositor/src/ffi.rs @@ -67,6 +67,20 @@ pub fn averr(ret: i32, ctx: &str) -> anyhow::Result<()> { Ok(()) } +/// Étiquette couleur d'un encodeur d'export, à poser avant `avcodec_open2` : l'encodeur +/// l'écrit dans le VUI, `avcodec_parameters_from_context` la recopie et le muxer MP4 en +/// fait l'atome `colr`. Le compositeur rend du sRGB, d'où primaires et transfert BT.709 ; +/// `matrix` est celle de la conversion RGB → YUV de la plateforme, en plage limitée. +/// +/// Sans elle le fichier ne dit rien et chaque lecteur devine : ffmpeg suppose BT.601 et +/// décalait jusqu'à 22 niveaux les couleurs saturées d'un export macOS en BT.709. +pub unsafe fn tag_export_color(ctx: *mut AVCodecContext, matrix: AVColorSpace::Type) { + (*ctx).color_primaries = AVColorPrimaries::AVCOL_PRI_BT709; + (*ctx).color_trc = AVColorTransferCharacteristic::AVCOL_TRC_BT709; + (*ctx).colorspace = matrix; + (*ctx).color_range = AVColorRange::AVCOL_RANGE_MPEG; +} + #[cfg(test)] mod tests { /// Les constantes Rust doivent valoir EXACTEMENT ce que les macros ffmpeg valent diff --git a/crates/compositor/src/pipeline_linux.rs b/crates/compositor/src/pipeline_linux.rs index a9688e86c..edc9ea623 100644 --- a/crates/compositor/src/pipeline_linux.rs +++ b/crates/compositor/src/pipeline_linux.rs @@ -291,6 +291,8 @@ impl VideoEncoder { Self::tune_openh264(ctx, name); // MP4 : header global dans l'extradata (pas par-paquet). (*ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as i32; + // `vk_shaders/yuv.wgsl` : BT.601 limité. + tag_export_color(ctx, AVColorSpace::AVCOL_SPC_SMPTE170M); if let Err(e) = averr(avcodec_open2(ctx, enc, ptr::null_mut()), "avcodec_open2(enc)") { avcodec_free_context(&mut ctx); return Err(e); @@ -1427,6 +1429,8 @@ impl VaapiEncoder { // muxer recupere ce qu'il trouve — mais un lecteur qui se fie a // `codecpar` seul aurait de quoi echouer. (*me.ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as i32; + // La matrice des frames que `send_dmabuf` pose, celle de `yuv.wgsl`. + tag_export_color(me.ctx, AVColorSpace::AVCOL_SPC_SMPTE170M); // Avec la copie, les surfaces de SORTIE du VPP, pas celles du mapping : // c'est elles que l'encodeur recoit. let out_frames = if me.graph.is_null() { @@ -1983,6 +1987,8 @@ mod tests { qmin: i32, qmax: i32, gop_size: i32, + /// Primaires, transfert, matrice, plage de `codecpar`, ce que le muxer écrit. + color: (i32, i32, i32, i32), } /// Encode `n` images 320x180 par le chemin RÉEL de l'export — `VideoEncoder::open`, @@ -2007,12 +2013,22 @@ mod tests { qmin: 0, qmax: 0, gop_size: 0, + color: (0, 0, 0, 0), }; unsafe { use crate::ffi::*; out.qmin = (*enc.ctx).qmin; out.qmax = (*enc.ctx).qmax; out.gop_size = (*enc.ctx).gop_size; + let mut par = avcodec_parameters_alloc(); + avcodec_parameters_from_context(par, enc.ctx); + out.color = ( + (*par).color_primaries as i32, + (*par).color_trc as i32, + (*par).color_space as i32, + (*par).color_range as i32, + ); + avcodec_parameters_free(&mut par); let (ptr, len) = ((*enc.ctx).extradata, (*enc.ctx).extradata_size); if !ptr.is_null() && len > 0 { out.extradata = std::slice::from_raw_parts(ptr, len as usize).to_vec(); @@ -2087,6 +2103,26 @@ mod tests { /// image clé pour tout le fichier — mesuré 1 image I sur 300 avant ce correctif. /// Le MP4 reste lisible, mais tout seek redécode depuis le début et un paquet /// abîmé emporte le reste. C'est le test qui aurait attrapé ça. + /// Le MP4 dit sa matrice : BT.601, celle de `yuv.wgsl`. Sans étiquette un + /// lecteur qui suppose BT.709 décale les couleurs saturées. + #[test] + fn l_export_h264_porte_son_etiquette_couleur() { + let Some(enc) = encode(10, 5) else { + eprintln!("libopenh264 indisponible — test sauté"); + return; + }; + use crate::ffi::*; + assert_eq!( + enc.color, + ( + AVColorPrimaries::AVCOL_PRI_BT709 as i32, + AVColorTransferCharacteristic::AVCOL_TRC_BT709 as i32, + AVColorSpace::AVCOL_SPC_SMPTE170M as i32, + AVColorRange::AVCOL_RANGE_MPEG as i32, + ) + ); + } + #[test] fn l_export_h264_emet_des_images_cles_periodiques() { let Some(enc) = encode(10, 45) else { diff --git a/crates/compositor/src/pipeline_macos.rs b/crates/compositor/src/pipeline_macos.rs index 57cad0d81..a7b54c2ae 100644 --- a/crates/compositor/src/pipeline_macos.rs +++ b/crates/compositor/src/pipeline_macos.rs @@ -952,6 +952,8 @@ impl VideoEncoder { (*ctx).framerate = crate::ffi::AVRational { num: fps, den: 1 }; (*ctx).bit_rate = bit_rate; (*ctx).flags |= crate::ffi::AV_CODEC_FLAG_GLOBAL_HEADER as i32; + // `rgb2y` / `rgb2uv` de `shaders.metal` : BT.709 limité. + crate::ffi::tag_export_color(ctx, crate::ffi::AVColorSpace::AVCOL_SPC_BT709); // VT : on attache le hw_frames_ctx. En pratique le call-site (run_composited_multi) // nous passera un `hw_frames_ctx` pré-construit lié au même device VideoToolbox diff --git a/crates/compositor/src/pipeline_windows.rs b/crates/compositor/src/pipeline_windows.rs index b99293dad..ea6e658b3 100644 --- a/crates/compositor/src/pipeline_windows.rs +++ b/crates/compositor/src/pipeline_windows.rs @@ -1553,6 +1553,8 @@ impl VideoEncoder { // d'ajouter n'émettent pas d'extradata sans ce drapeau, et personne ici n'a de machine // pour constater le MP4 bancal qui en sortirait. (*ctx).flags |= AV_CODEC_FLAG_GLOBAL_HEADER as i32; + // `rgb2y` / `rgb2uv` de `shaders.hlsl` : BT.709 limité. + crate::ffi::tag_export_color(ctx, AVColorSpace::AVCOL_SPC_BT709); if pix_fmt == AVPixelFormat::AV_PIX_FMT_D3D11 { (*ctx).hw_frames_ctx = av_buffer_ref(hw_frames); } @@ -2008,6 +2010,40 @@ mod tests { } } + /// Le MP4 dit sa matrice : BT.709, celle de `shaders.hlsl`. Sans étiquette ffmpeg + /// suppose BT.601 et décale les couleurs saturées. Sans pool D3D11 c'est un candidat + /// en mémoire système qui ouvre, par le même `try_open` que l'export. + #[test] + fn export_encoder_tags_its_colour() { + let opened = unsafe { + VideoEncoder::open(&ExportCodec::H264, 320, 180, 30, 1_000_000, ptr::null_mut()) + }; + let Ok(enc) = opened else { + eprintln!("aucun encodeur H.264 en mémoire système ici, test sauté"); + return; + }; + unsafe { + let mut par = avcodec_parameters_alloc(); + avcodec_parameters_from_context(par, enc.ctx); + let color = ( + (*par).color_primaries, + (*par).color_trc, + (*par).color_space, + (*par).color_range, + ); + avcodec_parameters_free(&mut par); + assert_eq!( + color, + ( + AVColorPrimaries::AVCOL_PRI_BT709, + AVColorTransferCharacteristic::AVCOL_TRC_BT709, + AVColorSpace::AVCOL_SPC_BT709, + AVColorRange::AVCOL_RANGE_MPEG, + ) + ); + } + } + #[test] fn hardware_prerequisite_classification() { use windows::Win32::Foundation::{E_INVALIDARG, E_OUTOFMEMORY, E_UNEXPECTED};