diff --git a/crates/pf-client-core/src/session.rs b/crates/pf-client-core/src/session.rs index cf2ba70f..50cc9908 100644 --- a/crates/pf-client-core/src/session.rs +++ b/crates/pf-client-core/src/session.rs @@ -424,11 +424,31 @@ fn pump( // Build the decoder for the codec the host resolved (never assume HEVC), honoring the // Settings backend preference (auto/vaapi/software). let codec_id = crate::video::ffmpeg_codec_id(connector.codec); - tracing::info!( - ?codec_id, - welcome_codec = connector.codec, - "negotiated video codec" - ); + // The WIRE codec is the negotiated truth; the FFmpeg id is meaningful only where + // FFmpeg decodes it. `ffmpeg_codec_id`'s fallthrough maps every unknown wire bit — + // PyroWave included — to HEVC, so logging it unconditionally claimed + // `codec_id=HEVC` for wavelet sessions that never touch FFmpeg at all. + let codec = match connector.codec { + punktfunk_core::quic::CODEC_H264 => "H264", + punktfunk_core::quic::CODEC_HEVC => "HEVC", + punktfunk_core::quic::CODEC_AV1 => "AV1", + punktfunk_core::quic::CODEC_PYROWAVE => "PyroWave", + _ => "unknown", + }; + if connector.codec == punktfunk_core::quic::CODEC_PYROWAVE { + tracing::info!( + codec, + welcome_codec = connector.codec, + "negotiated video codec" + ); + } else { + tracing::info!( + codec, + ?codec_id, + welcome_codec = connector.codec, + "negotiated video codec" + ); + } // A negotiated PyroWave session decodes on the presenter's device, no FFmpeg — // reachable only through the explicit preference above (resolve_codec never // auto-picks the bit), so failing loudly here is failing an opted-in experiment. diff --git a/crates/pf-client-core/src/video.rs b/crates/pf-client-core/src/video.rs index 96ee8c78..74a71965 100644 --- a/crates/pf-client-core/src/video.rs +++ b/crates/pf-client-core/src/video.rs @@ -321,6 +321,88 @@ pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id { } } +/// Select a decoder for `codec_id` that can actually drive `hw_pix_fmt` through +/// `hw_device_ctx` — the open-time capability check every hardware backend needs. +/// +/// `avcodec_find_decoder(id)` is NOT that: it returns the registry's FIRST decoder for +/// the id, and upstream orders the native `av1` decoder LAST on purpose ("hwaccel hooks +/// only, so prefer external decoders" — allcodecs.c), behind libdav1d/libaom. The ID +/// lookup therefore hands every AV1 session a pure software decoder that silently +/// ignores `hw_device_ctx` and never calls `get_format`; each frame then fails the +/// backend's hw-format guard and the session burns the demotion ladder MID-STREAM +/// (~1 s per rung — field-logged as 68 Vulkan fails → D3D11VA → 102 fails → software, +/// ~3 s of black) instead of failing here at open in milliseconds. H.264/HEVC never hit +/// this only because their native decoders happen to be registered first. +/// +/// The walk mirrors what `avcodec_find_decoder` would do, restricted to decoders whose +/// `avcodec_get_hw_config` advertises the wanted surface via +/// `AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX` — registry order still wins among those, +/// so H.264/HEVC keep selecting exactly the decoder they always did. The error names +/// the decoders that WERE found, so a log reader can tell "this build has no AV1 +/// hwaccel at all" from "no AV1 decoder exists, period". +pub(crate) fn find_hw_decoder( + codec_id: ffmpeg::codec::Id, + hw_pix_fmt: ffmpeg::ffi::AVPixelFormat, +) -> Result<*const ffmpeg::ffi::AVCodec> { + use ffmpeg::ffi; + let want: ffi::AVCodecID = codec_id.into(); + let mut found: Vec = Vec::new(); + // SAFETY: `av_codec_iterate` walks libav's static codec registry (`opaque` is its + // cursor) and returns static `AVCodec`s; `avcodec_get_hw_config` only reads the + // codec's own static hw-config table, NULL-terminated by returning null past the end. + unsafe { + let mut opaque = std::ptr::null_mut(); + loop { + let codec = ffi::av_codec_iterate(&mut opaque); + if codec.is_null() { + break; + } + if (*codec).id != want || ffi::av_codec_is_decoder(codec) == 0 { + continue; + } + for i in 0.. { + let cfg = ffi::avcodec_get_hw_config(codec, i); + if cfg.is_null() { + break; + } + if (*cfg).methods & ffi::AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX as i32 != 0 + && (*cfg).pix_fmt == hw_pix_fmt + { + return Ok(codec); + } + } + found.push( + std::ffi::CStr::from_ptr((*codec).name) + .to_string_lossy() + .into_owned(), + ); + } + } + if found.is_empty() { + bail!("no {codec_id:?} decoder in this FFmpeg build"); + } + bail!( + "no {codec_id:?} decoder in this FFmpeg build can drive {hw_pix_fmt:?} via \ + hw_device_ctx (found: {})", + found.join(", ") + ); +} + +/// The name of a registry `AVCodec` (`(*codec).name`), owned — the field every decode +/// log carries so `decoder="av1"` vs `decoder="libdav1d"` is one glance, not a debugger. +/// +/// # Safety +/// `codec` must point to a registered `AVCodec` (their `name` is a static NUL-terminated +/// string, valid for the process). +pub(crate) unsafe fn codec_name(codec: *const ffmpeg::ffi::AVCodec) -> String { + // SAFETY: caller guarantees a registered AVCodec; `name` is its static C string. + unsafe { + std::ffi::CStr::from_ptr((*codec).name) + .to_string_lossy() + .into_owned() + } +} + /// The `quic` codec bitfield this client can decode — whatever FFmpeg has a decoder for (HEVC/H.264 /// always; AV1 when built in). Advertised to the host so it never emits a codec we can't decode. pub fn decodable_codecs() -> u8 { @@ -435,7 +517,11 @@ impl Decoder { vaapi_tried = true; match VaapiDecoder::new(codec_id) { Ok(v) => { - tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)"); + tracing::info!( + ?codec_id, + decoder = v.name(), + "VAAPI hardware decode active (zero-copy dmabuf)" + ); return done(Backend::Vaapi(v)); } Err(e) => { @@ -470,6 +556,7 @@ impl Decoder { Ok(d) => { tracing::info!( ?codec_id, + decoder = d.name(), "D3D11VA hardware decode active (shared-texture hand-off)" ); return done(Backend::D3d11va(d)); @@ -490,6 +577,7 @@ impl Decoder { Ok(v) => { tracing::info!( ?codec_id, + decoder = v.name(), "Vulkan Video hardware decode active (presenter-shared device)" ); return done(Backend::Vulkan(v)); @@ -520,7 +608,11 @@ impl Decoder { if choice != "software" && choice != "vulkan" && !vaapi_tried { match VaapiDecoder::new(codec_id) { Ok(v) => { - tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)"); + tracing::info!( + ?codec_id, + decoder = v.name(), + "VAAPI hardware decode active (zero-copy dmabuf)" + ); return done(Backend::Vaapi(v)); } Err(e) => { @@ -548,6 +640,7 @@ impl Decoder { Ok(d) => { tracing::info!( ?codec_id, + decoder = d.name(), "D3D11VA hardware decode active (shared-texture hand-off)" ); return done(Backend::D3d11va(d)); @@ -724,6 +817,7 @@ impl Decoder { match VaapiDecoder::new(self.codec_id) { Ok(v) => { tracing::warn!(error = %e, fails = self.vaapi_fails, + decoder = v.name(), "Vulkan Video decode failing repeatedly — demoting to VAAPI"); self.backend = Backend::Vaapi(v); self.vaapi_fails = 0; @@ -745,6 +839,7 @@ impl Decoder { ) { Ok(d) => { tracing::warn!(error = %e, fails = self.vaapi_fails, + decoder = d.name(), "Vulkan Video decode failing repeatedly — demoting to D3D11VA"); self.backend = Backend::D3d11va(d); self.vaapi_fails = 0; diff --git a/crates/pf-client-core/src/video_d3d11.rs b/crates/pf-client-core/src/video_d3d11.rs index f3737ae8..de87e32f 100644 --- a/crates/pf-client-core/src/video_d3d11.rs +++ b/crates/pf-client-core/src/video_d3d11.rs @@ -552,6 +552,10 @@ pub(crate) struct D3d11vaDecoder { /// ([`crate::video::VulkanDecodeDevice::d3d11_hdr10`]) — PQ streams get the HDR /// pass-through ring; without it they keep the tonemap-to-sRGB ring. hdr10_out: bool, + /// The selected decoder's registry name (`(*codec).name`) — `"av1"` vs `"libdav1d"` + /// is the difference between hardware decode and a silent CPU fallback, so every + /// log a field report leans on carries it. + name: String, } // SAFETY: the libav pointers are this decoder's own allocations (freed once in `Drop`) and the COM @@ -609,10 +613,16 @@ impl D3d11vaDecoder { if !d3d11va_decode_supported(hw_device.as_ptr()) { bail!("GPU can't create the D3D11VA decode surface pool"); } - let codec = ffi::avcodec_find_decoder(codec_id.into()); - if codec.is_null() { - bail!("no {codec_id:?} decoder"); - } + // NOT `avcodec_find_decoder`: the ID lookup returns the registry's FIRST + // decoder, and for AV1 that is libdav1d (upstream orders the hwaccel-only + // native decoder last) — a software decoder that silently ignores + // `hw_device_ctx` and fails every frame's D3D11-format guard mid-stream, + // even when the DXVA profile + pool probes above all passed. Select by + // capability instead: the first decoder that can drive AV_PIX_FMT_D3D11 + // via hw_device_ctx, or fail here at open. + let codec = + crate::video::find_hw_decoder(codec_id, ffi::AVPixelFormat::AV_PIX_FMT_D3D11)?; + let name = crate::video::codec_name(codec); let ctx = ffi::avcodec_alloc_context3(codec); (*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr()); (*ctx).get_format = Some(get_format_d3d11); @@ -638,10 +648,16 @@ impl D3d11vaDecoder { video_context1, ring: None, hdr10_out, + name, }) } } + /// The selected decoder's registry name (e.g. `"av1"`) — see the field doc. + pub(crate) fn name(&self) -> &str { + &self.name + } + pub(crate) fn decode(&mut self, au: &[u8]) -> Result> { use ffmpeg::ffi; // SAFETY: `packet`/`frame`/`ctx` are this decoder's own allocations, live for its whole @@ -830,6 +846,7 @@ impl D3d11vaDecoder { src_desc.Height, index, color.is_pq(), + &self.name, ); Ok(D3d11Frame { width, @@ -883,7 +900,15 @@ impl Drop for D3d11vaDecoder { /// One-time dump of the first decoded surface's layout — the forensics for a new GPU/driver. /// `tex_*` is the DXVA-aligned decode surface (>= the frame); the gap is the padding the /// stream source rect excludes. -fn log_layout_once(width: u32, height: u32, tex_w: u32, tex_h: u32, index: u32, pq: bool) { +fn log_layout_once( + width: u32, + height: u32, + tex_w: u32, + tex_h: u32, + index: u32, + pq: bool, + decoder: &str, +) { use std::sync::atomic::{AtomicBool, Ordering}; static ONCE: AtomicBool = AtomicBool::new(true); if ONCE.swap(false, Ordering::Relaxed) { @@ -894,6 +919,7 @@ fn log_layout_once(width: u32, height: u32, tex_w: u32, tex_h: u32, index: u32, tex_h, slice = index, pq, + decoder, "D3D11VA first frame" ); } diff --git a/crates/pf-client-core/src/video_software.rs b/crates/pf-client-core/src/video_software.rs index e8646969..e57bbb4a 100644 --- a/crates/pf-client-core/src/video_software.rs +++ b/crates/pf-client-core/src/video_software.rs @@ -34,6 +34,12 @@ impl SoftwareDecoder { (*raw).thread_count = 0; // auto } let decoder = ctx.decoder().video().context("open video decoder")?; + // Every construction site (session open, preference, mid-stream demotion) says + // which decoder actually opened: for AV1 the ID lookup means libdav1d here — + // deliberately (fastest CPU path; the native `av1` decoder has no software + // path at all) — and the name in the log is what keeps that distinguishable + // from the hardware lanes' capability-selected decoders. + tracing::info!(?codec_id, decoder = codec.name(), "software decoder opened"); Ok(SoftwareDecoder { decoder, sws: None }) } diff --git a/crates/pf-client-core/src/video_vaapi.rs b/crates/pf-client-core/src/video_vaapi.rs index df28c24a..1aa79ec8 100644 --- a/crates/pf-client-core/src/video_vaapi.rs +++ b/crates/pf-client-core/src/video_vaapi.rs @@ -46,6 +46,10 @@ pub(crate) struct VaapiDecoder { hw_device: AvBuffer, packet: *mut ffmpeg::ffi::AVPacket, frame: *mut ffmpeg::ffi::AVFrame, + /// The selected decoder's registry name (`(*codec).name`) — `"av1"` vs `"libdav1d"` + /// is the difference between hardware decode and a silent CPU fallback, so every + /// log a field report leans on carries it. + name: String, } // SAFETY: the three raw pointers (`ctx`, `packet`, `frame`) are allocations this decoder makes in @@ -80,11 +84,15 @@ impl VaapiDecoder { // Owned from here: every `bail!` below drops it, so none of them unref by hand. let hw_device = AvBuffer::from_raw(hw_device) .context("av_hwdevice_ctx_create(VAAPI) gave no device")?; - // The negotiated codec's decoder id (av_codec_id maps 1:1 from ffmpeg::codec::Id). - let codec = ffi::avcodec_find_decoder(codec_id.into()); - if codec.is_null() { - bail!("no {codec_id:?} decoder"); - } + // NOT `avcodec_find_decoder`: the ID lookup returns the registry's FIRST + // decoder, and for AV1 that is libdav1d (upstream orders the hwaccel-only + // native decoder last) — a software decoder that silently ignores + // `hw_device_ctx` and fails every frame's VAAPI-format guard mid-stream. + // Select by capability instead: the first decoder that can drive + // AV_PIX_FMT_VAAPI via hw_device_ctx, or fail here at open. + let codec = + crate::video::find_hw_decoder(codec_id, ffi::AVPixelFormat::AV_PIX_FMT_VAAPI)?; + let name = crate::video::codec_name(codec); let ctx = ffi::avcodec_alloc_context3(codec); (*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr()); (*ctx).get_format = Some(pick_vaapi); @@ -109,10 +117,16 @@ impl VaapiDecoder { hw_device, packet: ffi::av_packet_alloc(), frame: ffi::av_frame_alloc(), + name, }) } } + /// The selected decoder's registry name (e.g. `"av1"`) — see the field doc. + pub(crate) fn name(&self) -> &str { + &self.name + } + pub(crate) fn decode(&mut self, au: &[u8]) -> Result> { use ffmpeg::ffi; // SAFETY: `packet`/`frame`/`ctx` are this decoder's own allocations, live for its whole @@ -207,7 +221,7 @@ impl VaapiDecoder { // a single modifier for the texture. let modifier = d.objects[0].format_modifier; - log_descriptor_once(d, sw_format, fourcc, modifier); + log_descriptor_once(d, sw_format, fourcc, modifier, &self.name); Ok(DmabufFrame { width: (*self.frame).width as u32, @@ -233,6 +247,7 @@ fn log_descriptor_once( sw: ffmpeg_next::ffi::AVPixelFormat, fourcc: u32, modifier: u64, + decoder: &str, ) { use std::sync::atomic::{AtomicBool, Ordering}; static ONCE: AtomicBool = AtomicBool::new(true); @@ -250,6 +265,7 @@ fn log_descriptor_once( nb_layers = d.nb_layers, ?layers, modifier = format_args!("{:#018x}", modifier), + decoder, "VAAPI dmabuf descriptor layout (first frame)" ); } diff --git a/crates/pf-client-core/src/video_vulkan.rs b/crates/pf-client-core/src/video_vulkan.rs index 6650bafc..3aa886e4 100644 --- a/crates/pf-client-core/src/video_vulkan.rs +++ b/crates/pf-client-core/src/video_vulkan.rs @@ -33,6 +33,10 @@ pub(crate) struct VulkanDecoder { /// (resolved through the same get_proc_addr chain FFmpeg uses). wait_semaphores: pf_ffvk::PFN_vkWaitSemaphores, vk_device: pf_ffvk::VkDevice, + /// The selected decoder's registry name (`(*codec).name`) — `"av1"` vs `"libdav1d"` + /// is the difference between hardware decode and a silent CPU fallback, so every + /// log a field report leans on carries it. + name: String, /// Storage `AVVulkanDeviceContext` points into (extension string arrays + the /// feature chain) — FFmpeg reads the extension lists past init (frames-context /// setup keys code paths off them), so this lives exactly as long as `hw_device`. @@ -245,10 +249,15 @@ impl VulkanDecoder { } let vk_device = (*hwctx).act_dev; - let codec = ffi::avcodec_find_decoder(codec_id.into()); - if codec.is_null() { - bail!("no {codec_id:?} decoder"); - } + // NOT `avcodec_find_decoder`: the ID lookup returns the registry's FIRST + // decoder, and for AV1 that is libdav1d (upstream orders the hwaccel-only + // native decoder last) — a software decoder that silently ignores + // `hw_device_ctx` and fails every frame's Vulkan-format guard mid-stream. + // Select by capability instead: the first decoder that can drive + // AV_PIX_FMT_VULKAN via hw_device_ctx, or fail here at open. + let codec = + crate::video::find_hw_decoder(codec_id, ffi::AVPixelFormat::AV_PIX_FMT_VULKAN)?; + let name = crate::video::codec_name(codec); let ctx = ffi::avcodec_alloc_context3(codec); (*ctx).hw_device_ctx = ffi::av_buffer_ref(hw_device.as_ptr()); (*ctx).get_format = Some(pick_vulkan); @@ -270,11 +279,17 @@ impl VulkanDecoder { frame: ffi::av_frame_alloc(), wait_semaphores, vk_device, + name, _ctx_storage: store, }) } } + /// The selected decoder's registry name (e.g. `"av1"`) — see the field doc. + pub(crate) fn name(&self) -> &str { + &self.name + } + pub(crate) fn decode(&mut self, au: &[u8]) -> Result> { use ffmpeg::ffi; // SAFETY: `packet`/`frame`/`ctx` are this decoder's own allocations, live for its whole @@ -388,6 +403,7 @@ impl VulkanDecoder { (*fc).width, (*fc).height, sw, + &self.name, ); Ok(VkVideoFrame { vkframe: vkf as usize, @@ -423,6 +439,7 @@ fn log_layout_once( pool_w: i32, pool_h: i32, sw: ffmpeg::ffi::AVPixelFormat, + decoder: &str, ) { use std::sync::atomic::{AtomicBool, Ordering}; static ONCE: AtomicBool = AtomicBool::new(true); @@ -433,6 +450,7 @@ fn log_layout_once( pool_w, pool_h, ?sw, + decoder, "Vulkan Video first frame" ); } diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index bd4070ce..b84e9c5c 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -204,6 +204,11 @@ struct StreamState { /// mid-stream re-syncs keep the end-to-end number honest after an NTP step / drift. clock_offset: Option>, hdr: bool, + /// The presented lane was the CPU/software one, where a PQ stream is shown RAW — the + /// software path has no tone-map pass at all (the presenter uploads swscale RGBA + /// as-is; the CSC mode-1 tonemap is hardware-lane only) — so the OSD badge reads + /// `HDR→SDR (raw)` there instead of claiming a tone-map that never ran. + hdr_untonemapped: bool, // Presenter-side 1 s window (design/stats-unification.md): end-to-end // capture→displayed (host-clock corrected) p50+p95, display = decoded→displayed p50. win_e2e_us: Vec, @@ -308,6 +313,7 @@ impl StreamState { latch_grid, clock_offset: None, hdr: false, + hdr_untonemapped: false, win_e2e_us: Vec::with_capacity(256), win_disp_us: Vec::with_capacity(256), win_start: Instant::now(), @@ -1103,6 +1109,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result &st.presented, st.hdr, presenter.hdr_active(), + st.hdr_untonemapped, st.profile.as_deref(), ); if stats_verbosity != StatsVerbosity::Off { @@ -1115,6 +1122,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result &st.presented, st.hdr, presenter.hdr_active(), + st.hdr_untonemapped, st.profile.as_deref(), ); println!("stats: {}", full.replace('\n', " | ")); @@ -1296,6 +1304,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result // HDR (PQ) pyrowave session presents through the HDR10 path exactly // like the H.26x codecs (design/pyrowave-444-hdr.md Phase 3). st.hdr = f.color.is_pq(); + st.hdr_untonemapped = false; match presenter.present( &window, FrameInput::PyroWave(f), @@ -1323,6 +1332,9 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result } DecodedImage::Cpu(c) => { st.hdr = c.color.is_pq(); + // The software lane shows PQ raw (no tone-map pass exists there) + // — the OSD badge must not claim `HDR→SDR` for it. + st.hdr_untonemapped = true; presenter.present(&window, FrameInput::Cpu(&c), overlay_frame.as_ref())? } #[cfg(target_os = "linux")] @@ -1330,6 +1342,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result if presenter.supports_dmabuf() && !st.dmabuf_demoted => { st.hdr = d.color.is_pq(); + st.hdr_untonemapped = false; match presenter.present( &window, FrameInput::Dmabuf(d), @@ -1380,6 +1393,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result #[cfg(windows)] DecodedImage::D3d11(d) if presenter.supports_d3d11() && !st.dmabuf_demoted => { st.hdr = d.color.is_pq(); + st.hdr_untonemapped = false; match presenter.present( &window, FrameInput::D3d11(d), @@ -1426,6 +1440,7 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result // demotion contract as the dmabuf path. DecodedImage::VkFrame(v) if !st.dmabuf_demoted => { st.hdr = v.color.is_pq(); + st.hdr_untonemapped = false; match presenter.present( &window, FrameInput::VkFrame(v), @@ -1910,6 +1925,7 @@ fn bump_stats_tier( &st.presented, st.hdr, presenter.hdr_active(), + st.hdr_untonemapped, st.profile.as_deref(), ), None => String::new(), @@ -2007,11 +2023,15 @@ const HINT_WITH_PAD: &str = "Click the stream to capture input · Ctrl+Alt+Shift /// /// The HDR tag is honest about the display path: `HDR` only when the swapchain actually /// runs HDR10 (`hdr_display`); a PQ stream tone-mapped onto an SDR surface (no HDR10 -/// format offered, HDR off in the compositor) shows `HDR→SDR` instead. +/// format offered, HDR off in the compositor) shows `HDR→SDR`; and a PQ stream on the +/// software-decode lane (`hdr_untonemapped`) shows `HDR→SDR (raw)` — that lane has no +/// tone-map pass at all, so the washed-out picture is named for what it is rather than +/// passed off as a tone-map. /// /// `profile` (the session's settings profile, `None` for the global defaults) closes the /// first line at every tier — the cheapest possible answer to "which profile am I on?" /// (design/client-settings-profiles.md §5.2). +#[allow(clippy::too_many_arguments)] fn stats_text( verbosity: StatsVerbosity, mode_line: &str, @@ -2019,6 +2039,7 @@ fn stats_text( p: &PresentedWindow, hdr_stream: bool, hdr_display: bool, + hdr_untonemapped: bool, profile: Option<&str>, ) -> String { let profile_tag = profile.map(|n| format!(" · {n}")).unwrap_or_default(); @@ -2068,6 +2089,7 @@ fn stats_text( if s.decoder.is_empty() { "-" } else { s.decoder }, match (hdr_stream, hdr_display) { (true, true) => " · HDR", + (true, false) if hdr_untonemapped => " · HDR→SDR (raw)", (true, false) => " · HDR→SDR", _ => "", }, @@ -2380,7 +2402,7 @@ mod tests { #[test] fn stats_text_tiers() { let (s, p) = sample(); - let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false, None); + let text = |v| stats_text(v, "1920×1080@120", &s, &p, true, false, false, None); assert_eq!(text(StatsVerbosity::Off), ""); @@ -2397,6 +2419,10 @@ mod tests { let detailed = text(StatsVerbosity::Detailed); assert!(detailed.contains("vulkan · HDR→SDR")); + assert!( + !detailed.contains("(raw)"), + "the hardware lane tone-maps — no raw tag" + ); assert!(detailed.contains("host 1.2 · net 0.9 · decode 1.8 · display 1.1 ms")); assert!(detailed.contains("host: queue 0.3 · encode 0.5 · xfer 0.1 · pace 0.3 ms")); assert!(detailed.contains("lost 3 (0.4%)")); @@ -2406,6 +2432,32 @@ mod tests { ); } + /// The honest HDR badges: a PQ stream on the software-decode lane is shown WITHOUT + /// tone-mapping (that lane has no PQ→sRGB pass), so its badge must not read as the + /// hardware lane's `HDR→SDR` tone-map — and an HDR10 swapchain shows plain `HDR` + /// whatever the lane claims (a CPU frame forces the swapchain to SDR anyway). + #[test] + fn hdr_badge_names_the_untonemapped_cpu_lane() { + let (s, p) = sample(); + let badge = |hdr_display, raw| { + stats_text( + StatsVerbosity::Detailed, + "m", + &s, + &p, + true, + hdr_display, + raw, + None, + ) + }; + assert!(badge(false, true).contains(" · HDR→SDR (raw)")); + assert!(!badge(false, false).contains("(raw)")); + assert!(badge(false, false).contains(" · HDR→SDR")); + assert!(badge(true, false).contains(" · HDR")); + assert!(!badge(true, false).contains("HDR→SDR")); + } + /// Detailed shows the negotiated encoder target next to the measured rate — the /// figure whose absence let the settings-drop bug ship four releases — tagged /// `(auto)` when the ABR owns it, plus the honest chroma tag when 4:4:4 was asked. @@ -2413,7 +2465,7 @@ mod tests { fn detailed_shows_target_and_chroma_resolution() { let (mut s, p) = sample(); let line1 = |s: &Stats, v| { - stats_text(v, "m", s, &p, false, false, None) + stats_text(v, "m", s, &p, false, false, false, None) .lines() .next() .unwrap() @@ -2446,7 +2498,7 @@ mod tests { #[test] fn stats_text_mic_line() { let (mut s, p) = sample(); - let text = |s: &Stats, v| stats_text(v, "m", s, &p, false, false, None); + let text = |s: &Stats, v| stats_text(v, "m", s, &p, false, false, false, None); assert!( !text(&s, StatsVerbosity::Detailed).contains("mic"), "no mic line while the mic is off" @@ -2473,7 +2525,16 @@ mod tests { s.lost = 0; let p = PresentedWindow::default(); assert_eq!( - stats_text(StatsVerbosity::Compact, "m", &s, &p, false, false, None), + stats_text( + StatsVerbosity::Compact, + "m", + &s, + &p, + false, + false, + false, + None + ), "120 fps · 24 Mb/s" ); } @@ -2491,6 +2552,7 @@ mod tests { &p, false, false, + false, Some("Game") ), "120 fps · 6.4 ms · 24 Mb/s · lost 3 · Game" @@ -2502,6 +2564,7 @@ mod tests { &p, false, false, + false, Some("Work"), ); assert_eq!( @@ -2515,13 +2578,22 @@ mod tests { &p, true, true, + false, Some("Work"), ); assert!(detailed.lines().next().unwrap().ends_with("· HDR · Work")); // No profile → the line is exactly what it always was. - assert!( - !stats_text(StatsVerbosity::Normal, "m", &s, &p, false, false, None).contains(" · ") - ); + assert!(!stats_text( + StatsVerbosity::Normal, + "m", + &s, + &p, + false, + false, + false, + None + ) + .contains(" · ")); } #[test] diff --git a/crates/pf-presenter/src/vk/present.rs b/crates/pf-presenter/src/vk/present.rs index 7e5f0693..a8b5bd12 100644 --- a/crates/pf-presenter/src/vk/present.rs +++ b/crates/pf-presenter/src/vk/present.rs @@ -40,7 +40,27 @@ impl Presenter { // PQ→sRGB pass. let frame_pq = match &input { FrameInput::Redraw => None, - FrameInput::Cpu(_) => Some(false), + FrameInput::Cpu(f) => { + // The swapchain answer stays `false` (above) — but a PQ stream on this + // lane is then shown RAW: no PQ→sRGB pass exists here (the CSC mode-1 + // tonemap is hardware-lane only; CPU frames are a straight RGBA upload), + // so the picture is washed out and the pq-downgrade warn below never + // fires. Say so once, or the only trace is an OSD badge. (A process-once + // latch, same idiom as the decoders' first-frame layout dumps — the + // condition is a property of the lane, not of one Presenter.) + if f.color.is_pq() { + use std::sync::atomic::{AtomicBool, Ordering}; + static WARNED: AtomicBool = AtomicBool::new(false); + if !WARNED.swap(true, Ordering::Relaxed) { + tracing::warn!( + "HDR10 (PQ) stream on the software-decode lane — it has no \ + PQ→sRGB pass, so the picture is shown untonemapped (washed \ + out). Hardware decode restores correct colour." + ); + } + } + Some(false) + } #[cfg(target_os = "linux")] FrameInput::Dmabuf(d) => Some(d.color.is_pq()), FrameInput::VkFrame(v) => Some(v.color.is_pq()),