fix(client/decode): AV1 hardware decode stops silently opening libdav1d
apple / swift (push) Successful in 1m27s
ci / rust (push) Successful in 5m28s
android / android (push) Successful in 5m59s
ci / rust-arm64 (push) Successful in 2m24s
ci / web (push) Successful in 1m55s
arch / build-publish (push) Successful in 9m23s
ci / docs-site (push) Successful in 1m45s
deb / build-publish-client-arm64 (push) Successful in 2m23s
windows-host / package (push) Successful in 12m12s
windows-host / winget-source (push) Skipped
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 37s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
release / apple (push) Successful in 12m6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
deb / build-publish (push) Successful in 5m54s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
deb / build-publish-host (push) Successful in 5m19s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m43s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m44s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
windows-host / canary-manifest (push) Successful in 24s
docker / deploy-docs (push) Successful in 45s
docker / builders-arm64cross (push) Successful in 8s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m7s
apple / screenshots (push) Successful in 5m51s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m17s
flatpak / build-publish (push) Successful in 5m38s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m7s

avcodec_find_decoder(id) 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). All three hardware backends
selected by id, so every AV1 session opened libdav1d: a software decoder that
silently ignores hw_device_ctx and never calls get_format. Each frame then
failed the backend's hw-format guard and the session burned the demotion
ladder MID-STREAM — field-logged as 68 Vulkan fails → D3D11VA → 102 fails →
software, ~3 s of black — with "hardware decode active" already printed and
the D3D11 profile/pool probes all green. H.264/HEVC never hit this only
because their native decoders happen to be registered first.

Selection is now by capability: find_hw_decoder walks av_codec_iterate and
takes the first decoder whose avcodec_get_hw_config advertises the backend's
surface via HW_DEVICE_CTX, so a build without a usable hw decoder fails at
OPEN in milliseconds and the ladder runs there — the idiom the D3D11 probes
already follow. Registry order still wins among capable decoders, so
H.264/HEVC select exactly what they always did. The software path keeps the
id lookup on purpose: libdav1d is the fastest CPU AV1, and the native av1
decoder has no software path at all.

Every decode log now carries the selected decoder's name — decoder="av1" vs
decoder="libdav1d" is the whole diagnosis, and no log line said it. The
session log names the WIRE codec and drops the FFmpeg id for PyroWave
(ffmpeg_codec_id's fallthrough claimed codec_id=HEVC for wavelet sessions
that never touch FFmpeg).

The CPU lane also stops passing raw PQ off as a tone-map: software-decoded
frames deliberately never take the HDR10 swapchain, but a PQ stream there was
then shown UNtonemapped (washed out) with no warning — the pq-downgrade warn
keys off the swapchain answer — while the Detailed OSD badge claimed the
"HDR→SDR" tone-map that only the hardware lane's CSC runs. The presenter now
warns once when a PQ CpuFrame arrives, and the badge distinguishes
"HDR→SDR (raw)" (no tone-map pass) from the hardware lane's real "HDR→SDR".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-02 09:54:43 +02:00
co-authored by Claude Fable 5
parent 652abeb397
commit caa47e28e6
8 changed files with 304 additions and 31 deletions
+25 -5
View File
@@ -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.
+97 -2
View File
@@ -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<String> = 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;
+31 -5
View File
@@ -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<Option<D3d11Frame>> {
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"
);
}
@@ -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 })
}
+22 -6
View File
@@ -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<Option<DmabufFrame>> {
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)"
);
}
+22 -4
View File
@@ -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<Option<VkVideoFrame>> {
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"
);
}
+80 -8
View File
@@ -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<Arc<std::sync::atomic::AtomicI64>>,
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<u64>,
@@ -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<Option<Outcome>
&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<Option<Outcome>
&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<Option<Outcome>
// 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<Option<Outcome>
}
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<Option<Outcome>
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<Option<Outcome>
#[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<Option<Outcome>
// 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]
+21 -1
View File
@@ -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 \
PQsRGB 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()),