From 79ee308a7ae6a59662583515275aa2886e09b1bd Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 25 Jul 2026 18:54:01 +0200 Subject: [PATCH 1/2] build(rpm): declare all seven FFmpeg pkg-config modules, not three `ffmpeg-next` is pulled with default features, so `ffmpeg-sys-next`'s build script pkg-config-probes codec/device/filter/format/util/resampling/scaling and panics on the first one missing. The spec named three. RPM Fusion's `ffmpeg-devel` ships all seven in one package, which hid it. On a host where those three instead resolve to Fedora's split `libav*-free-devel` packages, `dnf builddep` installs exactly three and the build dies in a build script: The system library `libavfilter` required by crate `ffmpeg-sys-next` was not found. --- packaging/rpm/punktfunk.spec | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packaging/rpm/punktfunk.spec b/packaging/rpm/punktfunk.spec index 0366572c..11a8a9f9 100644 --- a/packaging/rpm/punktfunk.spec +++ b/packaging/rpm/punktfunk.spec @@ -83,9 +83,19 @@ BuildRequires: pkgconfig(opus) # FFmpeg dev headers with NVENC — from RPM Fusion (ffmpeg-devel), NOT ffmpeg-free. # Version-agnostic: ffmpeg-sys-next auto-detects the installed FFmpeg, so this builds # against FFmpeg 7.x (libavcodec 61, e.g. Fedora 43 / Bazzite) or 8.x (libavcodec 62). +# ALL SEVEN modules, not just the three we call directly: `ffmpeg-next` is pulled with default +# features, so its `-sys` build script pkg-config-probes codec/device/filter/format/util/ +# resampling/scaling and panics on the first one missing. RPM Fusion's ffmpeg-devel ships the lot +# in one package, which hid the gap — on a box where these resolve to Fedora's split +# libav*-free-devel packages instead, dnf installed only the three named here and the build died +# in ffmpeg-sys-next's build.rs on `libavfilter`. BuildRequires: pkgconfig(libavcodec) +BuildRequires: pkgconfig(libavdevice) +BuildRequires: pkgconfig(libavfilter) BuildRequires: pkgconfig(libavformat) BuildRequires: pkgconfig(libavutil) +BuildRequires: pkgconfig(libswresample) +BuildRequires: pkgconfig(libswscale) # Zero-copy GPU path: src/zerocopy/ links libGL + libgbm (mesa) via hand-rolled FFI. BuildRequires: pkgconfig(gl) BuildRequires: pkgconfig(gbm) From 2a67c02f7e3da05776dd4e7a4388f0745d71d39e Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sat, 25 Jul 2026 18:53:45 +0200 Subject: [PATCH 2/2] fix(clients/cursor): the host must not composite a pointer under a released client's own cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Streaming a KDE desktop showed two cursors: the one the user was moving, and a second one sitting underneath it that never moved. It was not KDE's — KWin 6.7.3 in cursor-as-metadata mode calls `setRenderCursor(false)` on every recorded buffer and hands the cursor item to an exclusive `ItemTreeView`, so `shouldRenderItem()` skips it and no pointer is ever painted into that stream. It was ours. Both clients declared the render model as `captured && desktop`, so ANY released pointer handed compositing back to the host. But releasing does not remove the local cursor — it restores the ordinary window arrow over the video. The host then blends its own pointer in underneath, and since a released client forwards no motion, nothing drives it: it stays frozen wherever the host pointer was last left. Caught live on the host with the render-model diag: cursor diag: client_draws=false blended=true live=Some((-1, 622, true)) x = -1 — parked on the streamed output's left edge, unchanged sample after sample, while the user moved their own cursor around freely. Engaging capture flipped it to `client_draws=true blended=false` and the duplicate vanished, which is why it only looked "stuck when not dragging": dragging means engaged, and engaged was the one state that behaved. The host may composite ONLY while the client holds a grabbed, hidden pointer — the capture model, engaged — which is the single state with no local cursor on screen. Released now counts as "the client draws it": the host stops compositing and keeps forwarding shape/state over the channel (the forwarder ticks on this side of the flip), so re-engaging is seamless and the client's cached shape stays warm. --- .../PunktfunkKit/Views/StreamView.swift | 21 +++++++++++++---- crates/pf-presenter/src/run.rs | 23 +++++++++++++------ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index 60061f66..a0372cc9 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -538,14 +538,25 @@ public final class StreamLayerView: NSView { } } - /// Tell the host who renders the pointer (the §8 mid-stream render flip): we draw it only - /// while the DESKTOP model is engaged (the local OS cursor wears the host shape); under - /// the capture model — and while released — the host composites it into the video (full - /// fidelity, the pre-channel look). One edge-detected reconciler, called from every + /// Tell the host who renders the pointer (the §8 mid-stream render flip). The host may + /// composite one into the video ONLY while we are holding a grabbed, hidden pointer — the + /// capture model, engaged. That is the one state with no local cursor on screen. + /// + /// Every other state leaves a normal OS cursor visible over the video: the desktop model + /// draws it wearing the host's shape, and a RELEASED view shows the plain arrow. A + /// host-composited pointer then appears *underneath* it as a second cursor — and, because a + /// released view forwards no motion, one that never moves. On glass that reads as a frozen + /// duplicate stuck wherever the host pointer was last left (verified: `client_draws=false + /// blended=true live=(-1, 622)` — parked on the streamed output's left edge while the user + /// moved their own cursor around freely). + /// + /// So "released" counts as WE draw it: the host stops compositing, the client keeps + /// receiving shape/state over the channel (the forwarder only ticks on this side of the + /// flip), and re-engaging is seamless. One edge-detected reconciler, called from every /// transition (chord, engage/release, session start). private func reconcileCursorRender() { guard cursorChannelActive, let connection else { return } - let clientDraws = captured && desktopMouse + let clientDraws = !captured || desktopMouse guard sentClientDraws != clientDraws else { return } sentClientDraws = clientDraws connection.setCursorRender(clientDraws: clientDraws) diff --git a/crates/pf-presenter/src/run.rs b/crates/pf-presenter/src/run.rs index b91127ab..9eb6e7a2 100644 --- a/crates/pf-presenter/src/run.rs +++ b/crates/pf-presenter/src/run.rs @@ -1049,15 +1049,24 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result .as_ref() .is_some_and(|cap| cap.captured() && cap.desktop()); chan.pump(c, &mouse, desktop_active, fit_scale); - // §8 mid-stream render flip: tell the host who renders the pointer whenever - // the local model changes. Desktop-active = we draw it (host excludes + - // forwards); anything else — the capture model OR a released pointer — the - // host composites it into the video (full fidelity, the pre-channel look). + // §8 mid-stream render flip: tell the host who renders the pointer whenever the + // local model changes. The host may composite one ONLY while we hold a grabbed, + // hidden pointer — the capture model, engaged — because that is the one state + // with no local cursor on screen. Note this is deliberately NOT `desktop_active`: + // a RELEASED pointer leaves the ordinary window cursor visible over the video, + // and a host-composited pointer then sits UNDER it as a second cursor that never + // moves (released forwards no motion), which reads on glass as a frozen + // duplicate. Released therefore counts as "we draw it" — the host stops + // compositing and keeps forwarding shape/state, so re-engaging is seamless. // One edge-detected reconciler covers the chord, the M3 auto-flip, and // engage/release alike. - if chan.negotiated() && st.sent_client_draws != Some(desktop_active) { - st.sent_client_draws = Some(desktop_active); - let _ = c.set_cursor_render(desktop_active); + let client_draws = match st.capture.as_ref() { + Some(cap) => !cap.captured() || cap.desktop(), + None => true, + }; + if chan.negotiated() && st.sent_client_draws != Some(client_draws) { + st.sent_client_draws = Some(client_draws); + let _ = c.set_cursor_render(client_draws); } } // M3 — host-driven mode flip: `relative_hint` set = a host app grabbed/hid the