refactor(host/W6.2): make the Linux capture→encode edge one-way via ZeroCopyPolicy

The PipeWire capture thread re-derived three encode-backend facts by calling
encode::{linux_zero_copy_is_vaapi, resolved_backend_is_gpu,
pyrowave_capture_modifiers} — a capture→encode back-reference that would force
pf-capture to depend on pf-encode (and vice versa, since encode already speaks
the frame vocabulary). Resolve them ONCE in the host capture facade (which may
reach crate::encode) as a ZeroCopyPolicy { backend_is_vaapi, backend_is_gpu,
pyrowave_modifiers } and thread it into PortalCapturer::open /
from_virtual_output → spawn_pipewire → pipewire_thread.

capture/linux/mod.rs now makes ZERO crate::encode calls — the edge is one-way
(plan §2.4 / §W6), so pf-capture can be extracted depending only on pf-frame
(not pf-encode). pyrowave_modifiers is computed by the facade whenever the
encoder pref is pyrowave (which implies the VAAPI backend); the thread still
consumes them only inside its existing vaapi_passthrough guard, so behavior is
unchanged.

Verified: Linux clippy -D warnings (host nvenc,vulkan-encode,pyrowave
--all-targets); Windows clippy nvenc,amf-qsv --all-targets Finished exit 0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-17 10:20:17 +02:00
parent ccc4b08d45
commit 1de83ba51d
2 changed files with 72 additions and 14 deletions
+48 -2
View File
@@ -193,6 +193,51 @@ impl Capturer for FastSyntheticCapturer {
}
}
/// The encode-backend facts the Linux zero-copy negotiation needs, resolved **once** here (the host
/// facade, which may reach `crate::encode`) and passed **into** the capturer — so the capturer never
/// calls back into `encode`, keeping the capture→encode dependency one-way (plan §2.4 / §W6). The
/// three facts were formerly re-derived inside the PipeWire thread via
/// `encode::{linux_zero_copy_is_vaapi, resolved_backend_is_gpu, pyrowave_capture_modifiers}`.
#[cfg(target_os = "linux")]
#[derive(Clone, Default)]
pub struct ZeroCopyPolicy {
/// The GPU encode backend resolves to VAAPI (AMD/Intel) — the capturer hands raw dmabufs
/// straight through instead of the EGL→CUDA import ([`crate::encode::linux_zero_copy_is_vaapi`]).
pub backend_is_vaapi: bool,
/// The resolved backend produces GPU-resident frames (everything but the software encoder) —
/// used only to phrase the CPU-fallback warning ([`crate::encode::resolved_backend_is_gpu`]).
pub backend_is_gpu: bool,
/// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc,
/// resolved when the encoder pref is `pyrowave` (the passthrough advertises them so Mutter+NVIDIA,
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
pub pyrowave_modifiers: Vec<u64>,
}
/// Resolve the [`ZeroCopyPolicy`] for a Linux capture session from the encode backend. Called by the
/// facade openers below so the capturer receives the facts rather than re-deriving them.
#[cfg(target_os = "linux")]
fn zero_copy_policy() -> ZeroCopyPolicy {
let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi();
#[cfg(feature = "pyrowave")]
let pyrowave_modifiers =
if backend_is_vaapi && pf_host_config::config().encoder_pref.as_str() == "pyrowave" {
// BGRx is the capture path's canonical packed-RGB format (the modifier advertisement keys on
// it). `drm_fourcc(Bgrx)` is always `Some`.
drm_fourcc(PixelFormat::Bgrx)
.map(crate::encode::pyrowave_capture_modifiers)
.unwrap_or_default()
} else {
Vec::new()
};
#[cfg(not(feature = "pyrowave"))]
let pyrowave_modifiers = Vec::new();
ZeroCopyPolicy {
backend_is_vaapi,
backend_is_gpu: crate::encode::resolved_backend_is_gpu(),
pyrowave_modifiers,
}
}
/// Open a live capturer for a client-sized monitor via the xdg ScreenCast portal
/// (`ashpd`) → PipeWire (`pipewire`). Implemented in the `linux` submodule.
#[cfg(target_os = "linux")]
@@ -201,7 +246,8 @@ pub fn open_portal_monitor() -> Result<Box<dyn Capturer>> {
// session so it inherits that grant headlessly; wlroots/Sway has no RemoteDesktop portal,
// so use a plain ScreenCast session there.
let anchored = crate::inject::default_backend() == crate::inject::Backend::Libei;
linux::PortalCapturer::open(anchored).map(|c| Box::new(c) as Box<dyn Capturer>)
linux::PortalCapturer::open(anchored, zero_copy_policy())
.map(|c| Box::new(c) as Box<dyn Capturer>)
}
#[cfg(not(target_os = "linux"))]
@@ -226,7 +272,7 @@ pub fn capture_virtual_output(
// worker's planar-YUV444 GPU convert for tiled dmabufs (the 4:4:4 zero-copy path). `gpu =
// false` (4:4:4 without zero-copy) forces the CPU mmap path so the encoder gets CPU-resident
// RGB to swscale into YUV444P.
linux::PortalCapturer::from_virtual_output(vout, want.gpu, want.chroma_444)
linux::PortalCapturer::from_virtual_output(vout, want.gpu, want.chroma_444, zero_copy_policy())
.map(|c| Box::new(c) as Box<dyn Capturer>)
}
+24 -12
View File
@@ -20,7 +20,7 @@
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
#![deny(clippy::undocumented_unsafe_blocks)]
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat};
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
use anyhow::{anyhow, Context, Result};
use std::os::fd::OwnedFd;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -77,7 +77,7 @@ impl PortalCapturer {
/// `anchored` drives ScreenCast off a RemoteDesktop session (KWin/GNOME) so it inherits the
/// RemoteDesktop grant and never raises a separate ScreenCast dialog; `false` uses a plain
/// ScreenCast session (wlroots, which has no RemoteDesktop portal).
pub fn open(anchored: bool) -> Result<PortalCapturer> {
pub fn open(anchored: bool, policy: ZeroCopyPolicy) -> Result<PortalCapturer> {
// Portal handshake (async) on its own thread; hands back the PW fd + node id.
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
thread::Builder::new()
@@ -101,7 +101,10 @@ impl PortalCapturer {
"ScreenCast portal session started; connecting PipeWire"
);
// This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before.
Ok(spawn_pipewire(Some(fd), node_id, None, true, false)?.into_capturer(node_id, None))
Ok(
spawn_pipewire(Some(fd), node_id, None, true, false, policy)?
.into_capturer(node_id, None),
)
}
/// Build a capturer from an already-created virtual output ([`crate::vdisplay::VirtualOutput`]):
@@ -116,6 +119,7 @@ impl PortalCapturer {
vout: crate::vdisplay::VirtualOutput,
allow_zerocopy: bool,
want_444: bool,
policy: ZeroCopyPolicy,
) -> Result<PortalCapturer> {
tracing::info!(
node_id = vout.node_id,
@@ -130,6 +134,7 @@ impl PortalCapturer {
vout.preferred_mode,
allow_zerocopy,
want_444,
policy,
)?
.into_capturer(node_id, Some(vout.keepalive)))
}
@@ -186,6 +191,9 @@ fn spawn_pipewire(
// 4:4:4 session: tiled dmabufs convert to planar YUV444 on the GPU (`ImportKind::Tiled444`)
// instead of NV12/RGB, so the session stays zero-copy at full chroma.
want_444: bool,
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way
// capture→encode edge (plan §W6).
policy: ZeroCopyPolicy,
) -> Result<PwHandles> {
// Frames flow from the pipewire thread over a small bounded channel.
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8);
@@ -207,7 +215,7 @@ fn spawn_pipewire(
// negotiation-timeout branch knows a failed negotiation was the LINEAR-dmabuf offer.
let vaapi_dmabuf = zerocopy
&& std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() != Ok("1")
&& crate::encode::linux_zero_copy_is_vaapi();
&& policy.backend_is_vaapi;
let join = thread::Builder::new()
.name("punktfunk-pipewire".into())
.spawn(move || {
@@ -223,6 +231,7 @@ fn spawn_pipewire(
want_444,
preferred,
quit_rx,
policy,
) {
tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed");
}
@@ -604,7 +613,7 @@ fn portal_thread_remote_desktop(setup_tx: std::sync::mpsc::Sender<Result<(OwnedF
mod pipewire {
//! The PipeWire consumer, confined to its own thread (the PW types are `!Send`).
use super::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat};
use super::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
use anyhow::{Context, Result};
use pipewire as pw;
use pw::{properties::properties, spa};
@@ -1549,6 +1558,9 @@ mod pipewire {
want_444: bool,
preferred: Option<(u32, u32, u32)>,
quit_rx: pw::channel::Receiver<()>,
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way
// capture→encode edge (plan §W6).
policy: ZeroCopyPolicy,
) -> Result<()> {
crate::pwinit::ensure_init();
@@ -1581,7 +1593,7 @@ mod pipewire {
// would waste a CUDA probe — or worse, on an NVIDIA box forced to PUNKTFUNK_ENCODER=vaapi,
// succeed and produce CUDA payloads the VAAPI encoder must reject. Also skipped once
// repeated worker deaths latched the import off (a wedged GPU stack must not crash-loop).
let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi();
let backend_is_vaapi = policy.backend_is_vaapi;
let mut importer = if zerocopy && !backend_is_vaapi {
if crate::zerocopy::gpu_import_disabled() {
tracing::warn!(
@@ -1624,12 +1636,12 @@ mod pipewire {
}
// PyroWave passthrough: the encoder imports through Vulkan, not libva — extend the
// advertisement with every modifier its device samples from, so compositors that
// never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs.
// never allocate LINEAR (Mutter+NVIDIA) still negotiate zero-copy dmabufs. The modifiers
// were resolved by the facade (`ZeroCopyPolicy::pyrowave_modifiers`) — non-empty only when
// the encoder pref is `pyrowave` — so capture never calls back into `encode`.
#[cfg(feature = "pyrowave")]
if vaapi_passthrough && pf_host_config::config().encoder_pref.as_str() == "pyrowave" {
for m in crate::encode::pyrowave_capture_modifiers(
crate::zerocopy::drm_fourcc(PixelFormat::Bgrx).unwrap(),
) {
if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() {
for &m in &policy.pyrowave_modifiers {
if !modifiers.contains(&m) {
modifiers.push(m);
}
@@ -1657,7 +1669,7 @@ mod pipewire {
sample = ?&modifiers[..modifiers.len().min(6)],
"zero-copy: advertising EGL-importable dmabuf modifiers"
);
} else if backend_is_vaapi && crate::encode::resolved_backend_is_gpu() {
} else if backend_is_vaapi && policy.backend_is_gpu {
// A VAAPI session on the CPU path pays three full-frame CPU touches (mmap de-pad +
// swscale RGB→NV12 + surface upload) — make the silent fallback visible.
tracing::warn!(