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
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:
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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)"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user