Files
punktfunk/crates/pf-capture/src/linux/pipewire.rs
T
enricobuehlerandClaude Opus 5 306f4a514d refactor(pf-capture): structural splits + collapse the restated signal set (Phase 5)
Refactors LAST, after every defect fix, so no behaviour change hides in a move diff — and so the
newly-exposed review surface was already clean when it landed (the WP7 discipline). 28f8fc71's
recorded trap (an inline `use super::X` inside a moved body silently changing meaning) was audited
first: every `use super::` in the moved code is either at module level — where `super` still means
`linux` — or inside a `mod tests` that moved with its parent.

5.1 `mod pipewire` → `linux/pipewire.rs`. It was 1,900 of that file's lines. `mod.rs` is a directory
module, so a plain `mod pipewire;` resolves with no `#[path]`.

5.2 out of that, `linux/pw_pods.rs` (the 7 param-pod builders + `serialize_pod` + the PQ constant
and its test) and `linux/pw_cursor.rs` (`CursorState`, `decode_bitmap_pixel`, `update_cursor_meta`,
`dst_offsets`, both `composite_cursor*`). The pods are the crate's WIRE surface — a missing property
is not a compile error but a link that stalls in `negotiating`; the cursor half is producer-driven
and bounds-critical. Both are now pure enough to unit-test without a compositor, which is what
Phase 6 needs.

5.3 `linux/portal.rs`: the ScreenCast/RemoteDesktop handshake, the cursor-mode choice and GNOME's
BT.2100 probe — the async/tokio/zbus control plane, separated from the realtime half. Zero
re-exports changed: `mod.rs` re-exports `gnome_hdr_monitor_active` at its old path.

5.4 `idd_push/open.rs` (the whole one-time construction path + `SharedObjectSa` + `AttachTexFail`)
and `idd_push/compose_kick.rs`. Read `open.rs` when a session will not START and the parent when one
stops flowing; the steady state stays with the parent by decision. Also moved the ~65-line stall
REPORTING block out of `try_consume` (the hot loop) into `StallWatch::report`, taking its two
correlation counters with it — they were capturer fields nothing else touched.

5.5 `windows/dxgi/selftest.rs`: `p010_reference`, both self-tests, `hdr_p010_convert_bars_on_luid`,
`f32_to_f16` and the two test modules — the validation path, none of which runs in a session. The
two `pub` entry points are re-exported, so `main.rs`'s subcommand and pf-encode's live e2e keep
their paths. (An explicit `#[path]`, like `idd_push`'s children: this file is itself reached through
a `#[path]`, so a bare `mod` would resolve to `windows/selftest.rs`.)

5.6 `CaptureSignals`. The seven shared flags/slots were created in `spawn_pipewire`, `_cb`-cloned one
by one, passed as seven of `pipewire_thread`'s parameters, and then re-declared as fields on
`PwHandles`, `PortalCapturer` AND `UserData` — the same list written four times, each with its own
drifting copy of the doc comment. One `#[derive(Clone)]` struct instead (a refcount bump per field),
which also makes it structurally obvious that producer and consumer share ONE set. And `CaptureOpts`
for the four trailing `bool`s: four adjacent same-typed positional arguments are a
silent-transposition footgun — swap `want_444` and `want_hdr` and it compiles, negotiates the wrong
pod family, and surfaces as a black screen ten seconds later.

5.7 trait shape, targeted: `set_active(&self)` → `&mut self` (it took `&self` only because the flag
happened to be an `Arc<AtomicBool>` — an implementation detail leaking into the contract);
`capture_target_id` ⇒ `resize_output`'s pairing rule stated on both methods and in a cluster note;
one-line cluster headers over the 13 methods.

NOT done from 5.7: taking the gamescope targets as an `open_*` option instead of the trait method.
It would put a Linux-only parameter on the cross-platform `open_virtual_output` and on the host's
`capture_virtual_output` facade — a `#[cfg]`-shaped wart in two shared signatures to delete one
already-`cfg`'d defaulted method whose lifecycle rule 2.5c had already made harmless. Wrong trade
under 5.7's own "no churn for no defect fixed" principle.

File sizes: `linux/mod.rs` 2,778 → 770 (target ≤900 ✓), `windows/dxgi.rs` 1,374 → 954, and
`windows/idd_push.rs` 2,694 → 1,922. The last two miss the plan's ≤850/≤1,300 targets, which were
computed against the ORIGINAL line counts — Phases 1–4 added several hundred lines of SAFETY proofs
and defect rationale to exactly these files before the split. The moves themselves are the ones the
plan specifies; closing the rest would mean inventing new splits it does not call for.

Zero behaviour delta. pf-capture 20/20; workspace clippy --all-targets clean on Linux and on
windows-msvc.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 11:25:39 +02:00

1612 lines
81 KiB
Rust

//! The PipeWire consumer, confined to its own thread (the PW types are `!Send`).
use super::pw_cursor::{composite_cursor, update_cursor_meta, CursorState};
use super::pw_pods::{
build_cursor_meta_param, build_default_format_obj, build_dmabuf_buffers, build_dmabuf_format,
build_hdr_dmabuf_format, build_mappable_buffers, build_shm_only_buffers, serialize_pod,
};
use super::{CapturedFrame, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
use anyhow::{Context, Result};
use pipewire as pw;
use pw::{properties::properties, spa};
use std::os::fd::{FromRawFd, OwnedFd};
use std::sync::atomic::Ordering;
use std::sync::mpsc::SyncSender;
use std::time::{SystemTime, UNIX_EPOCH};
use spa::param::video::{VideoFormat, VideoInfoRaw};
use spa::pod::Pod;
/// Map a negotiated SPA video format to a layout the encoder can consume. Returns
/// `None` for formats we don't handle (the frame is then skipped).
fn map_format(f: VideoFormat) -> Option<PixelFormat> {
Some(match f {
VideoFormat::BGRx => PixelFormat::Bgrx,
VideoFormat::RGBx => PixelFormat::Rgbx,
VideoFormat::BGRA => PixelFormat::Bgra,
VideoFormat::RGBA => PixelFormat::Rgba,
VideoFormat::RGB => PixelFormat::Rgb,
VideoFormat::BGR => PixelFormat::Bgr,
VideoFormat::NV12 => PixelFormat::Nv12,
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10; only ever negotiated by
// the `want_hdr` offer, whose MANDATORY colorimetry props pin them to PQ/BT.2020).
VideoFormat::xRGB_210LE => PixelFormat::X2Rgb10,
VideoFormat::xBGR_210LE => PixelFormat::X2Bgr10,
_ => return None,
})
}
struct UserData {
info: VideoInfoRaw,
/// Negotiated layout (`None` until param_changed, or if unsupported).
format: Option<PixelFormat>,
/// Negotiated DRM format modifier (for dmabuf import); 0 = LINEAR.
modifier: u64,
/// The one-deep frame mailbox (see [`super::FrameSlot`]) + its wakeup sender. Written
/// through [`UserData::publish`], never directly.
slot: super::FrameSlot,
wake: SyncSender<()>,
/// Everything this thread publishes to the capturer — see [`super::CaptureSignals`], which also
/// documents each flag's contract (it used to be restated here, and drifted).
signals: super::CaptureSignals,
/// Consecutive tiled-import failures (reset on success); see [`IMPORT_FAIL_POISON`].
import_fail_streak: u32,
/// Present when zero-copy is enabled on NVIDIA: imports a dmabuf → CUDA device buffer,
/// normally via the isolated worker process (`pf_zerocopy::Importer::Remote`).
importer: Option<pf_zerocopy::Importer>,
/// VAAPI zero-copy: hand the raw dmabuf to the encoder (which imports + GPU-CSCs it) instead
/// of a CUDA import. Set when zero-copy is on, the EGL→CUDA importer is unavailable, and the
/// encoder backend is VAAPI (AMD/Intel).
vaapi_passthrough: bool,
/// `PUNKTFUNK_NV12`: on the tiled EGL/GL zero-copy path, convert to NV12 on the GPU and feed
/// NVENC native YUV (Tier 2A). Off ⇒ the BGRx path is unchanged.
nv12: bool,
/// 4:4:4 session: on the tiled EGL/GL zero-copy path, convert to planar YUV444 on the GPU
/// (`ImportKind::Tiled444`) and feed NVENC native full-chroma YUV — takes precedence over
/// `nv12` (a 4:4:4 session must never subsample).
yuv444: bool,
/// The LINEAR (gamescope) NV12 compute CSC failed once — RGB for the rest of the stream
/// (T2.5b's per-stream fallback latch; cleared by the next stream's fresh `Ud`).
linear_nv12_failed: bool,
/// Rate-limit counter for the latest-frame-only diagnostic log (see `.process`).
dbg_log_n: u64,
/// Cursor-as-metadata state, composited into the CPU de-pad path (see `consume_frame`).
cursor: CursorState,
/// `Some((w, h))` while the producer's negotiated size is a sacrificial birth mode and a
/// renegotiation to these dims is guaranteed (KWin virtual outputs — kwin.rs `create`):
/// `.process` skips whole buffers until the negotiated size matches, then clears this
/// (self-disarming — later legitimate resizes are unaffected). `None` = no gating.
expect_dims: Option<(u32, u32)>,
/// Buffers skipped by the `expect_dims` gate (rate-limits its log).
gate_skips: u64,
/// When the gate first held a buffer — after [`GATE_DEADLINE`] with no renegotiation the
/// gate disarms and accepts what the producer serves (degraded dims beat a session wedged
/// into the first-frame-timeout retry loop; the promised renegotiation normally lands
/// within a frame or two).
gate_since: Option<std::time::Instant>,
}
impl UserData {
/// Hand `frame` to the consumer as THE latest, OVERWRITING any frame it has not taken yet
/// (drop-oldest — see [`super::FrameSlot`]), then poke the wakeup edge.
///
/// Never blocks the PipeWire loop, which is the hard constraint here: this runs inside
/// `.process`, so blocking would stall the compositor's stream. Both operations are
/// best-effort — a full wakeup channel means an edge is already pending (nothing lost, the
/// slot is the truth), and a poisoned mutex is unreachable in practice (the only critical
/// sections are a `take` and this store).
fn publish(&self, frame: CapturedFrame) {
if let Ok(mut slot) = self.slot.lock() {
*slot = Some(frame);
}
let _ = self.wake.try_send(());
}
}
/// Everything the zero-copy negotiation decision depends on, gathered at ONE point in time.
/// Split out from [`pipewire_thread`]'s prologue so the decision is a pure function of these
/// facts — testable, and consumable by both the thread and `spawn_pipewire` (see
/// [`NegotiationPlan`]).
#[derive(Debug, Clone, Copy)]
pub(super) struct NegotiationInputs {
/// `allow_zerocopy && pf_zerocopy::enabled()`.
pub zerocopy: bool,
/// `PUNKTFUNK_FORCE_SHM` — the race-free download path.
pub force_shm: bool,
/// This session offers the 10-bit PQ/BT.2020 formats.
pub want_hdr: bool,
/// This session negotiated full-chroma 4:4:4.
pub want_444: bool,
/// [`ZeroCopyPolicy::backend_is_vaapi`].
pub backend_is_vaapi: bool,
/// [`ZeroCopyPolicy::pyrowave_session`].
pub pyrowave_session: bool,
/// [`ZeroCopyPolicy::native_nv12_session`].
pub native_nv12_session: bool,
/// `pf_zerocopy::raw_dmabuf_import_disabled()` — the scoped raw-passthrough latch.
pub raw_dmabuf_import_disabled: bool,
/// `pf_zerocopy::gpu_import_disabled()` — repeated import-worker deaths.
pub gpu_import_disabled: bool,
/// `PUNKTFUNK_PIPEWIRE_NV12` (default ON) — allow the producer-side NV12 preference.
pub native_nv12_env_on: bool,
}
/// The resolved zero-copy negotiation decision — **one resolver, consumed by the PipeWire thread
/// AND by `spawn_pipewire`.**
///
/// `spawn_pipewire` used to hand-mirror `vaapi_passthrough` so the capturer's
/// negotiation-timeout branch could tell which offer had failed, and the copy drifted: it omitted
/// `raw_dmabuf_import_disabled`, so after that latch fired the mirror still said "raw
/// passthrough" while the thread had already fallen back — and the timeout branch then latched a
/// downgrade for an offer it had not made. Deriving both consumers from this struct is what makes
/// that class of drift unrepresentable rather than merely fixed (same shape as WP7.6's single
/// Linux encode-backend resolver).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct NegotiationPlan {
/// Build the EGL→CUDA importer for this capture.
pub build_importer: bool,
/// Hand raw dmabufs straight to the encoder instead of importing to CUDA.
pub vaapi_passthrough: bool,
/// Offer gamescope's producer-side NV12 pod ahead of packed RGB.
pub prefer_native_nv12: bool,
/// Carried through so [`want_dmabuf`](Self::want_dmabuf) needs no second copy of it.
pub force_shm: bool,
/// Diagnostic: this capture WOULD have taken the raw passthrough, but its scoped latch is
/// set (the encoder repeatedly failed to import, or a previous negotiation timed out).
pub raw_dmabuf_latched: bool,
/// Diagnostic: this capture WOULD have built the EGL→CUDA importer, but repeated
/// import-worker deaths latched the GPU import off.
pub gpu_import_latched: bool,
}
/// Resolve the negotiation plan. **Pure** — every environment read is already in `i`.
///
/// The four invariants this encodes were previously prose-only comments spread across the
/// prologue; `negotiation_plan_invariants` in the tests below pins each one:
/// 1. HDR never builds the EGL→CUDA importer (its de-tile blit is 8-bit RGBA8 → silent depth
/// loss); the HDR consumers are the CPU mmap path and the raw passthrough.
/// 2. 4:4:4 never prefers producer NV12 (a 4:4:4 session must not be subsampled).
/// 3. Producer-native NV12 only on a `native_nv12_session` under an active raw passthrough
/// (libav VAAPI would misread the two-plane buffer; the CUDA importer expects packed RGB).
/// 4. The raw passthrough is off whenever its own latch has fired.
pub(super) fn negotiation_plan(i: NegotiationInputs) -> NegotiationPlan {
// The frames' consumer imports raw dmabufs itself: the VAAPI backend (libva import + GPU
// CSC) or a PyroWave session (the wavelet encoder's own Vulkan device, any vendor).
let raw_passthrough = i.backend_is_vaapi || i.pyrowave_session;
// Building the EGL→CUDA importer would waste a CUDA probe under a raw passthrough — or
// worse, succeed and produce CUDA payloads only NVENC can consume. HDR is excluded by
// invariant 1. `gpu_import_disabled` is the repeated-worker-death latch (a wedged GPU stack
// must not crash-loop).
let build_importer = i.zerocopy && !raw_passthrough && !i.want_hdr && !i.gpu_import_disabled;
// Note there is no `importer.is_none()` term, unlike the expression this replaces: it was
// redundant (`build_importer` already excludes `raw_passthrough`, so the importer is
// necessarily absent here) and it is what made the decision look impure — the reason
// `spawn_pipewire` "had to" mirror it by hand.
let vaapi_passthrough =
i.zerocopy && !i.force_shm && raw_passthrough && !i.raw_dmabuf_import_disabled;
let prefer_native_nv12 = i.native_nv12_env_on
&& i.native_nv12_session
&& i.backend_is_vaapi
&& vaapi_passthrough
&& !i.pyrowave_session
&& !i.want_444
&& !i.want_hdr;
NegotiationPlan {
build_importer,
vaapi_passthrough,
prefer_native_nv12,
force_shm: i.force_shm,
raw_dmabuf_latched: i.zerocopy
&& !i.force_shm
&& raw_passthrough
&& i.raw_dmabuf_import_disabled,
gpu_import_latched: i.zerocopy && !raw_passthrough && !i.want_hdr && i.gpu_import_disabled,
}
}
impl NegotiationPlan {
/// Whether to request dmabuf buffers. Not part of the plan proper: it depends on whether the
/// importer actually CONSTRUCTED (`have_importer` — a GPU/driver fact) and on the modifier
/// list that construction yielded.
pub(super) fn want_dmabuf(&self, have_importer: bool, modifiers: &[u64]) -> bool {
(have_importer || self.vaapi_passthrough) && !modifiers.is_empty() && !self.force_shm
}
}
/// Consecutive tiled-import failures (worker alive, e.g. a per-buffer `EGL_BAD_MATCH`) before
/// the stream is poisoned for rebuild. A tiled import failure must NEVER fall through to the
/// CPU mmap path — de-padding tiled bytes as linear produces a scrambled image — so after a
/// short streak of dropped frames the capturer fails loudly and the session renegotiates.
const IMPORT_FAIL_POISON: u32 = 3;
/// Log a frame-drop reason once per process (the process callback runs per frame; a stuck
/// pipeline must say why without flooding).
fn warn_once(msg: &'static str) {
use std::sync::Mutex;
static SEEN: Mutex<Vec<&'static str>> = Mutex::new(Vec::new());
let mut seen = SEEN.lock().unwrap();
if !seen.contains(&msg) {
seen.push(msg);
tracing::warn!("{msg}");
}
}
/// A read-only mmap of a dmabuf fd, unmapped on drop. Used when MAP_BUFFERS didn't map the
/// buffer (producers don't always flag dmabufs mappable, e.g. gamescope's Vulkan exports).
struct DmabufMap {
ptr: *mut std::ffi::c_void,
len: usize,
}
impl DmabufMap {
fn new(fd: i32, len: usize) -> Option<DmabufMap> {
// SAFETY: a null `addr` lets the kernel choose the mapping address; `fd` is a caller-owned
// dmabuf/MemFd fd, valid for the duration of this call, and `len` is the requested map length.
// `mmap` reads no Rust memory — it installs a fresh PROT_READ/MAP_SHARED page mapping and
// returns its base (or MAP_FAILED, checked below before `DmabufMap` adopts it). The returned
// region is a brand-new VMA, so it aliases no live Rust object, and it keeps the underlying
// object mapped independently of `fd` (which may be closed after this returns).
let ptr = unsafe {
libc::mmap(
std::ptr::null_mut(),
len,
libc::PROT_READ,
libc::MAP_SHARED,
fd,
0,
)
};
(ptr != libc::MAP_FAILED).then_some(DmabufMap { ptr, len })
}
}
impl Drop for DmabufMap {
fn drop(&mut self) {
// SAFETY: `self.ptr`/`self.len` are exactly the base+length of a successful `mmap` in
// `DmabufMap::new` (constructed only when `ptr != MAP_FAILED`). This `DmabufMap` uniquely owns
// that mapping and `drop` runs once, so `munmap` releases a live mapping exactly once — no
// double-unmap. Every `&[u8]` derived from the mapping is bounded by this `DmabufMap`'s
// lifetime, so no borrow outlives the unmap.
unsafe {
libc::munmap(self.ptr, self.len);
}
}
}
/// De-pad / import a single PipeWire buffer and push it to the encoder. Called from the
/// `.process` callback with the NEWEST drained buffer (latest-frame-only). `datas` is sourced
/// via the same transparent cast libspa's `Buffer::datas_mut` performs, so the safe `Data`
/// accessors (`.type_()`, `.chunk()`, `.data()`, `.fd()`, `.as_raw()`) keep working.
fn consume_frame(ud: &mut UserData, spa_buf: *mut spa::sys::spa_buffer) {
// No active stream: release the buffer without the (expensive at 5K) de-pad.
if !ud.signals.active.load(Ordering::Relaxed) {
return;
}
// Poisoned (GPU import lost): the capturer is already surfacing an error to the encode
// loop; skip per-frame work until the rebuild tears this stream down.
if ud.signals.broken.load(Ordering::Relaxed) {
return;
}
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the PipeWire buffer we dequeued and still hold for
// this `.process` callback (not requeued until after `consume_frame` returns), so it is live. The
// block null-checks `spa_buf`, requires `n_datas != 0`, and null-checks the `datas` array pointer
// before forming any slice. `(*spa_buf).datas` points to `n_datas` libspa `spa_data` structs, and
// `pw::spa::buffer::Data` is `#[repr(transparent)]` over `spa_data` (the same cast
// `Buffer::datas_mut` performs — see the function doc), so the pointer cast + length describe
// exactly that array, in bounds. The PipeWire loop is single-threaded and owns the buffer here, so
// this `&mut` slice is the only reference to it (no aliasing/data race).
let datas: &mut [pw::spa::buffer::Data] = unsafe {
if spa_buf.is_null() || (*spa_buf).n_datas == 0 || (*spa_buf).datas.is_null() {
&mut []
} else {
std::slice::from_raw_parts_mut(
(*spa_buf).datas as *mut pw::spa::buffer::Data,
(*spa_buf).n_datas as usize,
)
}
};
if datas.is_empty() {
return;
}
let sz = ud.info.size();
let (w, h) = (sz.width as usize, sz.height as usize);
if w == 0 || h == 0 {
return; // format not negotiated yet
}
// Implicit-fence wait: Mutter renders into the dmabuf and hands it over at
// GPU-submit time; with no producer explicit sync (Mutter+NVIDIA can't) we snapshot
// the buffer's implicit fence and wait the producer's render before sampling —
// closing the stale/old-frame race on NVIDIA. No-op for shm buffers or drivers that
// attach no fence. Covers both the GPU import and the CPU mmap read below.
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
match pf_zerocopy::dmabuf_fence::wait_read_ready(datas[0].fd(), 100) {
Ok(waited) => {
static F1: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
if F1.swap(false, Ordering::Relaxed) {
tracing::info!(
waited,
"dmabuf implicit-fence sync active (waited=true → driver fences \
the render, race closed; false → no implicit fence, zero-copy \
may still show stale frames)"
);
}
}
Err(e) => {
static F2: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
if F2.swap(false, Ordering::Relaxed) {
tracing::warn!(
error = %e,
"dmabuf EXPORT_SYNC_FILE failed — no implicit-fence sync; NVIDIA \
zero-copy may show stale frames (no producer explicit sync)"
);
}
}
}
}
// Raw DMA-BUF passthrough: packed RGB is imported for GPU CSC; producer-native NV12 can
// be consumed by the Vulkan Video encoder without another color conversion.
if ud.vaapi_passthrough {
if let Some(fmt) = ud.format {
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
if let Some(fourcc) = pf_frame::drm_fourcc(fmt) {
let chunk = datas[0].chunk();
let offset = chunk.offset();
let stride = chunk.stride().max(0) as u32;
// Native NV12 usually arrives as a two-plane SPA buffer over ONE buffer
// object; plane 1's chunk carries the REAL UV offset/stride (compositors
// may align the Y plane before UV). Pass it through instead of assuming
// contiguity. Each spa_data holds its own (dup'd) fd, so BO identity is
// by inode, not fd number; a genuinely two-BO frame cannot travel through
// the single-fd import — drop it with a diagnosis instead of streaming
// garbage chroma.
let plane1 =
if fmt == PixelFormat::Nv12 && datas.len() >= 2 && datas[1].fd() > 0 {
// SAFETY: zeroed `libc::stat` is a valid POD initializer; both fds are
// owned by the live PipeWire buffer for this callback, and `fstat`
// only writes the out-param structs, whose fields are read only after
// the `== 0` success checks.
let same_bo = unsafe {
let mut s0: libc::stat = std::mem::zeroed();
let mut s1: libc::stat = std::mem::zeroed();
libc::fstat(datas[0].fd() as i32, &mut s0) == 0
&& libc::fstat(datas[1].fd() as i32, &mut s1) == 0
&& (s0.st_dev, s0.st_ino) == (s1.st_dev, s1.st_ino)
};
if !same_bo {
warn_once(
"NV12 planes live in different buffer objects — frames \
dropped (single-fd import only)",
);
return;
}
let c1 = datas[1].chunk();
Some((c1.offset(), c1.stride().max(0) as u32))
} else {
None
};
// dup the fd so it survives the SPA buffer recycle — the encode thread
// imports it. Content stability across the brief import/encode window relies
// on the compositor's buffer-pool depth, like any zero-copy capture.
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
// The original stays owned by PipeWire; the dup is a new fd we own (checked >= 0).
let dup =
unsafe { libc::fcntl(datas[0].fd() as i32, libc::F_DUPFD_CLOEXEC, 0) };
if dup >= 0 {
let pts_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
ud.publish(CapturedFrame {
width: w as u32,
height: h as u32,
pts_ns,
format: fmt,
payload: FramePayload::Dmabuf(DmabufFrame {
// SAFETY: `dup` is the fresh fd `fcntl(F_DUPFD_CLOEXEC)` just returned
// (checked `dup >= 0`); nothing else owns it, so `OwnedFd` takes sole
// ownership and closes it exactly once on drop — no alias, no
// double-close.
fd: unsafe { OwnedFd::from_raw_fd(dup) },
fourcc,
modifier: ud.modifier,
offset,
stride,
plane1,
}),
// Cursor-as-metadata is blended only by RGB→NV12 backends. Gamescope
// embeds its pointer in the produced pixels, so native NV12 has none.
cursor: ud.cursor.overlay(),
});
static ONCE: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(true);
if ONCE.swap(false, Ordering::Relaxed) {
tracing::info!(
w,
h,
modifier = ud.modifier,
fourcc = format_args!("{:#010x}", fourcc),
source = if fmt == PixelFormat::Nv12 {
"producer-native NV12"
} else {
"packed RGB (encoder GPU CSC)"
},
"zero-copy: handing the raw DMA-BUF to the encoder"
);
}
return;
}
}
}
}
// Not a dmabuf (or unmappable format) — fall through to the CPU de-pad path.
}
// Zero-copy path: if the buffer is a dmabuf and we have an importer, import it
// into a CUDA device buffer (no CPU touch) and deliver that. Otherwise fall
// through to the shm de-pad copy below.
let mut gpu_import_broken = false;
if let (Some(importer), Some(fmt)) = (ud.importer.as_mut(), ud.format) {
// Defense-in-depth: the 10-bit PQ formats must never enter the EGL→CUDA import (its
// de-tile blit is 8-bit RGBA8 — silent depth loss). An HDR offer never builds the
// importer, so this gate only matters if those invariants ever drift apart.
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf && !fmt.is_hdr_rgb10() {
let plane = pf_zerocopy::DmabufPlane {
fd: datas[0].fd(),
offset: datas[0].chunk().offset(),
stride: datas[0].chunk().stride().max(0) as u32,
};
// Tiled modifier → EGL/GL de-tile import; LINEAR (0/unset, e.g.
// gamescope) → direct CUDA external-memory import (NVIDIA EGL can't
// sample LINEAR).
let modifier = (ud.modifier != 0).then_some(ud.modifier);
if let Some(fourcc) = pf_frame::drm_fourcc(fmt) {
// GPU converts: a 4:4:4 session gets the planar-YUV444 convert on the tiled
// EGL/GL path (full chroma, takes precedence over NV12 — 4:4:4 must never
// subsample), otherwise `PUNKTFUNK_NV12` gets NV12 — tiled via the EGL/GL
// blit, LINEAR/gamescope via the Vulkan bridge's compute CSC (latency plan
// T2.5b) — so NVENC encodes native YUV and skips its internal RGB→YUV CSC on
// the contended SM. A 4:4:4 session on LINEAR frames has no convert and
// stays RGB, falling to the encoder's clear-error path (`want_444` with an
// RGB CUDA payload) rather than silently subsampling. A LINEAR NV12 convert
// failure latches RGB for the stream (mid-frame fallback, no drop).
let yuv444 = ud.yuv444 && modifier.is_some();
let mut nv12 = ud.nv12 && !ud.yuv444;
let imported = if let Some(m) = modifier {
if yuv444 {
importer.import_yuv444(&plane, w as u32, h as u32, fourcc, Some(m))
} else if nv12 {
importer.import_nv12(&plane, w as u32, h as u32, fourcc, Some(m))
} else {
importer.import(&plane, w as u32, h as u32, fourcc, Some(m))
}
} else if nv12 && !ud.linear_nv12_failed {
match importer.import_linear_nv12(&plane, w as u32, h as u32) {
Ok(buf) => Ok(buf),
Err(e) => {
ud.linear_nv12_failed = true;
nv12 = false;
tracing::warn!(error = %format!("{e:#}"),
"LINEAR NV12 compute CSC failed — RGB for the rest of this \
stream (NVENC does the CSC internally)");
importer.import_linear(&plane, w as u32, h as u32)
}
}
} else {
nv12 = false;
importer.import_linear(&plane, w as u32, h as u32)
};
match imported {
Ok(devbuf) => {
ud.import_fail_streak = 0;
pf_zerocopy::note_gpu_import_ok();
static ONCE: std::sync::atomic::AtomicBool =
std::sync::atomic::AtomicBool::new(true);
if ONCE.swap(false, Ordering::Relaxed) {
tracing::info!(
w,
h,
modifier = ud.modifier,
nv12,
yuv444,
"zero-copy: dmabuf imported to CUDA (no CPU copy)"
);
}
let pts_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
ud.publish(CapturedFrame {
width: w as u32,
height: h as u32,
pts_ns,
format: if yuv444 {
PixelFormat::Yuv444
} else if nv12 {
PixelFormat::Nv12
} else {
fmt
},
payload: FramePayload::Cuda(devbuf),
// Cursor-as-metadata: blended by the CUDA encoder into its owned
// device surface. (RGB LINEAR-import case; YUV sessions blend planes.)
cursor: ud.cursor.overlay(),
});
return;
}
Err(e) => {
let dead = importer.dead();
if dead {
pf_zerocopy::note_gpu_import_death();
}
if modifier.is_some() {
// Tiled buffer: the CPU fallback below would mmap TILED bytes
// and de-pad them as linear — a scrambled image, worse than no
// frame. Drop the frame instead; on a dead worker (it absorbed a
// driver fault) or a short failure streak, poison the stream so
// the session's capture-loss rebuild renegotiates cleanly.
ud.import_fail_streak += 1;
if dead || ud.import_fail_streak >= IMPORT_FAIL_POISON {
tracing::error!(error = %format!("{e:#}"), dead,
"tiled GPU import lost — failing this capture for rebuild");
ud.signals.broken.store(true, Ordering::Relaxed);
} else {
tracing::warn!(error = %format!("{e:#}"),
streak = ud.import_fail_streak,
"tiled dmabuf GPU import failed — frame dropped");
}
return;
}
// LINEAR dmabuf: CPU-mappable, so disable the importer and fall
// through to the CPU mmap path — degraded, not dead.
tracing::warn!(error = %format!("{e:#}"),
"LINEAR dmabuf GPU import failed — falling back to the CPU copy path");
gpu_import_broken = true;
}
}
} else {
return; // format has no DRM fourcc mapping — skip the frame
}
}
}
if gpu_import_broken {
ud.importer = None;
}
let d = &mut datas[0];
// CPU path may also receive LINEAR dmabufs (gamescope offers only those once its
// modifier-bearing format pod wins); capture the fd before `data()` borrows `d`.
let data_type = d.type_();
// fd-backed buffer (MemFd SHM, or DmaBuf)? Capture the fd before `data()` borrows `d`.
let raw_fd = d.fd();
// `mapoffset` is where THIS spa_data's region begins inside the fd — non-zero for a pooled
// producer that carves every buffer out of one shared fd. PipeWire's own MAP_BUFFERS slice
// already starts there, but our self-mmap below maps the fd from 0, so that path must add it
// (see `region_off`). Reading it without it made the self-mmap path index the WRONG buffer,
// and the `needed > avail` guard could not catch that: `avail` came from the whole-fd
// mapping, so it was large enough for any single buffer's span.
let map_off = d.as_raw().mapoffset as usize;
let (size, chunk_off, stride) = {
let c = d.chunk();
(
c.size() as usize,
c.offset() as usize,
c.stride().max(0) as usize,
)
};
let Some(fmt) = ud.format else { return }; // unsupported/not negotiated
// The de-pad below assumes ONE packed plane of `bytes_per_pixel` bytes. `bytes_per_pixel`'s
// catch-all answers 4 for NV12, so a producer-native NV12 buffer (stride ≈ w, two planes)
// computed `row = 4w` and always tripped the `stride < row` guard below — blaming the
// PRODUCER's stride for a host limitation. NV12 cannot be de-padded on this path at all
// (the second plane is not even in `datas[0]`'s span), so say so honestly. It only arrives
// here if a native-NV12 negotiation happens without the zero-copy passthrough that is
// supposed to consume it, which is a host bug, not a producer one.
if matches!(fmt, PixelFormat::Nv12) {
warn_once(
"negotiated producer-native NV12 but this capture fell back to the CPU de-pad path, \
which handles single-plane packed formats only — frames dropped (the NV12 offer is \
only valid under the raw-dmabuf passthrough that imports it directly)",
);
return;
}
let bpp = fmt.bytes_per_pixel();
let row = w * bpp;
let stride = if stride == 0 { row } else { stride };
if stride < row {
warn_once("chunk stride < row — frames dropped");
return;
}
let needed = stride * (h - 1) + row;
// dmabuf chunks commonly report size 0; fall back to the computed span.
let size = if size == 0 { needed } else { size };
// For fd-backed buffers (MemFd SHM, DmaBuf) mmap the fd OURSELVES, sized to the fd's real
// length (fstat), rather than trusting PipeWire's MAP_BUFFERS slice: xdg-desktop-portal-wlr
// hands MemFd buffers whose reported `data.maxsize` exceeds the bytes actually mapped into
// our process, so reading to maxsize segfaults (it also covers the original case — MAP_BUFFERS
// not mapping Vulkan dmabufs, e.g. gamescope). The `needed > avail` guard below then drops
// cleanly if the real buffer is genuinely too small. MemPtr buffers (no fd) are same-process —
// trust `d.data()`.
let fd_len = if raw_fd > 0 {
// SAFETY: `libc::stat` is a C plain-old-data struct for which all-zero is a valid value, so
// `mem::zeroed()` is a sound initializer. `raw_fd` is the buffer's fd (`> 0` checked here) and
// valid for this callback; `fstat` writes metadata into `&mut st`, a live, aligned,
// correctly-sized stack `stat` that outlives the synchronous call. `st.st_size` is read only
// after the return value is confirmed `== 0`. `st` is a fresh local, so nothing aliases it.
unsafe {
let mut st: libc::stat = std::mem::zeroed();
(libc::fstat(raw_fd as i32, &mut st) == 0 && st.st_size > 0)
.then_some(st.st_size as usize)
}
} else {
None
};
let _mapping; // keeps a manual mmap alive for the copy below
// Prefer our own fstat-sized mmap of the fd; fall back to PipeWire's MAP_BUFFERS slice
// (and finally drop) so an fd PipeWire could map but we can't never silently over-reads.
//
// `fd_len` is REQUIRED, not preferred: it used to fall back to
// `offset + needed`, i.e. a length invented from producer-controlled geometry.
// That defeated the whole point of the fstat (the "buffer smaller than the
// frame span" guard compares against exactly the number the producer just
// supplied) and could map — and then read — past the end of the object, which
// is a SIGBUS, not an `Err`. Without a real length we decline to self-map and
// let PipeWire's own slice serve, which is bounded by construction.
let self_mapped: Option<&[u8]> = if raw_fd > 0 {
match fd_len.and_then(|map_len| DmabufMap::new(raw_fd as i32, map_len)) {
Some(m) => {
_mapping = m;
// SAFETY: `_mapping` is the `DmabufMap` just stored; its `ptr`/`len` come from a
// successful `mmap` of `map_len` PROT_READ bytes, so `ptr` is non-null, page-aligned,
// and the VMA is one allocated object of `len` bytes valid for reads. In the common
// path `map_len == fd_len` (the fd's real size from `fstat`), so the mapping spans the
// whole object; the de-pad copy below is further bounded by the `offset <= buf.len()`
// and `needed > avail` guards. The `&[u8]` borrows `_mapping`, which lives to the end
// of `consume_frame`, so the slice never outlives the mapping, and the memory is only
// read here, so there is no aliasing/mutation.
Some(unsafe { std::slice::from_raw_parts(_mapping.ptr as *const u8, _mapping.len) })
}
None => None,
}
} else {
None
};
// Which base `chunk.offset` is relative to differs by path: our self-mmap starts at fd
// offset 0, so this spa_data's region begins at `mapoffset`; PipeWire's MAP_BUFFERS slice
// already begins there. Checked add — both halves are producer-controlled.
let (buf, region_off): (&[u8], usize) = if let Some(b) = self_mapped {
match map_off.checked_add(chunk_off) {
Some(off) => (b, off),
None => {
warn_once("mapoffset + chunk offset overflows — frames dropped");
return;
}
}
} else if let Some(data) = d.data() {
(data, chunk_off)
} else {
warn_once("buffer has no mappable data — frames dropped");
return;
};
// Need stride*(h-1)+row valid bytes within [region_off, region_off+size).
if region_off > buf.len() {
return;
}
let avail = buf.len() - region_off;
{
// One-time geometry dump — makes a new compositor/GPU's buffer layout visible in the
// logs (the kind of mismatch that crashed xdpw MemFd capture before the self-mmap fix).
use std::sync::atomic::{AtomicBool, Ordering};
static ONCE: AtomicBool = AtomicBool::new(true);
if ONCE.swap(false, Ordering::Relaxed) {
tracing::info!(
stride, size, chunk_off, map_off, region_off, buf_len = buf.len(), needed,
data_type = ?data_type, fd_len = ?fd_len, self_mapped = self_mapped.is_some(),
"capture CPU de-pad geometry (first frame)"
);
}
}
if needed > avail || needed > size {
warn_once("buffer smaller than frame span — frames dropped");
return;
}
let region = &buf[region_off..region_off + size.min(avail)];
// De-pad into a tightly-packed buffer (chunk stride may exceed w*bpp).
let mut tight = vec![0u8; row * h];
for y in 0..h {
tight[y * row..y * row + row].copy_from_slice(&region[y * stride..y * stride + row]);
}
// Cursor-as-metadata: blit the latched pointer into the frame (no-op when hidden or when
// the layout isn't packed RGB). This is the CPU path's counterpart to the producer's
// hardware cursor plane, which stays out of the captured buffer.
composite_cursor(&mut tight, w, h, fmt, &ud.cursor);
let pts_ns = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let frame = CapturedFrame {
width: w as u32,
height: h as u32,
pts_ns,
format: fmt,
payload: FramePayload::Cpu(tight),
// Already composited inline into `tight` above — nothing for the encoder to blend.
cursor: None,
};
// Overwrite whatever the consumer has not taken yet — never block the pipewire loop.
ud.publish(frame);
}
#[allow(clippy::too_many_arguments)]
pub fn pipewire_thread(
fd: Option<OwnedFd>,
node_id: u32,
// The frame mailbox + its wakeup sender (see `super::FrameSlot`): publishing OVERWRITES,
// so a stalled consumer costs the intermediate frames, never the freshest one.
slot: super::FrameSlot,
wake: SyncSender<()>,
// Every flag/slot this thread PUBLISHES, in one struct (see `super::CaptureSignals`). It used to
// be seven separate parameters here, seven `Arc` clones at the call site, and the same seven
// fields restated on `UserData`.
signals: super::CaptureSignals,
// THE zero-copy negotiation decision, resolved once by `spawn_pipewire` (which consumes the
// same struct for the capturer's timeout diagnosis) — never re-derived here.
plan: NegotiationPlan,
// The session's capture options (see `super::CaptureOpts`) — `want_444`/`want_hdr` pick the pod
// family, `expect_exact_dims` arms the sacrificial-mode gate. `allow_zerocopy` is already folded
// into `plan`.
opts: super::CaptureOpts,
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<()> {
let super::CaptureOpts {
want_444,
want_hdr,
expect_exact_dims,
..
} = opts;
crate::pwinit::ensure_init();
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
// A quit signal (capturer `Drop`) lands here on the loop thread and stops `run()` so the
// thread unwinds instead of blocking to process exit. Hold the attachment for the loop's
// life; the cloned loop handle is the one the callback quits.
let quit_loop = mainloop.clone();
let _quit_attach = quit_rx.attach(mainloop.loop_(), move |()| {
tracing::debug!("pipewire: quit signal received — stopping capture loop");
quit_loop.quit();
});
let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?;
// A portal source hands us an fd to a (sandboxed) PipeWire remote; the KWin
// virtual-output source has no fd — its node lives on the user's default daemon.
let core = match fd {
Some(fd) => context
.connect_fd_rc(fd, None)
.context("pw connect_fd (portal remote)")?,
None => context
.connect_rc(None)
.context("pw connect (default daemon)")?,
};
// The negotiation decision arrives pre-resolved in `plan` (see `negotiation_plan`): what to
// build, which offer to make, and whether to prefer producer NV12. Nothing here re-derives
// any of it — that duplication is what F1/L3 was.
let backend_is_vaapi = policy.backend_is_vaapi;
let force_shm = plan.force_shm;
let vaapi_passthrough = plan.vaapi_passthrough;
let prefer_native_nv12 = plan.prefer_native_nv12;
// Build the GPU importer — normally the ISOLATED worker process
// (design/zerocopy-worker-isolation.md), so a driver fault on a dying compositor's dmabuf
// kills the worker, not this host. If construction fails, log and fall back to the CPU path
// (we simply won't request dmabuf below); `plan.build_importer` already encodes WHEN to try
// at all (not under a raw passthrough, not for HDR, not once repeated worker deaths latched
// the import off — see `negotiation_plan`).
if plan.gpu_import_latched {
tracing::warn!(
"zero-copy GPU import disabled after repeated import-worker deaths — using CPU path"
);
}
let mut importer = if plan.build_importer {
match pf_zerocopy::Importer::new_for_capture() {
Ok(i) => Some(i),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "zero-copy import unavailable — using CPU path");
None
}
}
} else {
None
};
if prefer_native_nv12 {
tracing::info!(
"zero-copy: preferring gamescope producer-side NV12 LINEAR DMA-BUF (no host \
RGB CSC; PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation)"
);
}
// Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR
// (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
// radeonsi/iHD import it and any compositor can allocate it.
let mut modifiers = importer
.as_mut()
.map(|i| i.supported_modifiers(pf_frame::drm_fourcc(PixelFormat::Bgrx).unwrap()))
.unwrap_or_default();
if (importer.is_some() || vaapi_passthrough) && !modifiers.contains(&0) {
modifiers.push(0); // DRM_FORMAT_MOD_LINEAR
}
// 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. The modifiers
// were resolved by the facade (`ZeroCopyPolicy::pyrowave_modifiers`) — non-empty only when
// the host's `pyrowave` feature is on AND the session (or the global encoder pref) is
// PyroWave — so capture never calls back into `encode` and needs no feature gate of its
// own (the emptiness check gates it).
if vaapi_passthrough && !policy.pyrowave_modifiers.is_empty() {
for &m in &policy.pyrowave_modifiers {
if !modifiers.contains(&m) {
modifiers.push(m);
}
}
tracing::info!(
count = modifiers.len(),
"zero-copy: advertising the PyroWave device's Vulkan-importable dmabuf modifiers"
);
}
// The one runtime-dependent half of the decision (see `NegotiationPlan::want_dmabuf`): it
// needs the modifier list the importer's construction actually yielded.
let want_dmabuf = plan.want_dmabuf(importer.is_some(), &modifiers);
if force_shm {
tracing::info!(
"capture: PUNKTFUNK_FORCE_SHM — race-free SHM download path (no dmabuf, no zero-copy)"
);
} else if plan.raw_dmabuf_latched {
tracing::warn!(
"zero-copy raw-dmabuf passthrough disabled for this host process (repeated encoder \
import failures, or a previous dmabuf negotiation timeout) — capturing CPU frames \
instead"
);
} else if !want_dmabuf && (plan.build_importer || plan.vaapi_passthrough) {
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
} else if vaapi_passthrough {
// The raw-passthrough advertisement. Covers the PyroWave case too: its extra
// Vulkan-importable modifiers were appended (and logged) just above, so this arm must
// NOT be gated on `pyrowave_modifiers.is_empty()` — that gate is what dropped a fully
// zero-copy PyroWave session through to the CPU-path warning below (L11).
tracing::info!(
native_nv12_preferred = prefer_native_nv12,
modifier_count = modifiers.len(),
pyrowave_extended = !policy.pyrowave_modifiers.is_empty(),
"zero-copy: advertising DMA-BUF modifiers for direct encoder import (LINEAR \
always; native NV12 first when enabled, packed RGB fallback)"
);
} else if want_dmabuf {
tracing::info!(
count = modifiers.len(),
sample = ?&modifiers[..modifiers.len().min(6)],
"zero-copy: advertising EGL-importable dmabuf modifiers"
);
} else if backend_is_vaapi && policy.backend_is_gpu {
// Reached only when no dmabuf is advertised at all (every arm above rules out a
// zero-copy path), so this genuinely IS the CPU capture path: a VAAPI session then pays
// three full-frame CPU touches (mmap de-pad + swscale RGB→NV12 + surface upload) —
// make the silent fallback visible.
// The `raw_dmabuf_latched` arm above catches the latched downgrade, so by here zero-copy
// is off at the source: the env var, or the session's own output format.
tracing::warn!(
"VAAPI encode with the CPU capture path (per-frame de-pad + swscale CSC + \
upload) — zero-copy is off for this capture ({}); clear PUNKTFUNK_ZEROCOPY to \
restore the dmabuf default",
if std::env::var_os("PUNKTFUNK_ZEROCOPY").is_some() {
"PUNKTFUNK_ZEROCOPY is set falsy"
} else {
"this session's output format asked for CPU frames"
}
);
}
if want_dmabuf && !vaapi_passthrough && want_444 {
tracing::info!(
"4:4:4 zero-copy: tiled dmabufs convert to planar YUV444 (BT.709) on the GPU — \
NVENC fed native full-chroma YUV, no CPU pixel path"
);
} else if want_dmabuf && !vaapi_passthrough && pf_zerocopy::nv12_enabled() {
tracing::info!(
"PUNKTFUNK_NV12: tiled dmabufs convert to NV12 (BT.709 limited) on the GPU — NVENC \
fed native YUV (no internal RGB→YUV CSC)"
);
}
let data = UserData {
info: VideoInfoRaw::default(),
format: None,
modifier: 0,
slot,
wake,
signals,
import_fail_streak: 0,
importer,
vaapi_passthrough,
nv12: pf_zerocopy::nv12_enabled(),
yuv444: want_444,
linear_nv12_failed: false,
dbg_log_n: 0,
cursor: CursorState::default(),
expect_dims: if expect_exact_dims {
preferred.map(|(w, h, _)| (w, h))
} else {
None
},
gate_skips: 0,
gate_since: None,
};
let stream = pw::stream::StreamBox::new(
&core,
"punktfunk-screencast",
properties! {
*pw::keys::MEDIA_TYPE => "Video",
*pw::keys::MEDIA_CATEGORY => "Capture",
*pw::keys::MEDIA_ROLE => "Screen",
// Never let the session manager re-target this stream to a different node when
// its target goes away: an orphaned stream auto-linked to a fresh Video/Source
// wedges that node — and a stuck link head-blocks the PipeWire daemon's shared
// work queue, stalling ALL new link negotiation system-wide.
"node.dont-reconnect" => "true",
},
)
.context("pw Stream")?;
let _listener = stream
.add_local_listener_with_user_data(data)
.state_changed(|_stream, ud, old, new| {
tracing::info!(?old, ?new, "pipewire stream state");
// Track whether the node is actively producing. A live source sits in `Streaming`
// (a static desktop just sends no buffers); anything else — `Paused`/`Unconnected`/
// `Error` — means the source went away (compositor died, virtual output removed on a
// Gaming↔Desktop switch). `try_latest` turns a sustained non-Streaming state into a
// capture-loss so the encode loop rebuilds instead of freezing on the last frame.
ud.signals.streaming.store(
matches!(new, pw::stream::StreamState::Streaming),
Ordering::Relaxed,
);
})
.param_changed(|_stream, ud, id, param| {
let Some(param) = param else { return };
if id != pw::spa::param::ParamType::Format.as_raw() {
return;
}
let Ok((media_type, media_subtype)) =
pw::spa::param::format_utils::parse_format(param)
else {
return;
};
if media_type != pw::spa::param::format::MediaType::Video
|| media_subtype != pw::spa::param::format::MediaSubtype::Raw
{
return;
}
if ud.info.parse(param).is_ok() {
ud.signals.negotiated.store(true, Ordering::Relaxed);
// A (re)negotiation replaces the buffer pool: every cached per-buffer import
// (stored fds in the worker, the Vulkan bridge's per-fd sources) keys on
// buffers that no longer exist — and a recycled fd number/inode must never
// resolve to a stale import. No-op on the first negotiation (empty caches).
if let Some(imp) = ud.importer.as_mut() {
imp.clear_cache();
}
let sz = ud.info.size();
// Publish the negotiated size for the gamescope cursor source's root→frame
// scaling (`xfixes_cursor::scale_to_frame`); a renegotiation updates it.
ud.signals.frame_size.store(
(u64::from(sz.width) << 32) | u64::from(sz.height),
Ordering::Relaxed,
);
ud.format = map_format(ud.info.format());
ud.modifier = ud.info.modifier();
// HDR: the 10-bit PQ formats are only ever offered with MANDATORY BT.2020/PQ
// colorimetry props, so a 10-bit negotiation IS an HDR negotiation — but log
// what the producer actually fixated for diagnosis.
let hdr = ud.format.is_some_and(|f| f.is_hdr_rgb10());
ud.signals.hdr_negotiated.store(hdr, Ordering::Relaxed);
tracing::info!(
width = sz.width,
height = sz.height,
spa_format = ?ud.info.format(),
mapped = ?ud.format,
modifier = ud.modifier,
hdr,
transfer_function = ud.info.transfer_function(),
color_primaries = ud.info.color_primaries(),
"pipewire format negotiated"
);
if ud.format.is_none() {
tracing::error!(
spa_format = ?ud.info.format(),
"negotiated a pixel format the encoder cannot consume — frames will be skipped"
);
}
}
})
.process(|stream, ud| {
// Latest-frame-only (OBS pattern): Mutter delivers buffers in bursts and recycles its
// pool; an older queued buffer carries a STALE frame. Drain all queued buffers, requeue
// the older ones, keep only the newest. This dequeue/requeue runs OUTSIDE the
// `catch_unwind` below — they are non-panicking C FFI pointer ops, and `newest` is
// requeued exactly once AFTER the panic-containing region. Previously the whole thing was
// inside the catch, so a caught panic (in `update_cursor_meta`/`consume_frame`) stranded
// `newest` forever, permanently shrinking the stream's fixed pool until capture wedged.
// SAFETY: `stream` is the live stream PipeWire passes into this `.process` callback on the
// loop thread; `dequeue_raw_buffer` returns a stream-owned `*mut pw_buffer` or null
// (null-checked), single-threaded so no concurrent access.
let mut newest = unsafe { stream.dequeue_raw_buffer() };
if newest.is_null() {
return;
}
let mut drained = 1u32;
loop {
// SAFETY: same stream/loop-thread contract; returns the next stream-owned buffer or null.
let next = unsafe { stream.dequeue_raw_buffer() };
if next.is_null() {
break;
}
// SAFETY: `newest` was dequeued from this stream and not yet requeued; we immediately
// overwrite it, so the requeued pointer is never touched again.
unsafe { stream.queue_raw_buffer(newest) };
newest = next;
drained += 1;
}
// Sacrificial-mode gate (kwin.rs `create`): until the producer renegotiates to the
// expected dims, every buffer — frame AND cursor meta, whose positions are in the
// doomed mode's space — belongs to the birth mode; consuming one would build the
// pipeline at the wrong size. Self-disarms on the first matching negotiation, or
// after `GATE_DEADLINE` without one — degraded dims beat wedging the session into
// the first-frame-timeout retry loop when the promised renegotiation never comes.
if let Some((ew, eh)) = ud.expect_dims {
/// The renegotiation normally lands within a frame or two of recording; well
/// past that, the producer is not going to deliver it (the on-glass case: the
/// real mode never actually applied) — stop starving the pipeline.
const GATE_DEADLINE: std::time::Duration = std::time::Duration::from_secs(3);
let sz = ud.info.size();
if sz.width == ew && sz.height == eh {
tracing::info!(
skipped = ud.gate_skips,
width = ew,
height = eh,
"producer renegotiated to the expected mode — frames flow"
);
ud.expect_dims = None;
} else if ud
.gate_since
.get_or_insert_with(std::time::Instant::now)
.elapsed()
> GATE_DEADLINE
{
tracing::warn!(
negotiated_w = sz.width,
negotiated_h = sz.height,
expected_w = ew,
expected_h = eh,
skipped = ud.gate_skips,
"producer never renegotiated to the expected mode — accepting its \
dims (session runs degraded rather than wedged)"
);
ud.expect_dims = None;
} else {
ud.gate_skips += 1;
if ud.gate_skips == 1 || ud.gate_skips.is_power_of_two() {
tracing::info!(
negotiated_w = sz.width,
negotiated_h = sz.height,
expected_w = ew,
expected_h = eh,
n = ud.gate_skips,
"holding frames until the producer renegotiates to the expected mode"
);
}
// SAFETY: `newest` was dequeued from this stream and not yet requeued;
// requeued exactly once here, then never touched (mirrors the null path).
unsafe { stream.queue_raw_buffer(newest) };
return;
}
}
// PipeWire dispatches from a C trampoline with no catch_unwind; a panic crossing that FFI
// boundary would abort the whole host. Contain the inspect/consume work — the only Rust
// code here that can panic — and requeue `newest` unconditionally after it.
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
// SAFETY: `newest` is the non-null buffer we still own (dequeued, not requeued);
// `.buffer` is a `*mut spa_buffer` field libpipewire populated. This is a single field
// load through a valid pointer — no mutation or aliasing.
let spa_buf = unsafe { (*newest).buffer };
// Refresh cursor-as-metadata BEFORE the stale-frame skip below: Mutter delivers
// pointer-only movements as metadata-only "corrupted" buffers we drop for their
// frame, but their cursor meta is fresh and must still move our overlay.
update_cursor_meta(&mut ud.cursor, spa_buf);
// Publish the LIVE overlay (frames or not) so the encode loop's forwarder
// tracks pointer-only motion on a static desktop — the frame-attached overlay
// alone stales between damage frames. ONLY when we actually have one: a
// gamescope node carries no `SPA_META_Cursor`, so `overlay()` is always `None`
// here, and writing that would clobber — at frame rate — the `Some` the
// attached XFixes source publishes into this SAME slot, strobing the
// composited pointer on/off. Portal cursors are `None` only before the first
// bitmap (nothing to drop), and a HIDDEN pointer is still `Some(visible:false)`.
if let Some(overlay) = ud.cursor.overlay() {
if let Ok(mut slot) = ud.signals.cursor_live.lock() {
*slot = Some(overlay);
}
}
// Inspect the newest buffer's header + first chunk for the diagnostic and the
// CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null.
// SAFETY: `spa_buf` is the `*mut spa_buffer` of the buffer we still hold.
// `spa_buffer_find_meta_data` scans that buffer's metadata array for a `SPA_META_Header`
// of at least `size_of::<spa_meta_header>()` bytes and returns a pointer into the held
// buffer's metadata (or null). The size argument matches the struct the result is cast
// to, and the pointer stays valid as long as the buffer is held (until requeue). Null is
// handled below.
let hdr = unsafe {
spa::sys::spa_buffer_find_meta_data(
spa_buf,
spa::sys::SPA_META_Header,
std::mem::size_of::<spa::sys::spa_meta_header>(),
) as *const spa::sys::spa_meta_header
};
let hdr_flags = if hdr.is_null() {
0u32
} else {
// SAFETY: reached only when `hdr` is non-null; it points to a `spa_meta_header`
// inside the live buffer's metadata (returned for a size >=
// `size_of::<spa_meta_header>()`, so `.flags` is in bounds). A single field read
// while the buffer is still held.
unsafe { (*hdr).flags }
};
// First data chunk's size + flags (used for the diagnostic + CORRUPTED check)
// and its data type (a dmabuf legitimately reports chunk size 0, so the size-0
// stale skip only applies to mappable SHM buffers).
// SAFETY: every dereference is guarded in order before any field read — `spa_buf`
// non-null, `n_datas > 0`, the `datas` (`*mut spa_data`) array non-null, and the first
// element's `chunk` (`*mut spa_chunk`) non-null. `d0` is that first `spa_data` and `c`
// its chunk; reading `(*d0).type_`, `(*c).size`, `(*c).flags` are in-bounds field loads
// of libspa structs inside the buffer we still hold. Single-threaded loop, no mutation.
let (chunk_size, chunk_flags, is_dmabuf) = unsafe {
if !spa_buf.is_null()
&& (*spa_buf).n_datas > 0
&& !(*spa_buf).datas.is_null()
&& !(*(*spa_buf).datas).chunk.is_null()
{
let d0 = (*spa_buf).datas;
let c = (*d0).chunk;
let is_dmabuf =
(*d0).type_ == spa::sys::SPA_DATA_DmaBuf;
((*c).size, (*c).flags, is_dmabuf)
} else {
(0u32, 0i32, false)
}
};
let corrupted = (hdr_flags & spa::sys::SPA_META_HEADER_FLAG_CORRUPTED) != 0
|| (chunk_flags & spa::sys::SPA_CHUNK_FLAG_CORRUPTED as i32) != 0;
// THE GNOME FLASH FIX: skip Mutter's CORRUPTED / size-0 cursor-update buffers.
// When the pointer moves (e.g. dragging a window) Mutter sends metadata-only
// buffers flagged CORRUPTED (chunk size 0) that still reference a RECYCLED old
// frame; consuming them encodes "the window at its old position" — the flash.
// Confirmed live on worker-3 (chunk_flags=CORRUPTED, size 0) for both the zero-copy
// and SHM paths. The size-0 half is SHM-only (a real dmabuf legitimately reports
// chunk size 0). `drained` is the latest-frame-only depth — a cheap extra defense
// against bursty delivery, though here Mutter sends one buffer per callback.
if corrupted || (chunk_size == 0 && !is_dmabuf) {
ud.dbg_log_n += 1;
if ud.dbg_log_n.is_power_of_two() {
tracing::debug!(
skipped = ud.dbg_log_n,
drained,
"capture: skipped a stale CORRUPTED/cursor buffer (GNOME)"
);
}
// Skip this stale/cursor buffer — `newest` is requeued unconditionally below.
return;
}
consume_frame(ud, spa_buf);
}));
// Hand `newest` back to the stream exactly once, on EVERY path — normal, corrupted-skip,
// or a caught panic in the closure above. This single requeue is what keeps the fixed
// buffer pool from draining.
// SAFETY: all reads of `spa_buf`/`newest` (update_cursor_meta, consume_frame) completed
// inside the closure above; `newest` was dequeued from this stream and not yet requeued.
unsafe { stream.queue_raw_buffer(newest) };
if outcome.is_err() {
// In the per-frame `.process` callback: a deterministic panic (e.g. a bad
// format) would fire this every frame, so power-of-two throttle it — enough to
// surface the fault without evicting the whole log ring.
static PANICS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
let n = PANICS.fetch_add(1, Ordering::Relaxed) + 1;
if n.is_power_of_two() {
tracing::error!(count = n, "panic in pipewire process callback — frame dropped");
}
}
})
.register()
.context("register stream listener")?;
// Debug knob: offer a single fixed format (PUNKTFUNK_PW_FIXED_POD="WxH") to bisect
// negotiation failures against a producer's exact EnumFormat (e.g. gamescope).
let fixed_pod: Option<(u32, u32)> = std::env::var("PUNKTFUNK_PW_FIXED_POD")
.ok()
.and_then(|v| v.split_once('x').map(|(w, h)| (w.parse(), h.parse())))
.and_then(|(w, h)| Some((w.ok()?, h.ok()?)));
// Request raw video in any encoder-mappable layout, any size/framerate.
let obj = if let Some((fw, fh)) = fixed_pod {
tracing::info!(
fw,
fh,
"pipewire: offering a fixed BGRx format pod (PUNKTFUNK_PW_FIXED_POD)"
);
pw::spa::pod::object!(
pw::spa::utils::SpaTypes::ObjectParamFormat,
pw::spa::param::ParamType::EnumFormat,
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::MediaType,
Id,
pw::spa::param::format::MediaType::Video
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::MediaSubtype,
Id,
pw::spa::param::format::MediaSubtype::Raw
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::VideoFormat,
Id,
VideoFormat::BGRx
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::VideoSize,
Rectangle,
pw::spa::utils::Rectangle {
width: fw,
height: fh
}
),
pw::spa::pod::property!(
pw::spa::param::format::FormatProperties::VideoFramerate,
Fraction,
pw::spa::utils::Fraction { num: 0, denom: 1 }
),
)
} else {
build_default_format_obj(preferred)
};
// gamescope trap — the Steam overlay's presence in the stream is decided HERE by omission:
// gamescope's `paint_pipewire()` composites the overlay (Shift+Tab / Quick Access Menu) into
// the node it hands us ONLY when the consumer-negotiated `gamescope_focus_appid` is 0 — the
// default, and the "mirror the focused window + overlay" branch (gamescope ≥ 3.16.23; see
// `MIN_GAMESCOPE_OVERLAY`). None of the EnumFormat pods below advertise the
// `SPA_FORMAT_VIDEO_gamescope_focus_appid` property, so gamescope reads 0 and paints the
// overlay for us for free. DO NOT add a non-zero focus-appid (e.g. to "isolate the game" in a
// dedicated session) — that flips gamescope into the Remote-Play branch that deliberately
// drops the overlay (and all host chrome) back out of the capture. The cursor, external
// overlay (MangoHUD), and notifications are excluded from the node on EVERY gamescope
// version and are composited host-side instead (see `xfixes_cursor.rs`).
//
// When zero-copy is on, offer ONLY a BGRx dmabuf format with our EGL-importable modifiers
// (offering shm too makes the compositor pick shm). The modifier list goes out as a plain
// MANDATORY `ChoiceEnum::Enum` and the producer fixates one of the alternatives directly —
// this is NOT the two-step DONT_FIXATE handshake (libspa 0.9's `ChoiceFlags` cannot express
// `SPA_POD_PROP_FLAG_DONT_FIXATE`, and `param_changed` only READS the fixated format, it
// re-emits nothing). Worth revisiting if a multi-modifier offer is ever seen to fail
// negotiation on a compositor that needs the allocator round-trip. Otherwise offer the
// multi-format shm pod and let MAP_BUFFERS map it. An HDR session replaces ALL of this with the two 10-bit
// PQ pods (LINEAR dmabuf, MANDATORY colorimetry — see `build_hdr_dmabuf_format`): offering
// SDR alongside would make the producer pick its earlier-listed SDR format, and the
// negotiation-timeout path latches the process-wide SDR downgrade if nothing matches.
let format_pods: Vec<Vec<u8>> = if want_hdr {
tracing::info!(
"HDR capture: offering xRGB_210LE/xBGR_210LE LINEAR dmabufs with MANDATORY \
BT.2020 + SMPTE-2084 (PQ) colorimetry (GNOME 50+ monitor stream)"
);
vec![
build_hdr_dmabuf_format(VideoFormat::xRGB_210LE, preferred)?,
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?,
]
} else if want_dmabuf {
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 });
if prefer_native_nv12 {
// First compatible consumer pod wins. Gamescope advertises NV12 and BGRx; pinning
// BT.709 limited here selects its RGB→NV12 shader with our bitstream colorimetry.
pods.push(build_dmabuf_format(VideoFormat::NV12, &[0], preferred)?);
}
pods.push(build_dmabuf_format(
VideoFormat::BGRx,
&modifiers,
preferred,
)?);
pods
} else {
vec![serialize_pod(obj)?]
};
let buffers_values = if want_hdr || want_dmabuf {
// Dmabuf-only. For HDR this is load-bearing beyond zero-copy: Mutter's SHM record
// path paints 8-bit ARGB32 regardless of the negotiated format, so a MemFd buffer
// under a 10-bit format would carry mislabeled bytes.
Some(build_dmabuf_buffers()?)
} else if force_shm {
// True SHM: exclude DmaBuf so Mutter MUST download (glReadPixels orders against render).
Some(build_shm_only_buffers()?)
} else {
// CPU path still accepts mappable dmabufs (gamescope offers only those once its
// modifier-bearing format pod wins the intersection).
Some(build_mappable_buffers()?)
};
// Ask for cursor-as-metadata on every path (harmless if the producer can't supply it): the
// pointer rides as SPA_META_Cursor rather than being burned into the frame, so the
// compositor keeps its cheap hardware cursor plane (see `choose_cursor_mode`).
let cursor_meta = build_cursor_meta_param()?;
let mut byte_slices: Vec<&[u8]> = Vec::new();
for pod in &format_pods {
byte_slices.push(pod);
}
if let Some(b) = &buffers_values {
byte_slices.push(b);
}
byte_slices.push(&cursor_meta);
let mut params: Vec<&Pod> = byte_slices
.iter()
.map(|&b| Pod::from_bytes(b).context("pod from bytes"))
.collect::<Result<_>>()?;
stream
.connect(
spa::utils::Direction::Input,
Some(node_id),
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
&mut params,
)
.context("pw stream connect")?;
// Blocks this thread, pumping frame callbacks until the capturer's `Drop` fires the quit
// channel attached above (`_quit_attach` → `quit_loop.quit()`), at which point `run()`
// returns and the thread unwinds — releasing the importer / CUDA context deterministically.
mainloop.run();
Ok(())
}
#[cfg(test)]
mod tests {
use super::{negotiation_plan, NegotiationInputs};
/// A healthy NVENC session: zero-copy on, no latches, SDR 4:2:0, non-VAAPI backend.
fn nvenc() -> NegotiationInputs {
NegotiationInputs {
zerocopy: true,
force_shm: false,
want_hdr: false,
want_444: false,
backend_is_vaapi: false,
pyrowave_session: false,
native_nv12_session: false,
raw_dmabuf_import_disabled: false,
gpu_import_disabled: false,
native_nv12_env_on: true,
}
}
/// A gamescope-style VAAPI session that CAN take producer-native NV12.
fn vaapi_native_nv12() -> NegotiationInputs {
NegotiationInputs {
backend_is_vaapi: true,
native_nv12_session: true,
..nvenc()
}
}
/// The four invariants that were prose-only comments in `pipewire_thread`'s prologue.
#[test]
fn negotiation_plan_invariants() {
// 1. HDR NEVER builds the EGL→CUDA importer — its de-tile blit is 8-bit RGBA8, so an
// importer here would silently crush the 10-bit depth.
for want_444 in [false, true] {
let p = negotiation_plan(NegotiationInputs {
want_hdr: true,
want_444,
..nvenc()
});
assert!(!p.build_importer, "HDR must not build the importer");
}
// …on every backend, including the ones that take the raw passthrough.
assert!(
!negotiation_plan(NegotiationInputs {
want_hdr: true,
..vaapi_native_nv12()
})
.build_importer
);
// 2. 4:4:4 never prefers producer NV12 (a 4:4:4 session must not be subsampled).
let p = negotiation_plan(NegotiationInputs {
want_444: true,
..vaapi_native_nv12()
});
assert!(!p.prefer_native_nv12, "4:4:4 must not take NV12");
// Nor does HDR (no 10-bit NV12 path).
assert!(
!negotiation_plan(NegotiationInputs {
want_hdr: true,
..vaapi_native_nv12()
})
.prefer_native_nv12
);
// 3. Producer-native NV12 needs a `native_nv12_session` AND an active raw passthrough:
// libav VAAPI would misread the two-plane buffer, and the CUDA importer expects
// packed RGB.
assert!(negotiation_plan(vaapi_native_nv12()).prefer_native_nv12);
assert!(
!negotiation_plan(NegotiationInputs {
native_nv12_session: false,
..vaapi_native_nv12()
})
.prefer_native_nv12,
"a session whose encoder can't ingest NV12 must never be offered it"
);
assert!(
!negotiation_plan(NegotiationInputs {
force_shm: true,
..vaapi_native_nv12()
})
.prefer_native_nv12,
"no passthrough (force_shm) ⇒ no native NV12"
);
// A PyroWave session takes the passthrough but its CSC ingests packed RGB only.
assert!(
!negotiation_plan(NegotiationInputs {
pyrowave_session: true,
..vaapi_native_nv12()
})
.prefer_native_nv12
);
// 4. The pyrowave-modifier extension (and the passthrough generally) is off whenever
// the raw-dmabuf latch has fired.
let p = negotiation_plan(NegotiationInputs {
raw_dmabuf_import_disabled: true,
..vaapi_native_nv12()
});
assert!(!p.vaapi_passthrough, "latched ⇒ no raw passthrough");
assert!(!p.prefer_native_nv12);
assert!(p.raw_dmabuf_latched, "…and the operator gets told why");
}
/// The drift F1 was about: `spawn_pipewire` derived `vaapi_passthrough` by hand and its copy
/// omitted the raw-dmabuf latch, so after that latch fired the capturer believed it had made
/// the passthrough offer while the thread had already fallen back — and a timeout then
/// latched a downgrade for an offer nobody made. With one resolver the two cannot disagree,
/// so this pins the property that made the mirror wrong: the latch MUST move the decision.
#[test]
fn the_raw_dmabuf_latch_moves_the_passthrough_decision() {
for pyrowave in [false, true] {
let base = NegotiationInputs {
backend_is_vaapi: !pyrowave,
pyrowave_session: pyrowave,
..nvenc()
};
assert!(negotiation_plan(base).vaapi_passthrough);
assert!(
!negotiation_plan(NegotiationInputs {
raw_dmabuf_import_disabled: true,
..base
})
.vaapi_passthrough
);
}
}
/// A PyroWave session on an NVIDIA box takes the raw passthrough (the wavelet encoder's own
/// Vulkan device imports dmabufs on any vendor) and therefore must NOT also build the
/// EGL→CUDA importer — whose payloads only NVENC can consume.
#[test]
fn a_pyrowave_session_passes_through_without_a_cuda_importer() {
let p = negotiation_plan(NegotiationInputs {
pyrowave_session: true,
..nvenc()
});
assert!(p.vaapi_passthrough);
assert!(!p.build_importer);
}
/// `force_shm` is the race-free download path: no passthrough, and `want_dmabuf` stays false
/// even with an importer and a full modifier list.
#[test]
fn force_shm_wins_over_every_dmabuf_path() {
let p = negotiation_plan(NegotiationInputs {
force_shm: true,
..vaapi_native_nv12()
});
assert!(!p.vaapi_passthrough);
assert!(!p.want_dmabuf(true, &[0, 1, 2]));
// …and an SHM-forced NVENC session may still build the importer (it just won't be fed
// dmabufs), which is why `want_dmabuf` — not `build_importer` — is the gate.
let p = negotiation_plan(NegotiationInputs {
force_shm: true,
..nvenc()
});
assert!(p.build_importer);
assert!(!p.want_dmabuf(true, &[0]));
}
/// `want_dmabuf` needs a real modifier list: an importer that constructed but advertised
/// nothing importable falls back to the CPU path.
#[test]
fn want_dmabuf_needs_both_a_consumer_and_a_modifier() {
let p = negotiation_plan(nvenc());
assert!(p.want_dmabuf(true, &[0]));
assert!(!p.want_dmabuf(true, &[]), "no modifiers ⇒ CPU path");
assert!(
!p.want_dmabuf(false, &[0]),
"importer failed to construct and no passthrough ⇒ CPU path"
);
// The passthrough needs no importer at all.
let p = negotiation_plan(vaapi_native_nv12());
assert!(p.want_dmabuf(false, &[0]));
}
/// The GPU-import death latch stops the importer being rebuilt, and says so.
#[test]
fn the_gpu_import_death_latch_skips_the_importer() {
let p = negotiation_plan(NegotiationInputs {
gpu_import_disabled: true,
..nvenc()
});
assert!(!p.build_importer);
assert!(p.gpu_import_latched);
// It is NOT reported for a capture that would never have built one anyway (HDR, or a
// raw passthrough) — that would misdirect the operator.
assert!(
!negotiation_plan(NegotiationInputs {
gpu_import_disabled: true,
want_hdr: true,
..nvenc()
})
.gpu_import_latched
);
assert!(
!negotiation_plan(NegotiationInputs {
gpu_import_disabled: true,
..vaapi_native_nv12()
})
.gpu_import_latched
);
}
/// Zero-copy off ⇒ nothing at all: no importer, no passthrough, no NV12 preference.
#[test]
fn zerocopy_off_disables_every_branch() {
for i in [
NegotiationInputs {
zerocopy: false,
..nvenc()
},
NegotiationInputs {
zerocopy: false,
..vaapi_native_nv12()
},
] {
let p = negotiation_plan(i);
assert!(!p.build_importer);
assert!(!p.vaapi_passthrough);
assert!(!p.prefer_native_nv12);
assert!(!p.want_dmabuf(false, &[0]));
}
}
}