Files
punktfunk/crates/pf-client-core/src/video.rs
T
enricobuehler eecd04247f feat(client): AV1 is advertised on hardware, not on a decoder existing
The standing open item M7 was meant to close. `decodable_codecs` answered the
AV1 bit from `ffmpeg::decoder::find(AV1)`, which says yes on every build that
links libdav1d — a software decoder. So the client told the host "send me
AV1" on machines that would then decode a 4K stream on the CPU, and codec
negotiation happens once at Welcome, so there is nothing to fall back to
afterwards. A promise the client cannot keep is worse than not making it.

`av1_hardware_decodable` answers from device facts only: the presenter's
Vulkan device listing DECODE_AV1 among its decode queue family's codec
operations, or — on Windows — the D3D11 import path, which is the same gate
the D3D11VA rung sits behind and that rung decodes AV1 Profile 0 today.

VAAPI is deliberately not consulted: asking libva costs opening a display,
and this is called too early and too often for that. The Vulkan bit covers
the Mesa devices where VAAPI AV1 exists in practice, and a machine with
VAAPI AV1 but no Vulkan AV1 loses the advertisement, not a working path.

The test pins what the gate must not accept: a device that decodes H.264 and
H.265 but lists no AV1 operation, and a device whose caps word claims AV1
while it has no decode queue at all.

Gates: macOS fmt/clippy, container clippy -D warnings over six crates, 805
tests, workspace check.
2026-08-06 19:27:24 +02:00

2375 lines
122 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Video decode: reassembled HEVC access units → frames for the presenter.
//!
//! Backends, picked at session start (auto is vendor-ordered on BOTH desktop OSes —
//! see [`VulkanDecodeDevice::prefer_vulkan_first`]; on H.264 AND HEVC sessions the
//! native pf-vkdecode decoder (`video_vk_native`, gated by [`native_vulkan_gate`])
//! slots in immediately ABOVE the FFmpeg-Vulkan rung wherever the ladder reaches it —
//! the program's goal is dropping FFmpeg from the client, and a native INIT failure
//! falls through to FFmpeg-Vulkan; a runtime error streak demotes past it, same as
//! FFmpeg-Vulkan's own streaks do — EXCEPT while the native rung has never delivered
//! a frame, which falls through to FFmpeg-Vulkan too, see `decode_frame`). Linux:
//! native → vulkan → vaapi → software on NVIDIA and ALL AMD (VanGogh included), vaapi →
//! native → vulkan → software on Intel/unknown. Windows: native → vulkan → d3d11va →
//! software on NVIDIA/AMD, d3d11va → native → vulkan → software on Intel/unknown.
//! Override:
//! `PUNKTFUNK_DECODER=vulkan|vaapi|d3d11va|software|native-vulkan|native-d3d11va|native-vaapi`
//! — `vulkan` names the FFmpeg-Vulkan backend specifically; `native-vulkan` pins the
//! pf-vkdecode decoder by name, skipping the vendor-ordered rungs ahead of it;
//! `native-d3d11va` (Windows) pins M5's pf-dxvadec `ID3D11VideoDecoder` rung and
//! `native-vaapi` (Linux) pins M6's pf-vaadec libva rung. Both of those are reachable
//! ONLY by their pin — they are absent from every `auto` arm until they have the
//! hardware evidence M2's native rung had before IT joined `auto`):
//!
//! * **Vulkan Video**: FFmpeg's Vulkan decoder running on the PRESENTER's own VkDevice
//! (its handles arrive via [`VulkanDecodeDevice`]) — the decoded VkImage feeds the
//! presenter's CSC pass directly, zero copy, every vendor with the video extensions
//! (NVIDIA's only hardware path; measured 4K@144 with 0.1 ms decode).
//! * **VAAPI** (Intel/AMD fallback): libavcodec hwaccel; each frame is mapped to a
//! DRM-PRIME dmabuf (`av_hwframe_map`, zero copy) and handed over as fds + plane
//! layout for the presenter's Vulkan import. NVIDIA has no usable VAAPI
//! (nvidia-vaapi-driver is broken for this — Moonlight blacklists it); device
//! creation fails there. A mid-session error falls back — the host's IDR/RFI
//! recovery resynchronizes.
//! * **Software**: libavcodec on the CPU + swscale to RGBA (staging upload).
//! Slice threading only — frame threading would add a frame of latency per thread.
//!
//! Both run `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 Windows the VAAPI/dmabuf backend does not exist (DRM-PRIME is a Linux concept); the
//! hardware pair there is Vulkan Video and **D3D11VA** (`crate::video_d3d11` — the
//! vendor-agnostic DXVA path every Windows video player exercises), ordered per vendor:
//! Intel's 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 D3D11VA first and NVIDIA/AMD keep Vulkan first.
//! Everything dmabuf-shaped is `cfg(target_os = "linux")`-gated inline.
// 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)]
use anyhow::{anyhow, bail, Context as _, Result};
use ffmpeg_next as ffmpeg;
#[cfg(target_os = "linux")]
use std::os::fd::RawFd;
pub use crate::video_color::{csc_rows, ColorDesc};
use crate::video_software::SoftwareDecoder;
#[cfg(target_os = "linux")]
use crate::video_vaapi::VaapiDecoder;
use crate::video_vk_native::{NativeCodec, NativeVulkanDecoder};
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 {
Cpu(CpuFrame),
#[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.
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, also 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 and HEVC Main, P010 for Main
/// 10, the two-plane 4:4:4 formats for RExt), 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,
/// 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;
}
}
}
/// 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.
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 native rung can answer: libavcodec parses the SEI internally and
/// surfaces nothing of it (its `AV_FRAME_FLAG_KEY` is IDR-only), MediaCodec and
/// VideoToolbox likewise. 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.
pub fn local_recovery(&self) -> punktfunk_core::reanchor::LocalRecovery {
match self {
DecodedImage::NativeVk(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),
}
}
}
/// RGBA pixels for `GdkMemoryTexture` (which takes a stride).
pub struct CpuFrame {
pub width: u32,
pub height: u32,
/// RGBA row stride in bytes (≥ width*4 — swscale pads rows for SIMD).
pub stride: usize,
pub rgba: Vec<u8>,
/// Signaling of the source frame. swscale already undid the YUV matrix + range (the
/// pixels are full-range RGB), but a PQ/BT.2020 stream keeps its transfer + primaries
/// baked in — the presenter tags the texture so GTK tone-maps it.
pub color: ColorDesc,
/// Intra keyframe (IDR/I) — the pump's post-loss re-anchor signal. See [`VkVideoFrame`].
pub keyframe: bool,
}
/// 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 {
Vulkan(VulkanDecoder),
/// Native Vulkan Video H.264/HEVC (pf-vkdecode) on the presenter's device —
/// auto's rung immediately above FFmpeg-Vulkan since the 2026-08-05 ladder
/// decision (WP-D closed bit-exact; the program's goal is dropping FFmpeg from
/// the client), 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>),
#[cfg(target_os = "linux")]
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.
/// **Pin-only** (`PUNKTFUNK_DECODER=native-vaapi`) and deliberately NOT in the
/// automatic ladder, on the same rule M5's native D3D11VA rung follows: `auto`
/// admission is earned with hardware parity and a soak, and this rung has decoded
/// nothing yet. 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>),
#[cfg(windows)]
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.
/// **Pin-only** (`PUNKTFUNK_DECODER=native-d3d11va`) and deliberately NOT in the
/// automatic ladder: M2's native Vulkan rung was admitted to `auto` only after
/// hardware parity, and this one has decoded nothing yet. 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>),
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,
/// 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 (~100300 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 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,
}
/// 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. (AV1 is 0x4
/// and deliberately has no constant here: pf-vkdecode has no AV1 decoder, so the bit
/// would only invite a gate that admits a session nothing can decode.)
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, and it is what
/// [`av1_hardware_decodable`] reads.
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). AV1 has a Vulkan decode
/// op and real hardware advertises it — but there is no AV1 decoder in pf-vkdecode,
/// so those sessions must keep falling through to the FFmpeg rungs.
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)),
_ => 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),
_ => 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),
_ => None,
}
}
/// The native Vulkan Video admission gate (WP-C of the native-decode program, widened
/// by the 2026-08-05 ladder decision and again by M3 WP-2's HEVC wiring): 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 native is the rung
/// immediately ABOVE FFmpeg-Vulkan: WP-D closed with bit-exact parity against
/// libavcodec (250/250 AUs on three drivers, clean 92-minute soak), and the program's
/// goal is dropping FFmpeg from the client, so native goes first wherever the ladder
/// would reach FFmpeg-Vulkan — a native INIT failure falls through to that rung, so
/// admission can't cost a session its decoder at start. A runtime error streak demotes
/// past FFmpeg-Vulkan to VAAPI/D3D11VA/software like every hardware rung's streaks do,
/// with ONE exception: a native backend that never delivered a single frame demotes to
/// FFmpeg-Vulkan first, because a rung the session never actually had must not cost it
/// the rung below (see [`Decoder::decode_frame`]). The explicit `vulkan` pin still
/// names the FFmpeg-Vulkan backend specifically; it — and every other explicit backend
/// pin — refuses.
///
/// Beyond the choice: the negotiated wire codec must be one pf-vkdecode speaks —
/// H.264 or H.265 ([`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. AV1 sessions refuse outright.
///
/// 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;
};
matches!(choice, "native-vulkan" | "auto" | "" | "hardware")
&& video_decode
&& decode_video_caps & codec_op != 0
}
/// 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".
pub(crate) fn find_hw_decoder(
codec_id: ffmpeg::codec::Id,
hw_pix_fmt: ffmpeg::ffi::AVPixelFormat,
) -> Result<*const ffmpeg::ffi::AVCodec> {
use ffmpeg::ffi;
let want: ffi::AVCodecID = codec_id.into();
let mut found: Vec<String> = Vec::new();
// SAFETY: `av_codec_iterate` walks libav's static codec registry (`opaque` is its
// cursor) and returns static `AVCodec`s; `avcodec_get_hw_config` only reads the
// codec's own static hw-config table, NULL-terminated by returning null past the end.
unsafe {
let mut opaque = std::ptr::null_mut();
loop {
let codec = ffi::av_codec_iterate(&mut opaque);
if codec.is_null() {
break;
}
if (*codec).id != want || ffi::av_codec_is_decoder(codec) == 0 {
continue;
}
for i in 0.. {
let cfg = ffi::avcodec_get_hw_config(codec, i);
if cfg.is_null() {
break;
}
if (*cfg).methods & ffi::AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX as i32 != 0
&& (*cfg).pix_fmt == hw_pix_fmt
{
return Ok(codec);
}
}
found.push(
std::ffi::CStr::from_ptr((*codec).name)
.to_string_lossy()
.into_owned(),
);
}
}
if found.is_empty() {
bail!("no {codec_id:?} decoder in this FFmpeg build");
}
bail!(
"no {codec_id:?} decoder in this FFmpeg build can drive {hw_pix_fmt:?} via \
hw_device_ctx (found: {})",
found.join(", ")
);
}
/// The name of a registry `AVCodec` (`(*codec).name`), owned — the field every decode
/// log carries so `decoder="av1"` vs `decoder="libdav1d"` is one glance, not a debugger.
///
/// # Safety
/// `codec` must point to a registered `AVCodec` (their `name` is a static NUL-terminated
/// string, valid for the process).
pub(crate) unsafe fn codec_name(codec: *const ffmpeg::ffi::AVCodec) -> String {
// SAFETY: caller guarantees a registered AVCodec; `name` is its static C string.
unsafe {
std::ffi::CStr::from_ptr((*codec).name)
.to_string_lossy()
.into_owned()
}
}
/// The `quic` codec bitfield this client can decode — whatever FFmpeg has a decoder for (HEVC/H.264
/// always; AV1 when built in). Advertised to the host so it never emits a codec we can't decode.
///
/// ⚠ **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`].
pub fn decodable_codecs() -> u8 {
let _ = ffmpeg::init();
let mut bits = 0u8;
for (id, bit) in [
(ffmpeg::codec::Id::HEVC, punktfunk_core::quic::CODEC_HEVC),
(ffmpeg::codec::Id::H264, punktfunk_core::quic::CODEC_H264),
(ffmpeg::codec::Id::AV1, punktfunk_core::quic::CODEC_AV1),
] {
if ffmpeg::decoder::find(id).is_some() {
bits |= bit;
}
}
bits
}
/// 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, which is the same gate the
/// D3D11VA rung itself sits behind — that rung decodes AV1 Profile 0 today
/// (`video_d3d11.rs`'s profile table), so a machine reaching it has hardware AV1.
///
/// ⚠ 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.
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;
}
#[cfg(windows)]
{
return vk.is_some_and(|v| v.d3d11_import);
}
#[cfg(not(windows))]
false
}
/// [`decodable_codecs`] plus the PyroWave bit when the presenter's device passed the
/// compute-feature probe. 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>) -> 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;
}
#[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).
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"
),
}
}
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`]); on H.264 and HEVC sessions the
/// native pf-vkdecode rung sits immediately above FFmpeg-Vulkan wherever the
/// ladder reaches it ([`native_vulkan_gate`] — the program is dropping FFmpeg, and
/// a native INIT failure falls through to FFmpeg-Vulkan). Linux: native → Vulkan →
/// 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);
/// VAAPI → native → Vulkan → software on Intel/unknown. Windows (no VAAPI
/// there): native → Vulkan → D3D11VA → software on NVIDIA/AMD, D3D11VA →
/// native → Vulkan → software on Intel/unknown (Intel's driver advertises Vulkan
/// Video, but FFmpeg-Vulkan on it strobes/overruns the budget — B580 field
/// report).
///
/// `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> {
ffmpeg::init().context("ffmpeg init")?;
quiet_ffmpeg_log();
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(_)));
Ok(Decoder {
backend,
codec_id,
vaapi_fails: 0,
first_fail: None,
want_keyframe: false,
delivered: false,
vk: vk.cloned(),
#[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) — PIN ONLY, ahead of everything because a pin is a
// pin. It is deliberately absent from every `auto` arm below: M2's native Vulkan rung
// joined `auto` only after WP-D closed bit-exact against libavcodec on three drivers
// and a 92-minute soak, and this rung has decoded nothing yet. 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 or HEVC \
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) — PIN ONLY, ahead of everything because a pin is a
// pin, and absent from every `auto` arm below for the same reason its D3D11VA
// sibling is: `auto` admission is earned with hardware parity and a soak. 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 or HEVC \
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 or HEVC session \
and a presenter device whose decode family advertises that codec) — \
standard ladder"
);
}
choice = "auto".to_string();
}
// Linux `auto`: try VAAPI 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;
match VaapiDecoder::new(codec_id) {
Ok(v) => {
tracing::info!(
?codec_id,
decoder = v.name(),
"VAAPI hardware decode active (zero-copy dmabuf)"
);
return done(Backend::Vaapi(v));
}
Err(e) => {
tracing::info!(reason = %e, "VAAPI unavailable — trying Vulkan Video");
}
}
}
// 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.
#[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())
{
if let Some(v) = vk.filter(|v| v.d3d11_import) {
d3d11_tried = true;
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 done(Backend::D3d11va(d));
}
Err(e) => {
tracing::info!(reason = %format!("{e:#}"),
"D3D11VA unavailable — trying Vulkan Video");
}
}
}
}
// Native Vulkan Video (pf-vkdecode) — auto's rung immediately ABOVE
// FFmpeg-Vulkan (2026-08-05 ladder decision: WP-D closed with bit-exact parity
// against libavcodec on three drivers and a clean soak, and the program's goal
// is dropping FFmpeg from the client entirely — so wherever auto would reach
// FFmpeg-Vulkan, native goes first). [`native_vulkan_gate`] carries the whole
// decision, including the choice: the explicit `vulkan` pin is NOT this rung —
// it names the FFmpeg-Vulkan backend specifically and keeps meaning exactly
// that. An init failure logs and falls through to FFmpeg-Vulkan 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 — trying FFmpeg Vulkan Video"),
}
}
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 => {}
}
}
// Deck/NVIDIA note: `auto` reaches VAAPI 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 {
match VaapiDecoder::new(codec_id) {
Ok(v) => {
tracing::info!(
?codec_id,
decoder = v.name(),
"VAAPI hardware decode active (zero-copy dmabuf)"
);
return done(Backend::Vaapi(v));
}
Err(e) => {
if choice == "vaapi" {
return Err(e.context("PUNKTFUNK_DECODER=vaapi but VAAPI failed"));
}
tracing::warn!(error = %e, "VAAPI unavailable — falling back to software decode");
}
}
}
// Windows: D3D11VA as the fallback rung for NVIDIA/AMD auto (Vulkan Video missing
// or failed to open) and the explicit `d3d11va` preference — gated on the presenter
// having the win32 external-memory import path, else its frames could never reach
// the screen. (On Intel/unknown auto it was already tried above — `d3d11_tried`
// skips the repeat.)
#[cfg(windows)]
if choice != "software" && choice != "vulkan" && !d3d11_tried {
match vk.filter(|v| v.d3d11_import) {
Some(v) => {
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 done(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 — software decode");
}
}
}
None if choice == "d3d11va" => bail!(
"PUNKTFUNK_DECODER=d3d11va but the presenter's device lacks the win32 \
external-memory import extensions — see the presenter log"
),
None => {}
}
}
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"
);
}
done(Backend::Software(SoftwareDecoder::new(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 {
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,
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,
#[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)
}
/// 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");
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
self.vaapi_fails = 0;
self.first_fail = None;
self.delivered = false;
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 {
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(target_os = "linux")]
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(windows)]
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 {
Backend::Vulkan(_) => "Vulkan Video",
Backend::NativeVulkan(_) => "native Vulkan Video",
#[cfg(windows)]
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.
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.backend = Backend::Vulkan(fallback);
self.vaapi_fails = 0;
self.first_fail = None;
self.delivered = false;
return Ok(None);
}
Err(fe) => tracing::info!(reason = %format!("{fe:#}"),
"FFmpeg Vulkan Video 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 — and while that rung is pin-only, this is the
// only way a lab session that pinned it keeps hardware decode.
#[cfg(target_os = "linux")]
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.backend = Backend::Vaapi(v);
self.vaapi_fails = 0;
self.first_fail = None;
self.delivered = false;
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 — and while that rung is pin-only, this is the only way a
// lab session that pinned it keeps hardware decode.
#[cfg(windows)]
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.backend = Backend::D3d11va(d);
self.vaapi_fails = 0;
self.first_fail = None;
self.delivered = false;
return Ok(None);
}
Err(dx) => tracing::info!(reason = %dx,
"D3D11VA unavailable for demotion — software decode"),
}
}
tracing::warn!(error = %e, fails = self.vaapi_fails,
"{which} decode failing repeatedly — demoting to software");
self.backend = Backend::Software(SoftwareDecoder::new(self.codec_id)?);
self.vaapi_fails = 0;
self.first_fail = None;
self.delivered = false;
} 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).
pub(crate) const AVERROR_EAGAIN: i32 = -11;
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`).
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) VAAPI path calls this outside tests; the constants are worth
// locking on every platform, so it stays compiled rather than cfg-gated with its caller.
#[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::*;
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}"
);
}
/// 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());
}
/// The native-Vulkan admission gate (WP-C, widened by the 2026-08-05 ladder
/// decision and again by M3 WP-2's HEVC wiring): the pin AND the auto family
/// admit on a capable H.264 or HEVC session — native sits immediately above
/// FFmpeg-Vulkan because the program is dropping FFmpeg — while every explicit
/// 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)));
}
/// 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"
);
const H264_OP: u32 = VIDEO_CODEC_OP_DECODE_H264;
const H265_OP: u32 = VIDEO_CODEC_OP_DECODE_H265;
// `VK_VIDEO_CODEC_OPERATION_DECODE_AV1_BIT_KHR` — a real bit on real
// hardware, and never enough on its own (no AV1 decoder exists here).
const AV1_OP: u32 = 0x4;
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 refuses whatever the family advertises — pf-vkdecode has no AV1
// decoder, so the session must fall through to the FFmpeg rungs.
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:?}"
);
// 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:?}"
);
}
// 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)
);
assert!(native_codec(Id::AV1).is_none());
assert!(native_codec(Id::VP9).is_none());
}
/// 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.
#[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
);
}
}