feat(linux/capture): serve the LIVE cursor overlay through Capturer::cursor

The forwarder read only frame.cursor — the overlay attached to the LAST
EMITTED frame. Mutter's pointer-only buffers update the capture-side cursor
state but are skipped as frames, so between damage frames the forwarder
re-sent a stale snapshot: on-glass the client cursor appeared exactly when
clicking or crossing hover targets (damage) and froze/vanished otherwise —
the same pointer-only-motion gap the Windows IddCx channel fills, which is
why the encode tick already prefers the capturer's LIVE cursor. Publish the
overlay from every dequeued buffer (frames or not) into a shared slot and
implement the Capturer::cursor hook on PortalCapturer.

Host-composite mode on Linux still updates the blended pointer only on
damage frames (no re-encode of a static desktop on cursor motion) — the
regen-on-cursor-change the Windows path has is a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 23:47:53 +02:00
co-authored by Claude Fable 5
parent 63d83217ef
commit 088004cac4
+32
View File
@@ -57,6 +57,12 @@ pub struct PortalCapturer {
/// renegotiation before declaring the source lost. Cleared whenever a frame arrives or the stream /// renegotiation before declaring the source lost. Cleared whenever a frame arrives or the stream
/// is `Streaming`. /// is `Streaming`.
stall_since: Option<std::time::Instant>, stall_since: Option<std::time::Instant>,
/// The LIVE cursor overlay, published by the PipeWire thread from every buffer's
/// `SPA_META_Cursor` — including the cursor-only "corrupted" buffers that never become
/// frames. [`Capturer::cursor`] serves it 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 same gap the Windows IddCx channel fills, and why the tick prefers LIVE).
cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
/// True when this capture runs the VAAPI dmabuf passthrough (a LINEAR-dmabuf-only offer). If /// True when this capture runs the VAAPI dmabuf passthrough (a LINEAR-dmabuf-only offer). If
/// that offer never negotiates, [`next_frame`](Capturer::next_frame)'s timeout branch latches /// that offer never negotiates, [`next_frame`](Capturer::next_frame)'s timeout branch latches
/// the process-wide downgrade ([`pf_zerocopy::note_vaapi_dmabuf_failed`]) so the pipeline /// the process-wide downgrade ([`pf_zerocopy::note_vaapi_dmabuf_failed`]) so the pipeline
@@ -176,6 +182,8 @@ struct PwHandles {
hdr_offer: bool, hdr_offer: bool,
/// See [`PortalCapturer::hdr_negotiated`]. /// See [`PortalCapturer::hdr_negotiated`].
hdr_negotiated: Arc<AtomicBool>, hdr_negotiated: Arc<AtomicBool>,
/// See [`PortalCapturer::cursor_live`].
cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
quit: ::pipewire::channel::Sender<()>, quit: ::pipewire::channel::Sender<()>,
join: thread::JoinHandle<()>, join: thread::JoinHandle<()>,
} }
@@ -195,6 +203,7 @@ impl PwHandles {
vaapi_dmabuf: self.vaapi_dmabuf, vaapi_dmabuf: self.vaapi_dmabuf,
hdr_offer: self.hdr_offer, hdr_offer: self.hdr_offer,
hdr_negotiated: self.hdr_negotiated, hdr_negotiated: self.hdr_negotiated,
cursor_live: self.cursor_live,
node_id, node_id,
quit: Some(self.quit), quit: Some(self.quit),
join: Some(self.join), join: Some(self.join),
@@ -237,6 +246,8 @@ fn spawn_pipewire(
let broken_cb = broken.clone(); let broken_cb = broken.clone();
let hdr_negotiated = Arc::new(AtomicBool::new(false)); let hdr_negotiated = Arc::new(AtomicBool::new(false));
let hdr_negotiated_cb = hdr_negotiated.clone(); let hdr_negotiated_cb = hdr_negotiated.clone();
let cursor_live = Arc::new(std::sync::Mutex::new(None::<pf_frame::CursorOverlay>));
let cursor_live_cb = cursor_live.clone();
// pipewire's own cross-thread channel: the receiver attaches to the loop and quits it; the // 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 // sender lives on the capturer and fires in its `Drop`. Absolute `::pipewire` path — the
// inner `mod pipewire` shadows the crate name at this scope. // inner `mod pipewire` shadows the crate name at this scope.
@@ -272,6 +283,7 @@ fn spawn_pipewire(
streaming_cb, streaming_cb,
broken_cb, broken_cb,
hdr_negotiated_cb, hdr_negotiated_cb,
cursor_live_cb,
zerocopy, zerocopy,
want_444, want_444,
want_hdr, want_hdr,
@@ -292,6 +304,7 @@ fn spawn_pipewire(
vaapi_dmabuf, vaapi_dmabuf,
hdr_offer: want_hdr, hdr_offer: want_hdr,
hdr_negotiated, hdr_negotiated,
cursor_live,
quit: quit_tx, quit: quit_tx,
join, join,
}) })
@@ -302,6 +315,12 @@ impl Capturer for PortalCapturer {
self.frame_within(Duration::from_secs(10)) 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`.
self.cursor_live.lock().ok().and_then(|slot| slot.clone())
}
fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> { fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
self.frame_within(budget) self.frame_within(budget)
} }
@@ -945,6 +964,9 @@ mod pipewire {
dbg_log_n: u64, dbg_log_n: u64,
/// Cursor-as-metadata state, composited into the CPU de-pad path (see `consume_frame`). /// Cursor-as-metadata state, composited into the CPU de-pad path (see `consume_frame`).
cursor: CursorState, cursor: CursorState,
/// LIVE overlay slot shared with [`super::PortalCapturer::cursor_live`] — refreshed after
/// every `update_cursor_meta`, including from cursor-only buffers that never become frames.
cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
} }
/// Consecutive tiled-import failures (worker alive, e.g. a per-buffer `EGL_BAD_MATCH`) before /// Consecutive tiled-import failures (worker alive, e.g. a per-buffer `EGL_BAD_MATCH`) before
@@ -2065,6 +2087,9 @@ mod pipewire {
streaming: Arc<AtomicBool>, streaming: Arc<AtomicBool>,
broken: Arc<AtomicBool>, broken: Arc<AtomicBool>,
hdr_negotiated: Arc<AtomicBool>, hdr_negotiated: Arc<AtomicBool>,
// LIVE cursor publisher (see `PortalCapturer::cursor_live`): refreshed from every
// dequeued buffer's cursor meta, frames or not.
cursor_live: Arc<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
zerocopy: bool, zerocopy: bool,
// 4:4:4 session: tiled dmabufs take the worker's planar-YUV444 GPU convert. // 4:4:4 session: tiled dmabufs take the worker's planar-YUV444 GPU convert.
want_444: bool, want_444: bool,
@@ -2260,6 +2285,7 @@ mod pipewire {
linear_nv12_failed: false, linear_nv12_failed: false,
dbg_log_n: 0, dbg_log_n: 0,
cursor: CursorState::default(), cursor: CursorState::default(),
cursor_live,
}; };
let stream = pw::stream::StreamBox::new( let stream = pw::stream::StreamBox::new(
@@ -2384,6 +2410,12 @@ mod pipewire {
// pointer-only movements as metadata-only "corrupted" buffers we drop for their // 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. // frame, but their cursor meta is fresh and must still move our overlay.
update_cursor_meta(&mut ud.cursor, spa_buf); update_cursor_meta(&mut ud.cursor, spa_buf);
// Publish the LIVE overlay (frames or not): the encode loop's forwarder polls
// it per tick, so pointer-only motion tracks on a static desktop — the
// frame-attached overlay alone stales between damage frames.
if let Ok(mut slot) = ud.cursor_live.lock() {
*slot = ud.cursor.overlay();
}
// Inspect the newest buffer's header + first chunk for the diagnostic and the // Inspect the newest buffer's header + first chunk for the diagnostic and the
// CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null. // CORRUPTED skip. SPA_META_Header is optional — `hdr` may be null.