From 2e6eceed6481da4b56fad1c811742224b0e5faf0 Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Wed, 30 Sep 2026 23:55:38 +0200 Subject: [PATCH] fix(linux): convert and tag the PipeWire helper's video as BT.709 The compositor decodes every recording as BT.709 studio range. The PipeWire helper converted with swscale's default matrix, BT.601, and tagged nothing, so every Linux take shifted hue and saturation: pure red came out at Y 81, Cb 90 where BT.709 is 63 and 102. - swscale converts with BT.709 coefficients, full-range RGB in, studio range out. - The dmabuf path's scale_vaapi is asked for the same (out_color_matrix, out_range, primaries, transfer) instead of the driver's default. - The codec context carries range, matrix, primaries and transfer. software_path_converts_and_tags_bt709 stages a red frame through the software encoder and reads the YUV back: 63/102/240, and all four tags. Run under WSL (nix develop .#compositor-view, ffmpeg n8.1.2): it passes, the crate's 93 tests pass, and with the BT.709 call removed it fails on Y 81, Cb 90. Fixes #926 --- .../pipewire-capture/src/dmabuf_import.rs | 4 +- .../native/pipewire-capture/src/encoder.rs | 47 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/electron/native/pipewire-capture/src/dmabuf_import.rs b/electron/native/pipewire-capture/src/dmabuf_import.rs index 4dc60412f..59e900a84 100644 --- a/electron/native/pipewire-capture/src/dmabuf_import.rs +++ b/electron/native/pipewire-capture/src/dmabuf_import.rs @@ -266,8 +266,10 @@ impl DmabufImporter { // Output size = the recorded (out) size. For a monitor that equals the // source; for a window it is the crop rectangle, and the per-frame crop // fields set in `import` pick which region of the source is scaled into it. + // BT.709 studio range out, as `ensure_sws` converts the CPU path and as + // the compositor decodes; left to the driver it is not guaranteed (#926). let scale_args = std::ffi::CString::new(format!( - "w={}:h={}:format=nv12", + "w={}:h={}:format=nv12:out_color_matrix=bt709:out_range=limited: out_color_primaries=bt709:out_color_transfer=bt709", self.out_width, self.out_height )) .map_err(|_| "scale_vaapi args contained a NUL".to_owned())?; diff --git a/electron/native/pipewire-capture/src/encoder.rs b/electron/native/pipewire-capture/src/encoder.rs index a2e041bfe..ceb92fac4 100644 --- a/electron/native/pipewire-capture/src/encoder.rs +++ b/electron/native/pipewire-capture/src/encoder.rs @@ -355,6 +355,12 @@ impl VideoEncoder { (*codec_ctx).framerate = ff::AVRational { num: params.fps, den: 1 }; (*codec_ctx).pix_fmt = backend.codec_pixel_format(); (*codec_ctx).bit_rate = params.bitrate; + // What the samples are (see `ensure_sws` and the dmabuf VPP), said in + // the stream so no player has to guess from the frame size (#926). + (*codec_ctx).color_range = ff::AVCOL_RANGE_MPEG; + (*codec_ctx).colorspace = ff::AVCOL_SPC_BT709; + (*codec_ctx).color_primaries = ff::AVCOL_PRI_BT709; + (*codec_ctx).color_trc = ff::AVCOL_TRC_BT709; // Half a second, not the two seconds a streaming preset would use. // // This file is an EDITING SOURCE, and the editor scrubs it. Seeking @@ -787,6 +793,17 @@ impl VideoEncoder { ff::err_to_string(initialised) )); } + // BT.709 studio range, which is what the compositor decodes every + // recording as. swscale's default matrix is BT.601, so left alone every + // Linux take played back with shifted hue and saturation + // (getopenscreen/openscreen#926). Full range in: the source is RGB. + let bt709 = ff::sws_getCoefficients(ff::SWS_CS_ITU709 as i32); + let unity = 1 << 16; + if ff::sws_setColorspaceDetails(self.sws, bt709, 1, bt709, 0, 0, unity, unity) < 0 { + return Err(format!( + "swscale refused a BT.709 conversion from pixel format {src_format} to {dst_format}" + )); + } // Read back rather than assume: libswscale is free to ignore a thread // count it cannot use for a given conversion, and a request that was // silently dropped looks exactly like threading that did not help. @@ -1423,6 +1440,36 @@ mod tests { assert!(size > 1024, "the muxed file is {size} bytes, which cannot be 120 frames"); } + #[test] + fn software_path_converts_and_tags_bt709() { + // The compositor decodes every recording as BT.709 studio range; swscale's + // default is BT.601, which puts pure red at Y 81, Cb 90 (#926). + let (width, height) = (64, 32); + let mut encoder = VideoEncoder::open( + VideoParams { width, height, fps: 30, bitrate: 2_000_000 }, + Some(Backend::Software), + |_, _| {}, + ) + .expect("the software encoder always opens"); + let stride = width as usize * 4; + let mut frame = vec![0u8; stride * height as usize]; + for pixel in frame.chunks_exact_mut(4) { + pixel.copy_from_slice(&[0, 0, 255, 255]); // BGRA pure red + } + encoder.stage(&frame, stride, ff::AV_PIX_FMT_BGRA).expect("stage"); + // SAFETY: `stage` just filled `sw_frame`, a YUV420P frame of this size. + let (y, cb, cr, ctx) = unsafe { + let staged = encoder.sw_frame; + (*(*staged).data[0], *(*staged).data[1], *(*staged).data[2], &*encoder.codec_ctx) + }; + let close = |got: u8, want: i32| (i32::from(got) - want).abs() <= 1; + assert!(close(y, 63) && close(cb, 102) && close(cr, 240), "red came out Y {y} Cb {cb} Cr {cr}"); + assert_eq!(ctx.color_range, ff::AVCOL_RANGE_MPEG); + assert_eq!(ctx.colorspace, ff::AVCOL_SPC_BT709); + assert_eq!(ctx.color_primaries, ff::AVCOL_PRI_BT709); + assert_eq!(ctx.color_trc, ff::AVCOL_TRC_BT709); + } + #[test] fn radv_perftest_is_added_without_clobbering_an_existing_value() { std::env::set_var("RADV_PERFTEST", "gpl");