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>
1612 lines
81 KiB
Rust
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(®ion[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]));
|
|
}
|
|
}
|
|
}
|