fix(client): Linux auto decoder tries VAAPI before FFmpeg-Vulkan on desktop Mesa
ci / web (push) Successful in 48s
ci / docs-site (push) Successful in 56s
decky / build-publish (push) Successful in 16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m1s
arch / build-publish (push) Successful in 11m4s
ci / bench (push) Successful in 6m7s
android / android (push) Successful in 15m32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7m29s
flatpak / build-publish (push) Failing after 8m12s
deb / build-publish (push) Successful in 11m17s
ci / rust (push) Successful in 22m25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 14m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m36s
docker / deploy-docs (push) Successful in 26s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 4m30s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 5m8s
apple / swift (push) Successful in 4m47s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m56s
apple / screenshots (push) Successful in 19m45s

Mesa now exposes Vulkan Video 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 under `auto` — user-reported
(CachyOS/KDE) to judder or error-streak into the software demotion while an
explicit VAAPI pick streams perfectly. Auto's hardware order is now
device-aware (`VulkanDecodeDevice::prefer_vulkan_over_vaapi`, fed
vendor id + device name by the presenter): Vulkan-first stays only where it
is the established right answer — NVIDIA (no usable VAAPI) and the Deck's
VanGogh (VAAPI dmabuf import chroma-fringes) — and everything else gets the
battle-tested zero-copy VAAPI first, with Vulkan as its fallback.

A mid-session Vulkan failure streak now also demotes to VAAPI before
software, so a broken Mesa Vulkan path can never strand a box with a
perfectly good VAAPI driver on CPU decode.

The GTK shell's decoder setting gains the missing "Vulkan Video" option
(values now mirror the console UI's auto/vulkan/vaapi/software) and drops
its pre-Vulkan "Automatic (VAAPI → software)" label.

Verified on the RTX 5070 Ti box (loopback session, auto → "Vulkan Video
hardware decode active", 60 fps); policy locked by unit test; clippy -D
warnings + pf-client-core/pf-presenter tests green on Linux.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:41:37 +02:00
parent 9b7fc127ef
commit 46b7ffc001
4 changed files with 139 additions and 14 deletions
+6 -4
View File
@@ -29,7 +29,7 @@ const COMPOSITORS: &[&str] = &["auto", "kwin", "wlroots", "mutter", "gamescope"]
/// Codec setting values (persisted) paired with their display labels below.
const CODECS: &[&str] = &["auto", "hevc", "h264", "av1"];
const CODEC_LABELS: &[&str] = &["Automatic", "HEVC (H.265)", "H.264 (AVC)", "AV1"];
const DECODERS: &[&str] = &["auto", "vaapi", "software"];
const DECODERS: &[&str] = &["auto", "vulkan", "vaapi", "software"];
/// Touch-input model values (persisted) paired with their display labels below — the
/// cross-client set (Android/Apple). Only meaningful on a touchscreen (Deck/tablet).
const TOUCH_MODES: &[&str] = &["trackpad", "pointer", "touch"];
@@ -324,10 +324,12 @@ pub fn show(
&dialog,
inline,
"Video decoder",
"Automatic tries VAAPI hardware decode, then software",
"Automatic picks the best hardware decode for this GPU (VAAPI on AMD/Intel, \
Vulkan Video on NVIDIA), falling back to software",
&[
"Automatic (VAAPI → software)",
"Hardware (VAAPI)",
"Automatic (hardware → software)",
"Vulkan Video",
"VAAPI",
"Software",
],
);
+127 -9
View File
@@ -1,6 +1,8 @@
//! Video decode: reassembled HEVC access units → frames for the presenter.
//!
//! Three backends, picked at session start (auto: vulkan → vaapi → software;
//! Three backends, picked at session start (auto on Linux: vaapi → vulkan → software on
//! desktop Mesa, vulkan first on NVIDIA/VanGogh — see
//! [`VulkanDecodeDevice::prefer_vulkan_over_vaapi`];
//! override: `PUNKTFUNK_DECODER=vulkan|vaapi|software`):
//!
//! * **Vulkan Video**: FFmpeg's Vulkan decoder running on the PRESENTER's own VkDevice
@@ -384,8 +386,11 @@ impl Decoder {
/// `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
/// (Vulkan → VAAPI → software; no VAAPI on Windows).
/// hatch, and the documented knob), then the setting; both default to auto.
/// Auto's hardware order on Linux depends on the device
/// ([`VulkanDecodeDevice::prefer_vulkan_over_vaapi`]): VAAPI → Vulkan → software on
/// desktop Mesa (AMD/Intel), Vulkan → VAAPI → software on NVIDIA and the Deck's
/// VanGogh. Windows is Vulkan → D3D11VA → software (no VAAPI there).
pub fn new(
codec_id: ffmpeg::codec::Id,
pref: &str,
@@ -405,6 +410,31 @@ impl Decoder {
want_keyframe: false,
})
};
// 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_over_vaapi())
{
vaapi_tried = true;
match VaapiDecoder::new(codec_id) {
Ok(v) => {
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
return done(Backend::Vaapi(v));
}
Err(e) => {
tracing::info!(reason = %e, "VAAPI unavailable — trying 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
@@ -423,7 +453,7 @@ impl Decoder {
return Err(e.context("PUNKTFUNK_DECODER=vulkan but it failed"));
}
tracing::info!(reason = %format!("{e:#}"),
"Vulkan Video unavailable — trying VAAPI");
"Vulkan Video unavailable — falling back");
}
},
None if choice == "vulkan" => {
@@ -435,12 +465,13 @@ impl Decoder {
None => {}
}
}
// Deck note: `auto` reaches VAAPI when Vulkan Video isn't available. 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.
// 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" {
if choice != "software" && choice != "vulkan" && !vaapi_tried {
match VaapiDecoder::new(codec_id) {
Ok(v) => {
tracing::info!(?codec_id, "VAAPI hardware decode active (zero-copy dmabuf)");
@@ -558,6 +589,24 @@ impl Decoder {
self.vaapi_fails += 1;
self.want_keyframe = true;
if self.vaapi_fails >= VAAPI_DEMOTE_AFTER {
// A failing Vulkan backend 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.
#[cfg(target_os = "linux")]
if matches!(self.backend, Backend::Vulkan(_)) {
match VaapiDecoder::new(self.codec_id) {
Ok(v) => {
tracing::warn!(error = %e, fails = self.vaapi_fails,
"Vulkan Video decode failing repeatedly — demoting to VAAPI");
self.backend = Backend::Vaapi(v);
self.vaapi_fails = 0;
return Ok(None);
}
Err(va) => tracing::info!(reason = %va,
"VAAPI 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)?);
@@ -1002,6 +1051,12 @@ pub struct VulkanDecodeDevice {
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_over_vaapi`].
pub vendor_id: u32,
/// The driver's device-name string (e.g. "AMD RADV VANGOGH") — the VanGogh/Deck
/// detection for [`Self::prefer_vulkan_over_vaapi`].
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).
@@ -1035,6 +1090,25 @@ pub struct VulkanDecodeDevice {
pub queue_lock: std::sync::Arc<QueueLock>,
}
impl VulkanDecodeDevice {
/// Should `auto` try Vulkan Video BEFORE VAAPI on this device? Only where that's the
/// established right answer:
/// * **NVIDIA** — Vulkan is its only hardware path (no usable VAAPI; the
/// nvidia-vaapi-driver is broken for this, Moonlight blacklists it).
/// * **VanGogh (Steam Deck)** — VAAPI's separate-plane dmabuf import shows chroma
/// fringing there; the session binary opts RADV into `video_decode` precisely to
/// get the Vulkan path.
///
/// Every other Mesa device (desktop RADV, ANV) keeps the battle-tested zero-copy
/// VAAPI first: Mesa now exposes decode queues by default, and FFmpeg-Vulkan-on-Mesa
/// regressing (judder, error-streaks that used to demote to software) is a real,
/// user-reported failure — while VAAPI is the path every other Linux client uses.
pub fn prefer_vulkan_over_vaapi(&self) -> bool {
const VENDOR_NVIDIA: u32 = 0x10DE;
self.vendor_id == VENDOR_NVIDIA || self.device_name.to_ascii_uppercase().contains("VANGOGH")
}
}
/// `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)
@@ -1505,6 +1579,50 @@ unsafe extern "C" fn pick_vulkan(
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,
video_decode: true,
d3d11_import: false,
adapter_luid: None,
queue_lock: std::sync::Arc::new(QueueLock::new()),
}
}
/// Auto's Linux hardware order: Vulkan-first ONLY on NVIDIA (no usable VAAPI) and the
/// Deck's VanGogh (VAAPI chroma-fringes); desktop RADV/ANV keep VAAPI first — the
/// user-reported judder/software regression came from FFmpeg-Vulkan-on-Mesa winning auto.
#[test]
fn vulkan_over_vaapi_only_on_nvidia_and_vangogh() {
assert!(decode_device(0x10DE, "NVIDIA GeForce RTX 5070 Ti").prefer_vulkan_over_vaapi());
assert!(decode_device(0x1002, "AMD RADV VANGOGH").prefer_vulkan_over_vaapi());
assert!(
decode_device(0x1002, "AMD Custom GPU 0405 (RADV VANGOGH)").prefer_vulkan_over_vaapi()
);
assert!(
!decode_device(0x1002, "AMD Radeon RX 7800 XT (RADV NAVI32)")
.prefer_vulkan_over_vaapi()
);
assert!(
!decode_device(0x8086, "Intel(R) Arc(tm) A770 Graphics (DG2)")
.prefer_vulkan_over_vaapi()
);
}
fn desc(matrix: u8, full_range: bool) -> ColorDesc {
ColorDesc {
primaries: 1,
+5
View File
@@ -661,6 +661,11 @@ impl Presenter {
instance: instance.handle().as_raw() as usize,
physical_device: pdev.as_raw() as usize,
device: device.handle().as_raw() as usize,
vendor_id: dev_props.vendor_id,
device_name: dev_props
.device_name_as_c_str()
.map(|c| c.to_string_lossy().into_owned())
.unwrap_or_default(),
graphics_qf: qfi,
graphics_queue_flags: qf_props[qfi as usize].queue_flags.as_raw(),
decode_qf,
+1 -1
View File
@@ -158,7 +158,7 @@ A few knobs are read by the native **clients**, not the host:
| Setting | Values | Meaning |
|---|---|---|
| `PUNKTFUNK_DECODER` | `software` · `vaapi` (Linux) | Force the decode path. Default auto-selects hardware (VAAPI on Intel/AMD, D3D11VA on Windows) with a software fallback. |
| `PUNKTFUNK_DECODER` | `software` · `vaapi` · `vulkan` (Linux) · `d3d11va` (Windows) | Force the decode path. Default auto-selects hardware (VAAPI on Intel/AMD, Vulkan Video on NVIDIA and the Steam Deck, D3D11VA/Vulkan on Windows) with a software fallback. |
## Bitrate