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};