hw: propagate stream colour tags to VAAPI frames (fixes washed-out HDR)

Symptom: properly-converted BT.2020 PQ/HLG clips rendered progressively
desaturated vs the SDR reference — the signature of the transfer/primaries tags
not reaching the NV12→RGB shader, so HDR code values were decoded as plain
sRGB/BT.709 (no gamut expansion, wrong EOTF).

VAAPI hardware-decoded frames frequently leave color_trc/color_primaries/
colorspace/color_range unspecified — the authoritative values live on the codec
context (parsed from the bitstream), which the importer never sees. Before
importing, copy any unspecified colour field from the codec context onto the
frame, so the importer detects PQ/HLG + BT.2020 correctly.

Also add a one-time importer log (LB_VIDEO_DEBUG) of the detected
ten_bit/full_range/colorspace/primaries/trc to confirm what's picked up.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Skyler Lehmkuhl 2026-06-26 03:35:16 -04:00
parent 83410bd4b4
commit 7c2ac0e4d8
2 changed files with 34 additions and 0 deletions

View File

@ -460,6 +460,27 @@ impl VideoDecoder {
if gpu_out {
// Hardware + GPU consumer: import the VAAPI surface as wgpu NV12 textures
// (no CPU copy).
// VAAPI hw frames often don't carry the stream's colour tags, so the
// importer (which only sees the frame) would mis-detect transfer/gamut.
// Copy the authoritative values from the codec context (parsed from the
// bitstream) onto the frame when it left them unspecified.
unsafe {
use ffmpeg::ffi::*;
let fp = frame.as_mut_ptr();
let cp = decoder.as_ptr();
if (*fp).color_trc == AVColorTransferCharacteristic::AVCOL_TRC_UNSPECIFIED {
(*fp).color_trc = (*cp).color_trc;
}
if (*fp).color_primaries == AVColorPrimaries::AVCOL_PRI_UNSPECIFIED {
(*fp).color_primaries = (*cp).color_primaries;
}
if (*fp).colorspace == AVColorSpace::AVCOL_SPC_UNSPECIFIED {
(*fp).colorspace = (*cp).colorspace;
}
if (*fp).color_range == AVColorRange::AVCOL_RANGE_UNSPECIFIED {
(*fp).color_range = (*cp).color_range;
}
}
let importer = self.importer.as_ref().unwrap();
match unsafe { importer.import(frame.as_mut_ptr() as *mut std::ffi::c_void) } {
Some(gpu) => {

View File

@ -17,6 +17,8 @@ use std::sync::{Arc, Mutex};
struct SharedHwImporter {
device: wgpu::Device,
adapter: wgpu::Adapter,
/// Log the detected colour info once (under LB_VIDEO_DEBUG) rather than per frame.
logged: std::sync::atomic::AtomicBool,
}
impl HwVideoImporter for SharedHwImporter {
@ -99,6 +101,16 @@ impl HwVideoImporter for SharedHwImporter {
};
let coeffs = ycbcr_coeffs(kr, kb);
if std::env::var("LB_VIDEO_DEBUG").is_ok()
&& !self.logged.swap(true, std::sync::atomic::Ordering::Relaxed)
{
eprintln!(
"[hw_video] {}x{} ten_bit={} full_range={} colorspace={:?} primaries={:?} trc={:?}",
width, height, ten_bit, full_range,
(*frame).colorspace, (*frame).color_primaries, (*frame).color_trc,
);
}
// Transfer characteristic → which EOTF the compositor applies to reach scene-linear.
let transfer = match (*frame).color_trc {
ff::AVColorTransferCharacteristic::AVCOL_TRC_SMPTE2084 => VideoTransfer::Pq,
@ -143,6 +155,7 @@ pub fn install(vm: &Arc<Mutex<VideoManager>>, device: &wgpu::Device, adapter: &w
let importer = Arc::new(SharedHwImporter {
device: device.clone(),
adapter: adapter.clone(),
logged: std::sync::atomic::AtomicBool::new(false),
});
if let Ok(mut vm) = vm.lock() {
vm.set_hardware_decode(