ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m10s
ci / bench (push) Successful in 5m43s
windows-host / package (push) Successful in 10m48s
ci / rust-arm64 (push) Successful in 13m38s
decky / build-publish (push) Successful in 34s
deb / build-publish (push) Successful in 12m32s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 40s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 15s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 21s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
android / android (push) Successful in 17m7s
deb / build-publish-host (push) Successful in 9m49s
arch / build-publish (push) Successful in 17m59s
ci / rust (push) Successful in 21m32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m51s
docker / build-push-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-client-arm64 (push) Successful in 9m55s
apple / swift (push) Successful in 6m10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 23m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 23m24s
apple / screenshots (push) Successful in 24m29s
Upstream landed `fc335b39` (negotiate the cursor around what the encoder can blend) on the same files this sweep restructured. Merged rather than rebased: the sweep MOVED ~2,000 lines out of `linux/mod.rs` into `pipewire.rs`/`portal.rs`/`pw_*.rs`, so a rebase would have re-fought the same conflict in up to nine commits; merging resolves it once with both sides in view. The repo already carries merge commits (`land/sweep-all`). Two conflicts, both resolved by keeping BOTH changes: * `linux/mod.rs` — `PortalCapturer::open` gains upstream's `want_metadata_cursor` AND keeps the sweep's `PortalSession` teardown, so the portal threads now take `(setup_tx, quit_rx, want_metadata_cursor)`. The second conflict was upstream editing inside the region Phase 5.1/5.3 moved out; the split wins and upstream's change is ported to where the code now lives: `choose_cursor_mode`'s new `want_metadata` ladder (4 arms — prefer Embedded when the encoder can't blend, and warn-then-take Metadata when Embedded isn't advertised) is now in `portal.rs`, taken verbatim. * `gamestream/stream.rs` — the pooled-capturer slot keeps upstream's third reuse key (`metadata_cursor`, beside HDR-ness) AND the sweep's `result.is_ok() && capturer.is_alive()` re-pool gate. Both guard the same slot against different failures: theirs against reusing a session negotiated for the wrong cursor mode, the sweep's (L2) against reusing a dead one. Everything else auto-merged, including the `want_metadata_cursor` thread through `host/capture.rs`'s facade and `native/stream.rs`'s `session_plan::cursor_blend_for`. Verified after the merge: workspace `cargo test` green, `clippy --workspace --all-targets` clean on Linux AND on x86_64-pc-windows-msvc, `cargo fmt --check --all` clean, pf-capture 38/38. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
793 lines
41 KiB
Rust
793 lines
41 KiB
Rust
//! Live capture: xdg ScreenCast portal (`ashpd`) → PipeWire (`pipewire`), CPU-copy path.
|
|
//!
|
|
//! Two dedicated threads, because both stacks are tied to their thread:
|
|
//! * **portal thread** drives the async ashpd handshake on a multi-thread tokio runtime
|
|
//! (control plane — never the per-frame path), then parks on a oneshot receiver so the
|
|
//! `proxy` + its zbus connection stay alive (the cast is torn down when that connection
|
|
//! drops; ashpd's `Session` has no `Drop`);
|
|
//! * **pipewire thread** owns the (`!Send`) MainLoop/Stream and pumps frames.
|
|
//!
|
|
//! The portal hands the PipeWire remote fd + node id to the pipewire thread; decoded BGRx
|
|
//! frames leave the pipewire thread over a bounded channel. The authoritative frame size
|
|
//! comes from the negotiated PipeWire format, not the portal's size hint.
|
|
//!
|
|
//! Cleanup: BOTH threads are stopped deterministically — [`PortalCapturer`]'s `Drop` sends a
|
|
//! pipewire `channel` quit and joins that thread (releasing its EGL importer / CUDA context
|
|
//! promptly instead of leaking it to process exit), and [`PortalSession`]'s `Drop` fires the
|
|
//! portal thread's quit oneshot and waits, bounded, for it to unwind — which drops the zbus
|
|
//! connection and so ENDS the compositor's ScreenCast session. Dropping a capturer (session end,
|
|
//! or a retried/failed pipeline build) therefore leaves nothing behind on either side.
|
|
|
|
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
|
#![deny(clippy::undocumented_unsafe_blocks)]
|
|
|
|
use super::{CapturedFrame, Capturer, DmabufFrame, FramePayload, PixelFormat, ZeroCopyPolicy};
|
|
use anyhow::{anyhow, Context, Result};
|
|
|
|
// gamescope cursor source (remote-desktop-sweep Phase C) — feeds `cursor_live` from XFixes when
|
|
// the PipeWire node carries no `SPA_META_Cursor` (gamescope's does not).
|
|
mod xfixes_cursor;
|
|
use std::os::fd::OwnedFd;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError, TryRecvError};
|
|
use std::sync::Arc;
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
|
|
/// The frame hand-off from the PipeWire thread to the consumer: a ONE-DEEP slot the producer
|
|
/// OVERWRITES, i.e. genuinely drop-**oldest**.
|
|
///
|
|
/// This was a `sync_channel(8)` fed by `try_send`, which is drop-**newest**: with the consumer
|
|
/// stalled the queue filled and every fresh frame was discarded, so `next_frame` then handed back
|
|
/// the OLDEST of eight stale frames (`try_latest` drained to the newest of them, which is no better
|
|
/// — it is still the frame from the moment the queue filled). Shrinking the bound would not fix
|
|
/// that; only overwriting does. `SyncSender` cannot pop, so the queue is replaced outright.
|
|
///
|
|
/// A one-deep slot is also the right resource shape: a queued [`CapturedFrame`] can own a dup'd
|
|
/// dmabuf fd or a CUDA buffer, so a backlog of eight pinned eight compositor buffers.
|
|
type FrameSlot = Arc<std::sync::Mutex<Option<CapturedFrame>>>;
|
|
|
|
/// The per-session capture options — the four `bool`s that used to ride as consecutive positional
|
|
/// parameters through `spawn_pipewire` and on into `pipewire_thread`.
|
|
///
|
|
/// Named fields because four adjacent same-typed arguments are a silent-transposition footgun: swap
|
|
/// `want_444` and `want_hdr` at a call site and it compiles, negotiates the wrong pod family, and
|
|
/// shows up as a black screen ten seconds later. (Sweep Phase 5.6.)
|
|
#[derive(Clone, Copy)]
|
|
struct CaptureOpts {
|
|
/// Allow GPU zero-copy capture (dmabuf→CUDA/VA). `false` forces the CPU mmap path even when
|
|
/// `PUNKTFUNK_ZEROCOPY` is set — the session plan passes `gpu = false` when 4:4:4 has no
|
|
/// zero-copy convert available (see `SessionPlan::output_format`).
|
|
allow_zerocopy: bool,
|
|
/// 4:4:4 session: tiled dmabufs convert to planar YUV444 on the GPU (`ImportKind::Tiled444`)
|
|
/// instead of NV12/RGB, so the session stays zero-copy at full chroma.
|
|
want_444: bool,
|
|
/// HDR session (GNOME 50+ monitor mirror): offer ONLY the 10-bit PQ/BT.2020 formats as LINEAR
|
|
/// dmabufs. SHM cannot carry them — Mutter's SHM record path paints 8-bit ARGB32 regardless of
|
|
/// the negotiated format, and the tiled EGL de-tile blit is 8-bit.
|
|
want_hdr: bool,
|
|
/// The producer's FIRST negotiation is for a sacrificial mode and a renegotiation to
|
|
/// `preferred`'s dims is guaranteed to follow (KWin virtual outputs — see kwin.rs `create`):
|
|
/// skip whole buffers until the negotiated size matches, so the pipeline never builds against
|
|
/// the doomed birth mode. `false` everywhere else (Mutter SIZES the monitor from negotiation and
|
|
/// gamescope fixates its own — gating those would starve legitimate first frames).
|
|
expect_exact_dims: bool,
|
|
}
|
|
|
|
/// The shared state the PipeWire thread PUBLISHES and the capturer READS — one struct instead of
|
|
/// seven separately-plumbed `Arc`s.
|
|
///
|
|
/// Those seven were created in `spawn_pipewire`, `_cb`-cloned one by one, passed as seven of
|
|
/// `pipewire_thread`'s parameters (most of its arity, and most of the reason for three
|
|
/// `too_many_arguments` allows), and then re-declared as fields on `PwHandles`, `PortalCapturer` AND
|
|
/// `UserData` — the same list written out four times, each with its own drifting copy of the doc
|
|
/// comment. Collapsed in sweep Phase 5.6: `Clone` is a refcount bump per field, and the producer and
|
|
/// consumer now demonstrably share ONE set.
|
|
#[derive(Clone)]
|
|
struct CaptureSignals {
|
|
/// A stream is active: the PipeWire thread does the (expensive at 5K) per-frame de-pad only
|
|
/// while this is set, so a pooled capturer costs almost nothing between streams.
|
|
active: Arc<AtomicBool>,
|
|
/// Set once the stream agrees a video format. Read in `next_frame`'s timeout branch to tell
|
|
/// "format never negotiated" (modifier/format mismatch) apart from "negotiated but no buffers
|
|
/// arrived" (compositor idle/unmapped) — the two black-screen root causes.
|
|
negotiated: Arc<AtomicBool>,
|
|
/// True only while the stream is `Streaming`. `try_latest` reads it to tell a static desktop
|
|
/// (alive, no new buffers) from a dead source, and `is_alive` gates re-pooling on it.
|
|
streaming: Arc<AtomicBool>,
|
|
/// Poison flag: the zero-copy GPU import is irrecoverably gone for this stream (the import
|
|
/// worker died — e.g. absorbing the driver fault of a crashing compositor — or tiled imports
|
|
/// failed repeatedly, where the CPU fallback would de-pad scrambled tiled bytes). Both
|
|
/// `next_frame` and `try_latest` surface it as an error, so the session's capture-loss rebuild
|
|
/// runs instead of freezing or corrupting. Never cleared.
|
|
broken: Arc<AtomicBool>,
|
|
/// Set once the stream negotiated one of the 10-bit PQ formats, i.e. frames really are
|
|
/// PQ/BT.2020 — drives `hdr_meta`.
|
|
hdr_negotiated: Arc<AtomicBool>,
|
|
/// The LIVE cursor overlay, published from every buffer's `SPA_META_Cursor` — including the
|
|
/// cursor-only "corrupted" buffers that never become frames — so the encode loop's forwarder
|
|
/// tracks pointer-only motion on a static desktop (the frame-attached overlay alone goes stale
|
|
/// between damage frames). The gamescope XFixes source publishes into this SAME slot.
|
|
cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
|
|
/// The NEGOTIATED frame size, packed `(w << 32) | h`; `0` until `param_changed` runs. Read by
|
|
/// the gamescope cursor source, which must map root-space pointer coordinates into FRAME space
|
|
/// (gamescope's `-w/-h` and `-W/-H` are independent knobs).
|
|
frame_size: Arc<std::sync::atomic::AtomicU64>,
|
|
}
|
|
|
|
impl CaptureSignals {
|
|
fn new() -> Self {
|
|
Self {
|
|
active: Arc::new(AtomicBool::new(false)),
|
|
negotiated: Arc::new(AtomicBool::new(false)),
|
|
streaming: Arc::new(AtomicBool::new(false)),
|
|
broken: Arc::new(AtomicBool::new(false)),
|
|
hdr_negotiated: Arc::new(AtomicBool::new(false)),
|
|
cursor_live: Arc::new(std::sync::Mutex::new(None)),
|
|
frame_size: Arc::new(std::sync::atomic::AtomicU64::new(0)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Live monitor capturer backed by the portal + PipeWire threads. Kept alive (reused) across
|
|
/// streams — [`set_active`](Capturer::set_active) gates the per-frame de-pad copy so it costs
|
|
/// almost nothing between streams while the screencast session stays up (instant reconnect,
|
|
/// and no second session to conflict with).
|
|
pub struct PortalCapturer {
|
|
/// THE frame the PipeWire thread produced most recently and the consumer has not taken yet.
|
|
/// See [`FrameSlot`] for why this is a one-deep overwriting slot rather than a queue.
|
|
slot: FrameSlot,
|
|
/// Wakeup edges from the producer (one `()` per publish, best-effort). The SLOT is the truth —
|
|
/// this only un-blocks a waiter, so a coalesced edge loses nothing. Also the producer-liveness
|
|
/// signal: its sender dies with the PipeWire thread, so `Disconnected` here means the thread
|
|
/// ended.
|
|
wake: Receiver<()>,
|
|
signals: CaptureSignals,
|
|
/// When the stream first dropped out of `Streaming` with no new frame; used to grace a transient
|
|
/// renegotiation before declaring the source lost. Cleared whenever a frame arrives or the stream
|
|
/// is `Streaming`.
|
|
stall_since: Option<std::time::Instant>,
|
|
/// True when this capture runs the raw-dmabuf passthrough offer (VAAPI backend, or ANY vendor on
|
|
/// a PyroWave session). Taken verbatim from the ONE
|
|
/// [`NegotiationPlan`](pipewire::NegotiationPlan) the thread also consumes — never re-derived
|
|
/// here, which is what let this bool drift from the thread's real decision. If the offer never
|
|
/// negotiates, [`next_frame`](Capturer::next_frame)'s timeout branch latches the scoped
|
|
/// downgrade ([`pf_zerocopy::note_raw_dmabuf_negotiation_failed`]) so the pipeline rebuild
|
|
/// retries on the CPU offer instead of failing identically forever.
|
|
vaapi_dmabuf: bool,
|
|
/// This capture ran the HDR (10-bit PQ/BT.2020 dmabuf) offer — see [`Self::open`]'s
|
|
/// `want_hdr`. Read by the negotiation-timeout diagnosis (a failed HDR offer latches the
|
|
/// process-wide SDR downgrade) and by [`hdr_meta`](Capturer::hdr_meta).
|
|
hdr_offer: bool,
|
|
/// The PipeWire node this capturer consumes — surfaced in error messages for diagnosis.
|
|
node_id: u32,
|
|
/// Stops the PipeWire loop on teardown (sent in `Drop`). Without it a dropped or failed
|
|
/// capturer leaks its PipeWire thread — and its EGL importer / CUDA context — because
|
|
/// `mainloop.run()` otherwise blocks until process exit. `Option` so `Drop` can take it.
|
|
quit: Option<::pipewire::channel::Sender<()>>,
|
|
/// Joined in `Drop` (after `quit`) so teardown is synchronous: the importer/CUDA context is
|
|
/// released before the next pipeline builds, not left racing it.
|
|
join: Option<thread::JoinHandle<()>>,
|
|
/// Owns the virtual output (if this capturer was built from one) — dropped when the capturer
|
|
/// is, releasing the compositor-side output via the keepalive's own `Drop`. `None` for the
|
|
/// portal source (its session ends with the portal thread's zbus connection).
|
|
_keepalive: Option<Box<dyn Send>>,
|
|
/// The portal control-plane thread's teardown handles ([`PortalSession`]). `Some` only on the
|
|
/// [`open`](Self::open) path; `None` for a virtual-output capturer (no portal thread). Held
|
|
/// purely for its `Drop` (like `_keepalive`) — that is what ends the compositor's screencast.
|
|
_portal: Option<PortalSession>,
|
|
/// The gamescope XFixes cursor reader (remote-desktop-sweep Phase C), when this capturer
|
|
/// serves a gamescope node. `Some` after
|
|
/// [`attach_gamescope_cursor`](Capturer::attach_gamescope_cursor); its `Drop` stops the reader
|
|
/// thread, so it lives exactly as long as the capturer. `None` on the portal path (its cursor
|
|
/// comes from `SPA_META_Cursor`).
|
|
_gs_cursor: Option<xfixes_cursor::XFixesCursorSource>,
|
|
}
|
|
|
|
/// Teardown handles for the portal control-plane thread. Firing `quit` un-parks
|
|
/// `portal_thread`(`_remote_desktop`), so `rt.block_on` returns, the tokio runtime and the zbus
|
|
/// connection drop — and **dropping that connection is what ends the compositor's ScreenCast
|
|
/// session** (ashpd's `Session` has no `Drop`). Without this the thread parked forever on
|
|
/// `future::pending()`, so every dropped portal capturer permanently leaked a thread, a 2-worker
|
|
/// runtime, a zbus connection AND a live screencast: a GameStream HDR↔SDR reconnect drops the
|
|
/// pooled capturer and opens a fresh session, so the sessions accumulated — the exact "second
|
|
/// conflicting screencast" the capturer pooling exists to prevent.
|
|
struct PortalSession {
|
|
/// Un-parks the portal thread. `Option` so `Drop` can take (and thereby fire) it; dropping the
|
|
/// sender without a send resolves the receiver with `Err`, which the thread treats identically.
|
|
quit: Option<tokio::sync::oneshot::Sender<()>>,
|
|
/// Signalled by the spawned closure after the runtime has been dropped — lets `Drop` bound its
|
|
/// wait instead of blocking on `join()` behind a wedged D-Bus round-trip.
|
|
done: Receiver<()>,
|
|
join: Option<thread::JoinHandle<()>>,
|
|
}
|
|
|
|
impl Drop for PortalSession {
|
|
fn drop(&mut self) {
|
|
// Un-park the thread, then wait — BOUNDED — for it to unwind. The bound matters because the
|
|
// thread may be inside a D-Bus round-trip against a wedged portal, and teardown runs on the
|
|
// session path: an unbounded `join()` there would hang the host, not just leak. On timeout
|
|
// we detach (the thread owns only its own runtime + connection and exits on its own once the
|
|
// call returns), having at least fired the quit.
|
|
drop(self.quit.take()); // send-or-drop: both resolve the receiver
|
|
let joinable = match self.done.recv_timeout(Duration::from_millis(750)) {
|
|
Ok(()) => true,
|
|
Err(_) => {
|
|
tracing::warn!(
|
|
"portal thread did not unwind within 750ms — detaching it (the compositor's \
|
|
ScreenCast session may linger until the host exits)"
|
|
);
|
|
false
|
|
}
|
|
};
|
|
if let Some(join) = self.join.take() {
|
|
if joinable {
|
|
let _ = join.join(); // returns immediately: `done` already fired
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl PortalCapturer {
|
|
/// `anchored` drives ScreenCast off a RemoteDesktop session (KWin/GNOME) so it inherits the
|
|
/// RemoteDesktop grant and never raises a separate ScreenCast dialog; `false` uses a plain
|
|
/// ScreenCast session (wlroots, which has no RemoteDesktop portal). `want_hdr` offers the
|
|
/// GNOME 50+ HDR formats (10-bit PQ/BT.2020, dmabuf-only) instead of the SDR set.
|
|
/// `want_metadata_cursor` picks the cursor mode — `true` asks for `SPA_META_Cursor` (this
|
|
/// session's encode path composites it), `false` asks the compositor to EMBED the pointer; see
|
|
/// `portal::choose_cursor_mode`.
|
|
pub fn open(
|
|
anchored: bool,
|
|
want_hdr: bool,
|
|
want_metadata_cursor: bool,
|
|
policy: ZeroCopyPolicy,
|
|
) -> Result<PortalCapturer> {
|
|
// Portal handshake (async) on its own thread; hands back the PW fd + node id.
|
|
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
|
// Teardown plumbing (see `PortalSession`): `quit_rx` parks the thread once the handshake is
|
|
// done, `done_tx` reports that it has fully unwound.
|
|
let (quit_tx, quit_rx) = tokio::sync::oneshot::channel::<()>();
|
|
let (done_tx, done_rx) = sync_channel::<()>(1);
|
|
let join = thread::Builder::new()
|
|
.name("punktfunk-portal".into())
|
|
.spawn(move || {
|
|
if anchored {
|
|
portal_thread_remote_desktop(setup_tx, quit_rx, want_metadata_cursor)
|
|
} else {
|
|
portal_thread(setup_tx, quit_rx, want_metadata_cursor)
|
|
}
|
|
// After the runtime has been dropped inside the fn above, so a successful
|
|
// `recv_timeout` in `Drop` means the zbus connection is really gone. Covers the
|
|
// early-return paths too (a runtime that failed to build).
|
|
let _ = done_tx.send(());
|
|
})
|
|
.context("spawn portal thread")?;
|
|
let portal = PortalSession {
|
|
quit: Some(quit_tx),
|
|
done: done_rx,
|
|
join: Some(join),
|
|
};
|
|
|
|
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
|
Ok(Ok(v)) => v,
|
|
Ok(Err(e)) => return Err(anyhow!("ScreenCast portal setup failed: {e}")),
|
|
Err(_) => return Err(anyhow!("timed out waiting for the ScreenCast portal")),
|
|
};
|
|
tracing::info!(
|
|
node_id,
|
|
want_hdr,
|
|
"ScreenCast portal session started; connecting PipeWire"
|
|
);
|
|
// This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before.
|
|
// NOTE the `?`: on a PipeWire-spawn failure `portal` drops here, which fires the quit sender
|
|
// and tears the just-created screencast session down instead of stranding it.
|
|
Ok(spawn_pipewire(
|
|
Some(fd),
|
|
node_id,
|
|
None,
|
|
CaptureOpts {
|
|
allow_zerocopy: true,
|
|
want_444: false,
|
|
want_hdr,
|
|
expect_exact_dims: false,
|
|
},
|
|
policy,
|
|
)?
|
|
.into_capturer(node_id, None, Some(portal)))
|
|
}
|
|
|
|
/// Build a capturer from an already-created virtual output's PipeWire node. The host facade
|
|
/// explodes its `vdisplay::VirtualOutput` into these primitives so this crate never depends on
|
|
/// the vdisplay type: `remote_fd` selects portal-remote vs. default-daemon, `node_id` is the
|
|
/// output's screencast node, `preferred_mode` seeds format negotiation, and `keepalive` owns the
|
|
/// output (dropping the capturer releases it). `allow_zerocopy` mirrors
|
|
/// [`OutputFormat::gpu`](pf_frame::OutputFormat): `false` forces the CPU mmap path, `true` keeps
|
|
/// the GPU zero-copy path subject to `PUNKTFUNK_ZEROCOPY`. `want_444` (a 4:4:4 session) makes the
|
|
/// zero-copy worker convert tiled dmabufs to planar YUV444 on the GPU instead of NV12/RGB.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn from_virtual_output(
|
|
remote_fd: Option<OwnedFd>,
|
|
node_id: u32,
|
|
preferred_mode: Option<(u32, u32, u32)>,
|
|
keepalive: Box<dyn Send>,
|
|
allow_zerocopy: bool,
|
|
want_444: bool,
|
|
policy: ZeroCopyPolicy,
|
|
expect_exact_dims: bool,
|
|
) -> Result<PortalCapturer> {
|
|
tracing::info!(
|
|
node_id,
|
|
allow_zerocopy,
|
|
want_444,
|
|
expect_exact_dims,
|
|
"connecting PipeWire to virtual output"
|
|
);
|
|
// Virtual outputs are SDR-only upstream (Mutter's RecordVirtual streams advertise 8-bit
|
|
// BGRx/BGRA exclusively, GNOME 50 and 51-dev alike) — never run the HDR offer here.
|
|
Ok(spawn_pipewire(
|
|
remote_fd,
|
|
node_id,
|
|
preferred_mode,
|
|
CaptureOpts {
|
|
allow_zerocopy,
|
|
want_444,
|
|
// Virtual outputs are SDR-only upstream (see the comment above).
|
|
want_hdr: false,
|
|
expect_exact_dims,
|
|
},
|
|
policy,
|
|
)?
|
|
// No portal thread on this path: the node belongs to a virtual output the caller created,
|
|
// and `keepalive` is what releases it.
|
|
.into_capturer(node_id, Some(keepalive), None))
|
|
}
|
|
}
|
|
|
|
/// Live PipeWire-thread handles returned by [`spawn_pipewire`]: the frame channel, the
|
|
/// activation flag the per-frame copy gates on, a "format negotiated" flag (timeout diagnostics),
|
|
/// a quit sender that stops the loop, and the thread's join handle (synchronous teardown).
|
|
struct PwHandles {
|
|
/// See [`FrameSlot`] / [`PortalCapturer::slot`].
|
|
slot: FrameSlot,
|
|
/// See [`PortalCapturer::wake`].
|
|
wake: Receiver<()>,
|
|
signals: CaptureSignals,
|
|
/// This capture will offer LINEAR-dmabuf-only for the VAAPI passthrough (see
|
|
/// [`PortalCapturer::vaapi_dmabuf`]).
|
|
vaapi_dmabuf: bool,
|
|
/// This capture ran the HDR offer (see [`PortalCapturer::hdr_offer`]).
|
|
hdr_offer: bool,
|
|
quit: ::pipewire::channel::Sender<()>,
|
|
join: thread::JoinHandle<()>,
|
|
}
|
|
|
|
impl PwHandles {
|
|
/// Assemble a [`PortalCapturer`] around these handles. `node_id` is carried for diagnostics;
|
|
/// `keepalive` owns the virtual output (drops after the PipeWire thread is joined); `portal`
|
|
/// carries the control-plane thread's teardown handles on the [`PortalCapturer::open`] path.
|
|
fn into_capturer(
|
|
self,
|
|
node_id: u32,
|
|
keepalive: Option<Box<dyn Send>>,
|
|
portal: Option<PortalSession>,
|
|
) -> PortalCapturer {
|
|
PortalCapturer {
|
|
slot: self.slot,
|
|
wake: self.wake,
|
|
signals: self.signals,
|
|
stall_since: None,
|
|
vaapi_dmabuf: self.vaapi_dmabuf,
|
|
hdr_offer: self.hdr_offer,
|
|
node_id,
|
|
quit: Some(self.quit),
|
|
join: Some(self.join),
|
|
_keepalive: keepalive,
|
|
_portal: portal,
|
|
_gs_cursor: None,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Spawn the PipeWire consumer thread for `node_id` (fd `Some` = portal remote, `None` =
|
|
/// default daemon) and return its [`PwHandles`]. `preferred` seeds the format negotiation's
|
|
/// default size/framerate — for Mutter virtual monitors this is what actually sizes the monitor.
|
|
fn spawn_pipewire(
|
|
fd: Option<OwnedFd>,
|
|
node_id: u32,
|
|
preferred: Option<(u32, u32, u32)>,
|
|
opts: CaptureOpts,
|
|
// Encode-backend facts resolved by the facade (never re-derived here) — the one-way
|
|
// capture→encode edge (plan §W6).
|
|
policy: ZeroCopyPolicy,
|
|
) -> Result<PwHandles> {
|
|
// `expect_exact_dims` is forwarded to the thread inside `opts`, not read here.
|
|
let CaptureOpts {
|
|
allow_zerocopy,
|
|
want_444,
|
|
want_hdr,
|
|
..
|
|
} = opts;
|
|
// Frames hand over through the one-deep overwriting mailbox (see `FrameSlot`); the channel
|
|
// carries only wakeup EDGES, so depth 1 is exactly right — a coalesced edge loses nothing
|
|
// because the slot, not the channel, holds the frame.
|
|
let slot: FrameSlot = Arc::new(std::sync::Mutex::new(None));
|
|
let slot_cb = slot.clone();
|
|
let (wake_tx, wake_rx) = sync_channel::<()>(1);
|
|
let signals = CaptureSignals::new();
|
|
let signals_cb = signals.clone();
|
|
// pipewire's own cross-thread channel: the receiver attaches to the loop and quits it; the
|
|
// sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
|
|
// inner `mod pipewire` shadows the crate name at this scope.
|
|
let (quit_tx, quit_rx) = ::pipewire::channel::channel::<()>();
|
|
let zerocopy = allow_zerocopy && pf_zerocopy::enabled();
|
|
// HDR cannot ride the SHM path (see `want_hdr` above): under PUNKTFUNK_FORCE_SHM the HDR
|
|
// offer is dropped — SDR capture, loudly.
|
|
let force_shm = std::env::var("PUNKTFUNK_FORCE_SHM").as_deref() == Ok("1");
|
|
let want_hdr = if want_hdr && force_shm {
|
|
tracing::warn!(
|
|
"HDR capture requested but PUNKTFUNK_FORCE_SHM=1 — the SHM path is 8-bit only; \
|
|
offering SDR"
|
|
);
|
|
false
|
|
} else {
|
|
want_hdr
|
|
};
|
|
// THE negotiation decision, resolved once here and handed to the thread — no mirror (L3/F1).
|
|
// Every environment/latch read the decision depends on happens at this single point.
|
|
let plan = pipewire::negotiation_plan(pipewire::NegotiationInputs {
|
|
zerocopy,
|
|
force_shm,
|
|
want_hdr,
|
|
want_444,
|
|
backend_is_vaapi: policy.backend_is_vaapi,
|
|
pyrowave_session: policy.pyrowave_session,
|
|
native_nv12_session: policy.native_nv12_session,
|
|
raw_dmabuf_import_disabled: pf_zerocopy::raw_dmabuf_import_disabled(),
|
|
gpu_import_disabled: pf_zerocopy::gpu_import_disabled(),
|
|
// Default ON; `PUNKTFUNK_PIPEWIRE_NV12=0` (or any falsy spelling — the shared parser, not a
|
|
// bare `!= "0"` string compare) restores the packed-RGB negotiation.
|
|
native_nv12_env_on: pf_host_config::env_on("PUNKTFUNK_PIPEWIRE_NV12").unwrap_or(true),
|
|
});
|
|
// The capturer's timeout diagnosis reads the SAME resolved bool the thread acts on.
|
|
let vaapi_dmabuf = plan.vaapi_passthrough;
|
|
let join = thread::Builder::new()
|
|
.name("punktfunk-pipewire".into())
|
|
.spawn(move || {
|
|
if let Err(e) = pipewire::pipewire_thread(
|
|
fd,
|
|
node_id,
|
|
slot_cb,
|
|
wake_tx,
|
|
signals_cb,
|
|
plan,
|
|
// `allow_zerocopy` is already folded into `plan`; the rest still matter downstream.
|
|
CaptureOpts { want_hdr, ..opts },
|
|
preferred,
|
|
quit_rx,
|
|
policy,
|
|
) {
|
|
tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed");
|
|
}
|
|
})
|
|
.context("spawn pipewire thread")?;
|
|
Ok(PwHandles {
|
|
slot,
|
|
wake: wake_rx,
|
|
signals,
|
|
vaapi_dmabuf,
|
|
hdr_offer: want_hdr,
|
|
quit: quit_tx,
|
|
join,
|
|
})
|
|
}
|
|
|
|
impl Capturer for PortalCapturer {
|
|
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
|
self.frame_within(Duration::from_secs(10))
|
|
}
|
|
|
|
fn cursor(&mut self) -> Option<pf_frame::CursorOverlay> {
|
|
// The PipeWire thread's live cursor slot (fed by every buffer's meta, frames or not) —
|
|
// lets the forwarder track pointer-only motion on a static desktop. See `cursor_live`.
|
|
// On a gamescope node the meta never arrives; the XFixes source (attached below) fills
|
|
// the same slot instead.
|
|
self.signals
|
|
.cursor_live
|
|
.lock()
|
|
.ok()
|
|
.and_then(|slot| slot.clone())
|
|
}
|
|
|
|
fn attach_gamescope_cursor(&mut self, targets: crate::GamescopeCursorTargets) {
|
|
// gamescope paints no `SPA_META_Cursor`, so `cursor_live` would stay empty. Spawn the
|
|
// XFixes reader to publish gamescope's pointer into that SAME slot — `cursor()` above then
|
|
// serves it and the encode loop composites it, exactly like the portal path. It connects to
|
|
// every nested Xwayland the provider reports, RE-RUNS the provider so a game's Xwayland
|
|
// that appears later is adopted, and follows whichever one gamescope draws the pointer on.
|
|
// `frame_size` lets it map root-space coordinates into frame space.
|
|
self._gs_cursor = xfixes_cursor::XFixesCursorSource::spawn(
|
|
targets,
|
|
Arc::clone(&self.signals.cursor_live),
|
|
Arc::clone(&self.signals.frame_size),
|
|
);
|
|
}
|
|
|
|
fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
|
self.frame_within(budget)
|
|
}
|
|
|
|
fn supports_arrival_wait(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn wait_arrival(&mut self, deadline: std::time::Instant) {
|
|
// Frame-driven trigger (latency plan T1.1): block until the compositor delivers a frame or
|
|
// the deadline passes. Now that the hand-off is a peekable slot this honours the trait's
|
|
// "must NOT consume" contract directly — it observes the slot and leaves the frame for
|
|
// `try_latest`, so the `pending` stash the old un-peekable channel forced no longer exists.
|
|
// A broken/ended stream just returns; the following `try_latest` surfaces the error.
|
|
if self.signals.broken.load(Ordering::Relaxed) {
|
|
return;
|
|
}
|
|
loop {
|
|
if self.slot.lock().is_ok_and(|s| s.is_some()) {
|
|
return;
|
|
}
|
|
let Some(left) = deadline.checked_duration_since(std::time::Instant::now()) else {
|
|
return;
|
|
};
|
|
// Timeout OR a dead producer: either way stop waiting and let `try_latest` classify.
|
|
if self.wake.recv_timeout(left).is_err() {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn try_latest(&mut self) -> Result<Option<CapturedFrame>> {
|
|
if self.signals.broken.load(Ordering::Relaxed) {
|
|
return Err(anyhow!(
|
|
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
|
|
failed repeatedly — rebuilding capture",
|
|
self.node_id
|
|
));
|
|
}
|
|
// Take the newest frame, if any; `None` means the compositor hasn't produced one since the
|
|
// last call (static/idle desktop). Wakeup edges are drained first — they are pure edges, so
|
|
// stale ones must not make the next `wait_arrival` return early — and `Disconnected` there
|
|
// is how a dead PipeWire thread surfaces (a frame it left behind is still served first).
|
|
let mut producer_gone = false;
|
|
loop {
|
|
match self.wake.try_recv() {
|
|
Ok(()) => continue,
|
|
Err(TryRecvError::Empty) => break,
|
|
Err(TryRecvError::Disconnected) => {
|
|
producer_gone = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
let latest = self.take_frame();
|
|
if producer_gone && latest.is_none() {
|
|
return Err(anyhow!("PipeWire capture thread ended"));
|
|
}
|
|
if latest.is_some() || self.signals.streaming.load(Ordering::Relaxed) {
|
|
// A frame arrived, or the source is alive but idle (static desktop) — normal. Clear any
|
|
// stall and repeat the last frame on `None`, exactly as before.
|
|
self.stall_since = None;
|
|
return Ok(latest);
|
|
}
|
|
// No new frame AND the stream has left `Streaming` (Paused/Unconnected/Error). The source
|
|
// went away — a compositor torn down on a Gaming↔Desktop switch, a removed virtual output.
|
|
// Grace a brief window (a transient mid-stream renegotiation can blip out of Streaming and
|
|
// back) before declaring it lost so the encode loop rebuilds in place rather than freezing
|
|
// on the last frame forever.
|
|
const STALL_GRACE: Duration = Duration::from_millis(1500);
|
|
let since = *self.stall_since.get_or_insert_with(std::time::Instant::now);
|
|
if since.elapsed() >= STALL_GRACE {
|
|
self.stall_since = None;
|
|
return Err(anyhow!(
|
|
"PipeWire source stalled (node {}): stream left Streaming for >{}ms with no frames \
|
|
— the compositor/virtual output went away (session switch?)",
|
|
self.node_id,
|
|
STALL_GRACE.as_millis()
|
|
));
|
|
}
|
|
Ok(latest)
|
|
}
|
|
|
|
fn set_active(&mut self, active: bool) {
|
|
self.signals.active.store(active, Ordering::Relaxed);
|
|
if !active {
|
|
// Flush the mailbox on stream end (L10): a reused capturer used to open the NEXT stream
|
|
// by handing over the PREVIOUS session's last frame — wrong content, and stamped with a
|
|
// `pts_ns` from before the new stream's clock. `active == false` also stops the producer
|
|
// publishing, so nothing refills it until the next `set_active(true)`.
|
|
if let Ok(mut slot) = self.slot.lock() {
|
|
*slot = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// All three of this backend's terminal states, checked without consuming a frame — every one
|
|
/// of them is sticky, so an `Err` from `next_frame`/`try_latest` here is permanent:
|
|
/// * `broken` — the zero-copy GPU import is irrecoverably gone for this stream (worker death,
|
|
/// or a tiled-import failure streak). Never cleared.
|
|
/// * the PipeWire thread has exited (its `Drop`-less death is otherwise indistinguishable from
|
|
/// an idle desktop: the frame channel just goes quiet, and `streaming` keeps its last value).
|
|
/// * the stream left `Streaming` for good — the compositor or virtual output went away.
|
|
///
|
|
/// A static desktop stays `Streaming` (it simply sends no buffers), so an idle-but-healthy
|
|
/// capture is never reported dead.
|
|
fn is_alive(&self) -> bool {
|
|
!self.signals.broken.load(Ordering::Relaxed)
|
|
&& self.signals.streaming.load(Ordering::Relaxed)
|
|
&& self.join.as_ref().is_some_and(|j| !j.is_finished())
|
|
}
|
|
|
|
/// Generic HDR10 mastering metadata once the stream negotiated a 10-bit PQ format. Mutter
|
|
/// exposes no per-monitor mastering volume through the screencast, so this is the standard
|
|
/// HDR10 default block (BT.2020 primaries, D65 white, 1000 / 0.005 cd/m², CLL unknown) — the
|
|
/// same fallback Windows uses when a display reports nothing. The native stream loop prefers
|
|
/// the client display's own volume when the client sent one (`Hello::display_hdr`).
|
|
fn hdr_meta(&self) -> Option<punktfunk_core::quic::HdrMeta> {
|
|
if !self.signals.hdr_negotiated.load(Ordering::Relaxed) {
|
|
return None;
|
|
}
|
|
Some(punktfunk_core::quic::HdrMeta {
|
|
// ST.2086 order G, B, R; (x, y) chromaticity in 1/50000 units.
|
|
display_primaries: [[8500, 39850], [6550, 2300], [35400, 14600]],
|
|
white_point: [15635, 16450], // D65
|
|
max_display_mastering_luminance: 10_000_000, // 1000 cd/m² (0.0001 units)
|
|
min_display_mastering_luminance: 50, // 0.005 cd/m²
|
|
max_cll: 0,
|
|
max_fall: 0,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl PortalCapturer {
|
|
/// The blocking first-frame wait behind [`Capturer::next_frame`] /
|
|
/// [`Capturer::next_frame_within`]. First frame can lag behind format negotiation; later
|
|
/// frames arrive at ~fps. Wait in short slices so a GPU-import poison (worker death) fails
|
|
/// the capture within ~0.5 s instead of sitting out the full first-frame budget.
|
|
fn frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
|
let deadline = std::time::Instant::now() + budget;
|
|
loop {
|
|
if self.signals.broken.load(Ordering::Relaxed) {
|
|
return Err(anyhow!(
|
|
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
|
|
failed repeatedly — rebuilding capture",
|
|
self.node_id
|
|
));
|
|
}
|
|
// The slot before the wakeup: a publish that coalesced its edge (or landed while we were
|
|
// not waiting) is still visible here.
|
|
if let Some(f) = self.take_frame() {
|
|
return Ok(f);
|
|
}
|
|
let slice = Duration::from_millis(500)
|
|
.min(deadline.saturating_duration_since(std::time::Instant::now()));
|
|
match self.wake.recv_timeout(slice) {
|
|
Ok(()) => continue, // a publish happened — the loop re-reads the slot
|
|
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => continue,
|
|
Err(e) => {
|
|
// A last frame can be sitting in the slot even as the producer exits.
|
|
if let Some(f) = self.take_frame() {
|
|
return Ok(f);
|
|
}
|
|
return self.next_frame_timed_out(e, budget);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Take the mailbox's frame, if the producer has published one since the last take.
|
|
fn take_frame(&self) -> Option<CapturedFrame> {
|
|
self.slot.lock().ok().and_then(|mut s| s.take())
|
|
}
|
|
|
|
/// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it
|
|
/// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
|
|
fn next_frame_timed_out(
|
|
&self,
|
|
err: RecvTimeoutError,
|
|
budget: Duration,
|
|
) -> Result<CapturedFrame> {
|
|
let within = budget.as_secs_f32();
|
|
match err {
|
|
RecvTimeoutError::Timeout => {
|
|
// Split the two black-screen root causes apart so the operator gets a cause, not
|
|
// just a symptom: did the format negotiate (compositor produced no buffers) or
|
|
// not (no acceptable format / node never emitted a param)?
|
|
if self.signals.negotiated.load(Ordering::Relaxed) {
|
|
Err(anyhow!(
|
|
"no PipeWire frame within {within}s (node {}): format negotiated but no \
|
|
buffers arrived — the compositor produced no frames (virtual output \
|
|
idle/unmapped, capture never started, or a stream bound during a \
|
|
compositor (re)start that will never deliver — a reconnect fixes that)",
|
|
self.node_id
|
|
))
|
|
} else if self.hdr_offer {
|
|
// The HDR (10-bit PQ dmabuf) offer was never accepted — the monitor left HDR
|
|
// mode between the probe and the negotiation, the compositor pre-dates the
|
|
// GNOME 50 HDR formats, or its allocator can't do LINEAR for XR30/XB30.
|
|
// Latch the process-wide SDR downgrade so the next session (Moonlight
|
|
// auto-reconnects) negotiates SDR instead of re-running this same timeout.
|
|
super::note_hdr_capture_failed();
|
|
Err(anyhow!(
|
|
"no PipeWire frame within {within}s (node {}): the compositor never \
|
|
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer — is the mirrored \
|
|
monitor in HDR mode on GNOME 50+? Downgrading this host to SDR capture; \
|
|
reconnect to stream SDR",
|
|
self.node_id
|
|
))
|
|
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() {
|
|
// The dmabuf-only raw-passthrough offer was never accepted. Latch the
|
|
// downgrade so the encode loop's pipeline rebuild retries on the CPU offer
|
|
// instead of failing this same negotiation forever. The latch is SCOPED to the
|
|
// raw-passthrough decision: it used to be `note_vaapi_dmabuf_failed`, which fed
|
|
// `pf_zerocopy::enabled()` and therefore dropped every later session on this
|
|
// host — NVENC's EGL→CUDA path included — to CPU capture. Since this offer is
|
|
// also the PyroWave one (any vendor), a single PyroWave negotiation timeout was
|
|
// enough to do that.
|
|
pf_zerocopy::note_raw_dmabuf_negotiation_failed();
|
|
Err(anyhow!(
|
|
"no PipeWire frame within {within}s (node {}): the compositor never \
|
|
accepted the dmabuf-only offer (raw-dmabuf passthrough) — downgrading \
|
|
THIS path to CPU capture for the rest of the process; the pipeline \
|
|
rebuild will renegotiate without dmabuf",
|
|
self.node_id
|
|
))
|
|
} else {
|
|
Err(anyhow!(
|
|
"no PipeWire frame within {within}s (node {}): format negotiation never \
|
|
completed — the compositor offered no format this consumer accepts \
|
|
(pixel-format/modifier mismatch) or the node never emitted a Format param",
|
|
self.node_id
|
|
))
|
|
}
|
|
}
|
|
RecvTimeoutError::Disconnected => Err(anyhow!(
|
|
"PipeWire capture thread ended before a frame (node {})",
|
|
self.node_id
|
|
)),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for PortalCapturer {
|
|
fn drop(&mut self) {
|
|
// Stop the PipeWire loop and wait for the thread to unwind BEFORE the keepalive (virtual
|
|
// output) drops: quit → join releases the EGL importer / CUDA context, then field-drop
|
|
// order releases the output. Without this, `mainloop.run()` blocks until process exit, so
|
|
// every dropped/failed capturer (e.g. a retried first-frame attempt) leaks a thread + GPU
|
|
// context. `send` errors only if the thread already exited — then `join` returns at once.
|
|
if let Some(quit) = self.quit.take() {
|
|
let _ = quit.send(());
|
|
}
|
|
if let Some(join) = self.join.take() {
|
|
let _ = join.join();
|
|
}
|
|
}
|
|
}
|
|
|
|
// The portal CONTROL PLANE (the ScreenCast/RemoteDesktop handshake + GNOME's colour-mode probe),
|
|
// split out in sweep Phase 5.3 — async/tokio/zbus, none of it per-frame. `gnome_hdr_monitor_active`
|
|
// is the host's HDR gate, re-exported from `lib.rs`.
|
|
mod portal;
|
|
pub use portal::gnome_hdr_monitor_active;
|
|
use portal::{portal_thread, portal_thread_remote_desktop};
|
|
|
|
// The PipeWire consumer (the PW types are `!Send`, so it owns its own thread). Split out of this
|
|
// file in the pf-capture sweep Phase 5.1: it was 1,900 of these 2,778 lines. `mod.rs` is a
|
|
// directory module, so the plain `mod pipewire;` resolves to `linux/pipewire.rs` with no
|
|
// `#[path]` — and `super` inside it still means `linux`, so every `use super::…` in the moved
|
|
// code keeps its meaning (the trap recorded in 28f8fc71).
|
|
mod pipewire;
|
|
// Two leaf halves of that consumer, carved out in sweep Phase 5.2: the negotiation POD builders
|
|
// (the crate's wire surface — what a compositor intersects against) and the cursor-as-metadata
|
|
// parser + CPU composite blits (the producer-driven, bounds-critical half). Both are pure enough to
|
|
// unit-test without a compositor, which is the point.
|
|
mod pw_cursor;
|
|
mod pw_pods;
|