fix(video): honor the signaled CSC matrix end-to-end + tvOS HDR presentation
Clients derive Y'CbCr->RGB from the stream's SIGNALED matrix x range x depth via shared csc rows (Rust csc_rows + Swift CscRows) instead of hardcoded 709/2020 - a BT.601-signaled stream (a Linux host's RGB-input NVENC) no longer renders with a constant hue error. Host-side signaling made honest across NVENC/VAAPI/openh264/GameStream and the session plan's chroma/bit-depth. Decoded color-bar fixtures (601/709 x limited/full) pin the math in tests on both cores. Same presenter, tvOS HDR: tvOS has no Metal EDR API and a bare PQ colorspace tag composites UNTONE-MAPPED (the "overblown" Apple TV report), so HDR now splits on the display's live EDR headroom - PQ passthrough when the per-session AVDisplayManager mode switch landed (a real HDR10 output tone-maps itself), else an in-shader PQ->SDR tone-map (203-nit reference white, extended-Reinhard 1000-nit knee, 2020->709) into the proven SDR layer config. The 10-bit stream keeps its full decode depth either way. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -326,11 +326,19 @@ impl NvencEncoder {
|
||||
};
|
||||
}
|
||||
|
||||
// NV12 / 4:4:4 paths: we do the RGB→YUV conversion ourselves as BT.709 *limited* range
|
||||
// (swscale), so signal that in the bitstream VUI (colorspace/range/primaries/transfer) —
|
||||
// otherwise the client decoder assumes a default and the picture comes out washed-out /
|
||||
// wrong-contrast. The RGB-input 4:2:0 path leaves these unset (NVENC's internal CSC writes
|
||||
// its own VUI). Matches the Windows NV12 path's BT.709 limited-range signalling.
|
||||
// NV12 / 4:4:4 paths: we do the RGB→YUV conversion ourselves as BT.709 (swscale), so
|
||||
// signal that in the bitstream VUI (colorspace/range/primaries/transfer) — otherwise the
|
||||
// client decoder assumes a default and the picture comes out washed-out / wrong-contrast.
|
||||
// The RGB-input 4:2:0 path leaves these unset (NVENC's internal CSC writes its own VUI).
|
||||
// Matches the Windows NV12 path's BT.709 limited-range signalling.
|
||||
//
|
||||
// PUNKTFUNK_444_FULLRANGE=1 (experimental, 4:4:4-only): convert AND signal FULL range —
|
||||
// recovers the ~12% of code space limited-range quantization gives up, for the exact
|
||||
// text/UI chroma 4:4:4 exists for. Every punktfunk client honors the signaled range
|
||||
// (csc_rows / the Apple rows port); ship as default only if the on-glass A/B shows a
|
||||
// visible win. Linux-only: the Windows path's NVENC-internal CSC range is unmeasured.
|
||||
let full_range_444 = want_444
|
||||
&& std::env::var("PUNKTFUNK_444_FULLRANGE").is_ok_and(|v| v.trim() == "1");
|
||||
if matches!(format, PixelFormat::Nv12) || want_444 {
|
||||
// SAFETY: same `video` builder — `raw = video.as_mut_ptr()` is the non-null, properly-
|
||||
// aligned, sole-owned, not-yet-opened `AVCodecContext`. We set its four VUI colour enum
|
||||
@@ -339,7 +347,11 @@ impl NvencEncoder {
|
||||
unsafe {
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG; // limited/studio
|
||||
(*raw).color_range = if full_range_444 {
|
||||
ffi::AVColorRange::AVCOL_RANGE_JPEG // full
|
||||
} else {
|
||||
ffi::AVColorRange::AVCOL_RANGE_MPEG // limited/studio
|
||||
};
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
|
||||
(*raw).color_trc = ffi::AVColorTransferCharacteristic::AVCOL_TRC_BT709;
|
||||
}
|
||||
@@ -401,10 +413,12 @@ impl NvencEncoder {
|
||||
// SAFETY: `sws` is the non-null context from the call above (null-checked). The ITU-709
|
||||
// coefficient table from `sws_getCoefficients` is a process-lifetime libswscale static,
|
||||
// reused for src+dst matrices; `sws_setColorspaceDetails` only reads it and writes scalar
|
||||
// CSC settings into `sws` (limited-range dst: dstRange = 0). No Rust memory is passed.
|
||||
// CSC settings into `sws` (dstRange matches the VUI: 0 = limited, 1 = the
|
||||
// PUNKTFUNK_444_FULLRANGE experiment). No Rust memory is passed.
|
||||
unsafe {
|
||||
let cs709 = ffi::sws_getCoefficients(SWS_CS_ITU709);
|
||||
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, 0, 0, 1 << 16, 1 << 16);
|
||||
let dst_range = i32::from(full_range_444);
|
||||
ffi::sws_setColorspaceDetails(sws, cs709, 1, cs709, dst_range, 0, 1 << 16, 1 << 16);
|
||||
}
|
||||
Some(sws)
|
||||
} else {
|
||||
|
||||
@@ -204,8 +204,9 @@ unsafe fn open_vaapi_encoder_mode(
|
||||
let raw = video.as_mut_ptr();
|
||||
(*raw).rc_buffer_size = vbv_bits as i32;
|
||||
(*raw).gop_size = i32::MAX; // no periodic IDR (forced-IDR via pict_type=I on RFI)
|
||||
// We hand the encoder BT.709 *limited* NV12 (swscale CSC, or scale_vaapi which preserves the
|
||||
// input range we tag), so signal that VUI — else the client decoder washes the picture out.
|
||||
// We hand the encoder BT.709 *limited* NV12 (swscale CSC on the CPU path; scale_vaapi pinned
|
||||
// to `out_color_matrix=bt709:out_range=limited` on the zero-copy path, with the full-range
|
||||
// RGB input tagged), so signal that VUI — else the client decoder washes the picture out.
|
||||
(*raw).colorspace = ffi::AVColorSpace::AVCOL_SPC_BT709;
|
||||
(*raw).color_range = ffi::AVColorRange::AVCOL_RANGE_MPEG;
|
||||
(*raw).color_primaries = ffi::AVColorPrimaries::AVCOL_PRI_BT709;
|
||||
@@ -718,6 +719,11 @@ impl DmabufInner {
|
||||
(*par).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
|
||||
(*par).width = width as c_int;
|
||||
(*par).height = height as c_int;
|
||||
// Declare the link's colour up front (full-range RGB — the compositor's desktop) so
|
||||
// the per-frame tags in `submit` match the negotiated link instead of reading as a
|
||||
// mid-stream property change.
|
||||
(*par).color_space = ffi::AVColorSpace::AVCOL_SPC_RGB;
|
||||
(*par).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
|
||||
(*par).time_base = ffi::AVRational {
|
||||
num: 1,
|
||||
den: fps as c_int,
|
||||
@@ -751,7 +757,14 @@ impl DmabufInner {
|
||||
}
|
||||
init!(src, ptr::null(), "buffer");
|
||||
init!(hwmap, c"mode=read".as_ptr(), "hwmap");
|
||||
init!(scale, c"format=nv12".as_ptr(), "scale_vaapi");
|
||||
// Pin the VPP's output colour to what the encoder's VUI signals (BT.709 limited).
|
||||
// Without the explicit options the conversion matrix is whatever the driver defaults
|
||||
// to for an unspecified output (Mesa: BT.601) — a hue shift against the signaled VUI.
|
||||
init!(
|
||||
scale,
|
||||
c"format=nv12:out_color_matrix=bt709:out_range=limited".as_ptr(),
|
||||
"scale_vaapi"
|
||||
);
|
||||
init!(sink, ptr::null(), "buffersink");
|
||||
|
||||
let link = |a: *mut ffi::AVFilterContext, b: *mut ffi::AVFilterContext| -> c_int {
|
||||
@@ -879,6 +892,12 @@ impl DmabufInner {
|
||||
(*drm).format = ffi::AVPixelFormat::AV_PIX_FMT_DRM_PRIME as c_int;
|
||||
(*drm).width = self.width as c_int;
|
||||
(*drm).height = self.height as c_int;
|
||||
// The dmabuf is the compositor's rendered desktop: full-range RGB. Tag the frame so
|
||||
// the VPP's colour negotiation sees the real input instead of "unspecified" (an
|
||||
// untagged input lets the driver pick its own default for the RGB→NV12 conversion —
|
||||
// Mesa's is BT.601, contradicting the BT.709-limited VUI the encoder signals).
|
||||
(*drm).color_range = ffi::AVColorRange::AVCOL_RANGE_JPEG;
|
||||
(*drm).colorspace = ffi::AVColorSpace::AVCOL_SPC_RGB;
|
||||
(*drm).hw_frames_ctx = ffi::av_buffer_ref(self.drm_frames);
|
||||
(*drm).data[0] = Box::into_raw(desc) as *mut u8;
|
||||
// Own the descriptor so it frees with the frame (the fd is owned by the DmabufFrame,
|
||||
|
||||
Reference in New Issue
Block a user