`ffmpeg-fallback` on pf-client-core, default off on the crate. With it off the libavcodec rungs are not compiled, pf-ffvk leaves the dependency graph, and no ladder or demotion arm names them; with it on each sits exactly where it sits today, directly below its native twin. That is the switch which makes M10 a deletion rather than a redesign. The bake window and the regression criteria are the user's, per the plan, and nothing here claims the M9 gate is met. The hard part was not the feature, it was honesty. Two of the four native rungs have never decoded a frame on any hardware — native VAAPI at all, and native D3D11VA's AV1 leg — and making those the default would assert evidence that does not exist. So admission is per rung and per codec: a pair with hardware evidence joins `auto` always; a pair without it joins only when nothing proven is left below it (a build with no FFmpeg twin, where the alternative is not a proven rung but the CPU) or when the user asks with PUNKTFUNK_NATIVE_FIRST=1. Pins bypass it, so a lab run can still reach any rung. The shipping default therefore changes in exactly three ways, all evidence-backed: AV1 `auto` takes native Vulkan (250/250 bit-identical on an RTX 5070 Ti), Windows H.264/H.265 `auto` takes native D3D11VA above its FFmpeg twin (parity on two GPUs plus a 30-minute soak), and a failing Vulkan rung on Windows demotes to native D3D11VA first. Everything unproven is byte-for-byte as it was. The evidence state is written where it cannot rot: a table in video.rs's module docs, the same facts in code as `native_evidence()`, a test asserting them in both feature states, and a per-session log line carrying the rung, the codec, whether hardware has verified that pair and the evidence string — at WARN when it has not. A support engineer reading a log can now tell proven from assumed without asking anyone. Termination needed a new guarantee. With the FFmpeg twins gone, two native rungs in opposite per-vendor orders could hand a session back and forth forever, so a rung once entered is never re-entered and the walk is monotone to software. The never-delivered fall-through still works: with the feature on it is unchanged, and with it off it is redundant, because the next candidate already IS the rung below. ⚠ ffmpeg-next remains a hard dependency of pf-client-core, deliberately. What is left off-feature is three type-level residues — the codec-id vocabulary, the AVVkFrame guard that is pf-presenter's public import, and a pixel-format in one signature — every one of them an M10 §6 line item. Deleting them here would mean deleting the presenter's FFmpeg lane, 55 call sites, in a milestone whose gates cannot run a GPU. No libavcodec decoder is opened in a default build. ⚠ video_d3d11.rs was gated item by item rather than wholesale, and nothing in this tree compiles it — it needs a Windows check before anyone trusts it. Gates: both feature states, container clippy -D warnings and 158/159 tests, workspace check. The four decode crates are untouched, so the hardware rungs' 250/250 stands.
3720 lines
191 KiB
Rust
3720 lines
191 KiB
Rust
//! Video decode: reassembled access units → frames for the presenter.
|
||
//!
|
||
//! # The ladder (M9: native first)
|
||
//!
|
||
//! Since M9 every `auto` rung is a NATIVE rung — pf-vkdecode, pf-dxvadec, pf-vaadec,
|
||
//! openh264/rav1d — for every codec and on both desktop OSes. The three FFmpeg-backed
|
||
//! rungs still exist, but only when this crate is built with the **`ffmpeg-fallback`**
|
||
//! feature, and then only DIRECTLY BELOW their native counterpart, as the fall-through a
|
||
//! native init failure or error streak lands on. Vendor order is unchanged
|
||
//! ([`VulkanDecodeDevice::prefer_vulkan_first`]):
|
||
//!
|
||
//! | build | Linux, NVIDIA/AMD | Linux, Intel/unknown | Windows, NVIDIA/AMD | Windows, Intel/unknown |
|
||
//! |---|---|---|---|---|
|
||
//! | default (no `ffmpeg-fallback`) | native-vk → native-vaapi → sw | native-vaapi → native-vk → sw | native-vk → native-d3d11va → sw | native-d3d11va → native-vk → sw |
|
||
//! | `ffmpeg-fallback` | native-vk → vk → native-vaapi → vaapi → sw | native-vaapi → vaapi → native-vk → vk → sw | native-vk → vk → native-d3d11va → d3d11va → sw | native-d3d11va → d3d11va → native-vk → vk → sw |
|
||
//!
|
||
//! …with one filter on top, and it is the honest half of the milestone: **a rung that has
|
||
//! never decoded a frame on real hardware does not join `auto` while a proven rung is
|
||
//! still below it** ([`native_rung_admitted`]). So in an `ffmpeg-fallback` build the
|
||
//! rows above are what you get once the unproven rungs are switched in
|
||
//! (`PUNKTFUNK_NATIVE_FIRST=1`); without that switch the unproven rung/codec pairs are
|
||
//! skipped and the ladder is what shipped before M9, plus the pairs that DO have
|
||
//! hardware evidence. In a build without `ffmpeg-fallback` there is nothing proven left
|
||
//! below them, so every native rung is admitted and each session says so in its log.
|
||
//!
|
||
//! # Evidence — which rungs have actually decoded on hardware
|
||
//!
|
||
//! Recorded here because it is the fact that decides admission, and the fact a support
|
||
//! engineer needs when a session log names a rung. [`native_evidence`] is the same table
|
||
//! in code; it is what [`native_rung_admitted`] reads and what every session logs at
|
||
//! decoder construction (`hardware_verified` / `evidence` on the "decode rung active"
|
||
//! line).
|
||
//!
|
||
//! | rung | module | codecs | hardware that has decoded on it |
|
||
//! |---|---|---|---|
|
||
//! | native Vulkan Video | [`crate::video_vk_native`] | H.264 | **yes** — bit-exact vs libavcodec, 250/250 AUs on three drivers + a 92-minute soak (M2 WP-D) |
|
||
//! | native Vulkan Video | | H.265 (Main / Main10 / 4:4:4) | **yes** — same parity run + HDR chain and Deck/VanGogh legs (M3) |
|
||
//! | native Vulkan Video | | AV1 | **yes** — 250/250 bit-identical to libavcodec on an RTX 5070 Ti (M7); ONE vendor, no soak |
|
||
//! | native D3D11VA | [`crate::video_d3d11_native`] | H.264, H.265 | **yes** — frame-hash parity on an RTX 4090 and an AMD iGPU + a 30-minute soak (M5) |
|
||
//! | native D3D11VA | | AV1 | **NO** — has never decoded a frame anywhere (M7 wired it; the box was unavailable) |
|
||
//! | native VAAPI | [`crate::video_vaapi_native`] | H.264, H.265, AV1 | **NO** — has never decoded a frame anywhere (M6/M7; no VAAPI hardware was reachable) |
|
||
//! | software | `video_software` | H.264, AV1 | **NO on glass** — openh264 + rav1d, CPU unit tests only (M8) |
|
||
//!
|
||
//! The software rung's evidence is recorded for the same reason but does not gate
|
||
//! anything: it is the LAST rung, so there is nothing below it to protect.
|
||
//!
|
||
//! # Running the M9 field bake
|
||
//!
|
||
//! The bake window and the regression criteria are the user's call — nothing here
|
||
//! decides them, and nothing here claims the gate is met. What the code offers is two
|
||
//! ways to put a box on the M9 ladder:
|
||
//!
|
||
//! * **With the safety net** (recommended): ship as built today and set
|
||
//! `PUNKTFUNK_NATIVE_FIRST=1` in the session's environment. Every native rung joins
|
||
//! `auto`, the FFmpeg twin stays directly below it, and a rung that misbehaves demotes
|
||
//! into a proven one instead of onto the CPU.
|
||
//! * **Without it** (the M10 preview): build the client with
|
||
//! `--no-default-features` + the features you want minus `ffmpeg-fallback` — e.g.
|
||
//! `cargo build -p punktfunk-client-session --no-default-features --features ui,pyrowave`.
|
||
//! There is no FFmpeg rung anywhere in that binary.
|
||
//!
|
||
//! When the bake passes, the default flip is deleting `ffmpeg-fallback` from
|
||
//! `clients/session` + `pf-presenter`'s default feature lists; M10 then deletes the
|
||
//! feature and the rungs behind it.
|
||
//!
|
||
//! # The rungs
|
||
//!
|
||
//! * **native Vulkan Video** (`video_vk_native`): pf-vkdecode's H.264/H.265/AV1 decoders
|
||
//! on the PRESENTER's own VkDevice — the decoded VkImage feeds its CSC pass directly,
|
||
//! zero copy, no FFmpeg. Admission is [`native_vulkan_gate`].
|
||
//! * **native D3D11VA** (`video_d3d11_native`, Windows): pf-dxvadec plans driven into
|
||
//! `ID3D11VideoDecoder`, filling the same field-proven hand-off ring the FFmpeg rung
|
||
//! uses.
|
||
//! * **native VAAPI** (`video_vaapi_native`, Linux): pf-vaadec plans driven into a
|
||
//! dlopen'd libva, exporting the same DRM-PRIME dmabufs.
|
||
//! * **Vulkan Video** (`ffmpeg-fallback`): FFmpeg's Vulkan decoder on the same shared
|
||
//! device (its handles arrive via [`VulkanDecodeDevice`]); every vendor with the video
|
||
//! extensions (measured 4K@144 with 0.1 ms decode).
|
||
//! * **VAAPI** (`ffmpeg-fallback`, Linux): libavcodec hwaccel mapped to a DRM-PRIME
|
||
//! dmabuf (`av_hwframe_map`, zero copy). NVIDIA has no usable VAAPI
|
||
//! (nvidia-vaapi-driver is broken for this — Moonlight blacklists it); device creation
|
||
//! fails there.
|
||
//! * **D3D11VA** (`ffmpeg-fallback`, Windows): the vendor-agnostic DXVA path every
|
||
//! Windows video player exercises (`crate::video_d3d11`).
|
||
//! * **Software**: the CPU rung, FFmpeg-free since M8 — openh264 for H.264, rav1d
|
||
//! (dav1d) for AV1, planes uploaded straight to the presenter's planar CSC pass. It is
|
||
//! the LAST rung, so it never demotes further; and it has no HEVC decoder at all (none
|
||
//! exists under a permissive licence), which is a REFUSAL that reconnects the session
|
||
//! onto a codec this client can decode — see [`last_rung_verdict`] and
|
||
//! [`NoSoftwareRung`].
|
||
//!
|
||
//! Every FFmpeg rung runs `AV_CODEC_FLAG_LOW_DELAY`; the host encodes zero-reorder
|
||
//! streams (no B-frames, in-band parameter sets on every IDR), so decode is strictly
|
||
//! one-in/one-out on every rung.
|
||
//!
|
||
//! Windows has no VAAPI (DRM-PRIME is a Linux concept) and Linux no DXVA; the vendor
|
||
//! order differs for one reason worth keeping in view: Intel's Windows driver DOES
|
||
//! advertise Vulkan Video (Arc drivers since 2023), but FFmpeg-Vulkan on it strobes and
|
||
//! burns the frame budget (B580 field report, 2026-07) where D3D11VA streams clean — so
|
||
//! Intel/unknown take the DXVA pair first and NVIDIA/AMD keep the Vulkan pair first.
|
||
//! Everything dmabuf-shaped is `cfg(target_os = "linux")`-gated inline.
|
||
//!
|
||
//! # Overrides
|
||
//!
|
||
//! `PUNKTFUNK_DECODER=native-vulkan|native-d3d11va|native-vaapi|software` — and, in an
|
||
//! `ffmpeg-fallback` build, `vulkan|vaapi|d3d11va` for the FFmpeg rungs specifically.
|
||
//! A pin is a pin: it skips the vendor order AND the evidence filter, which is how a lab
|
||
//! run reaches a rung `auto` will not pick. `PUNKTFUNK_NATIVE_FIRST=1` switches the
|
||
//! unproven rungs into `auto` without pinning any of them (see the bake section above).
|
||
|
||
// bindgen's C-enum repr is target-dependent (u32 on Linux/clang, i32 on MSVC), so the
|
||
// pf-ffvk Vulkan flag/enum casts below are required on one platform and no-ops on the
|
||
// other — the lint would fire on whichever platform the cast is a no-op for.
|
||
#![allow(clippy::unnecessary_cast)]
|
||
|
||
// `anyhow!` (the FFmpeg `averr`) and `Context` (libav init) belong to the FFmpeg
|
||
// rungs; `bail!`/`Result` are the ladder's own.
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
use anyhow::{anyhow, Context as _};
|
||
use anyhow::{bail, Result};
|
||
use ffmpeg_next as ffmpeg;
|
||
#[cfg(target_os = "linux")]
|
||
use std::os::fd::RawFd;
|
||
|
||
pub use crate::video_color::{csc_rows, ColorDesc};
|
||
/// Re-exported so the SESSION layer (and its tests) can name the refusal by type — the
|
||
/// module itself stays private, like every other backend's.
|
||
pub use crate::video_software::NoSoftwareRung;
|
||
use crate::video_software::SoftwareDecoder;
|
||
#[cfg(all(target_os = "linux", feature = "ffmpeg-fallback"))]
|
||
use crate::video_vaapi::VaapiDecoder;
|
||
use crate::video_vk_native::{NativeCodec, NativeVulkanDecoder};
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
use crate::video_vulkan::VulkanDecoder;
|
||
|
||
/// One decoded frame headed for the presenter, carrying the host capture timestamp so the
|
||
/// UI can measure capture→displayed latency at the moment it presents.
|
||
pub struct DecodedFrame {
|
||
/// Host-clock capture pts (ns) of the AU this image decoded from — compare against
|
||
/// the local wall clock + `clock_offset_ns` at paintable-set time.
|
||
pub pts_ns: u64,
|
||
/// Local wall clock (ns) when the decoder emitted this image — the `decoded`
|
||
/// measurement point (design/stats-unification.md); the presenter subtracts it from
|
||
/// its paintable-set stamp for the client-local `display` stage.
|
||
pub decoded_ns: u64,
|
||
pub image: DecodedImage,
|
||
}
|
||
|
||
/// Re-exported so consumers (the presenter) name every frame type through `video::`.
|
||
#[cfg(windows)]
|
||
pub use crate::video_d3d11::D3d11Frame;
|
||
|
||
pub enum DecodedImage {
|
||
/// The SOFTWARE rung's output (M8): tightly-packed 8-bit I420 planes for the
|
||
/// presenter to upload and run its planar CSC pass over.
|
||
///
|
||
/// It REPLACES the old `Cpu(CpuFrame)` RGBA variant rather than joining it — there is
|
||
/// exactly one CPU rung, and the swscale conversion it used to carry (and its BT.601
|
||
/// default) is what this milestone deletes. Adding a second CPU variant would have
|
||
/// bought the [`DecodedImage::NativeDmabuf`] property below for a distinction that
|
||
/// does not exist: no `stats:` tag, no presenter path and no consumer would ever have
|
||
/// been able to reach the old one.
|
||
Cpu(CpuPlanarFrame),
|
||
#[cfg(target_os = "linux")]
|
||
Dmabuf(DmabufFrame),
|
||
/// The NATIVE VAAPI rung's output (`pf-vaadec` + `video_vaapi_native`, M6) —
|
||
/// physically the same thing as [`DecodedImage::Dmabuf`], and deliberately the
|
||
/// same payload type, because the import a consumer performs is identical:
|
||
/// dmabuf fds plus a plane layout. It is a separate VARIANT purely so the two
|
||
/// rungs can never be confused for one another.
|
||
///
|
||
/// That is not fastidiousness. Both D3D11VA rungs share one variant (they share
|
||
/// the hand-off ring on purpose), and the consequence had to be fixed in
|
||
/// `1573a987`: the `stats:` decode-path tag is derived from the variant, so a
|
||
/// "native" soak could silently have been an FFmpeg soak, and there was no way
|
||
/// to tell from the log. Here the compiler asks the question instead — every
|
||
/// `match` on `DecodedImage` must say which rung it means.
|
||
#[cfg(target_os = "linux")]
|
||
NativeDmabuf(DmabufFrame),
|
||
/// FFmpeg Vulkan Video output: a VkImage already on the PRESENTER's device.
|
||
///
|
||
/// ⚠ **Unreachable without `ffmpeg-fallback`** — [`crate::video_vulkan`] is its only
|
||
/// producer and that module is not compiled. The VARIANT and [`VkVideoFrame`] stay
|
||
/// unconditional anyway, because they are `pf-presenter`'s public import: its
|
||
/// `vk/present.rs` implements the `AVVkFrame` lock/unlock + `value + 1` write-back
|
||
/// contract against this exact type, over `pf-ffvk` handles of its own. Deleting the
|
||
/// type here is deleting that lane, which M10 does in one move (§6 deletes
|
||
/// `crates/pf-ffvk` too) rather than M9 doing it half-way through a milestone whose
|
||
/// gates cannot run a GPU.
|
||
VkFrame(VkVideoFrame),
|
||
/// D3D11VA output copied into a shareable NT-handle texture the presenter imports
|
||
/// (`VK_KHR_external_memory_win32`) — the DXVA path for GPUs without Vulkan Video
|
||
/// (Intel's Windows driver foremost). See `crate::video_d3d11`.
|
||
#[cfg(windows)]
|
||
D3d11(crate::video_d3d11::D3d11Frame),
|
||
/// PyroWave planar output: three R8 plane views on the presenter's own device,
|
||
/// decode already fence-complete, GENERAL layout — the presenter's planar CSC
|
||
/// samples them directly (BT.709 limited, the codec's fixed colour contract).
|
||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||
PyroWave(crate::video_pyrowave::PyroWavePlanarFrame),
|
||
/// Native Vulkan Video output (pf-vkdecode — auto's H.264/HEVC rung immediately
|
||
/// above FFmpeg-Vulkan, plus M7's pin-only AV1 leg; pinnable via
|
||
/// `PUNKTFUNK_DECODER=native-vulkan`): a decoded image + per-plane views already
|
||
/// on the PRESENTER's device — same zero-copy contract as
|
||
/// [`DecodedImage::VkFrame`], no FFmpeg involved. The picture format is the
|
||
/// stream's, carried on the frame ([`NativeVkFrame::vk_format`] — NV12 for H.264,
|
||
/// HEVC Main and AV1 Main 8-bit, P010 for Main 10, the two-plane 4:4:4 formats for
|
||
/// RExt and AV1 High), never assumed. The presenter waits the frame's timeline
|
||
/// pair, transitions the layer for sampling and BACK to
|
||
/// [`NativeVkFrame::layout`], and releases the decoder's slot by dropping the
|
||
/// frame (its guard sends the release token).
|
||
NativeVk(NativeVkFrame),
|
||
}
|
||
|
||
/// What the decode lane knows about this session's INTEGRITY — M4's telemetry
|
||
/// surface, and the answer to the question that started the whole native-decode
|
||
/// program: "was that stream actually clean, or could nothing here have told us?"
|
||
///
|
||
/// Only the native rung fills it in ([`Decoder::decode_health`] answers `None`
|
||
/// everywhere else), because only the native rung has the two detectors: a
|
||
/// bitstream planner that reports lost references, and a per-op `RESULT_STATUS`
|
||
/// query that reports what the DRIVER thought of the decode. FFmpeg's Vulkan
|
||
/// decoder creates no queries at all (`nb_queries = 0`), never sets
|
||
/// `AV_FRAME_FLAG_CORRUPT`, and reports trouble only as log lines — which is why
|
||
/// the Xbox Ally X corruption was undetectable rather than merely undetected.
|
||
///
|
||
/// Counters are session-cumulative and monotonic; the stats window diffs them the
|
||
/// way it already diffs `frames_dropped`. Nothing here allocates, and nothing here
|
||
/// is computed per frame beyond an add — the whole struct is read once per stats
|
||
/// window.
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||
pub struct DecodeHealth {
|
||
/// AUs whose plan needed CONCEALMENT: a reference the DPB no longer held, a
|
||
/// `frame_num` gap, a NALU walk that stopped early. The picture would have
|
||
/// been decoded from a substitute, so its output was released unshown.
|
||
pub damaged: u64,
|
||
/// Frames the DRIVER reported corrupt through their `RESULT_STATUS_ONLY`
|
||
/// query. Distinct from [`Self::damaged`] on purpose: damaged means the
|
||
/// bitstream arrived incomplete, failed means the hardware could not decode
|
||
/// what did arrive. They have different causes and different fixes, and
|
||
/// collapsing them is how "the stream is fine, it's your GPU" arguments start.
|
||
///
|
||
/// **Structurally 0 where [`Self::status_queries`] is false**, and
|
||
/// [`Self::note`] enforces that rather than trusting its callers: on such a
|
||
/// device `poll_status` still answers `Failed` for a lost device or an
|
||
/// unreadable timeline, and reporting THAT as a driver verdict would point a
|
||
/// support engineer at a verdict the hardware cannot produce ("driver-failed 1
|
||
/// · no driver status" on one line). Those frames still cost a picture, so
|
||
/// they still extend [`Self::run`] — they are just not attributed to a driver
|
||
/// that never spoke.
|
||
pub failed: u64,
|
||
/// AUs the decoder REFUSED outright: a plan error (a parse failure, an AU
|
||
/// outside the punktfunk envelope, a slice against a parameter set never
|
||
/// seen), or a Vulkan/session failure. The decoder produced no picture and
|
||
/// said so with an error.
|
||
///
|
||
/// Counted apart from [`Self::damaged`] because the two mean opposite things
|
||
/// about the RUNG: concealment says the decoder coped with a damaged stream,
|
||
/// refusal says the decoder could not run at all. A rung refusing every AU is
|
||
/// the shape of a host renegotiating outside the envelope — a frozen screen —
|
||
/// and without this counter its stats surface reads exactly like a clean
|
||
/// session, which is the founding failure mode of this whole program.
|
||
pub refused: u64,
|
||
/// Consecutive AUs that produced no showable picture, ending at the latest one
|
||
/// — 0 the moment a clean AU decodes.
|
||
///
|
||
/// This is the field a support engineer reads first, because it separates the
|
||
/// two failure shapes a raw count cannot: `damaged 40 · run 0` is a lossy link
|
||
/// that keeps recovering, `damaged 40 · run 40` is a stream that went down and
|
||
/// never came back. Both look identical as a total.
|
||
pub run: u32,
|
||
/// The longest [`Self::run`] of the session — the worst moment, which a
|
||
/// once-per-second sample of `run` will usually miss entirely.
|
||
pub worst_run: u32,
|
||
/// Frames that decoded CORRECTLY and were then discarded without ever being
|
||
/// shown, because the backend's deliverable queue overflowed
|
||
/// (`video_vk_native::MAX_DELIVERABLE` — a decoder making more pictures
|
||
/// display-ready per access unit than the pump can take one at a time).
|
||
///
|
||
/// Deliberately its own number and not folded into any of the three above:
|
||
/// nothing was damaged, nothing was refused and no driver failed, so counting
|
||
/// it as any of those would put a damage report on a healthy stream — and the
|
||
/// AU it happened on still showed a picture, so it must not extend
|
||
/// [`Self::run`] either. But it cannot be nothing at all: a session quietly
|
||
/// discarding a frame per AU is one running at half the frame rate it thinks
|
||
/// it is, and before this counter existed it read as perfectly clean.
|
||
///
|
||
/// Structurally 0 on every rung but native Vulkan — it is the only one with a
|
||
/// deliverable queue — and not on the session stats line today; the
|
||
/// rate-limited `warn` at the drop site is the field signal, and this is the
|
||
/// number a stats field would read.
|
||
pub dropped: u64,
|
||
/// This device answers per-op decode-status queries
|
||
/// (`queryResultStatusSupport`). When FALSE — RADV, where recording a query
|
||
/// anyway HANGS the VCN ring — [`Self::failed`] can only ever read 0, because
|
||
/// there is no verdict to read: the status degrades to timeline completion,
|
||
/// exactly what FFmpeg knows on every driver. A report that omits this cannot
|
||
/// tell "clean" from "unmeasured", which is the precise shape of the failure
|
||
/// this program exists to end.
|
||
pub status_queries: bool,
|
||
}
|
||
|
||
impl DecodeHealth {
|
||
/// Fold one AU's verdict. `damaged` = its plan needed concealment; `refused` =
|
||
/// the decoder rejected the AU outright (an `Err` out of `decode`); `failed` =
|
||
/// how many PRIOR frames just read a `Failed` decode status.
|
||
///
|
||
/// All three extend the run: a support engineer asking "did it ever recover?"
|
||
/// means the picture, and a refused AU or a driver-failed frame is as absent
|
||
/// from the screen as a concealed one.
|
||
///
|
||
/// The one asymmetry is deliberate and is the whole point of
|
||
/// [`Self::status_queries`]: where the device answers no status queries, a
|
||
/// `Failed` read is NOT a driver verdict — it is the degraded timeline path
|
||
/// (the session generation is gone, the device is lost, the semaphore could
|
||
/// not be read) — so it extends the run without ever being counted as
|
||
/// [`Self::failed`]. Enforced here, at the one place every counter is written,
|
||
/// rather than at each call site, because "clean" and "unmeasured" staying
|
||
/// distinguishable is the invariant this struct exists for.
|
||
pub(crate) fn note(&mut self, damaged: bool, refused: bool, failed: u32) {
|
||
if self.status_queries {
|
||
self.failed = self.failed.saturating_add(u64::from(failed));
|
||
}
|
||
if damaged {
|
||
self.damaged = self.damaged.saturating_add(1);
|
||
}
|
||
if refused {
|
||
self.refused = self.refused.saturating_add(1);
|
||
}
|
||
if damaged || refused || failed > 0 {
|
||
self.run = self.run.saturating_add(1);
|
||
self.worst_run = self.worst_run.max(self.run);
|
||
} else {
|
||
self.run = 0;
|
||
}
|
||
}
|
||
|
||
/// Note one correctly-decoded frame discarded unshown — see [`Self::dropped`].
|
||
///
|
||
/// Separate from [`Self::note`] because it is not an AU verdict: several frames
|
||
/// can be dropped within one access unit, and the access unit itself may well
|
||
/// have shipped a picture. It touches nothing but its own counter, and in
|
||
/// particular never [`Self::run`], which answers "did the picture come back"
|
||
/// and here it did.
|
||
pub(crate) fn note_dropped(&mut self) {
|
||
self.dropped = self.dropped.saturating_add(1);
|
||
}
|
||
}
|
||
|
||
/// A raw `VkFormat` code point, carried across the ash-free boundary.
|
||
///
|
||
/// A newtype rather than a bare `i32` because the two hardware frame types
|
||
/// ([`VkVideoFrame`], [`NativeVkFrame`]) carry OTHER `i32`s — `poc` foremost — and
|
||
/// the presenter's colour-math lookup takes exactly one number. Handed the wrong
|
||
/// one it compiles, warns once about an unmapped format, and renders every frame of
|
||
/// the session as 8-bit: decoded correctly, displayed wrong, silently. The wrapper
|
||
/// makes that a type error instead.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||
pub struct RawVkFormat(pub i32);
|
||
|
||
/// Every picture format the NATIVE decode lane can deliver, as raw `VkFormat` code
|
||
/// points — pf-vkdecode's own [`pf_vkdecode::OUTPUT_FORMATS`] vocabulary, not a copy
|
||
/// of it.
|
||
///
|
||
/// It is public so the PRESENTER can pin its per-format colour-math table against the
|
||
/// real producer. pf-presenter has no pf-vkdecode dependency, so without this its only
|
||
/// available check is the FFmpeg lane's table against itself — which stays green if
|
||
/// pf-vkdecode grows a fifth output format (12-bit RExt) that the CSC pass has no
|
||
/// depth mapping for. This crate sees both, so the fact crosses here.
|
||
pub fn native_picture_formats() -> Vec<RawVkFormat> {
|
||
pf_vkdecode::OUTPUT_FORMATS
|
||
.iter()
|
||
.map(|f| RawVkFormat(f.as_raw()))
|
||
.collect()
|
||
}
|
||
|
||
/// One Vulkan-decoded frame. The image lives on the presenter's own VkDevice (the
|
||
/// decoder was built over its handles), so presenting is: plane views → CSC pass — no
|
||
/// import, no copy. The live synchronization state (layout / timeline value / owning
|
||
/// queue family) is deliberately NOT snapshotted here: FFmpeg updates it per submission,
|
||
/// so the presenter reads it through `vkframe` under the frames-context lock at ITS
|
||
/// submit time (the `AVVulkanFramesContext.lock_frame` contract).
|
||
pub struct VkVideoFrame {
|
||
/// `AVVkFrame*` — img[0] is the (multiplanar) image; sem/sem_value/layout/
|
||
/// queue_family are the live sync state. Valid while `guard` lives.
|
||
pub vkframe: usize,
|
||
/// `AVHWFramesContext*` (FFmpeg's) — the first argument to the lock functions.
|
||
/// Valid while `guard` lives.
|
||
pub frames_ctx: usize,
|
||
/// `AVVulkanFramesContext.lock_frame` / `.unlock_frame` (filled in by FFmpeg's
|
||
/// init): the presenter MUST hold the lock while reading the live sync state and
|
||
/// writing back the incremented semaphore value around its submission.
|
||
pub lock_frame: usize,
|
||
pub unlock_frame: usize,
|
||
/// The frame pool's VkFormat (`AVVulkanFramesContext.format[0]`) — the
|
||
/// multiplanar format the presenter builds its per-plane views against.
|
||
pub vk_format: RawVkFormat,
|
||
/// The frame's timeline semaphore (raw VkSemaphore; creation-constant) and the
|
||
/// value FFmpeg's decode submission signals on completion — the pump waits this
|
||
/// pair AFTER shipping the frame to measure true GPU decode time (zero pipeline
|
||
/// cost: the presenter already waits the same pair on the GPU).
|
||
pub timeline_sem: u64,
|
||
pub decode_done_value: u64,
|
||
pub width: u32,
|
||
pub height: u32,
|
||
/// The decode POOL's allocated extent (`AVHWFramesContext.width`/`.height`) — the
|
||
/// CODED picture size (rounded up to the codec's macroblock alignment, then to the
|
||
/// driver's Vulkan picture-access granularity), so it is `>=` `width`/`height`. At
|
||
/// 1080p the pool is 1088 rows tall: 1080 is not a multiple of 16.
|
||
///
|
||
/// The presenter samples this image with NORMALIZED coordinates, so it needs both
|
||
/// numbers — `width`/`height` is what to display, `coded_*` is what the texture
|
||
/// actually spans. Sampling `0..1` without the ratio stretches the alignment padding
|
||
/// into view; because encoders fill those rows by replicating the picture's last
|
||
/// line, that reads as the bottom row smeared over the final few rows of the image
|
||
/// (field report 2026-07-31). Same class as the D3D11VA source-rect clamp in
|
||
/// `crate::video_d3d11`, which shows as a green bar there only because DXVA padding
|
||
/// is left uninitialized rather than replicated.
|
||
pub coded_width: u32,
|
||
pub coded_height: u32,
|
||
pub color: ColorDesc,
|
||
/// Intra keyframe (IDR/I): the stream's re-anchor point. The pump resumes display on
|
||
/// one after suppressing the concealed frames a reference loss leaves in its wake (on
|
||
/// RADV a lost reference decodes to a gray plate with the new motion painted on top).
|
||
pub keyframe: bool,
|
||
/// Keeps the cloned AVFrame (and through it the VkImage + frames context) alive
|
||
/// until the presenter's fence proves the GPU reads done — same mechanism as the
|
||
/// VAAPI path's DRM guard.
|
||
pub guard: DrmFrameGuard,
|
||
}
|
||
|
||
/// The layout a [`NativeVkFrame`]'s image layer is in when its semaphore signals —
|
||
/// pf-client-core's ash-free mirror of the two decode layouts, so the presenter can
|
||
/// transition for sampling and back without this crate naming `vk::ImageLayout`.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum NativeVkLayout {
|
||
/// `VIDEO_DECODE_DST_KHR` — distinct-mode output; the layer holds ONLY this
|
||
/// picture and the next decode into the slot discards it (UNDEFINED-old-layout).
|
||
DecodeDst,
|
||
/// `VIDEO_DECODE_DPB_KHR` — coincide-mode output: the picture IS a DPB slot and
|
||
/// may still be a live reference, so a consumer that transitions it for sampling
|
||
/// MUST transition it back to this layout in the same submission.
|
||
DecodeDpb,
|
||
}
|
||
|
||
/// The release token a presented/dropped [`NativeVkFrame`] hands back to the native
|
||
/// decode backend: `seq` names the shipped frame, `generation` the decoder session it
|
||
/// belongs to (a stale generation routes to the decoder's graveyard — retired pools
|
||
/// die on their last token), and `presented` reports whether the presenter SAMPLED
|
||
/// the image — i.e. whether its submission enqueued the frame's `value + 1` timeline
|
||
/// signal (the AVVkFrame write-back the decoder must wait before reusing the image).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct NativeReleaseToken {
|
||
pub seq: u64,
|
||
pub generation: u64,
|
||
/// The presenter's sampling submission (with its `value + 1` signal) was
|
||
/// enqueued for this frame. `false` for frames dropped unpresented
|
||
/// (newest-wins displacement, demotion drain, failed submit).
|
||
pub presented: bool,
|
||
}
|
||
|
||
/// Sends the frame's [`NativeReleaseToken`] exactly once, on drop — the native path's
|
||
/// analog of the VAAPI/VkFrame `DrmFrameGuard`s. The presenter holds the frame (and so
|
||
/// this guard) until its sampling submission's fence has been waited, which makes
|
||
/// "guard dropped" equal "the GPU is done with the image"; a frame dropped UNPRESENTED
|
||
/// (newest-wins displacement, demotion drain) releases through the very same drop. A
|
||
/// dead channel (the backend was demoted/rebuilt) is ignored — the decoder that owned
|
||
/// the slot is gone.
|
||
pub struct NativeReleaseGuard {
|
||
tx: std::sync::mpsc::Sender<NativeReleaseToken>,
|
||
token: Option<NativeReleaseToken>,
|
||
}
|
||
|
||
impl NativeReleaseGuard {
|
||
pub(crate) fn new(
|
||
tx: std::sync::mpsc::Sender<NativeReleaseToken>,
|
||
token: NativeReleaseToken,
|
||
) -> Self {
|
||
Self {
|
||
tx,
|
||
token: Some(token),
|
||
}
|
||
}
|
||
|
||
/// Record that the sampling submission — including the frame's `value + 1`
|
||
/// timeline signal — was enqueued. The presenter calls this exactly when its
|
||
/// submit succeeded; the token then tells the decoder to wait that write-back
|
||
/// before the image's next use.
|
||
pub fn mark_presented(&mut self) {
|
||
if let Some(token) = &mut self.token {
|
||
token.presented = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Drop for NativeReleaseGuard {
|
||
fn drop(&mut self) {
|
||
if let Some(token) = self.token.take() {
|
||
let _ = self.tx.send(token);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One natively decoded frame (pf-vkdecode). Everything is raw `u64`/plain data — this
|
||
/// crate stays ash-free, exactly like [`VulkanDecodeDevice`]. The handles BORROW the
|
||
/// decoder's pools: valid until the frame is released (the guard's drop) AND the
|
||
/// decoder generation they carry is current — the backend keeps the decoder alive
|
||
/// until every shipped frame's token has come back (bounded), so the presenter never
|
||
/// has to validate liveness itself.
|
||
pub struct NativeVkFrame {
|
||
/// The decode image (raw `VkImage`); the picture occupies array layer [`Self::layer`].
|
||
pub image: u64,
|
||
/// The picture's own `VkFormat` (same shape as [`VkVideoFrame::vk_format`]):
|
||
/// what the image was created with and what [`Self::plane_views`] alias.
|
||
///
|
||
/// Read it, never infer it from the codec. H.264 in this program is the 8-bit
|
||
/// 4:2:0 envelope, so its frames are always NV12 — but an H.265 session's format
|
||
/// is the STREAM's (Main → NV12, Main 10 → P010, RExt 4:4:4 → the two-plane 4:4:4
|
||
/// formats) and can change mid-stream when the host renegotiates. The presenter
|
||
/// derives the CSC pass's bit depth and MSB-packing factor from this; an assumed
|
||
/// 8 bits over a P010 surface decodes correctly and displays wrong, which is the
|
||
/// failure class this program exists to refuse.
|
||
pub vk_format: RawVkFormat,
|
||
/// Per-plane views (raw `VkImageView`s) in the formats pf-vkdecode resolves for
|
||
/// [`Self::vk_format`] — `R8`/`R8G8` for the 8-bit families, `R10X6`/`R10X6G10X6`
|
||
/// for the 10-bit ones — the presenter's planar CSC sampling contract, same shape
|
||
/// as the FFmpeg path's derived plane views.
|
||
pub plane_views: [u64; 2],
|
||
pub layer: u32,
|
||
/// The layout the layer is in when the semaphore signals; the presenter must
|
||
/// return it there after sampling (see [`NativeVkLayout`]).
|
||
pub layout: NativeVkLayout,
|
||
/// Timeline pair (raw `VkSemaphore` + value): pixels are ready when the semaphore
|
||
/// reaches the value — the presenter waits it on the GPU (submit wait list, like
|
||
/// the AVVkFrame path), never on the host.
|
||
pub semaphore: u64,
|
||
pub semaphore_value: u64,
|
||
/// The decoder session generation the handles belong to (rides the release token).
|
||
pub generation: u64,
|
||
/// Display size (the conformance-window crop) — what [`DecodedImage::dimensions`]
|
||
/// reports and what the presenter shows.
|
||
pub width: u32,
|
||
pub height: u32,
|
||
/// The image's allocated/coded extent (`>=` display) — the presenter scales its
|
||
/// sampling UVs by display/coded per axis or the alignment padding smears into
|
||
/// view (the 1088-row lesson; same contract as [`VkVideoFrame::coded_width`]).
|
||
pub coded_width: u32,
|
||
pub coded_height: u32,
|
||
/// Crop origin within the coded picture. Punktfunk hosts emit origin crops only;
|
||
/// the presenter's UV-scale path assumes (0,0) and a nonzero origin would show the
|
||
/// wrong window — carried so that assumption is checkable, not silent.
|
||
pub crop_x: u32,
|
||
pub crop_y: u32,
|
||
/// Colour signalling, read from the SPS active for THIS picture (the H.264/H.265
|
||
/// VUI → H.273 code points, with E.2.1's "unspecified" inference where the VUI is
|
||
/// silent) — per frame, like the FFmpeg rungs' AVFrame CICP, because the host
|
||
/// switches HDR in-band; "unspecified" resolves to the BT.709-limited SDR
|
||
/// default (`csc_rows`' documented fallback).
|
||
pub color: ColorDesc,
|
||
/// IDR — the stream's re-anchor point (the pump's post-loss resume signal). Truly
|
||
/// IDR: on H.265 a CRA/BLA does NOT set this (pf-bitstream keys it off the NALU
|
||
/// type), which costs nothing against punktfunk hosts — they emit IDR-only
|
||
/// re-entry points — and is the conservative direction anyway, since a CRA's
|
||
/// leading pictures may be undecodable.
|
||
pub keyframe: bool,
|
||
pub poc: i32,
|
||
/// What this frame's AU said about intra-refresh RECOVERY, read out of the
|
||
/// bitstream's own recovery point SEI (pf-vkdecode's `RecoveryWatch`).
|
||
///
|
||
/// [`Self::keyframe`] cannot answer for an intra-refresh session — the wave
|
||
/// never emits an IDR — so without this the pump has no clean point to lift a
|
||
/// post-loss freeze on and holds the last good picture until its 500 ms
|
||
/// backstop forces the very IDR the wave exists to avoid. The wire's
|
||
/// `USER_FLAG_RECOVERY_POINT` says the same thing when the host sets it, which
|
||
/// only one of the three wave-running encoder backends does (Linux
|
||
/// libav-NVENC); this is the same fact taken from the stream instead of from
|
||
/// the host, and it cannot be lost separately from the picture. Fed to
|
||
/// [`ReanchorGate::on_local_recovery`](punktfunk_core::reanchor::ReanchorGate::on_local_recovery).
|
||
pub recovery: punktfunk_core::reanchor::LocalRecovery,
|
||
/// This picture's position in DECODE order (pf-vkdecode's strictly increasing
|
||
/// per-session ordinal). Delivery order is not decode order: after a failed AU
|
||
/// the H.265 decoder flushes its DPB, handing back every buffered picture at
|
||
/// once — pictures decoded BEFORE the loss, carrying the recovery marks of the
|
||
/// wave they were decoded in. Arriving after the pump armed its freeze, those
|
||
/// marks would lift it on a heal that completed before the loss. The pump
|
||
/// stamps this ordinal at every arm and ignores [`Self::recovery`] from
|
||
/// anything older.
|
||
pub decode_order: u64,
|
||
/// Sends the release token on drop — see [`NativeReleaseGuard`].
|
||
pub guard: NativeReleaseGuard,
|
||
}
|
||
|
||
/// True if the decoder tagged this frame as a full IDR keyframe — a guaranteed clean re-anchor
|
||
/// after which the picture is loss-free, so the pump can lift a post-loss display freeze here.
|
||
///
|
||
/// Keys off `AV_FRAME_FLAG_KEY` (with `pict_type == I` as a belt for decoders that fill pict_type
|
||
/// but not the flag). NOTE: FFmpeg's H.264/HEVC decode layer sets this flag **only for true IDR
|
||
/// frames**, never for an *intra-refresh recovery point*. H.264 flags key only when a picture's
|
||
/// `recovery_frame_cnt == 0` (a moving band uses `> 0`); HEVC clears the flag on every non-IRAP
|
||
/// frame regardless of the recovery-point SEI. So an intra-refresh host (NVENC/AMF/QSV) heals the
|
||
/// picture over N P-frames with no decoded frame ever flagged key — this function cannot detect
|
||
/// that clean point, and the pump would freeze until the `REANCHOR_FREEZE_MAX` backstop (in
|
||
/// `session.rs`) forces a real IDR. Detecting an intra-refresh re-anchor requires an out-of-band
|
||
/// host wire signal on the AU that completes the wave; that is not yet plumbed.
|
||
///
|
||
/// # Safety
|
||
/// `frame` must point to a valid `AVFrame` alive for the duration of the call.
|
||
///
|
||
/// FFmpeg rungs only (`ffmpeg-fallback`): every native rung reads the NALU type out of
|
||
/// the bitstream through pf-bitstream, which is both earlier and — for intra refresh —
|
||
/// answerable at all.
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
pub unsafe fn frame_is_keyframe(frame: *const ffmpeg::ffi::AVFrame) -> bool {
|
||
// SAFETY: caller guarantees a live AVFrame; plain field reads.
|
||
unsafe {
|
||
((*frame).flags & ffmpeg::ffi::AV_FRAME_FLAG_KEY) != 0
|
||
|| (*frame).pict_type == ffmpeg::ffi::AVPictureType::AV_PICTURE_TYPE_I
|
||
}
|
||
}
|
||
|
||
impl DecodedImage {
|
||
/// Whether the frame is an intra keyframe — see [`frame_is_keyframe`]. The pump uses
|
||
/// this as the stream's re-anchor signal after a loss.
|
||
pub fn is_keyframe(&self) -> bool {
|
||
match self {
|
||
DecodedImage::Cpu(f) => f.keyframe,
|
||
#[cfg(target_os = "linux")]
|
||
DecodedImage::Dmabuf(f) | DecodedImage::NativeDmabuf(f) => f.keyframe,
|
||
DecodedImage::VkFrame(f) => f.keyframe,
|
||
#[cfg(windows)]
|
||
DecodedImage::D3d11(f) => f.keyframe,
|
||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||
DecodedImage::PyroWave(f) => f.keyframe,
|
||
DecodedImage::NativeVk(f) => f.keyframe,
|
||
}
|
||
}
|
||
|
||
/// What the decoder's OWN bitstream parser saw about an intra-refresh heal on
|
||
/// this frame's AU — the recovery point SEI, which no platform decoder exposes.
|
||
///
|
||
/// Only the rungs with their OWN parser can answer: libavcodec parses the SEI
|
||
/// internally and surfaces nothing of it (its `AV_FRAME_FLAG_KEY` is IDR-only),
|
||
/// MediaCodec and VideoToolbox likewise. That is the native Vulkan rung and — since
|
||
/// M8 — the CPU rung's H.264 leg, which plans every AU with the same `H264Planner`
|
||
/// and folds the SEI with the same `RecoveryWatch`. Everyone else reports
|
||
/// [`LocalRecovery::NONE`](punktfunk_core::reanchor::LocalRecovery::NONE) and
|
||
/// the pump's re-anchor behaviour on those lanes is byte-for-byte what it was.
|
||
///
|
||
/// ⚠ The CPU rung reports no [`Self::decode_order`], so its mark cannot be dated
|
||
/// against the pump's arm the way the native rung's is. It does not need to be:
|
||
/// openh264 is one-AU-in, at-most-one-picture-out with no DPB flush that replays
|
||
/// pictures decoded before a loss, which is the only thing that ordinal defends
|
||
/// against.
|
||
pub fn local_recovery(&self) -> punktfunk_core::reanchor::LocalRecovery {
|
||
match self {
|
||
DecodedImage::NativeVk(f) => f.recovery,
|
||
DecodedImage::Cpu(f) => f.recovery,
|
||
_ => punktfunk_core::reanchor::LocalRecovery::NONE,
|
||
}
|
||
}
|
||
|
||
/// This frame's position in DECODE order, where the lane knows one — see
|
||
/// [`NativeVkFrame::decode_order`]. `None` everywhere else, which is what the
|
||
/// pump reads as "this lane reports no local recovery either, so there is
|
||
/// nothing to date-stamp".
|
||
pub fn decode_order(&self) -> Option<u64> {
|
||
match self {
|
||
DecodedImage::NativeVk(f) => Some(f.decode_order),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The decoded image's pixel dimensions. The presenter's resize indicator uses these
|
||
/// as the mid-stream-resize END signal: a frame arriving at the target size means the
|
||
/// new-mode picture is on glass (the ack alone lands before the host's rebuild does).
|
||
pub fn dimensions(&self) -> (u32, u32) {
|
||
match self {
|
||
DecodedImage::Cpu(f) => (f.width, f.height),
|
||
#[cfg(target_os = "linux")]
|
||
DecodedImage::Dmabuf(f) | DecodedImage::NativeDmabuf(f) => (f.width, f.height),
|
||
DecodedImage::VkFrame(f) => (f.width, f.height),
|
||
#[cfg(windows)]
|
||
DecodedImage::D3d11(f) => (f.width, f.height),
|
||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||
DecodedImage::PyroWave(f) => (f.width, f.height),
|
||
DecodedImage::NativeVk(f) => (f.width, f.height),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One software-decoded picture as 8-bit 4:2:0 PLANES (M8) — Y, Cb and Cr back to back
|
||
/// in one allocation, every plane tightly packed at its own width.
|
||
///
|
||
/// "Tightly packed" is a load-bearing invariant, not a convenience: the presenter uploads
|
||
/// the buffer with a single `copy_nonoverlapping` and three `vkCmdCopyBufferToImage`
|
||
/// regions with `bufferRowLength = 0`, so a padded row here would shear the picture. The
|
||
/// decoders' own strides (openh264 pads for SIMD, dav1d aligns) are undone once, in
|
||
/// [`Self::from_i420`], which is also the only copy this rung makes per frame — where the
|
||
/// old RGBA path made a full swscale conversion pass and then handed over 4 bytes per
|
||
/// pixel instead of 1.5.
|
||
///
|
||
/// Colour is NOT applied here. The planes carry the stream's own Y′CbCr and `color`
|
||
/// carries what the bitstream said about it; the presenter's planar CSC shader converts
|
||
/// with [`csc_rows`], the same coefficients every hardware rung's frames go through. That
|
||
/// is the whole point of the milestone: there is no second CSC implementation on this
|
||
/// lane to get the matrix or the range wrong.
|
||
pub struct CpuPlanarFrame {
|
||
pub width: u32,
|
||
pub height: u32,
|
||
/// Y, then Cb, then Cr — see [`Self::plane`].
|
||
data: Vec<u8>,
|
||
/// Byte offset of each plane's first row in [`Self::data`].
|
||
offsets: [usize; 3],
|
||
/// Signalling of the source frame, read from the bitstream (not from the decoder —
|
||
/// see `video_software`'s module docs). Drives the CSC matrix/range AND, for a PQ
|
||
/// stream, the presenter's tone-map mode.
|
||
pub color: ColorDesc,
|
||
/// Intra keyframe (IDR) — the pump's post-loss re-anchor signal. See [`VkVideoFrame`].
|
||
pub keyframe: bool,
|
||
/// What this frame's AU said about intra-refresh RECOVERY — the same
|
||
/// `pf-vkdecode` [`RecoveryWatch`](pf_vkdecode::RecoveryWatch) fold the native rung
|
||
/// runs, over the same `AuPlan`. [`Self::keyframe`] cannot answer for an
|
||
/// intra-refresh session (the wave emits no IDR), so without this the pump freezes
|
||
/// until its 500 ms backstop forces the very IDR the wave exists to avoid.
|
||
///
|
||
/// H.264 only: AV1 carries no equivalent SEI (see `video_software`'s AV1 leg), so
|
||
/// that half reports [`LocalRecovery::NONE`](punktfunk_core::reanchor::LocalRecovery)
|
||
/// and behaves exactly as it did.
|
||
pub recovery: punktfunk_core::reanchor::LocalRecovery,
|
||
}
|
||
|
||
impl CpuPlanarFrame {
|
||
/// Chroma plane size for 4:2:0, rounding UP — an odd luma dimension still has a
|
||
/// chroma sample covering its last row/column, and rounding down would drop it.
|
||
pub fn chroma_dims(width: u32, height: u32) -> (u32, u32) {
|
||
(width.div_ceil(2), height.div_ceil(2))
|
||
}
|
||
|
||
/// Plane `i` (0 = Y, 1 = Cb, 2 = Cr), tightly packed.
|
||
pub fn plane(&self, i: usize) -> &[u8] {
|
||
let (w, h) = self.plane_dims(i);
|
||
let start = self.offsets[i];
|
||
&self.data[start..start + (w * h) as usize]
|
||
}
|
||
|
||
/// Plane `i`'s size in samples — `(width, height)` for luma, the 4:2:0 halves for
|
||
/// chroma. The presenter sizes its plane images from this.
|
||
pub fn plane_dims(&self, i: usize) -> (u32, u32) {
|
||
if i == 0 {
|
||
(self.width, self.height)
|
||
} else {
|
||
Self::chroma_dims(self.width, self.height)
|
||
}
|
||
}
|
||
|
||
/// Copy a decoder's strided I420 output into one tightly-packed allocation.
|
||
///
|
||
/// Refuses rather than truncates: a plane the decoder reported shorter than its own
|
||
/// geometry means the decoder and we disagree about the picture, and reading the rows
|
||
/// that ARE there would produce a plausible-looking picture over uninitialized
|
||
/// memory.
|
||
pub(crate) fn from_i420(
|
||
width: u32,
|
||
height: u32,
|
||
planes: [&[u8]; 3],
|
||
strides: [usize; 3],
|
||
color: ColorDesc,
|
||
keyframe: bool,
|
||
recovery: punktfunk_core::reanchor::LocalRecovery,
|
||
) -> Result<CpuPlanarFrame> {
|
||
anyhow::ensure!(width > 0 && height > 0, "empty picture {width}x{height}");
|
||
let (cw, ch) = Self::chroma_dims(width, height);
|
||
let dims = [(width, height), (cw, ch), (cw, ch)];
|
||
let total: usize = dims.iter().map(|(w, h)| *w as usize * *h as usize).sum();
|
||
let mut data = vec![0u8; total];
|
||
let mut offsets = [0usize; 3];
|
||
let mut at = 0usize;
|
||
for i in 0..3 {
|
||
let (w, h) = (dims[i].0 as usize, dims[i].1 as usize);
|
||
anyhow::ensure!(
|
||
strides[i] >= w,
|
||
"plane {i}: stride {} is narrower than {w} samples",
|
||
strides[i]
|
||
);
|
||
anyhow::ensure!(
|
||
planes[i].len() >= (h - 1) * strides[i] + w,
|
||
"plane {i}: decoder reported {} bytes for {w}x{h} at stride {}",
|
||
planes[i].len(),
|
||
strides[i]
|
||
);
|
||
offsets[i] = at;
|
||
for row in 0..h {
|
||
let src = row * strides[i];
|
||
data[at..at + w].copy_from_slice(&planes[i][src..src + w]);
|
||
at += w;
|
||
}
|
||
}
|
||
Ok(CpuPlanarFrame {
|
||
width,
|
||
height,
|
||
data,
|
||
offsets,
|
||
color,
|
||
keyframe,
|
||
recovery,
|
||
})
|
||
}
|
||
}
|
||
|
||
/// A decoded frame still on the GPU: dmabuf fds + plane layout for
|
||
/// `GdkDmabufTextureBuilder`. The fds belong to `guard`'s mapped DRM frame — they stay
|
||
/// valid until the guard drops (the texture's release func).
|
||
#[cfg(target_os = "linux")]
|
||
pub struct DmabufFrame {
|
||
pub width: u32,
|
||
pub height: u32,
|
||
/// Combined DRM fourcc of the whole surface (NV12 for 8-bit VAAPI output), derived
|
||
/// from the decoder's software format — NOT the per-plane component formats.
|
||
pub fourcc: u32,
|
||
pub modifier: u64,
|
||
pub planes: Vec<DmabufPlane>,
|
||
/// Signaling of the source frame — drives the `GdkDmabufTexture` color state (BT.709
|
||
/// narrow for SDR, BT.2020 PQ for an HDR stream).
|
||
pub color: ColorDesc,
|
||
/// Intra keyframe (IDR/I) — the pump's post-loss re-anchor signal. See [`VkVideoFrame`].
|
||
pub keyframe: bool,
|
||
pub guard: DrmFrameGuard,
|
||
}
|
||
|
||
#[cfg(target_os = "linux")]
|
||
pub struct DmabufPlane {
|
||
pub fd: RawFd,
|
||
pub offset: u32,
|
||
pub stride: u32,
|
||
}
|
||
|
||
/// Keeps a decoded surface alive until the consumer's GPU reads are done: dropping
|
||
/// it releases the surface back to its decoder's pool and closes the fds.
|
||
///
|
||
/// The consumer treats this as opaque — the presenter dups every dmabuf fd it
|
||
/// imports and simply holds the guard until its fence has been waited — so the only
|
||
/// thing the two variants differ in is WHO owns the surface. libavcodec's rungs hand
|
||
/// over a mapped `AVFrame`; the native VAAPI rung (`video_vaapi_native`, M6) owns a
|
||
/// `VASurface` from its own pool and has no `AVFrame` at all, which is precisely the
|
||
/// seam that had to be widened for it to exist. M10 deletes the FFmpeg variant and
|
||
/// this enum collapses again.
|
||
pub enum DrmFrameGuard {
|
||
/// A mapped DRM-PRIME `AVFrame` — the FFmpeg VAAPI hwaccel — or the cloned
|
||
/// `AVFrame` behind an `AVVkFrame` on the FFmpeg Vulkan path.
|
||
Av(*mut ffmpeg::ffi::AVFrame),
|
||
/// The native VAAPI rung's own owner: closes the exported PRIME fds and returns
|
||
/// the surface to the decoder's pool.
|
||
#[cfg(target_os = "linux")]
|
||
NativeVa(crate::video_vaapi_native::VaFrameGuard),
|
||
}
|
||
// SAFETY: the `Av` variant owns one `AVFrame` and frees it exactly once in `Drop`. libav's buffer
|
||
// refcounts are atomic and its hwframe pool is internally locked, so releasing the frame — and with
|
||
// it the VAAPI surface, back to the decoder's pool — from a different thread than the one that
|
||
// mapped it is sound. That is the whole point here: the guard is handed to GTK and dropped on the
|
||
// main thread while the pump thread keeps decoding. Moved, never shared; deliberately NOT `Sync`.
|
||
// The `NativeVa` variant is `Send` on its own (owned fds plus an `mpsc::Sender`) and needs no
|
||
// promise from here.
|
||
unsafe impl Send for DrmFrameGuard {}
|
||
|
||
impl Drop for DrmFrameGuard {
|
||
fn drop(&mut self) {
|
||
match self {
|
||
// SAFETY: this is the one `AVFrame` the guard owns; `av_frame_free` releases it
|
||
// exactly once (this `Drop` runs once) and nulls the pointer through the `&mut`.
|
||
DrmFrameGuard::Av(frame) => unsafe { ffmpeg::ffi::av_frame_free(frame) },
|
||
// The native guard releases through its own `Drop`, which runs as this value's
|
||
// fields are dropped — right after this match.
|
||
#[cfg(target_os = "linux")]
|
||
DrmFrameGuard::NativeVa(_) => {}
|
||
}
|
||
}
|
||
}
|
||
|
||
enum Backend {
|
||
/// FFmpeg's Vulkan Video rung — compiled only with `ffmpeg-fallback`, and then it
|
||
/// sits DIRECTLY BELOW [`Backend::NativeVulkan`] (M9).
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
Vulkan(VulkanDecoder),
|
||
/// Native Vulkan Video H.264/HEVC/AV1 (pf-vkdecode) on the presenter's device —
|
||
/// auto's TOP rung on both desktop OSes since M9, for all three codecs — every leg
|
||
/// has hardware parity against libavcodec (see this module's evidence table) — also
|
||
/// pinnable by name (`PUNKTFUNK_DECODER=native-vulkan`); see [`native_vulkan_gate`].
|
||
/// The negotiated codec picks the decoder once, at construction; everything else
|
||
/// about this backend is codec-agnostic. Errors ride the SAME streak/demotion
|
||
/// machinery as the FFmpeg-Vulkan rung.
|
||
/// Boxed: the decoder (planner + shipped-frame ledger) dwarfs the other variants,
|
||
/// same as PyroWave below.
|
||
NativeVulkan(Box<NativeVulkanDecoder>),
|
||
/// libavcodec's VAAPI hwaccel — compiled only with `ffmpeg-fallback`, directly below
|
||
/// [`Backend::NativeVaapi`] (M9).
|
||
#[cfg(all(target_os = "linux", feature = "ffmpeg-fallback"))]
|
||
Vaapi(VaapiDecoder),
|
||
/// Native VAAPI (`pf-vaadec` + `video_vaapi_native`) — M6's replacement for the
|
||
/// FFmpeg-backed [`Backend::Vaapi`] rung: libva driven straight from pf-bitstream
|
||
/// plans, dlopen'd, exporting the same DRM-PRIME dmabufs, no libavcodec.
|
||
/// Reachable by pin (`PUNKTFUNK_DECODER=native-vaapi`) always, and by `auto` under
|
||
/// [`native_rung_admitted`]: this rung has decoded NOTHING on hardware, so it joins
|
||
/// `auto` only where no proven rung is left below it (a build without
|
||
/// `ffmpeg-fallback`) or where the user asked (`PUNKTFUNK_NATIVE_FIRST=1`). Errors
|
||
/// ride the SAME streak/demotion machinery as every other hardware rung.
|
||
/// Boxed: the decoder (two planners, a display and a surface pool) dwarfs the
|
||
/// other variants.
|
||
#[cfg(target_os = "linux")]
|
||
NativeVaapi(Box<crate::video_vaapi_native::NativeVaapiDecoder>),
|
||
/// libavcodec's D3D11VA hwaccel — compiled only with `ffmpeg-fallback`, directly
|
||
/// below [`Backend::NativeD3d11va`] (M9).
|
||
#[cfg(all(windows, feature = "ffmpeg-fallback"))]
|
||
D3d11va(crate::video_d3d11::D3d11vaDecoder),
|
||
/// Native D3D11VA (`pf-dxvadec` + `video_d3d11_native`) — M5's replacement for the
|
||
/// FFmpeg-backed [`Backend::D3d11va`] rung: `ID3D11VideoDecoder` driven from
|
||
/// pf-bitstream plans, filling the same shareable-RGBA hand-off, no libavcodec.
|
||
/// Reachable by pin (`PUNKTFUNK_DECODER=native-d3d11va`) always, and by `auto` under
|
||
/// [`native_rung_admitted`]: its H.264/H.265 legs have hardware parity + a soak (M5)
|
||
/// and are in `auto`; its AV1 leg has decoded nothing anywhere and is not. Errors
|
||
/// ride the SAME streak/demotion machinery as every other hardware rung.
|
||
/// Boxed: the decoder (two planners plus a session) dwarfs the other variants.
|
||
#[cfg(windows)]
|
||
NativeD3d11va(Box<crate::video_d3d11_native::NativeD3d11Decoder>),
|
||
/// PyroWave (wired-LAN wavelet codec): pyrowave compute on the presenter's device,
|
||
/// no FFmpeg involvement (Linux + Windows — same Vulkan presenter on both). No demotion
|
||
/// rung — there is no other decoder for it.
|
||
/// Boxed: the decoder (pinned create-info hold + plane ring) dwarfs the other variants.
|
||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||
PyroWave(Box<crate::video_pyrowave::PyroWaveDecoder>),
|
||
/// The CPU rung (M8: openh264 / rav1d, no libavcodec). Last in every ladder, so it
|
||
/// never demotes — and the only rung that can fail to EXIST for a codec, which is a
|
||
/// different answer from failing to decode: see [`last_rung_verdict`].
|
||
Software(SoftwareDecoder),
|
||
}
|
||
|
||
/// The picture shape the host resolved in its Welcome, before a single AU arrives.
|
||
///
|
||
/// The in-band SPS stays authoritative — this is the NEGOTIATED answer, which is what
|
||
/// makes it available at decoder-construction time. It exists so a backend whose
|
||
/// support for a shape is device-dependent can refuse BEFORE it is chosen, where the
|
||
/// ladder's fall-through to the next rung is a plain construction failure, instead of
|
||
/// discovering it at the first decode where the only exit is an error-streak demotion
|
||
/// PAST that rung.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct StreamFormat {
|
||
/// `chroma_format_idc` — [`punktfunk_core::quic::CHROMA_IDC_420`] (1) or
|
||
/// [`punktfunk_core::quic::CHROMA_IDC_444`] (3). An older host that omitted it
|
||
/// reads as 4:2:0, never 0.
|
||
pub chroma_format_idc: u8,
|
||
/// Bits per component: 8, or 10 for a Main10/HDR session (an older host reads 8).
|
||
pub bit_depth: u8,
|
||
}
|
||
|
||
impl StreamFormat {
|
||
/// The 8-bit 4:2:0 envelope — what every H.264 session is, and what an older
|
||
/// host's Welcome decodes to.
|
||
pub const SDR_420_8: StreamFormat = StreamFormat {
|
||
chroma_format_idc: punktfunk_core::quic::CHROMA_IDC_420,
|
||
bit_depth: 8,
|
||
};
|
||
|
||
/// `bit_depth` as the `bit_depth_luma_minus8` the H.265 SPS (and pf-vkdecode's
|
||
/// profile key) speaks, or `None` for a depth outside the 8/10 envelope — which
|
||
/// is itself a refusal, not a "probe skipped".
|
||
pub(crate) fn bit_depth_minus8(self) -> Option<u8> {
|
||
self.bit_depth.checked_sub(8)
|
||
}
|
||
}
|
||
|
||
pub struct Decoder {
|
||
backend: Backend,
|
||
/// The negotiated codec (from the host's Welcome), so a mid-session VAAPI→software demotion
|
||
/// rebuilds the software decoder for the SAME codec.
|
||
codec_id: ffmpeg::codec::Id,
|
||
/// The same codec as the WIRE states it — what the software rung is built for and
|
||
/// what a refusal names. Derived once from [`Self::codec_id`] rather than carried
|
||
/// through a widened `Decoder::new` signature, because the ladder above still speaks
|
||
/// FFmpeg ids and will until M10 deletes its last FFmpeg rung; this field is the one
|
||
/// place the two vocabularies meet.
|
||
wire_codec: u8,
|
||
/// Consecutive hardware decode errors (Vulkan or VAAPI) — a single transient failure
|
||
/// (e.g. a reference-missing frame after packet loss) shouldn't cost the whole
|
||
/// session its hardware decoder.
|
||
vaapi_fails: u32,
|
||
/// When the current error streak started. Demotion needs the streak to be OLD as well
|
||
/// as long: one startup loss burst produces 3+ consecutive failing AUs within
|
||
/// milliseconds — demoting on count alone (live-hit: Intel iGPU, 2026-07-19, three
|
||
/// errors in 20 ms → software forever) never gives the IDR requested on the FIRST
|
||
/// error (~100–300 ms round trip) a chance to rescue the hardware decoder.
|
||
first_fail: Option<std::time::Instant>,
|
||
/// Set when the decoder needs a fresh IDR to resynchronize (after an error or a demotion).
|
||
/// The pump drains it and asks the host — under the infinite GOP there is no periodic
|
||
/// keyframe, so a rebuilt/erroring decoder would otherwise stay gray/frozen forever.
|
||
want_keyframe: bool,
|
||
/// The CURRENT backend has delivered at least one frame. A backend that never did
|
||
/// is one the session never actually had, so its error streak must not cost the
|
||
/// session the rung BELOW it — see the native→FFmpeg-Vulkan arm in
|
||
/// [`Decoder::decode_frame`]. Reset on every backend swap.
|
||
delivered: bool,
|
||
/// The presenter's device, kept so that same arm can build the FFmpeg-Vulkan
|
||
/// decoder mid-stream. Cloned once per session; its handles outlive every pump
|
||
/// (see [`VulkanDecodeDevice`]).
|
||
vk: Option<VulkanDecodeDevice>,
|
||
/// The negotiated picture shape, kept for the same reason `vk` is: since M9 the
|
||
/// demotion walk can build a NATIVE rung mid-stream, and every native constructor
|
||
/// takes it as its device probe ([`StreamFormat`]).
|
||
stream: StreamFormat,
|
||
/// Which hardware rungs this session has actually RUN — [`RUNG_BIT_NATIVE_VULKAN`]
|
||
/// and [`RUNG_BIT_NATIVE_PLATFORM`], set the moment a rung is installed.
|
||
///
|
||
/// It is what makes the demotion walk TERMINATE now that two native rungs can
|
||
/// demote into each other (the ladder is `native → other native → software` where
|
||
/// no FFmpeg twin is compiled, and the two orders are opposite per vendor — so
|
||
/// without this a Vulkan⇄platform pair could hand the session back and forth
|
||
/// forever, one error streak at a time, and never reach the CPU rung that would at
|
||
/// least show a picture). A rung already entered is never re-entered.
|
||
entered_rungs: u8,
|
||
/// The presenter has the win32 external-memory import path, so D3D11VA frames can reach
|
||
/// the screen — kept for the mid-session Vulkan→D3D11VA demotion rung (the Windows
|
||
/// analog of Linux's Vulkan→VAAPI rung).
|
||
#[cfg(windows)]
|
||
d3d11_import: bool,
|
||
/// The presenter adapter's LUID (see [`VulkanDecodeDevice::adapter_luid`]) so a demotion
|
||
/// rebuild lands on the SAME GPU.
|
||
#[cfg(windows)]
|
||
adapter_luid: Option<[u8; 8]>,
|
||
/// [`VulkanDecodeDevice::d3d11_hdr10`], for the same demotion rebuild.
|
||
#[cfg(windows)]
|
||
d3d11_hdr10: bool,
|
||
}
|
||
|
||
/// The native VULKAN rung ran this session — see [`Decoder::entered_rungs`].
|
||
const RUNG_BIT_NATIVE_VULKAN: u8 = 1 << 0;
|
||
/// The native PLATFORM rung (VAAPI on Linux, D3D11VA on Windows) ran this session.
|
||
const RUNG_BIT_NATIVE_PLATFORM: u8 = 1 << 1;
|
||
|
||
/// Which [`Decoder::entered_rungs`] bit a backend claims — 0 for the rungs that cannot
|
||
/// be a demotion TARGET twice (the FFmpeg twins are only ever reached downward, and
|
||
/// software is terminal), so they need no bookkeeping.
|
||
fn rung_bit(backend: &Backend) -> u8 {
|
||
match backend {
|
||
Backend::NativeVulkan(_) => RUNG_BIT_NATIVE_VULKAN,
|
||
#[cfg(target_os = "linux")]
|
||
Backend::NativeVaapi(_) => RUNG_BIT_NATIVE_PLATFORM,
|
||
#[cfg(windows)]
|
||
Backend::NativeD3d11va(_) => RUNG_BIT_NATIVE_PLATFORM,
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
/// Demote a hardware backend (Vulkan→VAAPI/D3D11VA, VAAPI/D3D11VA→software) only after
|
||
/// this many consecutive decode errors; a lone transient error just re-requests an IDR
|
||
/// and keeps the hardware decoder.
|
||
const VAAPI_DEMOTE_AFTER: u32 = 3;
|
||
|
||
/// ...AND only when the streak has lasted this long. Every error re-requests an IDR, and
|
||
/// one arriving + decoding resets the streak — so a genuinely broken driver (errors keep
|
||
/// flowing through multiple IDR cycles) still demotes ~a second in, while a burst of
|
||
/// consecutive bad AUs from a single loss event no longer strands the session on
|
||
/// software before the first requested IDR could even arrive.
|
||
const HW_DEMOTE_MIN_STREAK: std::time::Duration = std::time::Duration::from_millis(1000);
|
||
|
||
/// May a successful `decode` answer CLEAR the demotion error streak?
|
||
///
|
||
/// The streak is the hardware rungs' only escape hatch, and clearing it is a
|
||
/// claim: *this decoder is working*. A delivered frame proves that outright. So
|
||
/// does a clean `Ok(None)` — the decoder ran and had nothing to object to (it
|
||
/// buffered, or skipped an H.265 RASL picture after an open-GOP join).
|
||
///
|
||
/// What proves nothing is the third `Ok(None)`: the native rung's CONCEALMENT
|
||
/// answer, where the plan needed a substitute for something lost and the picture
|
||
/// was released unshown. That is deliberately not an `Err` — stream damage is not
|
||
/// a decoder fault, and three of them in a second must not demote the rung on
|
||
/// exactly the lossy links it exists to diagnose — but "not an error" was silently
|
||
/// read as "a success", and clearing on it is the dangerous half of that:
|
||
///
|
||
/// * a driver failing every OTHER AU on a lossy link has its `Err`s zeroed by the
|
||
/// concealment between them and never reaches [`VAAPI_DEMOTE_AFTER`];
|
||
/// * and a rung answering concealment FOREVER — a host framing regression putting
|
||
/// two pictures in one AU makes every AU conceal, and unlike a reference gap it
|
||
/// does not self-heal at an IDR — holds a frozen last-good frame with no escape
|
||
/// at all, where before this milestone the same stream demoted to a rung that
|
||
/// ignores AU boundaries and showed a picture.
|
||
///
|
||
/// Leaving the streak untouched costs nothing on a healthy link: one damaged AU
|
||
/// between good frames is cleared by the next good frame.
|
||
fn clears_demotion_streak(delivered: bool, concealed: bool) -> bool {
|
||
delivered || !concealed
|
||
}
|
||
|
||
/// `VK_VIDEO_CODEC_OPERATION_DECODE_H264_BIT_KHR` — the raw flag bit within
|
||
/// [`VulkanDecodeDevice::decode_video_caps`] (this crate stays ash-free).
|
||
const VIDEO_CODEC_OP_DECODE_H264: u32 = 0x0000_0001;
|
||
/// `VK_VIDEO_CODEC_OPERATION_DECODE_H265_BIT_KHR` — its H.265 sibling.
|
||
const VIDEO_CODEC_OP_DECODE_H265: u32 = 0x0000_0002;
|
||
|
||
/// `VK_VIDEO_CODEC_OPERATION_DECODE_AV1_BIT_KHR`. The Deck's VanGogh advertises
|
||
/// it alongside H.264/H.265/VP9; it is what [`av1_hardware_decodable`] reads and,
|
||
/// since M7, the caps bit [`native_codec`] demands for an AV1 session.
|
||
const VIDEO_CODEC_OP_DECODE_AV1: u32 = 0x0000_0004;
|
||
|
||
/// The native decoder for a negotiated wire codec, plus the
|
||
/// `VkVideoCodecOperationFlagBitsKHR` the presenter's decode family must advertise
|
||
/// for it — or `None` for a codec pf-vkdecode cannot decode natively.
|
||
///
|
||
/// The two are returned together on purpose: "which decoder" and "which caps bit"
|
||
/// are one fact, and splitting them is how a gate ends up admitting HEVC on an
|
||
/// H.264-only decode family (`vkCreateVideoSessionKHR` for a codec operation the
|
||
/// family cannot run is undefined behaviour, not an error).
|
||
///
|
||
/// ⚠ Being here is "pf-vkdecode has a decoder", NOT "the automatic ladder may pick
|
||
/// it". AV1 (M7) is pin-only; [`native_vulkan_gate`] holds that decision, and it
|
||
/// reads this map for the codec/caps pair only.
|
||
fn native_codec(codec_id: ffmpeg::codec::Id) -> Option<(NativeCodec, u32)> {
|
||
match codec_id {
|
||
ffmpeg::codec::Id::H264 => Some((NativeCodec::H264, VIDEO_CODEC_OP_DECODE_H264)),
|
||
ffmpeg::codec::Id::HEVC => Some((NativeCodec::H265, VIDEO_CODEC_OP_DECODE_H265)),
|
||
ffmpeg::codec::Id::AV1 => Some((NativeCodec::Av1, VIDEO_CODEC_OP_DECODE_AV1)),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The native DXVA decoder for a negotiated wire codec, or `None` for one pf-dxvadec
|
||
/// cannot decode. No caps bit accompanies it (unlike [`native_codec`]): DXVA advertises
|
||
/// support as a profile GUID on the adapter, which
|
||
/// [`crate::video_d3d11_native::NativeD3d11Decoder::new`] checks directly against the
|
||
/// device it is about to build on — there is no device-level "which codecs" flag to
|
||
/// consult first.
|
||
#[cfg(windows)]
|
||
fn native_d3d11_codec(codec_id: ffmpeg::codec::Id) -> Option<pf_dxvadec::Codec> {
|
||
match codec_id {
|
||
ffmpeg::codec::Id::H264 => Some(pf_dxvadec::Codec::H264),
|
||
ffmpeg::codec::Id::HEVC => Some(pf_dxvadec::Codec::H265),
|
||
// AV1 (M7). Not a widening of what this client can decode — the FFmpeg
|
||
// D3D11VA rung already decodes AV1 Profile 0 through the same profile GUID
|
||
// — but the native rung has to cover it, or dropping FFmpeg would drop a
|
||
// codec.
|
||
ffmpeg::codec::Id::AV1 => Some(pf_dxvadec::Codec::Av1),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The native VAAPI decoder for a negotiated wire codec, or `None` for one pf-vaadec
|
||
/// cannot decode. Like its DXVA twin there is no caps bit to consult first: VAAPI
|
||
/// advertises support as a profile/entrypoint pair on the DISPLAY, which
|
||
/// [`crate::video_vaapi_native::NativeVaapiDecoder::new`] queries on the device it is
|
||
/// about to build on.
|
||
#[cfg(target_os = "linux")]
|
||
fn native_vaapi_codec(codec_id: ffmpeg::codec::Id) -> Option<pf_vaadec::Codec> {
|
||
match codec_id {
|
||
ffmpeg::codec::Id::H264 => Some(pf_vaadec::Codec::H264),
|
||
ffmpeg::codec::Id::HEVC => Some(pf_vaadec::Codec::H265),
|
||
// AV1 (M7). Not a widening of what this client can decode — the FFmpeg VAAPI
|
||
// rung already decodes AV1 Profile 0 through the same libva profile — but the
|
||
// native rung has to cover it, or dropping FFmpeg would drop a codec.
|
||
ffmpeg::codec::Id::AV1 => Some(pf_vaadec::Codec::Av1),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// One NATIVE decode rung, named so the evidence table and the admission rule can talk
|
||
/// about rungs without naming a [`Backend`] variant (whose set is per-platform).
|
||
///
|
||
/// The CPU rung is here for completeness of the table only — see [`native_evidence`].
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum NativeRung {
|
||
/// pf-vkdecode on the presenter's device (`video_vk_native`).
|
||
Vulkan,
|
||
/// pf-dxvadec driving `ID3D11VideoDecoder` (`video_d3d11_native`, Windows).
|
||
D3d11va,
|
||
/// pf-vaadec driving a dlopen'd libva (`video_vaapi_native`, Linux).
|
||
Vaapi,
|
||
/// openh264 + rav1d (`video_software`).
|
||
Software,
|
||
}
|
||
|
||
impl NativeRung {
|
||
/// The name this rung goes by in logs and in `PUNKTFUNK_DECODER` — the same strings
|
||
/// the `stats:` decode-path tag uses, so a log line and a stats line name one thing.
|
||
pub fn name(self) -> &'static str {
|
||
match self {
|
||
NativeRung::Vulkan => "native-vulkan",
|
||
NativeRung::D3d11va => "native-d3d11va",
|
||
NativeRung::Vaapi => "native-vaapi",
|
||
NativeRung::Software => "software",
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What HARDWARE has actually decoded a frame on a given rung/codec pair — the fact M9's
|
||
/// default flip turns on, written down where it cannot rot.
|
||
///
|
||
/// This is deliberately not a confidence score or a tier. It answers exactly one
|
||
/// question — *has a real GPU ever produced a picture through this code path* — because
|
||
/// that is the question the admission rule needs and the one a support engineer reading
|
||
/// `hardware_verified=false` on a session's "decode rung active" line needs.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct RungEvidence {
|
||
/// A real device has decoded frames through this rung/codec pair and the result was
|
||
/// checked (frame-hash parity, a soak, or both).
|
||
pub verified: bool,
|
||
/// WHAT hardware, in one line — or, when `verified` is false, why there is none. Goes
|
||
/// verbatim into the session log so a report carries its own provenance.
|
||
pub note: &'static str,
|
||
}
|
||
|
||
/// The evidence table (this module's docs hold the readable copy), keyed by rung and WIRE
|
||
/// codec.
|
||
///
|
||
/// Wire bits rather than `ffmpeg::codec::Id` on purpose: this is a fact about punktfunk's
|
||
/// own decode lanes, it is read by code that will outlive the FFmpeg vocabulary, and M10
|
||
/// must not have to re-key it.
|
||
///
|
||
/// An unknown codec for a rung answers `verified: false` — the safe direction: a rung
|
||
/// grows a codec leg before anyone runs it on hardware, and the default must be "no
|
||
/// evidence", not "inherits its neighbour's".
|
||
pub fn native_evidence(rung: NativeRung, wire: u8) -> RungEvidence {
|
||
use punktfunk_core::quic::{CODEC_AV1, CODEC_H264, CODEC_HEVC};
|
||
let (verified, note) = match (rung, wire) {
|
||
(NativeRung::Vulkan, CODEC_H264) => (
|
||
true,
|
||
"bit-exact vs libavcodec, 250/250 AUs on three drivers + 92-min soak (M2 WP-D)",
|
||
),
|
||
(NativeRung::Vulkan, CODEC_HEVC) => (
|
||
true,
|
||
"bit-exact vs libavcodec incl. Main10/4:4:4, three drivers + HDR and Deck legs (M3)",
|
||
),
|
||
(NativeRung::Vulkan, CODEC_AV1) => (
|
||
true,
|
||
"250/250 bit-identical to libavcodec on an RTX 5070 Ti (M7) - one vendor, no soak",
|
||
),
|
||
(NativeRung::D3d11va, CODEC_H264 | CODEC_HEVC) => (
|
||
true,
|
||
"frame-hash parity on an RTX 4090 and an AMD iGPU + 30-min soak (M5)",
|
||
),
|
||
(NativeRung::D3d11va, CODEC_AV1) => (
|
||
false,
|
||
"NEVER decoded a frame on any hardware - wired in M7, the box was unavailable",
|
||
),
|
||
(NativeRung::Vaapi, _) => (
|
||
false,
|
||
"NEVER decoded a frame on any hardware - no VAAPI device was reachable (M6/M7)",
|
||
),
|
||
(NativeRung::Software, CODEC_H264 | CODEC_AV1) => (
|
||
false,
|
||
"never run on glass - openh264/rav1d have CPU unit tests only (M8)",
|
||
),
|
||
_ => (false, "no hardware run recorded for this rung and codec"),
|
||
};
|
||
RungEvidence { verified, note }
|
||
}
|
||
|
||
/// Has the user asked for the rungs that have NO hardware evidence to join `auto`?
|
||
///
|
||
/// `PUNKTFUNK_NATIVE_FIRST=1` (anything but empty or `0`) is the switch, and it is the M9
|
||
/// bake switch: it puts a box on the full native-first ladder while every FFmpeg rung is
|
||
/// still compiled in directly below, so a rung that misbehaves demotes into a proven one
|
||
/// instead of onto the CPU.
|
||
fn native_first_opt_in() -> bool {
|
||
std::env::var("PUNKTFUNK_NATIVE_FIRST")
|
||
.ok()
|
||
.is_some_and(|v| !v.is_empty() && v != "0")
|
||
}
|
||
|
||
/// May `auto` pick this native rung for this wire codec?
|
||
///
|
||
/// The rule, and the honest half of M9:
|
||
///
|
||
/// * a rung/codec pair WITH hardware evidence is admitted, always — that is what the
|
||
/// evidence was collected for;
|
||
/// * a pair without it is admitted only when nothing proven is left below it (this build
|
||
/// has no `ffmpeg-fallback`, so the alternative is not "a proven rung" but "the CPU"),
|
||
/// or when the user switched it in (`PUNKTFUNK_NATIVE_FIRST=1`).
|
||
///
|
||
/// The second clause is why the flip can land without claiming a bake that has not
|
||
/// happened: in the build people install, an unproven rung stays out of `auto` and the
|
||
/// ladder under it is the one that shipped before M9. In an `ffmpeg-fallback`-less build
|
||
/// — the M10 preview, and the thing the bake measures — barring it would cost the session
|
||
/// hardware decode outright, which is a worse answer than running it and saying so out
|
||
/// loud. Every session logs which case it is ([`Decoder::new`]).
|
||
///
|
||
/// ⚠ This governs `auto` ONLY. An explicit `PUNKTFUNK_DECODER=` pin bypasses it, exactly
|
||
/// as it bypasses the vendor order — a pin is how a lab run reaches a rung `auto` will
|
||
/// not pick, and taking that away would leave no way to GENERATE the missing evidence.
|
||
pub fn native_rung_admitted(rung: NativeRung, wire: u8) -> bool {
|
||
native_evidence(rung, wire).verified
|
||
|| cfg!(not(feature = "ffmpeg-fallback"))
|
||
|| native_first_opt_in()
|
||
}
|
||
|
||
/// The native Vulkan Video admission gate (WP-C of the native-decode program, widened by
|
||
/// the 2026-08-05 ladder decision, by M3 WP-2's HEVC wiring, by M7's AV1 wiring and — for
|
||
/// AV1's entry into `auto` — by M9): the pf-vkdecode backend engages when `choice` asks
|
||
/// for it, by name (`PUNKTFUNK_DECODER=native-vulkan` — `choice` is env-first, so that's
|
||
/// what carries it) or as the auto family (`auto`/``/`hardware`), where since M9 native
|
||
/// is auto's TOP rung for every codec it speaks.
|
||
///
|
||
/// A native INIT failure falls through to whatever is below — FFmpeg-Vulkan where
|
||
/// `ffmpeg-fallback` compiled it, the platform's native rung otherwise — so admission can
|
||
/// never cost a session its decoder at start. A runtime error streak demotes like every
|
||
/// hardware rung's streaks do, with ONE exception: a native backend that never delivered
|
||
/// a single frame demotes to the rung DIRECTLY below it first, because a rung the session
|
||
/// never actually had must not cost it the rung under that (see
|
||
/// [`Decoder::decode_frame`]). The explicit `vulkan` pin still names the FFmpeg-Vulkan
|
||
/// backend specifically; it — and every other explicit backend pin — refuses here.
|
||
///
|
||
/// Beyond the choice: the negotiated wire codec must be one pf-vkdecode speaks —
|
||
/// H.264, H.265 or AV1 ([`native_codec`]) — and the presenter's decode family must
|
||
/// advertise THAT codec's decode operation. `video_decode` alone proves the extension
|
||
/// stack, never the codec: an AV1-only decode family exists on real hardware, and
|
||
/// H.264-only ones are the common case on older silicon.
|
||
///
|
||
/// The AUTO family additionally passes through [`native_rung_admitted`], the evidence
|
||
/// filter M9 added — which admits all three codecs here, because all three legs of this
|
||
/// rung have hardware parity against libavcodec (the module's evidence table). It is
|
||
/// written as a rule rather than a per-codec `match` anyway: the per-codec decision now
|
||
/// lives in ONE table that the D3D11VA and VAAPI rungs read too, and a fourth codec that
|
||
/// arrives without a hardware run is then kept out of `auto` by default instead of by
|
||
/// somebody remembering to add an arm here.
|
||
///
|
||
/// What the gate deliberately does NOT check is the stream's picture SHAPE — that is
|
||
/// [`NativeVulkanDecoder::new`]'s construction-time probe, which has the negotiated
|
||
/// chroma format and bit depth and can ask the device directly. Keeping it there keeps
|
||
/// this decision pure (and CPU-testable) while still refusing before a decoder exists.
|
||
fn native_vulkan_gate(
|
||
choice: &str,
|
||
codec_id: ffmpeg::codec::Id,
|
||
video_decode: bool,
|
||
decode_video_caps: u32,
|
||
) -> bool {
|
||
let Some((_, codec_op)) = native_codec(codec_id) else {
|
||
return false;
|
||
};
|
||
let chosen = match choice {
|
||
// A pin is a pin: it skips the vendor order AND the evidence filter, which is how
|
||
// a lab run reaches a rung `auto` would not pick.
|
||
"native-vulkan" => true,
|
||
"auto" | "" | "hardware" => {
|
||
native_rung_admitted(NativeRung::Vulkan, wire_codec_of(codec_id))
|
||
}
|
||
_ => false,
|
||
};
|
||
chosen && video_decode && decode_video_caps & codec_op != 0
|
||
}
|
||
|
||
/// The `quic::CODEC_*` bit's human name — for logs, errors and the user-visible
|
||
/// reconnect toast. `?` for a bit this build does not know, which is honest: an unknown
|
||
/// codec must not print as one of the known ones.
|
||
pub fn wire_codec_name(wire: u8) -> &'static str {
|
||
match wire {
|
||
punktfunk_core::quic::CODEC_H264 => "H.264",
|
||
punktfunk_core::quic::CODEC_HEVC => "HEVC",
|
||
punktfunk_core::quic::CODEC_AV1 => "AV1",
|
||
punktfunk_core::quic::CODEC_PYROWAVE => "PyroWave",
|
||
_ => "?",
|
||
}
|
||
}
|
||
|
||
/// The `quic` codec bit for an FFmpeg decoder id — the inverse of [`ffmpeg_codec_id`],
|
||
/// for the one place that has an id in hand and needs the WIRE truth (the software rung's
|
||
/// refusal, which must name a codec the host understands). Dies with the ladder's last
|
||
/// FFmpeg id at M10.
|
||
fn wire_codec_of(id: ffmpeg::codec::Id) -> u8 {
|
||
match id {
|
||
ffmpeg::codec::Id::H264 => punktfunk_core::quic::CODEC_H264,
|
||
ffmpeg::codec::Id::AV1 => punktfunk_core::quic::CODEC_AV1,
|
||
_ => punktfunk_core::quic::CODEC_HEVC,
|
||
}
|
||
}
|
||
|
||
/// The `quic` codec bits this build can decode ON THE CPU — the ladder's last rung, and
|
||
/// therefore the set a session is guaranteed to survive to the end of.
|
||
///
|
||
/// One function so the answer cannot drift between the rung that refuses (the software
|
||
/// backend's own codec map) and the rule that decides what to reconnect as
|
||
/// ([`last_rung_verdict`]).
|
||
pub fn software_decodable_codecs() -> u8 {
|
||
punktfunk_core::quic::CODEC_H264 | punktfunk_core::quic::CODEC_AV1
|
||
}
|
||
|
||
/// What to do when the last rung has no decoder for the session's codec.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum LastRungVerdict {
|
||
/// Reconnect advertising these caps instead — they are non-empty, and they exclude
|
||
/// the codec that just ran out of rungs, so the host must pick something else.
|
||
Retry { caps: u8 },
|
||
/// Nothing is left to advertise: every codec this client offered has now exhausted
|
||
/// its rungs. Reconnecting would negotiate the same dead end, so the session ends
|
||
/// and says why.
|
||
Dead,
|
||
}
|
||
|
||
/// WHY the last rung had no answer — the two diagnoses behind a [`NoSoftwareRung`], and
|
||
/// the reason [`last_rung_verdict`] needs more than "a codec failed".
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub enum RungLoss {
|
||
/// The CODEC has no CPU rung in this build at all (HEVC). Every hardware rung for it
|
||
/// has already failed, so the retry may only offer codecs that DO have a CPU rung —
|
||
/// anything else is the same bet that just lost, one session later.
|
||
Codec,
|
||
/// The codec has a CPU rung; this stream's picture SHAPE is outside it (10-bit,
|
||
/// 4:4:4). The hardware rungs are not implicated at all — nothing failed, the CPU
|
||
/// decoder simply is not built for this picture — so every other advertised codec is
|
||
/// a genuine candidate and filtering by [`software_decodable_codecs`] here would end
|
||
/// sessions a plain HEVC retry would have finished.
|
||
Shape,
|
||
}
|
||
|
||
/// The reconnect rule, in one pure function: an HEVC session whose hardware rungs are
|
||
/// exhausted must come back as a session this client can finish.
|
||
///
|
||
/// The codec is fixed at Welcome and the control stream renegotiates shard payload only,
|
||
/// so there is no in-session move available — the only lever is what the NEXT Hello
|
||
/// advertises. `advertised` is what this session offered; the answer removes `negotiated`
|
||
/// from it, plus — for a [`RungLoss::Codec`] — any other codec that would land in the
|
||
/// same hole (one whose only remaining rung is a software one that does not exist). Two
|
||
/// sessions of the same failure is the shape this rules out.
|
||
///
|
||
/// `caps` is what the retry ACTUALLY advertises: the pump derives its `exclude_codecs`
|
||
/// from this set rather than from the failed codec alone, so the wire and this verdict
|
||
/// cannot disagree (they did until the M8 review — the wire re-offered PyroWave the rule
|
||
/// had removed).
|
||
///
|
||
/// Pure and total on purpose — this is the piece that gets tested as a first-class path,
|
||
/// because the on-glass version of it costs a real host with a real GPU failure.
|
||
pub fn last_rung_verdict(negotiated: u8, advertised: u8, loss: RungLoss) -> LastRungVerdict {
|
||
let survivors = advertised & !negotiated;
|
||
let caps = match loss {
|
||
// Everything still on the table that ALSO has a CPU rung underneath it.
|
||
RungLoss::Codec => survivors & software_decodable_codecs(),
|
||
RungLoss::Shape => survivors,
|
||
};
|
||
// A retry the host's precedence ladder cannot PICK is not a retry: `resolve_codec`
|
||
// deliberately keeps PyroWave out of that ladder (it is opt-in only), so a Hello
|
||
// whose survivors are PyroWave alone resolves to nothing and the host refuses the
|
||
// session. Judge liveness on the pickable ones and carry the rest along.
|
||
const PICKABLE: u8 = punktfunk_core::quic::CODEC_H264
|
||
| punktfunk_core::quic::CODEC_HEVC
|
||
| punktfunk_core::quic::CODEC_AV1;
|
||
if caps & PICKABLE == 0 {
|
||
LastRungVerdict::Dead
|
||
} else {
|
||
LastRungVerdict::Retry { caps }
|
||
}
|
||
}
|
||
|
||
/// Is video decode PINNED to the CPU rung — the Settings "Video decoder" value, or the
|
||
/// `PUNKTFUNK_DECODER` override that wins over it?
|
||
///
|
||
/// Same precedence as [`Decoder::new`] resolves (env first, then the setting), because a
|
||
/// second reading of the same two inputs is a second place for them to drift.
|
||
pub fn decode_pinned_to_software(pref: &str) -> bool {
|
||
std::env::var("PUNKTFUNK_DECODER")
|
||
.ok()
|
||
.filter(|v| !v.is_empty())
|
||
.unwrap_or_else(|| pref.to_string())
|
||
== "software"
|
||
}
|
||
|
||
/// Map a negotiated `quic` codec bit to the FFmpeg decoder id the client opens.
|
||
pub fn ffmpeg_codec_id(wire: u8) -> ffmpeg::codec::Id {
|
||
match wire {
|
||
punktfunk_core::quic::CODEC_H264 => ffmpeg::codec::Id::H264,
|
||
punktfunk_core::quic::CODEC_AV1 => ffmpeg::codec::Id::AV1,
|
||
_ => ffmpeg::codec::Id::HEVC,
|
||
}
|
||
}
|
||
|
||
/// 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".
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
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).
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
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 — the union of the codecs the RUNGS
|
||
/// THIS BUILD COMPILED speak. Advertised to the host so it never emits a codec we can't
|
||
/// decode.
|
||
///
|
||
/// It used to be a libavcodec registry walk (`ffmpeg::decoder::find` per id), and M9 is
|
||
/// where that stopped being an answer to the question asked: with the FFmpeg rungs behind
|
||
/// `ffmpeg-fallback` the registry describes decoders that are not in the ladder, and in a
|
||
/// default build it would describe decoders this crate never opens at all. It is now what
|
||
/// §3.6 of the plan asked for — a statement about our own rungs — and it does not change
|
||
/// between the two feature states, because the FFmpeg rungs never covered a codec the
|
||
/// native ones don't:
|
||
///
|
||
/// * native Vulkan Video (`video_vk_native`, both desktop OSes) decodes H.264, H.265 and
|
||
/// AV1, and it is compiled unconditionally;
|
||
/// * the platform native rungs (`video_d3d11_native` / `video_vaapi_native`) cover the
|
||
/// same three;
|
||
/// * the CPU rung ([`software_decodable_codecs`]) covers H.264 and AV1.
|
||
///
|
||
/// The three flags are constants rather than probes for the same reason they always were:
|
||
/// this is asked before a device exists (it feeds the very first Hello), so it can only
|
||
/// speak about what was BUILT. Everything device-shaped is [`decodable_codecs_for`].
|
||
///
|
||
/// ⚠ **AV1 here is a decoder EXISTING, not a decoder that can keep up.** Use
|
||
/// [`decodable_codecs_for`], which gates it on hardware — see [`av1_hardware_decodable`].
|
||
///
|
||
/// ⚠ **HEVC here is a HARDWARE decoder existing**, and since M8 that is the only kind
|
||
/// there is: the CPU rung has no HEVC ([`software_decodable_codecs`]). Advertising it
|
||
/// anyway is deliberate and is the plan's — hardware HEVC is the path most hosts and most
|
||
/// clients actually take, and refusing it up front would cost every one of them the codec
|
||
/// to protect the few whose hardware later fails. The exhaustion case is handled where it
|
||
/// happens, by [`last_rung_verdict`], and it is the ONE codec whose advertisement is a
|
||
/// promise this client cannot keep unconditionally. Where the client can KNOW in advance
|
||
/// that it cannot keep it — decode pinned to software — [`decodable_codecs_for`] drops
|
||
/// the bit before the first Hello instead.
|
||
pub fn decodable_codecs() -> u8 {
|
||
// The native Vulkan rung's three codecs (`native_codec`'s map is the same set), plus
|
||
// the CPU rung's — written as a union so that removing a rung's codec leg shows up
|
||
// here rather than silently keeping the advertisement alive.
|
||
punktfunk_core::quic::CODEC_H264
|
||
| punktfunk_core::quic::CODEC_HEVC
|
||
| punktfunk_core::quic::CODEC_AV1
|
||
| software_decodable_codecs()
|
||
}
|
||
|
||
/// Can this machine decode AV1 in HARDWARE?
|
||
///
|
||
/// The question exists because `ffmpeg::decoder::find(AV1)` answers yes on every
|
||
/// build that links libdav1d — a SOFTWARE decoder — so advertising AV1 off that
|
||
/// answer tells the host "send me AV1" on machines that will then try to decode a
|
||
/// 4K stream on the CPU. That is the standing open item M7 closes: the wire's codec
|
||
/// negotiation is a promise about capability, and a promise the client cannot keep
|
||
/// is worse than not making it, because the host has no other codec to fall back to
|
||
/// once the session is running.
|
||
///
|
||
/// Answered from device facts only, never from a decoder registry:
|
||
///
|
||
/// * the presenter's Vulkan device advertises `DECODE_AV1` in its decode queue
|
||
/// family's codec operations, or
|
||
/// * (Windows) the presenter can import D3D11 textures AND this build has a D3D11VA rung
|
||
/// that will actually be REACHED for AV1 — see below.
|
||
///
|
||
/// ⚠ Deliberately NOT consulted: VAAPI. Asking libva costs opening a display, which
|
||
/// this function is called too early and too often to do; the Vulkan bit covers the
|
||
/// Mesa devices where VAAPI AV1 exists in practice, and a machine with VAAPI AV1 but
|
||
/// no Vulkan AV1 loses only the ADVERTISEMENT, not a working path.
|
||
///
|
||
/// ⚠ The D3D11 arm is M9-conditional, and that is the point of this function surviving
|
||
/// the milestone. `d3d11_import` used to imply hardware AV1 because the FFmpeg D3D11VA
|
||
/// rung decodes AV1 Profile 0 through the adapter's profile GUID. With `ffmpeg-fallback`
|
||
/// off that rung does not exist, and the rung that replaces it — native D3D11VA AV1 —
|
||
/// has decoded NOTHING on hardware, so `auto` will not pick it
|
||
/// ([`native_rung_admitted`]). Left unchanged, this function would then promise the host
|
||
/// AV1 on an Intel/AMD Windows box whose only remaining AV1 rung is the CPU: the exact
|
||
/// unkeepable promise the AV1 hardware gate exists to prevent, one milestone later.
|
||
pub fn av1_hardware_decodable(vk: Option<&VulkanDecodeDevice>) -> bool {
|
||
if vk.is_some_and(|v| v.video_decode && v.decode_video_caps & VIDEO_CODEC_OP_DECODE_AV1 != 0) {
|
||
return true;
|
||
}
|
||
// The second answer is per-platform, so it is bound to a name rather than
|
||
// written as a cfg'd `return`: on Windows clippy calls that `needless_return`
|
||
// and fails `-D warnings`, which NO ci leg would have caught (nothing runs
|
||
// clippy on Windows — this surfaced only from a manual check on a box).
|
||
#[cfg(windows)]
|
||
let d3d11 = vk.is_some_and(|v| v.d3d11_import)
|
||
&& (cfg!(feature = "ffmpeg-fallback")
|
||
|| native_rung_admitted(NativeRung::D3d11va, punktfunk_core::quic::CODEC_AV1));
|
||
#[cfg(not(windows))]
|
||
let d3d11 = false;
|
||
d3d11
|
||
}
|
||
|
||
/// [`decodable_codecs`] plus the PyroWave bit when the presenter's device passed the
|
||
/// compute-feature probe, minus the codecs `decoder_pref` makes unreachable.
|
||
/// Advertisement-only: `resolve_codec` never auto-picks PyroWave — the session must also
|
||
/// name it `preferred_codec` (plan §3), which the client does only under its explicit
|
||
/// opt-in.
|
||
pub fn decodable_codecs_for(vk: Option<&VulkanDecodeDevice>, decoder_pref: &str) -> u8 {
|
||
let mut bits = decodable_codecs();
|
||
// AV1 is hardware-gated (M7). Without this the bit rides on libdav1d's mere
|
||
// presence and the host is told to send AV1 to a machine that would decode it on
|
||
// the CPU — and once the session is negotiated there is nothing to fall back to.
|
||
if bits & punktfunk_core::quic::CODEC_AV1 != 0 && !av1_hardware_decodable(vk) {
|
||
tracing::info!(
|
||
"AV1 not advertised: no hardware AV1 decode on this device (a software \
|
||
decoder exists, but a 4K AV1 stream is not survivable on it)"
|
||
);
|
||
bits &= !punktfunk_core::quic::CODEC_AV1;
|
||
}
|
||
// The one HEVC case the client can answer BEFORE the Hello (M8 review): decode is
|
||
// pinned to the CPU rung, and the CPU rung has no HEVC — so the advertisement would
|
||
// be a promise this build cannot keep for the whole session, exactly what
|
||
// `av1_hardware_decodable` exists to stop for AV1. Every other HEVC failure is a
|
||
// per-device fact only the session can learn, and `last_rung_verdict` answers it
|
||
// there. Guarded on something remaining: a Hello advertising ZERO codecs reads as
|
||
// "HEVC-only" to a host (`resolve_codec`'s pre-negotiation default), which would be
|
||
// the precise opposite of this.
|
||
if bits & punktfunk_core::quic::CODEC_HEVC != 0
|
||
&& bits & !punktfunk_core::quic::CODEC_HEVC != 0
|
||
&& decode_pinned_to_software(decoder_pref)
|
||
{
|
||
tracing::info!(
|
||
"HEVC not advertised: decode is pinned to software and there is no software \
|
||
HEVC decoder in this build"
|
||
);
|
||
bits &= !punktfunk_core::quic::CODEC_HEVC;
|
||
}
|
||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||
if vk.map(|v| v.pyrowave_decode).unwrap_or(false) {
|
||
return bits | punktfunk_core::quic::CODEC_PYROWAVE;
|
||
}
|
||
#[cfg(not(all(any(target_os = "linux", windows), feature = "pyrowave")))]
|
||
let _ = vk;
|
||
bits
|
||
}
|
||
|
||
/// libavcodec logs reference-frame recovery to the process stderr very verbosely
|
||
/// (`First slice in a frame missing`, `Could not find ref with POC …`, `Error
|
||
/// constructing the frame RPS`) — normal chatter while the decoder waits for a keyframe
|
||
/// after loss, but a raw flood in the user's terminal (it bypasses our tracing). Default
|
||
/// it to fatal-only; `PUNKTFUNK_FFMPEG_LOG=<quiet|error|warning|info|debug>` restores it
|
||
/// for decode debugging. Process-global; set once per decoder build (idempotent).
|
||
///
|
||
/// Nothing to quiet without `ffmpeg-fallback` — no libavcodec decoder is ever opened —
|
||
/// so it compiles out with the rungs whose chatter it exists to suppress.
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
fn quiet_ffmpeg_log() {
|
||
use ffmpeg::util::log::Level;
|
||
let level = match std::env::var("PUNKTFUNK_FFMPEG_LOG").ok().as_deref() {
|
||
Some("quiet") => Level::Quiet,
|
||
Some("error") => Level::Error,
|
||
Some("warning") => Level::Warning,
|
||
Some("info") => Level::Info,
|
||
Some("debug" | "trace") => Level::Debug,
|
||
_ => Level::Fatal,
|
||
};
|
||
ffmpeg::util::log::set_level(level);
|
||
}
|
||
|
||
/// Say what `PUNKTFUNK_AU_FAULT` will do to THIS session, once, at decoder
|
||
/// construction — including the two cases where the answer is "nothing".
|
||
///
|
||
/// The knob only bites on the native rung (its injector sits at that backend's
|
||
/// decode entry), so a lab run that armed it and landed anywhere else — an FFmpeg
|
||
/// rung, a shape the native rung refused, a session that demoted — must be told
|
||
/// so. Silence there is indistinguishable from "the fault was injected and
|
||
/// nothing detected it", which is precisely the conclusion a fault run exists to
|
||
/// make trustworthy. Unset is the normal state and says nothing at all.
|
||
fn report_au_fault_env(native_rung: bool) {
|
||
let Ok(spec) = std::env::var("PUNKTFUNK_AU_FAULT") else {
|
||
return;
|
||
};
|
||
if spec.is_empty() {
|
||
return;
|
||
}
|
||
match pf_vkdecode::AuFault::from_spec(&spec) {
|
||
// The native backend logs the arming itself (mode + period), with the
|
||
// decoder it is about to corrupt in hand — no need to say it twice.
|
||
Some(_) if native_rung => {}
|
||
Some(_) => tracing::warn!(
|
||
value = %spec,
|
||
"PUNKTFUNK_AU_FAULT is armed, but this session is NOT on the native \
|
||
Vulkan rung — no AU will be corrupted and no detector will fire"
|
||
),
|
||
None => tracing::warn!(
|
||
value = %spec,
|
||
"PUNKTFUNK_AU_FAULT not understood (want drop|truncate|flip[:period]) \
|
||
— ignored"
|
||
),
|
||
}
|
||
}
|
||
|
||
/// Name the rung a session just landed on, and say whether any hardware has ever decoded
|
||
/// a frame through it for this codec.
|
||
///
|
||
/// This is M9's honesty surface, and it exists because the milestone makes native rungs
|
||
/// the default while two of them have never decoded anything anywhere. A field report of
|
||
/// the form "the flip broke my stream" is only actionable if the log distinguishes *the
|
||
/// rung with three drivers and a 92-minute soak behind it* from *the rung nothing has ever
|
||
/// run*, and the `stats:` decode-path tag — which is a machine interface and stays
|
||
/// additive-only — names the rung but says nothing about its provenance.
|
||
///
|
||
/// So: `info` when the pair is hardware-verified, **`warn` when it is not**, with the
|
||
/// evidence string from [`native_evidence`] carried verbatim so the log explains itself
|
||
/// without a reader having to find this file. The FFmpeg rungs report as verified — they
|
||
/// are the pre-M9 shipping path, which is exactly what "has hardware behind it" means
|
||
/// here — and carry a note saying so.
|
||
fn log_rung(backend: &Backend, wire: u8) {
|
||
let (rung, evidence) = match backend {
|
||
Backend::NativeVulkan(_) => (
|
||
NativeRung::Vulkan.name(),
|
||
Some(native_evidence(NativeRung::Vulkan, wire)),
|
||
),
|
||
#[cfg(windows)]
|
||
Backend::NativeD3d11va(_) => (
|
||
NativeRung::D3d11va.name(),
|
||
Some(native_evidence(NativeRung::D3d11va, wire)),
|
||
),
|
||
#[cfg(target_os = "linux")]
|
||
Backend::NativeVaapi(_) => (
|
||
NativeRung::Vaapi.name(),
|
||
Some(native_evidence(NativeRung::Vaapi, wire)),
|
||
),
|
||
Backend::Software(_) => (
|
||
NativeRung::Software.name(),
|
||
Some(native_evidence(NativeRung::Software, wire)),
|
||
),
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
Backend::Vulkan(_) => ("vulkan", None),
|
||
#[cfg(all(target_os = "linux", feature = "ffmpeg-fallback"))]
|
||
Backend::Vaapi(_) => ("vaapi", None),
|
||
#[cfg(all(windows, feature = "ffmpeg-fallback"))]
|
||
Backend::D3d11va(_) => ("d3d11va", None),
|
||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||
Backend::PyroWave(_) => ("pyrowave", None),
|
||
};
|
||
let codec = wire_codec_name(wire);
|
||
match evidence {
|
||
Some(e) if e.verified => tracing::info!(
|
||
rung,
|
||
codec,
|
||
hardware_verified = true,
|
||
evidence = e.note,
|
||
"decode rung active"
|
||
),
|
||
Some(e) => tracing::warn!(
|
||
rung,
|
||
codec,
|
||
hardware_verified = false,
|
||
evidence = e.note,
|
||
"decode rung active — NO hardware has ever decoded a frame through this \
|
||
rung/codec pair (M9 evidence table, video.rs)"
|
||
),
|
||
None => tracing::info!(
|
||
rung,
|
||
codec,
|
||
hardware_verified = true,
|
||
evidence = "libavcodec rung (ffmpeg-fallback) — the pre-M9 shipping path",
|
||
"decode rung active"
|
||
),
|
||
}
|
||
}
|
||
|
||
impl Decoder {
|
||
/// `codec_id` is the codec the host resolved in the Welcome (never assume HEVC).
|
||
/// `pref` is the Settings "Video decoder" value (`auto`/`vulkan`/`vaapi`/`d3d11va`/
|
||
/// `software`; `hardware` — the WinUI shell's stored value — reads as auto).
|
||
/// `vk` is the presenter's shared Vulkan device when its stack can run FFmpeg's
|
||
/// Vulkan Video decoder — decode lands as VkImages the presenter samples directly.
|
||
/// Precedence: the `PUNKTFUNK_DECODER` env override wins (support/debug escape
|
||
/// hatch, and the documented knob), then the setting; both default to auto.
|
||
/// Auto's hardware order depends on the device on BOTH desktop OSes
|
||
/// ([`VulkanDecodeDevice::prefer_vulkan_first`]) and, since M9, every rung it walks
|
||
/// is a NATIVE rung with its FFmpeg twin — when `ffmpeg-fallback` compiled one —
|
||
/// directly below it as the fall-through. Linux: native-vk → vk → native-vaapi →
|
||
/// vaapi → software on NVIDIA and ALL AMD (`prefer_vulkan_first` is vendor-wide —
|
||
/// desktop RADV included, on-glass verdict — not just the Deck's VanGogh); the two
|
||
/// PAIRS swap on Intel/unknown. Windows (no VAAPI there): native-vk → vk →
|
||
/// native-d3d11va → d3d11va → software on NVIDIA/AMD, the pairs swapped on
|
||
/// Intel/unknown (Intel's driver advertises Vulkan Video, but FFmpeg-Vulkan on it
|
||
/// strobes/overruns the budget — B580 field report). A native rung with NO hardware
|
||
/// evidence is skipped while a proven rung is still below it — see
|
||
/// [`native_rung_admitted`], and this module's evidence table for who is who.
|
||
///
|
||
/// Whatever it lands on, the session logs `decode rung active` with the rung's name
|
||
/// and its evidence state, and that line is a WARNING when no hardware has ever
|
||
/// decoded a frame through the rung/codec pair the session just chose.
|
||
///
|
||
/// `stream` is the picture shape the host resolved ([`StreamFormat`]). Only the
|
||
/// native rung reads it — as its construction-time device probe — because it is
|
||
/// the one backend whose support for a shape is a per-device fact the ladder must
|
||
/// learn BEFORE it commits (FFmpeg's rungs open a codec and discover the pool
|
||
/// format themselves).
|
||
pub fn new(
|
||
codec_id: ffmpeg::codec::Id,
|
||
pref: &str,
|
||
vk: Option<&VulkanDecodeDevice>,
|
||
stream: StreamFormat,
|
||
) -> Result<Decoder> {
|
||
// libav is initialised (and hushed) only where a libav decoder can actually be
|
||
// opened — i.e. only when `ffmpeg-fallback` compiled one.
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
{
|
||
ffmpeg::init().context("ffmpeg init")?;
|
||
quiet_ffmpeg_log();
|
||
}
|
||
// The WIRE codec: what the evidence table and every admission decision are keyed
|
||
// on. Derived once here so the ladder below never re-derives it per rung.
|
||
let wire = wire_codec_of(codec_id);
|
||
let choice = std::env::var("PUNKTFUNK_DECODER")
|
||
.ok()
|
||
.filter(|v| !v.is_empty())
|
||
.unwrap_or_else(|| pref.to_string());
|
||
#[cfg(windows)]
|
||
let (d3d11_import, adapter_luid, d3d11_hdr10) = (
|
||
vk.is_some_and(|v| v.d3d11_import),
|
||
vk.and_then(|v| v.adapter_luid),
|
||
vk.is_some_and(|v| v.d3d11_hdr10),
|
||
);
|
||
let done = |backend: Backend| {
|
||
// Whatever rung this session landed on, say what `PUNKTFUNK_AU_FAULT`
|
||
// is going to do about it — see [`report_au_fault_env`]. Here, at the
|
||
// one exit every backend leaves through, rather than in the native
|
||
// backend's constructor: a lab run whose session never REACHES that
|
||
// constructor (an FFmpeg rung, a refused shape, a demotion) would
|
||
// otherwise sit silently un-faulted and read as a fault run that
|
||
// detected nothing.
|
||
report_au_fault_env(matches!(backend, Backend::NativeVulkan(_)));
|
||
// ...and say WHICH rung it is and whether any hardware has ever decoded a
|
||
// frame through it. M9 makes native rungs the default, and two of them have
|
||
// no hardware evidence at all — a session log that does not distinguish
|
||
// those from the proven ones would make every field report about the flip
|
||
// unfalsifiable. See [`log_rung`].
|
||
log_rung(&backend, wire);
|
||
Ok(Decoder {
|
||
entered_rungs: rung_bit(&backend),
|
||
backend,
|
||
codec_id,
|
||
wire_codec: wire,
|
||
vaapi_fails: 0,
|
||
first_fail: None,
|
||
want_keyframe: false,
|
||
delivered: false,
|
||
vk: vk.cloned(),
|
||
stream,
|
||
#[cfg(windows)]
|
||
d3d11_import,
|
||
#[cfg(windows)]
|
||
adapter_luid,
|
||
#[cfg(windows)]
|
||
d3d11_hdr10,
|
||
})
|
||
};
|
||
// Native Vulkan Video (pf-vkdecode), pinned by name (`PUNKTFUNK_DECODER=
|
||
// native-vulkan`). Since the 2026-08-05 ladder decision native is ALSO an
|
||
// auto rung (below, immediately above FFmpeg-Vulkan); the pin stays as the
|
||
// support/debug escape hatch that skips the vendor-ordered rungs ahead of
|
||
// it. Any refusal or init failure logs and DEMOTES to the standard ladder
|
||
// below exactly as if the native rung errored (choice reads as `auto` from
|
||
// here on) — a native failure must never be quieter, or land somewhere
|
||
// other, than the FFmpeg rungs' failures do.
|
||
let mut choice = choice;
|
||
// Native D3D11VA (M5, pf-dxvadec) pinned by name, ahead of everything because a pin
|
||
// is a pin: it skips the vendor order AND the evidence filter, which is how a lab run
|
||
// reaches the AV1 leg `auto` will not pick. (`auto` DOES reach this rung's H.264/H.265
|
||
// legs since M9 — they have hardware parity; the pair arm below is where that
|
||
// happens.) A refusal or an init failure logs and drops to the standard ladder
|
||
// (choice reads as `auto` from here on), exactly like the native-vulkan pin below —
|
||
// a native failure must never be quieter, or land somewhere other, than the FFmpeg
|
||
// rungs' failures do.
|
||
#[cfg(windows)]
|
||
if choice == crate::video_d3d11_native::DECODER_PIN {
|
||
match (native_d3d11_codec(codec_id), vk.filter(|v| v.d3d11_import)) {
|
||
(Some(codec), Some(v)) => {
|
||
match crate::video_d3d11_native::NativeD3d11Decoder::new(
|
||
codec,
|
||
stream,
|
||
v.adapter_luid,
|
||
v.d3d11_hdr10,
|
||
) {
|
||
Ok(d) => {
|
||
tracing::info!(
|
||
?codec_id,
|
||
decoder = d.name(),
|
||
"native D3D11VA hardware decode active \
|
||
(pf-dxvadec, shared-texture hand-off)"
|
||
);
|
||
return done(Backend::NativeD3d11va(Box::new(d)));
|
||
}
|
||
Err(e) => tracing::warn!(reason = %format!("{e:#}"),
|
||
"native D3D11VA init failed — demoting to the standard ladder"),
|
||
}
|
||
}
|
||
(None, _) => tracing::warn!(
|
||
?codec_id,
|
||
"PUNKTFUNK_DECODER=native-d3d11va refused (needs an H.264, HEVC or \
|
||
AV1 session) — standard ladder"
|
||
),
|
||
(_, None) => tracing::warn!(
|
||
"PUNKTFUNK_DECODER=native-d3d11va refused (the presenter's device lacks \
|
||
the win32 external-memory import extensions) — standard ladder"
|
||
),
|
||
}
|
||
choice = "auto".to_string();
|
||
}
|
||
// Native VAAPI (M6, pf-vaadec) pinned by name, ahead of everything because a pin is
|
||
// a pin — and here that matters most, because this rung has decoded nothing on any
|
||
// hardware, so `auto` reaches it only under [`native_rung_admitted`] and the pin is
|
||
// what a lab run uses to GENERATE the evidence that would change that. A refusal or
|
||
// an init failure logs and drops to the standard ladder (choice reads as `auto` from
|
||
// here on), so a native failure is never quieter, nor lands somewhere other, than an
|
||
// FFmpeg rung's failure.
|
||
#[cfg(target_os = "linux")]
|
||
if choice == crate::video_vaapi_native::DECODER_PIN {
|
||
match native_vaapi_codec(codec_id) {
|
||
Some(codec) => {
|
||
match crate::video_vaapi_native::NativeVaapiDecoder::new(codec, stream) {
|
||
Ok(d) => {
|
||
tracing::info!(
|
||
?codec_id,
|
||
decoder = d.name(),
|
||
"native VAAPI hardware decode active (pf-vaadec, zero-copy dmabuf)"
|
||
);
|
||
return done(Backend::NativeVaapi(Box::new(d)));
|
||
}
|
||
Err(e) => tracing::warn!(reason = %format!("{e:#}"),
|
||
"native VAAPI init failed — demoting to the standard ladder"),
|
||
}
|
||
}
|
||
None => tracing::warn!(
|
||
?codec_id,
|
||
"PUNKTFUNK_DECODER=native-vaapi refused (needs an H.264, HEVC or \
|
||
AV1 session) — standard ladder"
|
||
),
|
||
}
|
||
choice = "auto".to_string();
|
||
}
|
||
let mut native_tried = false;
|
||
if choice == "native-vulkan" {
|
||
if native_vulkan_gate(
|
||
&choice,
|
||
codec_id,
|
||
vk.is_some_and(|v| v.video_decode),
|
||
vk.map_or(0, |v| v.decode_video_caps),
|
||
) {
|
||
native_tried = true;
|
||
let vk = vk.expect("gate demands video_decode, so vk is Some");
|
||
let (codec, _) = native_codec(codec_id).expect("the gate admitted this codec");
|
||
match NativeVulkanDecoder::new(vk, codec, stream) {
|
||
Ok(n) => {
|
||
tracing::info!(
|
||
?codec_id,
|
||
"native Vulkan Video hardware decode active \
|
||
(pf-vkdecode, presenter-shared device)"
|
||
);
|
||
return done(Backend::NativeVulkan(Box::new(n)));
|
||
}
|
||
Err(e) => tracing::warn!(reason = %format!("{e:#}"),
|
||
"native Vulkan decode init failed — demoting to the standard ladder"),
|
||
}
|
||
} else {
|
||
tracing::warn!(
|
||
?codec_id,
|
||
video_decode = vk.is_some_and(|v| v.video_decode),
|
||
"PUNKTFUNK_DECODER=native-vulkan refused (needs an H.264, HEVC or AV1 \
|
||
session and a presenter device whose decode family advertises that \
|
||
codec) — standard ladder"
|
||
);
|
||
}
|
||
choice = "auto".to_string();
|
||
}
|
||
// Linux's VAAPI RUNG PAIR (M9): native VAAPI (pf-vaadec) first, libavcodec's VAAPI
|
||
// hwaccel directly below it. `auto` reaches this pair from two places — Intel/unknown
|
||
// take it before Vulkan, everyone else after — and both must apply the same order, so
|
||
// it lives here once instead of twice.
|
||
//
|
||
// The native half is admission-gated ([`native_rung_admitted`]): it has decoded
|
||
// nothing on any hardware, so in an `ffmpeg-fallback` build it is SKIPPED unless the
|
||
// user switched it in (`PUNKTFUNK_NATIVE_FIRST=1`) — which leaves this arm behaving
|
||
// exactly as it did before M9 — while in a build without the FFmpeg rung it is the
|
||
// only VAAPI there is and runs with the warning `done` logs.
|
||
#[cfg(target_os = "linux")]
|
||
let vaapi_pair = |choice: &str| -> Result<Option<Backend>> {
|
||
if native_rung_admitted(NativeRung::Vaapi, wire) {
|
||
if let Some(codec) = native_vaapi_codec(codec_id) {
|
||
match crate::video_vaapi_native::NativeVaapiDecoder::new(codec, stream) {
|
||
Ok(d) => {
|
||
tracing::info!(
|
||
?codec_id,
|
||
decoder = d.name(),
|
||
"native VAAPI hardware decode active (pf-vaadec, zero-copy dmabuf)"
|
||
);
|
||
return Ok(Some(Backend::NativeVaapi(Box::new(d))));
|
||
}
|
||
Err(e) => tracing::info!(reason = %format!("{e:#}"),
|
||
"native VAAPI unavailable — continuing down the ladder"),
|
||
}
|
||
}
|
||
}
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
match VaapiDecoder::new(codec_id) {
|
||
Ok(v) => {
|
||
tracing::info!(
|
||
?codec_id,
|
||
decoder = v.name(),
|
||
"VAAPI hardware decode active (zero-copy dmabuf)"
|
||
);
|
||
return Ok(Some(Backend::Vaapi(v)));
|
||
}
|
||
Err(e) => {
|
||
if choice == "vaapi" {
|
||
return Err(e.context("PUNKTFUNK_DECODER=vaapi but VAAPI failed"));
|
||
}
|
||
tracing::info!(reason = %e, "VAAPI unavailable — continuing down the ladder");
|
||
}
|
||
}
|
||
// `vaapi` names libavcodec's rung specifically (`native-vaapi` is the other one),
|
||
// so without `ffmpeg-fallback` the pin names something that is not in this build.
|
||
// Saying so beats silently landing the session two rungs lower.
|
||
#[cfg(not(feature = "ffmpeg-fallback"))]
|
||
if choice == "vaapi" {
|
||
bail!(
|
||
"PUNKTFUNK_DECODER=vaapi names libavcodec's VAAPI rung, which this build \
|
||
does not contain (no `ffmpeg-fallback` feature) — use native-vaapi"
|
||
);
|
||
}
|
||
Ok(None)
|
||
};
|
||
// Linux `auto`: try the VAAPI pair FIRST unless this device is one where Vulkan Video
|
||
// is the established right answer (NVIDIA — no usable VAAPI; VanGogh — VAAPI
|
||
// chroma-fringes). Mesa now exposes decode queues by default (and the session
|
||
// binary opts RADV in for the Deck's sake), which silently moved every desktop
|
||
// AMD/Intel box onto FFmpeg-Vulkan-on-Mesa — user-reported to judder/error-streak
|
||
// (then demote to software) where explicit VAAPI streams perfectly.
|
||
#[cfg(target_os = "linux")]
|
||
let mut vaapi_tried = false;
|
||
#[cfg(target_os = "linux")]
|
||
if matches!(choice.as_str(), "auto" | "" | "hardware")
|
||
&& !vk
|
||
.filter(|v| v.video_decode)
|
||
.is_some_and(|v| v.prefer_vulkan_first())
|
||
{
|
||
vaapi_tried = true;
|
||
if let Some(b) = vaapi_pair(&choice)? {
|
||
return done(b);
|
||
}
|
||
}
|
||
// Windows `auto`: D3D11VA FIRST unless this device is one where Vulkan Video is
|
||
// the established right answer (NVIDIA/AMD). Intel's Windows driver advertises
|
||
// Vulkan Video (Arc drivers since 2023) so the capability gate alone no longer
|
||
// keeps Intel off FFmpeg-Vulkan — and that combination is field-broken (B580,
|
||
// 2026-07: strobing between clean anchors and corrupt inter frames that never
|
||
// trips the error-streak demotion, 7 ms p50 decodes blowing the 120 Hz budget)
|
||
// where D3D11VA — the DXVA path every Windows video player exercises, and what
|
||
// this backend was built for — streams clean. Vulkan stays reachable below by
|
||
// explicit preference and as auto's fallback when D3D11VA can't be built.
|
||
//
|
||
// Windows' D3D11VA RUNG PAIR (M9), the DXVA twin of the VAAPI pair above: native
|
||
// D3D11VA (pf-dxvadec) first, libavcodec's D3D11VA hwaccel directly below it. Its
|
||
// H.264/H.265 legs HAVE hardware evidence (parity on an RTX 4090 and an AMD iGPU
|
||
// plus a 30-minute soak, M5), so `auto` picks them in a shipping build; its AV1 leg
|
||
// has none, so `auto` skips that one and lands on the FFmpeg rung exactly as before
|
||
// M9. Both halves need the presenter's win32 import path or their frames could never
|
||
// reach the screen — that check is the pair's, once, for the same reason the order is.
|
||
#[cfg(windows)]
|
||
let d3d11_pair = |choice: &str| -> Result<Option<Backend>> {
|
||
let Some(v) = vk.filter(|v| v.d3d11_import) else {
|
||
if choice == "d3d11va" {
|
||
bail!(
|
||
"PUNKTFUNK_DECODER=d3d11va but the presenter's device lacks the win32 \
|
||
external-memory import extensions — see the presenter log"
|
||
);
|
||
}
|
||
return Ok(None);
|
||
};
|
||
if native_rung_admitted(NativeRung::D3d11va, wire) {
|
||
if let Some(codec) = native_d3d11_codec(codec_id) {
|
||
match crate::video_d3d11_native::NativeD3d11Decoder::new(
|
||
codec,
|
||
stream,
|
||
v.adapter_luid,
|
||
v.d3d11_hdr10,
|
||
) {
|
||
Ok(d) => {
|
||
tracing::info!(
|
||
?codec_id,
|
||
decoder = d.name(),
|
||
"native D3D11VA hardware decode active \
|
||
(pf-dxvadec, shared-texture hand-off)"
|
||
);
|
||
return Ok(Some(Backend::NativeD3d11va(Box::new(d))));
|
||
}
|
||
Err(e) => tracing::info!(reason = %format!("{e:#}"),
|
||
"native D3D11VA unavailable — continuing down the ladder"),
|
||
}
|
||
}
|
||
}
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
match crate::video_d3d11::D3d11vaDecoder::new(codec_id, v.adapter_luid, v.d3d11_hdr10) {
|
||
Ok(d) => {
|
||
tracing::info!(
|
||
?codec_id,
|
||
decoder = d.name(),
|
||
"D3D11VA hardware decode active (shared-texture hand-off)"
|
||
);
|
||
return Ok(Some(Backend::D3d11va(d)));
|
||
}
|
||
Err(e) => {
|
||
if choice == "d3d11va" {
|
||
return Err(e.context("PUNKTFUNK_DECODER=d3d11va but it failed"));
|
||
}
|
||
tracing::info!(reason = %format!("{e:#}"),
|
||
"D3D11VA unavailable — continuing down the ladder");
|
||
}
|
||
}
|
||
// Same as the VAAPI pin above: `d3d11va` names libavcodec's rung specifically.
|
||
#[cfg(not(feature = "ffmpeg-fallback"))]
|
||
if choice == "d3d11va" {
|
||
bail!(
|
||
"PUNKTFUNK_DECODER=d3d11va names libavcodec's DXVA rung, which this build \
|
||
does not contain (no `ffmpeg-fallback` feature) — use native-d3d11va"
|
||
);
|
||
}
|
||
Ok(None)
|
||
};
|
||
#[cfg(windows)]
|
||
let mut d3d11_tried = false;
|
||
#[cfg(windows)]
|
||
if matches!(choice.as_str(), "auto" | "" | "hardware")
|
||
&& !vk
|
||
.filter(|v| v.video_decode)
|
||
.is_some_and(|v| v.prefer_vulkan_first())
|
||
{
|
||
d3d11_tried = true;
|
||
if let Some(b) = d3d11_pair(&choice)? {
|
||
return done(b);
|
||
}
|
||
}
|
||
// The VULKAN RUNG PAIR: native Vulkan Video (pf-vkdecode) with FFmpeg-Vulkan
|
||
// directly below it. Unlike the two pairs above it needs no closure — `auto`
|
||
// reaches it from exactly one place. [`native_vulkan_gate`] carries the whole
|
||
// admission decision, including the choice: the explicit `vulkan` pin is NOT this
|
||
// rung — it names the FFmpeg-Vulkan backend specifically and keeps meaning exactly
|
||
// that. Every codec leg of this rung has hardware parity against libavcodec (M2/M3
|
||
// for H.264/H.265, M7 for AV1 — this module's evidence table), which is what M9's
|
||
// flip rests on here; an init failure still logs and falls through to the rung
|
||
// below, so admission can never cost a session hardware decode it had before.
|
||
// (`native_tried` skips the repeat when the pin above already attempted — and
|
||
// failed — the same construction.)
|
||
if !native_tried
|
||
&& native_vulkan_gate(
|
||
&choice,
|
||
codec_id,
|
||
vk.is_some_and(|v| v.video_decode),
|
||
vk.map_or(0, |v| v.decode_video_caps),
|
||
)
|
||
{
|
||
let vk = vk.expect("gate demands video_decode, so vk is Some");
|
||
let (codec, _) = native_codec(codec_id).expect("the gate admitted this codec");
|
||
match NativeVulkanDecoder::new(vk, codec, stream) {
|
||
Ok(n) => {
|
||
tracing::info!(
|
||
?codec_id,
|
||
"native Vulkan Video hardware decode active \
|
||
(pf-vkdecode auto rung, presenter-shared device)"
|
||
);
|
||
return done(Backend::NativeVulkan(Box::new(n)));
|
||
}
|
||
Err(e) => tracing::info!(reason = %format!("{e:#}"),
|
||
"native Vulkan decode unavailable — continuing down the ladder"),
|
||
}
|
||
}
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
if matches!(choice.as_str(), "auto" | "" | "vulkan" | "hardware") {
|
||
// `video_decode` gates the Vulkan Video attempt: the presenter now exports its
|
||
// handle bundle even when the device has no decode queue (Windows D3D11 interop
|
||
// rides the same struct), so presence alone no longer implies a usable decoder.
|
||
match vk.filter(|v| v.video_decode) {
|
||
Some(vk) => match VulkanDecoder::new(codec_id, vk) {
|
||
Ok(v) => {
|
||
tracing::info!(
|
||
?codec_id,
|
||
decoder = v.name(),
|
||
"Vulkan Video hardware decode active (presenter-shared device)"
|
||
);
|
||
return done(Backend::Vulkan(v));
|
||
}
|
||
Err(e) => {
|
||
if choice == "vulkan" {
|
||
return Err(e.context("PUNKTFUNK_DECODER=vulkan but it failed"));
|
||
}
|
||
tracing::info!(reason = %format!("{e:#}"),
|
||
"Vulkan Video unavailable — falling back");
|
||
}
|
||
},
|
||
None if choice == "vulkan" => {
|
||
bail!(
|
||
"PUNKTFUNK_DECODER=vulkan but the presenter's device can't (missing \
|
||
video extensions/queue) — see the presenter log"
|
||
)
|
||
}
|
||
None => {}
|
||
}
|
||
}
|
||
// Same as the two platform pins: `vulkan` names FFmpeg's rung specifically, so in a
|
||
// build without it the pin names something absent. `native-vulkan` is the other one.
|
||
#[cfg(not(feature = "ffmpeg-fallback"))]
|
||
if choice == "vulkan" {
|
||
bail!(
|
||
"PUNKTFUNK_DECODER=vulkan names FFmpeg's Vulkan Video rung, which this build \
|
||
does not contain (no `ffmpeg-fallback` feature) — use native-vulkan"
|
||
);
|
||
}
|
||
// Deck/NVIDIA note: `auto` reaches the VAAPI pair here when Vulkan Video isn't
|
||
// available (on desktop Mesa it was already tried above — `vaapi_tried` skips the
|
||
// repeat). A presenter that can't display the dmabufs demotes this decoder to
|
||
// software mid-session via [`Decoder::force_software`]. Windows has no VAAPI — auto
|
||
// falls straight through to software there.
|
||
#[cfg(target_os = "linux")]
|
||
if choice != "software" && choice != "vulkan" && !vaapi_tried {
|
||
if let Some(b) = vaapi_pair(&choice)? {
|
||
return done(b);
|
||
}
|
||
}
|
||
// Windows: the D3D11VA pair as the fallback rung for NVIDIA/AMD auto (Vulkan Video
|
||
// missing or failed to open) and for the explicit `d3d11va` preference. (On
|
||
// Intel/unknown auto it was already tried above — `d3d11_tried` skips the repeat.)
|
||
#[cfg(windows)]
|
||
if choice != "software" && choice != "vulkan" && !d3d11_tried {
|
||
if let Some(b) = d3d11_pair(&choice)? {
|
||
return done(b);
|
||
}
|
||
}
|
||
if choice == "software" {
|
||
// Say WHY hardware wasn't even attempted — a stored "software" preference
|
||
// (or the env override) silently skipping vulkan/vaapi has burned real
|
||
// debugging time on boxes that could do better.
|
||
tracing::info!(
|
||
"software decode by preference (Settings decoder / PUNKTFUNK_DECODER) — \
|
||
hardware decode not attempted"
|
||
);
|
||
}
|
||
// `?` here can carry a `NoSoftwareRung` (an HEVC session that pinned software, or
|
||
// one whose device offered no hardware rung at all). It stays typed all the way
|
||
// to the pump, which turns it into the reconnect rather than a dead session —
|
||
// see [`last_rung_verdict`].
|
||
done(Backend::Software(SoftwareDecoder::new(wire_codec_of(
|
||
codec_id,
|
||
))?))
|
||
}
|
||
|
||
/// Wait for a Vulkan-Video frame's GPU decode to complete (timeline semaphore) —
|
||
/// the pump's decode-stat measurement. `false` = not a Vulkan backend, timeout, or
|
||
/// (native rung) a pair no longer in the shipped ledger / a stale session
|
||
/// generation — every false just declines the sample.
|
||
pub fn wait_hw_decoded(&self, timeline_sem: u64, value: u64, timeout_ns: u64) -> bool {
|
||
match &self.backend {
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
Backend::Vulkan(v) => v.wait_timeline(timeline_sem, value, timeout_ns),
|
||
Backend::NativeVulkan(d) => d.wait_timeline(timeline_sem, value, timeout_ns),
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// This session's decode-integrity counters, or `None` on a backend that has
|
||
/// no way to answer (every FFmpeg rung and PyroWave — see [`DecodeHealth`]).
|
||
///
|
||
/// `None` and `Some(DecodeHealth::default())` are deliberately different
|
||
/// answers, and the stats surface must keep them different: the first is "this
|
||
/// decoder cannot see corruption", the second is "this decoder looked and saw
|
||
/// none". Reporting the first as the second is exactly the mistake that let a
|
||
/// field corruption run undetected for a release.
|
||
pub fn decode_health(&self) -> Option<DecodeHealth> {
|
||
match &self.backend {
|
||
Backend::NativeVulkan(d) => Some(d.health()),
|
||
// The native DXVA rung has the bitstream planner, so it sees concealment and
|
||
// refusals — but D3D11VA exposes no per-picture status query at all, so its
|
||
// `status_queries` is false and `failed` stays structurally 0. That is the
|
||
// honest report: "this decoder looked at the STREAM and saw none" without
|
||
// claiming a driver verdict nothing can produce.
|
||
#[cfg(windows)]
|
||
Backend::NativeD3d11va(d) => Some(d.health()),
|
||
// Same shape as the DXVA rung above, for the same reason: libva has no
|
||
// per-picture decode-status query either.
|
||
#[cfg(target_os = "linux")]
|
||
Backend::NativeVaapi(d) => Some(d.health()),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The DECODE-order ordinal of the newest picture this lane has planned — the
|
||
/// watermark a caller stamps when it arms a post-loss freeze, so it can tell a
|
||
/// frame decoded before the loss from one decoded after it (see
|
||
/// [`NativeVkFrame::decode_order`]). 0 on every lane that has no bitstream
|
||
/// parser of its own, which is also every lane that reports no local recovery.
|
||
pub fn decode_order(&self) -> u64 {
|
||
match &self.backend {
|
||
Backend::NativeVulkan(d) => d.decode_order(),
|
||
_ => 0,
|
||
}
|
||
}
|
||
|
||
/// Drain the "please ask the host for an IDR" flag — the pump calls this each iteration
|
||
/// (throttled) so a demoted/erroring decoder can resynchronize under the infinite GOP.
|
||
/// Open a PyroWave decoder for a `CODEC_PYROWAVE` session (plan §4.5): pyrowave
|
||
/// compute on the presenter's device, no FFmpeg. `codec_id` is irrelevant (kept as
|
||
/// HEVC so an — impossible — demotion path stays well-formed).
|
||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||
pub fn new_pyrowave(
|
||
vk: &VulkanDecodeDevice,
|
||
width: u32,
|
||
height: u32,
|
||
shard_payload: usize,
|
||
chroma444: bool,
|
||
color: ColorDesc,
|
||
hdr16: bool,
|
||
) -> Result<Decoder> {
|
||
// Never the native rung — see [`report_au_fault_env`].
|
||
report_au_fault_env(false);
|
||
Ok(Decoder {
|
||
backend: Backend::PyroWave(Box::new(crate::video_pyrowave::PyroWaveDecoder::new(
|
||
vk,
|
||
width,
|
||
height,
|
||
shard_payload,
|
||
chroma444,
|
||
color,
|
||
hdr16,
|
||
)?)),
|
||
codec_id: ffmpeg::codec::Id::HEVC,
|
||
wire_codec: punktfunk_core::quic::CODEC_PYROWAVE,
|
||
vaapi_fails: 0,
|
||
first_fail: None,
|
||
want_keyframe: false,
|
||
delivered: false,
|
||
// A PyroWave session never demotes (nothing else decodes it — a failure
|
||
// renegotiates the codec instead), so the demotion-rebuild facts (the
|
||
// device here, the D3D11VA ones below) are unused; keep them well-formed
|
||
// rather than plumbing them in for nothing.
|
||
vk: None,
|
||
stream: StreamFormat::SDR_420_8,
|
||
// A PyroWave session never demotes, so nothing ever reads this.
|
||
entered_rungs: 0,
|
||
#[cfg(windows)]
|
||
d3d11_import: false,
|
||
#[cfg(windows)]
|
||
adapter_luid: None,
|
||
#[cfg(windows)]
|
||
d3d11_hdr10: false,
|
||
})
|
||
}
|
||
|
||
pub fn take_keyframe_request(&mut self) -> bool {
|
||
std::mem::take(&mut self.want_keyframe)
|
||
}
|
||
|
||
/// Install a rung: swap the backend in and reset everything that describes the OLD
|
||
/// one's health, in one place.
|
||
///
|
||
/// It exists because M9 doubled the number of demotion targets, and every one of
|
||
/// them has to clear the same four things — the error streak, its start stamp, the
|
||
/// delivered flag, and (new) the [`Self::entered_rungs`] bookkeeping the walk's
|
||
/// termination depends on. Four call sites each doing it by hand is how one of them
|
||
/// eventually forgets the bit and the ladder starts looping.
|
||
fn install(&mut self, backend: Backend) {
|
||
self.entered_rungs |= rung_bit(&backend);
|
||
self.backend = backend;
|
||
self.vaapi_fails = 0;
|
||
self.first_fail = None;
|
||
self.delivered = false;
|
||
}
|
||
|
||
/// Is the running rung the platform's NATIVE hardware rung (native VAAPI on Linux,
|
||
/// native D3D11VA on Windows)? The one demotion candidate that goes "sideways" —
|
||
/// into native Vulkan — fires only from here.
|
||
fn is_native_platform_rung(&self) -> bool {
|
||
#[cfg(target_os = "linux")]
|
||
let it = matches!(self.backend, Backend::NativeVaapi(_));
|
||
#[cfg(windows)]
|
||
let it = matches!(self.backend, Backend::NativeD3d11va(_));
|
||
#[cfg(not(any(target_os = "linux", windows)))]
|
||
let it = false;
|
||
it
|
||
}
|
||
|
||
/// Demote to software decode on the PRESENTER's verdict (dmabuf presentation impossible:
|
||
/// GL converter init failed, texture import rejected). Decode itself succeeds in that
|
||
/// state, so the error-streak demotion never fires — without this the stream would stay
|
||
/// black forever. No-op when already software.
|
||
pub fn force_software(&mut self) -> Result<()> {
|
||
if matches!(self.backend, Backend::Software(_)) {
|
||
return Ok(());
|
||
}
|
||
tracing::warn!("presenter can't display hardware frames — demoting to software decode");
|
||
// Same typed refusal as every other software-rung construction: on an HEVC
|
||
// session there is nothing below this and the pump reconnects.
|
||
self.install(Backend::Software(SoftwareDecoder::new(self.wire_codec)?));
|
||
self.want_keyframe = true;
|
||
Ok(())
|
||
}
|
||
|
||
/// Feed one access unit; returns the decoded frame (the host's streams are
|
||
/// one-in/one-out). A software decode error after packet loss is survivable — log
|
||
/// upstream and keep feeding. A VAAPI error re-requests an IDR and retries the hardware
|
||
/// decoder; only a persistent streak of failures (a genuinely broken driver, e.g.
|
||
/// nvidia-vaapi-driver) demotes to software. Either way `want_keyframe` is set so the
|
||
/// pump asks the host for a fresh IDR — under the infinite GOP nothing else resyncs a
|
||
/// rebuilt/erroring decoder, so skipping this leaves the picture gray/frozen for good.
|
||
pub fn decode(&mut self, au: &[u8]) -> Result<Option<DecodedImage>> {
|
||
self.decode_frame(au, 0, true)
|
||
}
|
||
|
||
/// [`decode`](Self::decode) with the AU's wire facts: `user_flags` (chunk-aligned AUs
|
||
/// are parsed in shard windows — [`punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED`])
|
||
/// and completeness (`false` = a partial delivery; only the PyroWave backend decodes
|
||
/// those — as one frame of localized blur, plan §4.4).
|
||
pub fn decode_frame(
|
||
&mut self,
|
||
au: &[u8],
|
||
// Only the PyroWave backend reads the flags; without that feature the param is unused.
|
||
#[cfg_attr(
|
||
not(all(any(target_os = "linux", windows), feature = "pyrowave")),
|
||
allow(unused_variables)
|
||
)]
|
||
user_flags: u32,
|
||
complete: bool,
|
||
) -> Result<Option<DecodedImage>> {
|
||
// Did THIS AU come back as a concealment — an `Ok(None)` the native rung
|
||
// produced because the picture was damaged, not because the decoder was
|
||
// buffering? Only the native rung can answer, and the answer decides
|
||
// whether the `Ok` below is allowed to clear the demotion streak.
|
||
let mut concealed = false;
|
||
let result = match &mut self.backend {
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
Backend::Vulkan(v) => {
|
||
debug_assert!(complete, "partial AUs are pyrowave-only");
|
||
v.decode(au).map(|f| f.map(DecodedImage::VkFrame))
|
||
}
|
||
Backend::NativeVulkan(n) => {
|
||
debug_assert!(complete, "partial AUs are pyrowave-only");
|
||
let r = n.decode(au).map(|f| f.map(DecodedImage::NativeVk));
|
||
// STREAM damage is not a decoder fault, and must not ride the
|
||
// demotion streak.
|
||
//
|
||
// This distinction only exists on the native rung, because it is
|
||
// the only one that can SEE damage — and that is precisely what
|
||
// makes it dangerous. An FFmpeg rung conceals a lost reference
|
||
// silently and keeps its job; if the native rung turned the same
|
||
// event into an error, three of them over a second would demote
|
||
// the program's own headline decoder exactly on the lossy links it
|
||
// was built to diagnose. So concealment comes back as `Ok(None)`
|
||
// plus this flag: the pump still asks for a re-anchor at the same
|
||
// moment and through the same throttle it always did, and the
|
||
// hardware rung survives the loss that caused it.
|
||
//
|
||
// A driver `RESULT_STATUS` verdict of Failed is NOT routed here —
|
||
// it stays an `Err` below. That one really is a statement about
|
||
// the decoder ("I could not decode what I was given"), and a
|
||
// driver making it repeatedly is the exact case demotion exists
|
||
// for; it is also the Xbox Ally X shape.
|
||
if n.take_recovery_request() {
|
||
self.want_keyframe = true;
|
||
concealed = true;
|
||
}
|
||
r
|
||
}
|
||
#[cfg(all(target_os = "linux", feature = "ffmpeg-fallback"))]
|
||
Backend::Vaapi(v) => v.decode(au).map(|f| f.map(DecodedImage::Dmabuf)),
|
||
#[cfg(target_os = "linux")]
|
||
Backend::NativeVaapi(v) => {
|
||
debug_assert!(complete, "partial AUs are pyrowave-only");
|
||
let r = v.decode(au).map(|f| f.map(DecodedImage::NativeDmabuf));
|
||
// Same split as the two native rungs above, for the same reason: this
|
||
// rung can SEE stream damage, and turning what an FFmpeg rung conceals
|
||
// silently into an error would demote it on exactly the lossy links it
|
||
// exists to diagnose.
|
||
if v.take_recovery_request() {
|
||
self.want_keyframe = true;
|
||
concealed = true;
|
||
}
|
||
r
|
||
}
|
||
#[cfg(all(windows, feature = "ffmpeg-fallback"))]
|
||
Backend::D3d11va(d) => d.decode(au).map(|f| f.map(DecodedImage::D3d11)),
|
||
#[cfg(windows)]
|
||
Backend::NativeD3d11va(d) => {
|
||
debug_assert!(complete, "partial AUs are pyrowave-only");
|
||
let r = d.decode(au).map(|f| f.map(DecodedImage::D3d11));
|
||
// Same split as the native Vulkan rung above, for the same reason: this
|
||
// rung can SEE stream damage, and turning what an FFmpeg rung conceals
|
||
// silently into an error would demote it on exactly the lossy links it
|
||
// exists to diagnose. Concealment comes back as `Ok(None)` plus a
|
||
// re-anchor request through the pump's one throttle.
|
||
if d.take_recovery_request() {
|
||
self.want_keyframe = true;
|
||
concealed = true;
|
||
}
|
||
r
|
||
}
|
||
// No demote ladder below PyroWave (nothing else decodes it): propagate the
|
||
// error; the pump surfaces it and the session falls back to HEVC by
|
||
// renegotiation (plan §4.6), not by decoder swap.
|
||
#[cfg(all(any(target_os = "linux", windows), feature = "pyrowave"))]
|
||
Backend::PyroWave(p) => {
|
||
let aligned = user_flags & punktfunk_core::packet::USER_FLAG_CHUNK_ALIGNED != 0;
|
||
return Ok(p
|
||
.decode_frame(au, aligned, complete)?
|
||
.map(DecodedImage::PyroWave));
|
||
}
|
||
Backend::Software(s) => return Ok(s.decode(au)?.map(DecodedImage::Cpu)),
|
||
};
|
||
match result {
|
||
Ok(f) => {
|
||
// Only an answer that PROVES the rung works may clear the streak —
|
||
// see [`clears_demotion_streak`] for the whole argument.
|
||
if clears_demotion_streak(f.is_some(), concealed) {
|
||
self.vaapi_fails = 0;
|
||
self.first_fail = None;
|
||
}
|
||
self.delivered |= f.is_some();
|
||
Ok(f)
|
||
}
|
||
Err(e) => {
|
||
let which = match self.backend {
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
Backend::Vulkan(_) => "Vulkan Video",
|
||
Backend::NativeVulkan(_) => "native Vulkan Video",
|
||
#[cfg(all(windows, feature = "ffmpeg-fallback"))]
|
||
Backend::D3d11va(_) => "D3D11VA",
|
||
#[cfg(windows)]
|
||
Backend::NativeD3d11va(_) => "native D3D11VA",
|
||
#[cfg(target_os = "linux")]
|
||
Backend::NativeVaapi(_) => "native VAAPI",
|
||
_ => "VAAPI",
|
||
};
|
||
self.vaapi_fails += 1;
|
||
self.want_keyframe = true;
|
||
let first = *self.first_fail.get_or_insert_with(std::time::Instant::now);
|
||
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER && first.elapsed() >= HW_DEMOTE_MIN_STREAK
|
||
{
|
||
// A NATIVE rung that never delivered a single frame is not a
|
||
// failing decoder — it is a decoder the session never had, and
|
||
// the cause is almost always a stream shape THIS DEVICE cannot
|
||
// host (`NativeVulkanDecoder::new`'s probe catches the ones the
|
||
// negotiation can see; a level above the device's `maxLevelIdc`,
|
||
// or an SPS that disagrees with the Welcome, only surface here).
|
||
// Demoting past FFmpeg-Vulkan for that would cost the session the
|
||
// rung it would have run on before this backend existed — on
|
||
// NVIDIA/Linux, where VAAPI is unusable, that means a 4K HEVC
|
||
// session on SOFTWARE. So the first streak in this state falls
|
||
// through to FFmpeg-Vulkan, exactly where a construction failure
|
||
// would have landed. Once a frame HAS been delivered the rung is
|
||
// proven and its streaks demote like every other Vulkan rung's.
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
if !self.delivered && matches!(self.backend, Backend::NativeVulkan(_)) {
|
||
// `take`: this arm is one-shot by construction (the native
|
||
// backend is gone after it), and taking is also what lets the
|
||
// rebuild borrow the device while `self.backend` is assigned.
|
||
if let Some(v) = self.vk.take().filter(|v| v.video_decode) {
|
||
match VulkanDecoder::new(self.codec_id, &v) {
|
||
Ok(fallback) => {
|
||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||
decoder = fallback.name(),
|
||
"native Vulkan Video never delivered a frame — \
|
||
demoting to FFmpeg Vulkan Video");
|
||
self.install(Backend::Vulkan(fallback));
|
||
return Ok(None);
|
||
}
|
||
Err(fe) => tracing::info!(reason = %format!("{fe:#}"),
|
||
"FFmpeg Vulkan Video unavailable for demotion — \
|
||
continuing down the ladder"),
|
||
}
|
||
}
|
||
}
|
||
// Without `ffmpeg-fallback` the arm above does not exist, and the
|
||
// `!delivered` rule is served by the walk that follows instead: the
|
||
// rung DIRECTLY below native Vulkan in that build is the platform's
|
||
// own native rung, which is the very next candidate. Nothing is
|
||
// special-cased for it, because nothing needs to be — the property
|
||
// the arm protects ("a rung the session never had must not cost it
|
||
// the rung below") holds as long as the next candidate IS the next
|
||
// rung, and here it is.
|
||
|
||
// M9's addition to this walk: the platform's NATIVE hardware rung,
|
||
// ABOVE its libavcodec twin, exactly as in `Decoder::new`'s ladder.
|
||
// Admission is the same evidence rule the ladder uses
|
||
// ([`native_rung_admitted`]), so in a shipping `ffmpeg-fallback`
|
||
// build an unproven rung is skipped here too and this walk is
|
||
// byte-for-byte the pre-M9 one.
|
||
//
|
||
// `entered_rungs` is what keeps it monotone: the two native rungs
|
||
// sit in opposite orders per vendor, so without it a demotion could
|
||
// climb back into a rung that already failed.
|
||
#[cfg(target_os = "linux")]
|
||
if self.entered_rungs & RUNG_BIT_NATIVE_PLATFORM == 0
|
||
&& native_rung_admitted(NativeRung::Vaapi, self.wire_codec)
|
||
{
|
||
if let Some(codec) = native_vaapi_codec(self.codec_id) {
|
||
match crate::video_vaapi_native::NativeVaapiDecoder::new(
|
||
codec,
|
||
self.stream,
|
||
) {
|
||
Ok(d) => {
|
||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||
from = which, decoder = d.name(),
|
||
"hardware decode failing repeatedly — demoting to \
|
||
native VAAPI");
|
||
self.install(Backend::NativeVaapi(Box::new(d)));
|
||
return Ok(None);
|
||
}
|
||
Err(va) => tracing::info!(reason = %format!("{va:#}"),
|
||
"native VAAPI unavailable for demotion — continuing down \
|
||
the ladder"),
|
||
}
|
||
}
|
||
}
|
||
#[cfg(windows)]
|
||
if self.entered_rungs & RUNG_BIT_NATIVE_PLATFORM == 0
|
||
&& self.d3d11_import
|
||
&& native_rung_admitted(NativeRung::D3d11va, self.wire_codec)
|
||
{
|
||
if let Some(codec) = native_d3d11_codec(self.codec_id) {
|
||
match crate::video_d3d11_native::NativeD3d11Decoder::new(
|
||
codec,
|
||
self.stream,
|
||
self.adapter_luid,
|
||
self.d3d11_hdr10,
|
||
) {
|
||
Ok(d) => {
|
||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||
from = which, decoder = d.name(),
|
||
"hardware decode failing repeatedly — demoting to \
|
||
native D3D11VA");
|
||
self.install(Backend::NativeD3d11va(Box::new(d)));
|
||
return Ok(None);
|
||
}
|
||
Err(dx) => tracing::info!(reason = %format!("{dx:#}"),
|
||
"native D3D11VA unavailable for demotion — continuing down \
|
||
the ladder"),
|
||
}
|
||
}
|
||
}
|
||
// A failing Vulkan backend (FFmpeg or native — the native rung
|
||
// demotes exactly like the FFmpeg one) still has a hardware rung
|
||
// below it on Linux — demote to VAAPI first (user-reported:
|
||
// FFmpeg-Vulkan-on-Mesa error-streaking where VAAPI streams
|
||
// perfectly); only when that can't be built either does the
|
||
// session land on software.
|
||
// The NATIVE VAAPI rung demotes here too, and to the same place: its
|
||
// failure is a statement about pf-vaadec's submission, not about
|
||
// VAAPI, so libavcodec's decoder on the very same profile is the
|
||
// right next rung — this is the rung DIRECTLY below it in the M9
|
||
// ladder, and it is what keeps a session on hardware when the
|
||
// native half of the pair is the half that broke.
|
||
#[cfg(all(target_os = "linux", feature = "ffmpeg-fallback"))]
|
||
if matches!(
|
||
self.backend,
|
||
Backend::Vulkan(_) | Backend::NativeVulkan(_) | Backend::NativeVaapi(_)
|
||
) {
|
||
match VaapiDecoder::new(self.codec_id) {
|
||
Ok(v) => {
|
||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||
from = which, decoder = v.name(),
|
||
"hardware decode failing repeatedly — demoting to VAAPI");
|
||
self.install(Backend::Vaapi(v));
|
||
return Ok(None);
|
||
}
|
||
Err(va) => tracing::info!(reason = %va,
|
||
"VAAPI unavailable for demotion — software decode"),
|
||
}
|
||
}
|
||
// Windows' hardware rung below Vulkan (FFmpeg or native) is D3D11VA
|
||
// (a 4K120 stream is not survivable on software) — same-GPU rebuild
|
||
// via the stashed LUID. The NATIVE D3D11VA rung demotes here too:
|
||
// its failure is a statement about pf-dxvadec's submission, not about
|
||
// DXVA, so the FFmpeg decoder on the very same profile is the right
|
||
// next rung — the rung DIRECTLY below it in the M9 ladder, and what
|
||
// keeps a session on hardware when the native half of the pair broke.
|
||
#[cfg(all(windows, feature = "ffmpeg-fallback"))]
|
||
if matches!(
|
||
self.backend,
|
||
Backend::Vulkan(_) | Backend::NativeVulkan(_) | Backend::NativeD3d11va(_)
|
||
) && self.d3d11_import
|
||
{
|
||
match crate::video_d3d11::D3d11vaDecoder::new(
|
||
self.codec_id,
|
||
self.adapter_luid,
|
||
self.d3d11_hdr10,
|
||
) {
|
||
Ok(d) => {
|
||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||
from = which, decoder = d.name(),
|
||
"hardware decode failing repeatedly — demoting to D3D11VA");
|
||
self.install(Backend::D3d11va(d));
|
||
return Ok(None);
|
||
}
|
||
Err(dx) => tracing::info!(reason = %dx,
|
||
"D3D11VA unavailable for demotion — software decode"),
|
||
}
|
||
}
|
||
// The last hardware candidate, and the one that only exists because
|
||
// M9 stacked two native rungs: a failing native PLATFORM rung on an
|
||
// Intel/unknown box has native Vulkan BELOW it (that vendor order
|
||
// puts the platform pair first), and in a build with no FFmpeg twin
|
||
// there is nothing between them. Without this the rung with the
|
||
// weakest evidence in the whole program — native VAAPI, which has
|
||
// decoded nothing anywhere — would take a 4K session straight to the
|
||
// CPU rung the moment it error-streaked. Only fires FROM a native
|
||
// platform rung, so no path this walk had before M9 changes.
|
||
if self.entered_rungs & RUNG_BIT_NATIVE_VULKAN == 0
|
||
&& self.is_native_platform_rung()
|
||
{
|
||
if let Some(v) = self.vk.clone().filter(|v| v.video_decode) {
|
||
if native_vulkan_gate("auto", self.codec_id, true, v.decode_video_caps)
|
||
{
|
||
let (codec, _) =
|
||
native_codec(self.codec_id).expect("the gate admitted it");
|
||
match NativeVulkanDecoder::new(&v, codec, self.stream) {
|
||
Ok(n) => {
|
||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||
from = which,
|
||
"hardware decode failing repeatedly — demoting to \
|
||
native Vulkan Video");
|
||
self.install(Backend::NativeVulkan(Box::new(n)));
|
||
return Ok(None);
|
||
}
|
||
Err(nv) => tracing::info!(reason = %format!("{nv:#}"),
|
||
"native Vulkan Video unavailable for demotion — \
|
||
software decode"),
|
||
}
|
||
}
|
||
}
|
||
}
|
||
tracing::warn!(error = %e, fails = self.vaapi_fails,
|
||
"{which} decode failing repeatedly — demoting to software");
|
||
// The ladder's bottom. On H.264/AV1 this always builds; on HEVC it
|
||
// NEVER does, and the `?` carries the typed `NoSoftwareRung` up to
|
||
// the pump, which reconnects with HEVC-less caps instead of leaving
|
||
// the session on a rung that cannot decode a single AU. That
|
||
// substitution — a refusal where a silently useless decoder used to
|
||
// sit — is the whole reason the drop of software HEVC is safe.
|
||
self.install(Backend::Software(SoftwareDecoder::new(self.wire_codec)?));
|
||
} else {
|
||
tracing::debug!(backend = which, error = %e,
|
||
"decode error — requesting keyframe, keeping hardware decode");
|
||
}
|
||
Ok(None)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// -EAGAIN. FFmpeg uses POSIX errno values on both our targets (MinGW's EAGAIN is 11 too).
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
pub(crate) const AVERROR_EAGAIN: i32 = -11;
|
||
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
pub(crate) fn averr(what: &str, code: i32) -> anyhow::Error {
|
||
anyhow!("{what}: {}", ffmpeg::Error::from(code))
|
||
}
|
||
|
||
/// Guard-less mutex serializing every `vkQueueSubmit`/`vkQueuePresentKHR`/
|
||
/// `vkQueueWaitIdle` on the device the presenter shares with FFmpeg.
|
||
///
|
||
/// Why it exists: the presenter created the device with ONE graphics-family queue and
|
||
/// told FFmpeg's `AVVulkanDeviceContext` to use that same family (`nb_graphics_queues
|
||
/// = 1` ⇒ queue index 0) for its transfer/compute prep work — so the presenter thread
|
||
/// and the session pump thread were submitting to the SAME `VkQueue` with no shared
|
||
/// lock. `vkQueueSubmit` requires external synchronization on the queue; the race
|
||
/// surfaced as intermittent `VK_ERROR_DEVICE_LOST` at exactly the moments FFmpeg puts
|
||
/// work on the graphics queue (decoder open / frames-context rebuild — i.e. stream
|
||
/// start and every adaptive-bitrate encoder rebuild; live-diagnosed 2026-07-09).
|
||
///
|
||
/// FFmpeg's hook for this is the `lock_queue`/`unlock_queue` callback pair on
|
||
/// `AVVulkanDeviceContext` — a raw lock/unlock shape with no RAII scope, hence this
|
||
/// guard-less primitive (`std::sync::Mutex`'s guard can't cross the C callbacks).
|
||
/// Contention is a handful of µs-scale critical sections per frame; a plain
|
||
/// Mutex+Condvar is more than enough.
|
||
pub struct QueueLock {
|
||
locked: std::sync::Mutex<bool>,
|
||
cv: std::sync::Condvar,
|
||
}
|
||
|
||
impl QueueLock {
|
||
#[allow(clippy::new_without_default)]
|
||
pub fn new() -> QueueLock {
|
||
QueueLock {
|
||
locked: std::sync::Mutex::new(false),
|
||
cv: std::sync::Condvar::new(),
|
||
}
|
||
}
|
||
|
||
/// Block until the queue is free, then take it. Pair with [`QueueLock::unlock`]
|
||
/// (FFmpeg's callbacks), or use [`QueueLock::guard`] from Rust callers.
|
||
pub fn lock(&self) {
|
||
let mut g = self
|
||
.locked
|
||
.lock()
|
||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||
while *g {
|
||
g = self
|
||
.cv
|
||
.wait(g)
|
||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||
}
|
||
*g = true;
|
||
}
|
||
|
||
pub fn unlock(&self) {
|
||
let mut g = self
|
||
.locked
|
||
.lock()
|
||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||
*g = false;
|
||
drop(g);
|
||
self.cv.notify_one();
|
||
}
|
||
|
||
/// RAII form for Rust call sites (presenter submits/presents, Skia flushes).
|
||
pub fn guard(&self) -> QueueLockGuard<'_> {
|
||
self.lock();
|
||
QueueLockGuard(self)
|
||
}
|
||
}
|
||
|
||
/// Releases the [`QueueLock`] on drop.
|
||
pub struct QueueLockGuard<'a>(&'a QueueLock);
|
||
|
||
impl Drop for QueueLockGuard<'_> {
|
||
fn drop(&mut self) {
|
||
self.0.unlock();
|
||
}
|
||
}
|
||
|
||
/// The presenter's Vulkan device handles, exported so FFmpeg's Vulkan Video decoder
|
||
/// runs on the SAME device the presenter samples from — the whole point: the decoded
|
||
/// VkImage is composited directly, no interop, no copy (plan: Vulkan Video phase).
|
||
///
|
||
/// Plain integers/strings on purpose: pf-client-core has no ash dependency; pf-ffvk
|
||
/// casts these into vulkan.h handle types when filling `AVVulkanDeviceContext`. All
|
||
/// handles stay valid for the presenter's lifetime, which outlives every session pump
|
||
/// (the run loop tears the pump down before the presenter).
|
||
#[derive(Clone)]
|
||
pub struct VulkanDecodeDevice {
|
||
/// `PFN_vkGetInstanceProcAddr` from the loader — FFmpeg resolves everything else.
|
||
pub get_instance_proc_addr: usize,
|
||
pub instance: usize,
|
||
pub physical_device: usize,
|
||
pub device: usize,
|
||
/// PCI vendor of the presenter's physical device (0x10DE NVIDIA, 0x1002 AMD,
|
||
/// 0x8086 Intel) — drives [`Self::prefer_vulkan_first`].
|
||
pub vendor_id: u32,
|
||
/// The driver's device-name string (e.g. "AMD RADV VANGOGH") — the VanGogh/Deck
|
||
/// detection for [`Self::prefer_vulkan_first`].
|
||
pub device_name: String,
|
||
/// The presenter's graphics+present family (FFmpeg's "required" tx/comp family too).
|
||
pub graphics_qf: u32,
|
||
/// Raw `VkQueueFlags` of that family (the qf[] entry wants the real capabilities).
|
||
pub graphics_queue_flags: u32,
|
||
/// The video-decode family (may equal `graphics_qf` on some hardware).
|
||
pub decode_qf: u32,
|
||
/// Raw `VkVideoCodecOperationFlagsKHR` the decode family advertises.
|
||
pub decode_video_caps: u32,
|
||
/// Everything enabled at instance/device creation — FFmpeg keys code paths off the
|
||
/// extension STRINGS, so the lists must match reality exactly.
|
||
pub instance_extensions: Vec<std::ffi::CString>,
|
||
pub device_extensions: Vec<std::ffi::CString>,
|
||
/// Features enabled at device creation (reported via `device_features`).
|
||
pub f_sampler_ycbcr: bool,
|
||
pub f_timeline_semaphore: bool,
|
||
pub f_synchronization2: bool,
|
||
/// Vulkan Video decode is actually usable on this device (decode queue + extensions +
|
||
/// features). The bundle now exists even without it — Windows D3D11 interop rides the
|
||
/// same struct — so consumers gate the FFmpeg-Vulkan decoder on THIS, not on `Some`.
|
||
pub video_decode: bool,
|
||
/// The presenter has REAL on-glass present timing (`VK_KHR_present_wait` — its
|
||
/// `PresentTimer` runs). Gates the `CLIENT_CAP_PHASE_LOCK` advertisement: without a
|
||
/// true latch stamp the desktop has no latch grid and must not claim the cap.
|
||
pub present_timing: bool,
|
||
/// PyroWave decode (the wired-LAN wavelet codec) is usable: Vulkan 1.3 + the compute
|
||
/// features its kernels need were present AND enabled at device creation
|
||
/// (`shaderInt16`, `storageBuffer8BitAccess`, subgroup size control). Gates the
|
||
/// `CODEC_PYROWAVE` advertisement and the pyrowave decoder backend.
|
||
pub pyrowave_decode: bool,
|
||
/// The feature facts + creation shape the pyrowave decoder's pinned create-info
|
||
/// reconstruction mirrors (pyrowave 0.4.0 requires the instance/device create infos —
|
||
/// content-accurate, kept alive — to share our VkDevice).
|
||
pub f_shader_int16: bool,
|
||
pub f_storage_buffer8: bool,
|
||
pub f_subgroup_size_control: bool,
|
||
pub f_compute_full_subgroups: bool,
|
||
pub f_shader_float16: bool,
|
||
/// `VkPhysicalDeviceProperties::apiVersion` of the presenter's device.
|
||
pub api_version: u32,
|
||
/// The queue families the device was created with (one `VkDeviceQueueCreateInfo` each,
|
||
/// one queue per family, priority 1.0) — mirrored by the reconstruction.
|
||
pub queue_families: Vec<u32>,
|
||
/// The presenter enabled `VK_KHR_external_memory_win32` + `VK_KHR_win32_keyed_mutex`:
|
||
/// D3D11 shared-texture frames can reach the screen. Always `false` off Windows.
|
||
pub d3d11_import: bool,
|
||
/// The presenter can also import the RGB10A2 hand-off texture AND offers an HDR10
|
||
/// swapchain — the D3D11VA backend emits its HDR (RGB10 PQ pass-through) ring flavor
|
||
/// for PQ streams instead of tone-mapping to sRGB. Always `false` off Windows.
|
||
pub d3d11_hdr10: bool,
|
||
/// `VkPhysicalDeviceIDProperties::deviceLUID` when the driver reports one — the D3D11VA
|
||
/// backend creates its decode device on the SAME adapter so shared textures never cross
|
||
/// GPUs. `None` when not reported (or off Windows, where it's unused).
|
||
pub adapter_luid: Option<[u8; 8]>,
|
||
/// The device's shared queue lock (see [`QueueLock`]). The presenter holds it around
|
||
/// its own submits/presents; the decoder wires it into FFmpeg's
|
||
/// `lock_queue`/`unlock_queue` callbacks so both sides serialize on the same queues.
|
||
pub queue_lock: std::sync::Arc<QueueLock>,
|
||
}
|
||
|
||
impl VulkanDecodeDevice {
|
||
/// Should `auto` try Vulkan Video BEFORE the platform's other hardware path (VAAPI on
|
||
/// Linux, D3D11VA on Windows) on this device?
|
||
/// * **NVIDIA** — Vulkan Video is the proven path (on Linux the only one: no usable
|
||
/// VAAPI — the nvidia-vaapi-driver is broken for this, Moonlight blacklists it;
|
||
/// on Windows it's the validated zero-copy default, 4K@144 with 0.1 ms decode).
|
||
/// * **AMD (RADV, VanGogh included)** — Vulkan decode outperforms VAAPI on RADV
|
||
/// (on-glass verdict), and on VanGogh VAAPI's separate-plane dmabuf import
|
||
/// additionally shows chroma fringing; the session binary opts RADV into
|
||
/// `video_decode` precisely to get the Vulkan path. Vulkan-first is safe here
|
||
/// because a mid-session Vulkan failure streak demotes to VAAPI (not software),
|
||
/// so a broken Mesa Vulkan path still lands on the working driver.
|
||
///
|
||
/// Intel and unknown vendors take the battle-tested path first: VAAPI on Linux (ANV's
|
||
/// Vulkan Video is the least-proven Mesa path), D3D11VA on Windows — Intel's Windows
|
||
/// driver advertises Vulkan Video (Arc drivers since 2023), but FFmpeg-Vulkan on it is
|
||
/// field-broken (B580, 2026-07: strobing + ~7 ms decodes) where DXVA streams clean.
|
||
pub fn prefer_vulkan_first(&self) -> bool {
|
||
const VENDOR_NVIDIA: u32 = 0x10DE;
|
||
const VENDOR_AMD: u32 = 0x1002;
|
||
self.vendor_id == VENDOR_NVIDIA || self.vendor_id == VENDOR_AMD
|
||
}
|
||
}
|
||
|
||
/// `fourcc(a,b,c,d)` — the DRM FourCC packing (little-endian, `a | b<<8 | c<<16 | d<<24`).
|
||
///
|
||
/// [`drm_fourcc_for`] is its only caller and dies with the FFmpeg VAAPI rung, so this
|
||
/// dies with it: the NATIVE VAAPI rung derives a surface's fourcc inside pf-vaadec, from
|
||
/// the libva format it actually configured, and pf-vaadec's own tests pin those values.
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
const fn fourcc(a: u8, b: u8, c: u8, d: u8) -> u32 {
|
||
(a as u32) | ((b as u32) << 8) | ((c as u32) << 16) | ((d as u32) << 24)
|
||
}
|
||
|
||
/// The combined DRM FourCC for a decoder software pixel format. The host streams 8-bit
|
||
/// 4:2:0 (NV12); P010 is here for the eventual 10-bit/HDR path.
|
||
// Only the (Linux-gated) FFmpeg VAAPI rung calls this outside tests — the NATIVE VAAPI
|
||
// rung derives its fourcc from pf-vaadec's own format map — so it rides `ffmpeg-fallback`
|
||
// with that rung, and so does the test that locks its three magic numbers.
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
#[cfg_attr(windows, allow(dead_code))]
|
||
pub(crate) fn drm_fourcc_for(sw: ffmpeg_next::ffi::AVPixelFormat) -> Option<u32> {
|
||
use ffmpeg_next::ffi::AVPixelFormat::*;
|
||
Some(match sw {
|
||
AV_PIX_FMT_NV12 => fourcc(b'N', b'V', b'1', b'2'),
|
||
AV_PIX_FMT_P010LE => fourcc(b'P', b'0', b'1', b'0'),
|
||
// Full-chroma 4:4:4 semi-planar (HEVC RExt decode on drivers that export it as
|
||
// two planes) — the presenter imports the full-size chroma plane like any other.
|
||
AV_PIX_FMT_NV24 => fourcc(b'N', b'V', b'2', b'4'),
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use punktfunk_core::quic::{CODEC_AV1, CODEC_H264, CODEC_HEVC, CODEC_PYROWAVE};
|
||
|
||
/// The reconnect rule, as the invariant it is: an exhausted codec must come back as
|
||
/// one this client can decode ALL THE WAY DOWN, and must never come back as itself.
|
||
///
|
||
/// This is the "first-class path" the risk register asks for, tested where it can be
|
||
/// tested exhaustively — the on-glass half needs a host, a GPU and a decode failure
|
||
/// nobody can schedule.
|
||
#[test]
|
||
fn an_exhausted_codec_reconnects_only_onto_one_with_a_cpu_rung() {
|
||
let sw = software_decodable_codecs();
|
||
assert_eq!(sw, CODEC_H264 | CODEC_AV1, "M8's CPU rung set");
|
||
assert_eq!(sw & CODEC_HEVC, 0, "software HEVC is what M8 dropped");
|
||
|
||
// The shipping case: a desktop advertises H.264+HEVC, HEVC runs out of rungs.
|
||
assert_eq!(
|
||
last_rung_verdict(CODEC_HEVC, CODEC_H264 | CODEC_HEVC, RungLoss::Codec),
|
||
LastRungVerdict::Retry { caps: CODEC_H264 }
|
||
);
|
||
// With hardware AV1 also advertised, both survivors stay on the table — the host
|
||
// picks; we only ever REMOVE.
|
||
assert_eq!(
|
||
last_rung_verdict(
|
||
CODEC_HEVC,
|
||
CODEC_H264 | CODEC_HEVC | CODEC_AV1,
|
||
RungLoss::Codec
|
||
),
|
||
LastRungVerdict::Retry {
|
||
caps: CODEC_H264 | CODEC_AV1
|
||
}
|
||
);
|
||
// A client that offered HEVC alone has nowhere to go: reconnecting would
|
||
// negotiate the same dead end, so say so instead of looping.
|
||
assert_eq!(
|
||
last_rung_verdict(CODEC_HEVC, CODEC_HEVC, RungLoss::Codec),
|
||
LastRungVerdict::Dead
|
||
);
|
||
// The retry NEVER re-offers the codec that just failed...
|
||
for advertised in 0u8..16 {
|
||
for negotiated in [CODEC_H264, CODEC_HEVC, CODEC_AV1] {
|
||
if let LastRungVerdict::Retry { caps } =
|
||
last_rung_verdict(negotiated, advertised, RungLoss::Codec)
|
||
{
|
||
assert_eq!(caps & negotiated, 0, "{negotiated:#x} re-offered");
|
||
// ...and, when the CODEC is what has no CPU rung, never offers one
|
||
// that would reach the same refusal a session later.
|
||
assert_eq!(caps & !software_decodable_codecs(), 0);
|
||
assert_ne!(caps, 0, "Retry must carry something to advertise");
|
||
}
|
||
}
|
||
}
|
||
// PyroWave is not in the software set and never reaches this rule (its sessions
|
||
// renegotiate the codec on failure instead of demoting) — but if it ever did, the
|
||
// answer must be Dead, not a retry that offers a codec with no CPU decoder.
|
||
assert_eq!(
|
||
last_rung_verdict(CODEC_PYROWAVE, CODEC_PYROWAVE, RungLoss::Codec),
|
||
LastRungVerdict::Dead
|
||
);
|
||
}
|
||
|
||
/// A picture SHAPE the CPU rung cannot decode is not "this codec has no CPU rung",
|
||
/// and the review found the rule conflating them: a 4:4:4 H.264 session ended with
|
||
/// "no other codec is available" while an HEVC retry — whose hardware rungs never
|
||
/// even ran — would have worked.
|
||
#[test]
|
||
fn a_shape_refusal_may_retry_onto_a_codec_with_no_cpu_rung() {
|
||
// The one that used to die. HEVC has no CPU rung, but nothing about HEVC failed:
|
||
// this client asked for 4:4:4, the host resolved it, and only the CPU DECODER is
|
||
// 4:2:0-only. A reconnect without H.264 re-resolves the shape too.
|
||
assert_eq!(
|
||
last_rung_verdict(CODEC_H264, CODEC_H264 | CODEC_HEVC, RungLoss::Shape),
|
||
LastRungVerdict::Retry { caps: CODEC_HEVC }
|
||
);
|
||
// Same inputs, the OTHER diagnosis: hardware H.264 exhausted and the CPU rung
|
||
// has no H.264 at all (impossible in this build, but the rule must not depend on
|
||
// that) — then HEVC really is the same losing bet and the session ends.
|
||
assert_eq!(
|
||
last_rung_verdict(CODEC_H264, CODEC_H264 | CODEC_HEVC, RungLoss::Codec),
|
||
LastRungVerdict::Dead
|
||
);
|
||
// The user's PyroWave opt-in survives a shape refusal — but never ALONE: the
|
||
// host's `resolve_codec` keeps PyroWave out of its precedence ladder, so a Hello
|
||
// offering nothing else resolves to no codec and the host refuses the session.
|
||
assert_eq!(
|
||
last_rung_verdict(
|
||
CODEC_H264,
|
||
CODEC_H264 | CODEC_HEVC | CODEC_PYROWAVE,
|
||
RungLoss::Shape
|
||
),
|
||
LastRungVerdict::Retry {
|
||
caps: CODEC_HEVC | CODEC_PYROWAVE
|
||
}
|
||
);
|
||
assert_eq!(
|
||
last_rung_verdict(CODEC_H264, CODEC_H264 | CODEC_PYROWAVE, RungLoss::Shape),
|
||
LastRungVerdict::Dead
|
||
);
|
||
// And a shape refusal still never re-offers the codec that raised it — the codec
|
||
// is fixed at Welcome, so it is the only lever there is.
|
||
for advertised in 0u8..16 {
|
||
for negotiated in [CODEC_H264, CODEC_HEVC, CODEC_AV1] {
|
||
if let LastRungVerdict::Retry { caps } =
|
||
last_rung_verdict(negotiated, advertised, RungLoss::Shape)
|
||
{
|
||
assert_eq!(caps & negotiated, 0, "{negotiated:#x} re-offered");
|
||
assert_ne!(caps, 0, "Retry must carry something to advertise");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The one HEVC promise the client can refuse to make BEFORE the Hello: decode
|
||
/// pinned to software has no HEVC rung at any level, so advertising it guarantees
|
||
/// the reconnect flow rather than risking it.
|
||
#[test]
|
||
fn a_software_pin_takes_hevc_off_the_advertisement() {
|
||
// The pin is read the way `Decoder::new` reads it: env first, then the setting —
|
||
// so a run with the override actually set has nothing here to assert about.
|
||
if std::env::var_os("PUNKTFUNK_DECODER").is_some() {
|
||
return;
|
||
}
|
||
assert!(decode_pinned_to_software("software"));
|
||
assert!(!decode_pinned_to_software("auto"));
|
||
assert!(!decode_pinned_to_software("vulkan"));
|
||
assert!(!decode_pinned_to_software(""));
|
||
}
|
||
|
||
/// The wire↔FFmpeg codec map must round-trip for every codec the software rung can
|
||
/// be built for. A mistake here builds an openh264 decoder for an AV1 session — which
|
||
/// then fails per AU rather than at construction, i.e. exactly the mid-stream burn
|
||
/// this ladder is written to avoid.
|
||
#[test]
|
||
fn the_wire_codec_of_an_ffmpeg_id_round_trips() {
|
||
for wire in [CODEC_H264, CODEC_HEVC, CODEC_AV1] {
|
||
assert_eq!(wire_codec_of(ffmpeg_codec_id(wire)), wire);
|
||
}
|
||
// PyroWave has no FFmpeg id (`ffmpeg_codec_id` folds it onto HEVC), so the
|
||
// inverse cannot round-trip it — the PyroWave decoder sets `wire_codec` itself.
|
||
assert_eq!(wire_codec_of(ffmpeg_codec_id(CODEC_PYROWAVE)), CODEC_HEVC);
|
||
}
|
||
|
||
/// `CpuPlanarFrame` is what the presenter uploads with no stride: prove the copy
|
||
/// really does undo the decoder's padding, and that a short plane is REFUSED rather
|
||
/// than read past.
|
||
#[test]
|
||
fn planar_frames_are_tightly_packed_and_short_planes_are_refused() {
|
||
let color = ColorDesc {
|
||
primaries: 1,
|
||
transfer: 1,
|
||
matrix: 1,
|
||
full_range: false,
|
||
};
|
||
// 4x2 luma, 2x1 chroma, all planes padded by 3 bytes per row.
|
||
let y: Vec<u8> = vec![1, 2, 3, 4, 9, 9, 9, 5, 6, 7, 8, 9, 9, 9];
|
||
let u: Vec<u8> = vec![10, 11, 9, 9, 9];
|
||
let v: Vec<u8> = vec![20, 21, 9, 9, 9];
|
||
let none = punktfunk_core::reanchor::LocalRecovery::NONE;
|
||
let f =
|
||
CpuPlanarFrame::from_i420(4, 2, [&y, &u, &v], [7, 5, 5], color, true, none).unwrap();
|
||
assert_eq!(f.plane(0), &[1, 2, 3, 4, 5, 6, 7, 8]);
|
||
assert_eq!(f.plane(1), &[10, 11]);
|
||
assert_eq!(f.plane(2), &[20, 21]);
|
||
assert_eq!(f.plane_dims(0), (4, 2));
|
||
assert_eq!(f.plane_dims(1), (2, 1));
|
||
// Odd dimensions round the chroma plane UP — the last column/row still has a
|
||
// chroma sample and dropping it would read past the plane on the next frame.
|
||
assert_eq!(CpuPlanarFrame::chroma_dims(5, 3), (3, 2));
|
||
// A plane shorter than its own geometry is a disagreement with the decoder, not
|
||
// something to truncate into a plausible picture.
|
||
let short: Vec<u8> = vec![1, 2, 3];
|
||
assert!(
|
||
CpuPlanarFrame::from_i420(4, 2, [&short, &u, &v], [7, 5, 5], color, true, none)
|
||
.is_err()
|
||
);
|
||
// A stride narrower than the picture is the same class of disagreement.
|
||
assert!(
|
||
CpuPlanarFrame::from_i420(4, 2, [&y, &u, &v], [2, 5, 5], color, true, none).is_err()
|
||
);
|
||
}
|
||
|
||
fn decode_device(vendor_id: u32, device_name: &str) -> VulkanDecodeDevice {
|
||
VulkanDecodeDevice {
|
||
get_instance_proc_addr: 0,
|
||
instance: 0,
|
||
physical_device: 0,
|
||
device: 0,
|
||
vendor_id,
|
||
device_name: device_name.into(),
|
||
graphics_qf: 0,
|
||
graphics_queue_flags: 0,
|
||
decode_qf: 0,
|
||
decode_video_caps: 0,
|
||
instance_extensions: Vec::new(),
|
||
device_extensions: Vec::new(),
|
||
f_sampler_ycbcr: true,
|
||
f_timeline_semaphore: true,
|
||
f_synchronization2: true,
|
||
f_shader_int16: false,
|
||
f_storage_buffer8: false,
|
||
f_subgroup_size_control: false,
|
||
f_compute_full_subgroups: false,
|
||
f_shader_float16: false,
|
||
api_version: 0,
|
||
queue_families: Vec::new(),
|
||
pyrowave_decode: false,
|
||
video_decode: true,
|
||
present_timing: false,
|
||
d3d11_import: false,
|
||
d3d11_hdr10: false,
|
||
adapter_luid: None,
|
||
queue_lock: std::sync::Arc::new(QueueLock::new()),
|
||
}
|
||
}
|
||
|
||
/// The demotion streak's escape hatch, stated as the invariant it is: an `Ok`
|
||
/// clears the streak only when it PROVES the rung works.
|
||
///
|
||
/// Concealment (`Ok(None)` with a recovery request) proves nothing — it is the
|
||
/// STREAM that was damaged — and before M4's review it cleared the streak
|
||
/// anyway, because the `Ok(_)` arm matched `Ok(None)` too. Two shapes followed
|
||
/// from that, and this test pins both away:
|
||
///
|
||
/// * a driver failing every other AU on a lossy link: `Err` / concealment /
|
||
/// `Err` / concealment … the concealment zeroed the count and
|
||
/// [`VAAPI_DEMOTE_AFTER`] was never reached;
|
||
/// * and a rung that conceals forever and ships nothing: a frozen picture with
|
||
/// no path down the ladder at all.
|
||
#[test]
|
||
fn only_an_answer_that_proves_the_rung_works_clears_the_demotion_streak() {
|
||
// A shipped frame is proof, concealed or not (the AU carried damage AND a
|
||
// picture — the decoder is plainly alive).
|
||
assert!(clears_demotion_streak(true, false));
|
||
assert!(clears_demotion_streak(true, true));
|
||
// A CLEAN no-output AU is proof too: the decoder ran and objected to
|
||
// nothing (it buffered, or skipped an H.265 RASL picture after an open-GOP
|
||
// join). Treating that as suspicious would demote healthy sessions.
|
||
assert!(clears_demotion_streak(false, false));
|
||
// Concealment with no picture is the one that proves nothing.
|
||
assert!(!clears_demotion_streak(false, true));
|
||
|
||
// The streak arithmetic that follows, spelled out on the milder and
|
||
// likelier shape: a broken driver alternating with concealment must still
|
||
// reach the demotion threshold.
|
||
let mut fails = 0u32;
|
||
for concealed_ok in [false, true, false, true, false] {
|
||
if concealed_ok {
|
||
if clears_demotion_streak(false, true) {
|
||
fails = 0;
|
||
}
|
||
} else {
|
||
fails += 1; // an Err from the driver's own verdict
|
||
}
|
||
}
|
||
assert!(
|
||
fails >= VAAPI_DEMOTE_AFTER,
|
||
"three driver errors interleaved with concealment must still reach the \
|
||
demotion threshold — they got to {fails}"
|
||
);
|
||
|
||
// ---- The AV1 shape (M7), and the reason its recovery wait is an `Err` ----
|
||
//
|
||
// A native rung waiting to re-anchor after a failure produces no picture for
|
||
// every AU of the wait, and all three codecs say so with an ERROR: H.264 and
|
||
// H.265 through their planners' `PlanError::AwaitingIdr`, AV1 through
|
||
// `VkDecodeError::AwaitingKeyAv1`. So the streak ticks for the whole wait and
|
||
// a rung that never recovers reaches the threshold.
|
||
let mut fails = 0u32;
|
||
for errored in [true; 5] {
|
||
// the failing AU, then four skipped ones
|
||
if errored {
|
||
fails += 1;
|
||
} else if clears_demotion_streak(false, false) {
|
||
fails = 0;
|
||
}
|
||
}
|
||
assert!(fails >= VAAPI_DEMOTE_AFTER);
|
||
|
||
// The counterfactual is the whole point, and it is what the AV1 rung was
|
||
// first wired as: answer the skipped AUs with a CLEAN `Ok(None)` instead —
|
||
// no picture, no warnings, nothing to object to — and every one of them
|
||
// clears the streak. The `Err` from each failure is then alone, and
|
||
// `VAAPI_DEMOTE_AFTER` is unreachable no matter how long the session runs.
|
||
//
|
||
// The stream this strands is real and named in `NativeVulkanDecoder::new`:
|
||
// an AV1 sequence with `film_grain_params_present = 1` on a device without
|
||
// the grain decode profile fails at `ensure_state` — at EVERY key frame, and
|
||
// only at a key frame. Key frame `Err`, inter frames "clean", next key frame
|
||
// `Err`: a frozen screen for the whole session, `refused N · damaged 0 ·
|
||
// run 0` on the stats line, and the `!delivered` fall-through to
|
||
// FFmpeg-Vulkan below never reached.
|
||
let mut fails = 0u32;
|
||
for errored in [true, false, false, true, false, false, true, false, false] {
|
||
if errored {
|
||
fails += 1;
|
||
} else if clears_demotion_streak(false, false) {
|
||
fails = 0;
|
||
}
|
||
}
|
||
assert!(
|
||
fails < VAAPI_DEMOTE_AFTER,
|
||
"a recovery wait answered as a CLEAN AU zeroes the streak once per frame \
|
||
— which is why it must not be answered that way; it got to {fails}"
|
||
);
|
||
}
|
||
|
||
/// Auto's hardware order (both OSes): Vulkan-first on NVIDIA (on Linux: no usable
|
||
/// VAAPI) and ALL AMD (Vulkan decode outperforms VAAPI on RADV — on-glass verdict;
|
||
/// VanGogh additionally chroma-fringes over VAAPI); Intel/unknown take the proven
|
||
/// path first — VAAPI on Linux (ANV's Vulkan Video is the least-proven Mesa path),
|
||
/// D3D11VA on Windows (Intel's driver advertises Vulkan Video since 2023, but
|
||
/// FFmpeg-Vulkan on it strobes — B580 field report). A Vulkan failure streak still
|
||
/// demotes to hardware (VAAPI/D3D11VA), so Vulkan-first can never strand a box on
|
||
/// software decode.
|
||
#[test]
|
||
fn vulkan_first_on_nvidia_and_amd_only() {
|
||
assert!(decode_device(0x10DE, "NVIDIA GeForce RTX 5070 Ti").prefer_vulkan_first());
|
||
assert!(decode_device(0x1002, "AMD RADV VANGOGH").prefer_vulkan_first());
|
||
assert!(decode_device(0x1002, "AMD Custom GPU 0405 (RADV VANGOGH)").prefer_vulkan_first());
|
||
assert!(decode_device(0x1002, "AMD Radeon RX 7800 XT (RADV NAVI32)").prefer_vulkan_first());
|
||
assert!(
|
||
!decode_device(0x8086, "Intel(R) Arc(tm) A770 Graphics (DG2)").prefer_vulkan_first()
|
||
);
|
||
// The Windows-side motivation: discrete Arc advertises Vulkan Video and must
|
||
// still land on D3D11VA in auto.
|
||
assert!(!decode_device(0x8086, "Intel(R) Arc(TM) B580 Graphics").prefer_vulkan_first());
|
||
assert!(!decode_device(0x8086, "Intel(R) Arc(TM) Pro Graphics").prefer_vulkan_first());
|
||
}
|
||
|
||
/// AV1 is advertised on a HARDWARE fact, never on a decoder existing.
|
||
///
|
||
/// The standing open item M7 closes. `ffmpeg::decoder::find(AV1)` says yes
|
||
/// wherever libdav1d is linked, so the old advertisement told the host "send me
|
||
/// AV1" on machines that would then decode it on the CPU — and codec negotiation
|
||
/// happens once, so there is no falling back afterwards.
|
||
#[test]
|
||
fn av1_is_advertised_only_where_hardware_can_decode_it() {
|
||
// No device at all: no claim.
|
||
assert!(!av1_hardware_decodable(None));
|
||
|
||
// A decode-capable device that does NOT list AV1 among its codec
|
||
// operations. `video_decode` alone is not the question — plenty of devices
|
||
// decode H.264 and H.265 and no AV1.
|
||
let mut dev = decode_device(0x10de, "no-av1");
|
||
dev.decode_video_caps = VIDEO_CODEC_OP_DECODE_H264 | VIDEO_CODEC_OP_DECODE_H265;
|
||
#[cfg(not(windows))]
|
||
assert!(
|
||
!av1_hardware_decodable(Some(&dev)),
|
||
"H.264+H.265 decode support says nothing about AV1"
|
||
);
|
||
|
||
// The AV1 operation bit is the yes.
|
||
let mut dev = decode_device(0x1002, "vangogh-ish");
|
||
dev.decode_video_caps =
|
||
VIDEO_CODEC_OP_DECODE_H264 | VIDEO_CODEC_OP_DECODE_H265 | VIDEO_CODEC_OP_DECODE_AV1;
|
||
assert!(av1_hardware_decodable(Some(&dev)));
|
||
|
||
// A device whose decode queue is absent cannot be taken at its caps word.
|
||
let mut dev = decode_device(0x1002, "no-decode-queue");
|
||
dev.decode_video_caps = VIDEO_CODEC_OP_DECODE_AV1;
|
||
dev.video_decode = false;
|
||
#[cfg(not(windows))]
|
||
assert!(!av1_hardware_decodable(Some(&dev)));
|
||
}
|
||
|
||
/// The native-Vulkan admission gate (WP-C, widened by the 2026-08-05 ladder
|
||
/// decision, by M3 WP-2's HEVC wiring, by M7's AV1 wiring and — for AV1's entry into
|
||
/// `auto` — by M9): the pin AND the auto family admit on a capable H.264, HEVC **or
|
||
/// AV1** session, every explicit other-backend pin refuses (`vulkan` names the
|
||
/// FFmpeg-Vulkan backend specifically and must keep meaning exactly that), and the
|
||
/// codec/device legs still refuse for every choice. The codec's OWN caps bit is the
|
||
/// device leg: admitting HEVC on an H.264-only decode family would create a video
|
||
/// session for an operation the family cannot run, which is undefined behaviour
|
||
/// rather than an error.
|
||
#[test]
|
||
fn native_vulkan_gate_admits_pin_and_auto_family_per_codec_on_a_capable_family() {
|
||
use ffmpeg::codec::Id;
|
||
// Pin the raw spec values, not the implementation constants — a typo'd bit
|
||
// would refuse every real driver's caps and native would silently never
|
||
// engage (the program's own nb_queries=0 lesson: silent non-engagement is
|
||
// the failure mode nothing flags).
|
||
assert_eq!(
|
||
VIDEO_CODEC_OP_DECODE_H264, 0x1,
|
||
"VK_VIDEO_CODEC_OPERATION_DECODE_H264_BIT_KHR"
|
||
);
|
||
assert_eq!(
|
||
VIDEO_CODEC_OP_DECODE_H265, 0x2,
|
||
"VK_VIDEO_CODEC_OPERATION_DECODE_H265_BIT_KHR"
|
||
);
|
||
assert_eq!(
|
||
VIDEO_CODEC_OP_DECODE_AV1, 0x4,
|
||
"VK_VIDEO_CODEC_OPERATION_DECODE_AV1_BIT_KHR"
|
||
);
|
||
const H264_OP: u32 = VIDEO_CODEC_OP_DECODE_H264;
|
||
const H265_OP: u32 = VIDEO_CODEC_OP_DECODE_H265;
|
||
const AV1_OP: u32 = VIDEO_CODEC_OP_DECODE_AV1;
|
||
for choice in ["native-vulkan", "auto", "", "hardware"] {
|
||
// The pin and the whole auto family admit both codecs pf-vkdecode
|
||
// speaks, on a family that advertises the matching op…
|
||
assert!(
|
||
native_vulkan_gate(choice, Id::H264, true, H264_OP),
|
||
"{choice:?}"
|
||
);
|
||
assert!(
|
||
native_vulkan_gate(choice, Id::HEVC, true, H265_OP),
|
||
"{choice:?}"
|
||
);
|
||
// …including the ordinary case of a family that runs both.
|
||
assert!(
|
||
native_vulkan_gate(choice, Id::H264, true, H264_OP | H265_OP),
|
||
"{choice:?}"
|
||
);
|
||
assert!(
|
||
native_vulkan_gate(choice, Id::HEVC, true, H264_OP | H265_OP),
|
||
"{choice:?}"
|
||
);
|
||
// Each codec needs ITS OWN bit: an H.264-only family (the common case on
|
||
// older silicon) must not take an HEVC session, and vice versa.
|
||
assert!(
|
||
!native_vulkan_gate(choice, Id::HEVC, true, H264_OP),
|
||
"{choice:?}"
|
||
);
|
||
assert!(
|
||
!native_vulkan_gate(choice, Id::H264, true, H265_OP),
|
||
"{choice:?}"
|
||
);
|
||
// AV1 joined the auto family at M9, on the evidence rule and not on a
|
||
// date: `native_evidence(Vulkan, CODEC_AV1)` is verified (250/250
|
||
// bit-identical to libavcodec on an RTX 5070 Ti, M7), so
|
||
// `native_rung_admitted` says yes for the auto family exactly as it does
|
||
// for the other two codecs. Before M9 this pair asserted `choice ==
|
||
// "native-vulkan"`; the flip is what changed, and it changed HERE.
|
||
assert!(
|
||
native_vulkan_gate(choice, Id::AV1, true, AV1_OP),
|
||
"{choice:?}"
|
||
);
|
||
assert!(
|
||
native_vulkan_gate(choice, Id::AV1, true, H264_OP | H265_OP | AV1_OP),
|
||
"{choice:?}"
|
||
);
|
||
// …and the pin is still not a licence to skip the device leg: an AV1
|
||
// session on a family that does not advertise the AV1 op would create a
|
||
// video session for an operation the family cannot run.
|
||
assert!(
|
||
!native_vulkan_gate(choice, Id::AV1, true, H264_OP | H265_OP),
|
||
"{choice:?}"
|
||
);
|
||
assert!(
|
||
!native_vulkan_gate(choice, Id::AV1, false, AV1_OP),
|
||
"{choice:?}"
|
||
);
|
||
// No Vulkan-Video-capable presenter device.
|
||
assert!(
|
||
!native_vulkan_gate(choice, Id::H264, false, H264_OP),
|
||
"{choice:?}"
|
||
);
|
||
assert!(
|
||
!native_vulkan_gate(choice, Id::HEVC, false, H265_OP),
|
||
"{choice:?}"
|
||
);
|
||
// A decode family advertising NO codec op, or only a foreign one,
|
||
// refuses even with the extension stack present — the caps BIT is the
|
||
// codec gate, not `video_decode`.
|
||
assert!(!native_vulkan_gate(choice, Id::H264, true, 0), "{choice:?}");
|
||
assert!(!native_vulkan_gate(choice, Id::HEVC, true, 0), "{choice:?}");
|
||
assert!(
|
||
!native_vulkan_gate(choice, Id::H264, true, AV1_OP),
|
||
"{choice:?}"
|
||
);
|
||
assert!(
|
||
!native_vulkan_gate(choice, Id::HEVC, true, AV1_OP),
|
||
"{choice:?}"
|
||
);
|
||
}
|
||
// Never for an explicit OTHER-backend pin, capable device or not.
|
||
for choice in ["vulkan", "vaapi", "d3d11va", "software"] {
|
||
assert!(
|
||
!native_vulkan_gate(choice, Id::H264, true, H264_OP),
|
||
"{choice:?}"
|
||
);
|
||
assert!(
|
||
!native_vulkan_gate(choice, Id::HEVC, true, H265_OP),
|
||
"{choice:?}"
|
||
);
|
||
assert!(
|
||
!native_vulkan_gate(choice, Id::AV1, true, AV1_OP),
|
||
"{choice:?}"
|
||
);
|
||
}
|
||
// The decoder the gate implies — the construction sites `expect()` this
|
||
// exact agreement, so a codec admitted with no decoder behind it would be a
|
||
// panic rather than a demotion.
|
||
assert_eq!(
|
||
native_codec(Id::H264).map(|(c, _)| c),
|
||
Some(NativeCodec::H264)
|
||
);
|
||
assert_eq!(
|
||
native_codec(Id::HEVC).map(|(c, _)| c),
|
||
Some(NativeCodec::H265)
|
||
);
|
||
// AV1 has a decoder AND the caps bit here — being in this map is what the
|
||
// pin construction path reads. Whether `auto` may pick it is the gate's
|
||
// decision above, and deliberately not this one's.
|
||
assert_eq!(
|
||
native_codec(Id::AV1),
|
||
Some((NativeCodec::Av1, VIDEO_CODEC_OP_DECODE_AV1))
|
||
);
|
||
assert!(native_codec(Id::VP9).is_none());
|
||
}
|
||
|
||
/// The evidence table, asserted as the FACT it is — which rung/codec pairs have
|
||
/// actually decoded on hardware and which have not.
|
||
///
|
||
/// This test is the reason the table can be trusted a milestone from now. M9 turns
|
||
/// native rungs on by default, and the argument for doing that honestly rests
|
||
/// entirely on the claim "these five pairs are proven and these six are not". A
|
||
/// table nobody checks drifts into a table that says everything is fine — which is
|
||
/// the exact failure this whole program exists to end, one layer up.
|
||
#[test]
|
||
fn the_evidence_table_says_exactly_which_rungs_have_run_on_hardware() {
|
||
for (rung, codec, what) in [
|
||
(
|
||
NativeRung::Vulkan,
|
||
CODEC_H264,
|
||
"native Vulkan H.264 (M2 WP-D)",
|
||
),
|
||
(NativeRung::Vulkan, CODEC_HEVC, "native Vulkan H.265 (M3)"),
|
||
(
|
||
NativeRung::Vulkan,
|
||
CODEC_AV1,
|
||
"native Vulkan AV1 (M7, RTX 5070 Ti)",
|
||
),
|
||
(NativeRung::D3d11va, CODEC_H264, "native D3D11VA H.264 (M5)"),
|
||
(NativeRung::D3d11va, CODEC_HEVC, "native D3D11VA H.265 (M5)"),
|
||
] {
|
||
assert!(
|
||
native_evidence(rung, codec).verified,
|
||
"{what} has hardware parity recorded"
|
||
);
|
||
}
|
||
for (rung, codec, why) in [
|
||
(
|
||
NativeRung::D3d11va,
|
||
CODEC_AV1,
|
||
"the DXVA AV1 leg never ran (M7)",
|
||
),
|
||
(
|
||
NativeRung::Vaapi,
|
||
CODEC_H264,
|
||
"no VAAPI device was reachable",
|
||
),
|
||
(
|
||
NativeRung::Vaapi,
|
||
CODEC_HEVC,
|
||
"no VAAPI device was reachable",
|
||
),
|
||
(
|
||
NativeRung::Vaapi,
|
||
CODEC_AV1,
|
||
"no VAAPI device was reachable",
|
||
),
|
||
(
|
||
NativeRung::Software,
|
||
CODEC_H264,
|
||
"openh264 never ran on glass",
|
||
),
|
||
(NativeRung::Software, CODEC_AV1, "rav1d never ran on glass"),
|
||
] {
|
||
assert!(
|
||
!native_evidence(rung, codec).verified,
|
||
"{why} — claiming otherwise is the dishonesty M9 must not ship"
|
||
);
|
||
}
|
||
// A codec leg nobody wrote an arm for reads as UNVERIFIED, never as its
|
||
// neighbour's evidence: the next codec this program grows must be kept out of
|
||
// `auto` by the default, not by somebody remembering to add a row.
|
||
assert!(!native_evidence(NativeRung::Vulkan, CODEC_PYROWAVE).verified);
|
||
assert!(!native_evidence(NativeRung::Software, CODEC_HEVC).verified);
|
||
assert!(!native_evidence(NativeRung::D3d11va, 0).verified);
|
||
// Every answer explains itself in the session log.
|
||
for rung in [
|
||
NativeRung::Vulkan,
|
||
NativeRung::D3d11va,
|
||
NativeRung::Vaapi,
|
||
NativeRung::Software,
|
||
] {
|
||
for codec in [CODEC_H264, CODEC_HEVC, CODEC_AV1, 0] {
|
||
assert!(
|
||
!native_evidence(rung, codec).note.is_empty(),
|
||
"{} / {codec} must carry a note",
|
||
rung.name()
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// M9's admission rule, in both builds — the honest half of the default flip.
|
||
///
|
||
/// The gate runs this test twice, once per feature state, and the two branches are
|
||
/// the whole design: in the build people install, an unproven rung is NOT in `auto`
|
||
/// and the ladder beneath it is the pre-M9 one; in a build with no FFmpeg twin, the
|
||
/// alternative to an unproven rung is the CPU, so it runs — and `log_rung` says so
|
||
/// at `warn`.
|
||
#[test]
|
||
fn auto_admits_an_unproven_rung_only_when_nothing_proven_is_left_below_it() {
|
||
// Hardware-verified pairs are in, in every build. That is what the evidence was
|
||
// collected for.
|
||
assert!(native_rung_admitted(NativeRung::Vulkan, CODEC_H264));
|
||
assert!(native_rung_admitted(NativeRung::Vulkan, CODEC_HEVC));
|
||
assert!(native_rung_admitted(NativeRung::Vulkan, CODEC_AV1));
|
||
assert!(native_rung_admitted(NativeRung::D3d11va, CODEC_H264));
|
||
assert!(native_rung_admitted(NativeRung::D3d11va, CODEC_HEVC));
|
||
|
||
let unproven = [
|
||
(NativeRung::Vaapi, CODEC_H264),
|
||
(NativeRung::Vaapi, CODEC_HEVC),
|
||
(NativeRung::Vaapi, CODEC_AV1),
|
||
(NativeRung::D3d11va, CODEC_AV1),
|
||
];
|
||
// `native_first_opt_in` reads the process environment, which a test must not
|
||
// write (the whole binary shares it, and these run in parallel). Reading it is
|
||
// fine, and it keeps the assertion exact rather than conditional-on-nothing.
|
||
if cfg!(feature = "ffmpeg-fallback") && !native_first_opt_in() {
|
||
for (rung, codec) in unproven {
|
||
assert!(
|
||
!native_rung_admitted(rung, codec),
|
||
"{} must stay out of auto while its FFmpeg twin is compiled in",
|
||
rung.name()
|
||
);
|
||
}
|
||
} else {
|
||
for (rung, codec) in unproven {
|
||
assert!(
|
||
native_rung_admitted(rung, codec),
|
||
"{} is the only rung left above the CPU here — barring it would cost \
|
||
the session hardware decode outright",
|
||
rung.name()
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What this client advertises it can decode is a statement about OUR rungs, and it
|
||
/// does not move when the FFmpeg rungs are compiled out.
|
||
///
|
||
/// That invariance is the point: the wire's codec negotiation is a promise, M10
|
||
/// deletes the FFmpeg rungs, and a client whose Hello changed on that deletion would
|
||
/// renegotiate every session in the field for a refactor. It used to be a
|
||
/// libavcodec registry walk, which would have answered differently in the two builds
|
||
/// — and, worse, would have answered about decoders the ladder never reaches.
|
||
#[test]
|
||
fn advertised_codecs_describe_our_rungs_and_not_libavcodecs_registry() {
|
||
let bits = decodable_codecs();
|
||
assert_eq!(
|
||
bits,
|
||
CODEC_H264 | CODEC_HEVC | CODEC_AV1,
|
||
"the three codecs the native rungs speak"
|
||
);
|
||
assert_eq!(
|
||
bits & CODEC_PYROWAVE,
|
||
0,
|
||
"pyrowave rides decodable_codecs_for"
|
||
);
|
||
// The CPU rung's codecs are a subset — the ladder must never advertise a codec
|
||
// whose LAST rung it does not have... except HEVC, which is the one deliberate
|
||
// exception this module documents at length.
|
||
assert_eq!(
|
||
software_decodable_codecs() & !bits,
|
||
0,
|
||
"a codec with a CPU rung but no advertisement would be unreachable"
|
||
);
|
||
assert_eq!(
|
||
bits & !software_decodable_codecs(),
|
||
CODEC_HEVC,
|
||
"HEVC is the ONE advertised codec with no CPU rung (last_rung_verdict owns it)"
|
||
);
|
||
}
|
||
|
||
/// Lock the DRM FourCC magic numbers against typos — these are the exact values
|
||
/// `<drm_fourcc.h>` defines, and a wrong one is what painted the Steam Deck green.
|
||
#[cfg(feature = "ffmpeg-fallback")]
|
||
#[test]
|
||
fn drm_fourcc_constants() {
|
||
assert_eq!(fourcc(b'N', b'V', b'1', b'2'), 0x3231_564e);
|
||
assert_eq!(fourcc(b'P', b'0', b'1', b'0'), 0x3031_3050);
|
||
assert_eq!(
|
||
drm_fourcc_for(ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NV12),
|
||
Some(0x3231_564e)
|
||
);
|
||
assert_eq!(
|
||
drm_fourcc_for(ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_NV24),
|
||
Some(0x3432_564e)
|
||
);
|
||
assert_eq!(
|
||
drm_fourcc_for(ffmpeg::ffi::AVPixelFormat::AV_PIX_FMT_RGBA),
|
||
None
|
||
);
|
||
}
|
||
}
|