From af8754905283c392b08d5175b504b9734fe64a09 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 22 Jul 2026 18:43:20 +0200 Subject: [PATCH] fix(linux/vdisplay): KWin virtual outputs stream above 60 Hz via sacrificial-birth renegotiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KWin's ScreenCastStream builds its PipeWire format offer — including the maxFramerate cap it actively throttles delivery to — ONCE at stream creation, when the virtual output sits at its hardcoded birth 60 Hz. A kscreen custom-mode change afterwards updates the OUTPUT (readback says 240) but never the offer, so every consumer negotiates max=60 and the stream delivers 60 (pw-dump-verified on KWin 6.6.4). The only path that rebuilds the offer is the stream's own resize handling: a source SIZE change while recording re-runs buildFormats — picking up the output's current refresh — and renegotiates the live stream. So above 60 Hz, birth the output at a sacrificial height (+16), then install + select the real WxH@hz custom mode: the first frame recorded after the consumer connects triggers KWin's resize path, which renegotiates the live stream to WxH@hz. The capturer holds (requeues) buffers until the negotiated size matches — new expect_exact_dims plumbing VirtualOutput → registry (fresh creates only) → open_virtual_output → a self-disarming gate in the process callback — bounded by a 3 s deadline that accepts the producer's dims rather than wedging the session into the first-frame retry loop. set_custom_refresh reads back the full active mode; a size reject (pre-6.6 KWin) recreates plain at the real size @60. Every kscreen operation now addresses the output by its NUMERIC id, resolved by matching the managed name-prefix AND the just-created birth size (newest id wins): a mode-switch supersede reuses the per-slot output NAME (deliberately, for KWin's per-name config persistence) while the superseded sibling is still alive, and name addressing hit the FIRST match = the OLD output — on-glass that resized the live session's display out from under it (wrong-res/black), read the old output back as 'mode applied', and set the old output primary while the replacement starved at its birth mode. Co-Authored-By: Claude Fable 5 --- crates/pf-capture/src/lib.rs | 2 + crates/pf-capture/src/linux/mod.rs | 101 +++++- crates/pf-vdisplay/src/vdisplay/backend.rs | 8 + .../src/vdisplay/linux/gamescope.rs | 2 + .../src/vdisplay/linux/hyprland.rs | 1 + crates/pf-vdisplay/src/vdisplay/linux/kwin.rs | 303 +++++++++++++----- .../pf-vdisplay/src/vdisplay/linux/wlroots.rs | 1 + crates/pf-vdisplay/src/vdisplay/registry.rs | 9 +- crates/punktfunk-host/src/capture.rs | 1 + 9 files changed, 339 insertions(+), 89 deletions(-) diff --git a/crates/pf-capture/src/lib.rs b/crates/pf-capture/src/lib.rs index ce0b1edc..522f0cd6 100644 --- a/crates/pf-capture/src/lib.rs +++ b/crates/pf-capture/src/lib.rs @@ -453,6 +453,7 @@ pub fn open_virtual_output( allow_zerocopy: bool, want_444: bool, policy: ZeroCopyPolicy, + expect_exact_dims: bool, ) -> Result> { linux::PortalCapturer::from_virtual_output( remote_fd, @@ -462,6 +463,7 @@ pub fn open_virtual_output( allow_zerocopy, want_444, policy, + expect_exact_dims, ) .map(|c| Box::new(c) as Box) } diff --git a/crates/pf-capture/src/linux/mod.rs b/crates/pf-capture/src/linux/mod.rs index 3692fe1f..35e93649 100644 --- a/crates/pf-capture/src/linux/mod.rs +++ b/crates/pf-capture/src/linux/mod.rs @@ -120,10 +120,17 @@ impl PortalCapturer { "ScreenCast portal session started; connecting PipeWire" ); // This portal path (GameStream / monitor capture) is always 4:2:0, so allow zero-copy as before. - Ok( - spawn_pipewire(Some(fd), node_id, None, true, false, want_hdr, policy)? - .into_capturer(node_id, None), - ) + Ok(spawn_pipewire( + Some(fd), + node_id, + None, + true, + false, + want_hdr, + policy, + false, + )? + .into_capturer(node_id, None)) } /// Build a capturer from an already-created virtual output's PipeWire node. The host facade @@ -143,11 +150,13 @@ impl PortalCapturer { allow_zerocopy: bool, want_444: bool, policy: ZeroCopyPolicy, + expect_exact_dims: bool, ) -> Result { 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 @@ -160,6 +169,7 @@ impl PortalCapturer { want_444, false, policy, + expect_exact_dims, )? .into_capturer(node_id, Some(keepalive))) } @@ -233,6 +243,12 @@ fn spawn_pipewire( // Encode-backend facts resolved by the facade (never re-derived here) — the one-way // capture→encode edge (plan §W6). policy: ZeroCopyPolicy, + // 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, + // gamescope fixates its own — gating those would starve legitimate first frames). + expect_exact_dims: bool, ) -> Result { // Frames flow from the pipewire thread over a small bounded channel. let (frame_tx, frame_rx) = sync_channel::(8); @@ -290,6 +306,7 @@ fn spawn_pipewire( preferred, quit_rx, policy, + expect_exact_dims, ) { tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed"); } @@ -967,6 +984,18 @@ mod pipewire { /// 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>>, + /// `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, } /// Consecutive tiled-import failures (worker alive, e.g. a per-buffer `EGL_BAD_MATCH`) before @@ -2101,6 +2130,9 @@ mod pipewire { // Encode-backend facts resolved by the facade (never re-derived here) — the one-way // capture→encode edge (plan §W6). policy: ZeroCopyPolicy, + // See `spawn_pipewire`: the first negotiation is for a sacrificial mode; hold frames + // until the producer renegotiates to `preferred`'s dims. + expect_exact_dims: bool, ) -> Result<()> { crate::pwinit::ensure_init(); @@ -2286,6 +2318,13 @@ mod pipewire { dbg_log_n: 0, cursor: CursorState::default(), cursor_live, + 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( @@ -2397,6 +2436,60 @@ mod pipewire { 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. diff --git a/crates/pf-vdisplay/src/vdisplay/backend.rs b/crates/pf-vdisplay/src/vdisplay/backend.rs index 0d804ddd..d24bd58e 100644 --- a/crates/pf-vdisplay/src/vdisplay/backend.rs +++ b/crates/pf-vdisplay/src/vdisplay/backend.rs @@ -70,6 +70,12 @@ pub struct VirtualOutput { /// Linux-only (the keep-alive pool is Linux). #[cfg(target_os = "linux")] pub pool_gen: Option, + /// The backend created the output at a SACRIFICIAL mode and the producer will renegotiate the + /// live stream to `preferred_mode`'s dims (KWin's screencast only rebuilds its format offer — + /// and thus its refresh cap — on a size change while recording; see kwin.rs `create`). The + /// capturer must hold frames until that renegotiation lands. Linux-only. + #[cfg(target_os = "linux")] + pub expect_exact_dims: bool, } impl VirtualOutput { @@ -93,6 +99,8 @@ impl VirtualOutput { reused_gen: None, #[cfg(target_os = "linux")] pool_gen: None, + #[cfg(target_os = "linux")] + expect_exact_dims: false, } } } diff --git a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs index f792d13f..3f89d4c6 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/gamescope.rs @@ -253,6 +253,7 @@ impl VirtualDisplay for GamescopeDisplay { ownership: DisplayOwnership::External, reused_gen: None, pool_gen: None, + expect_exact_dims: false, }); } check_gamescope_version(); // diagnostic only — warns on known-deadlock-prone versions @@ -409,6 +410,7 @@ fn managed_output(node_id: u32, mode: Mode) -> VirtualOutput { ownership: DisplayOwnership::SessionManaged, reused_gen: None, pool_gen: None, + expect_exact_dims: false, } } diff --git a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs index f6d524e8..4ade5a37 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs @@ -199,6 +199,7 @@ impl VirtualDisplay for HyprlandDisplay { ownership: DisplayOwnership::Owned, reused_gen: None, pool_gen: None, + expect_exact_dims: false, }) } } diff --git a/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs b/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs index 3b511622..e944442f 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/kwin.rs @@ -82,8 +82,10 @@ pub struct KwinDisplay { /// `None` for shared/anonymous) — reported to the registry via [`last_identity_slot`] so it can key /// the group arrangement + `/display/state` slot to the same id this backend named the output with. last_slot: Option, - /// The base output name the last `create` used (`punktfunk` / `punktfunk-`) — so - /// [`apply_position`](VirtualDisplay::apply_position) can address the KWin output `Virtual-`. + /// The RESOLVED kscreen address of the last `create`'s output — the numeric kscreen output id + /// when [`resolve_kscreen_addr`] found it, else the `Virtual-` fallback — so + /// [`apply_position`](VirtualDisplay::apply_position) addresses OUR output even while a + /// superseded same-name sibling is still alive. last_name: Option, /// The topology-restore action the last `create` prepared (re-enable the outputs an `exclusive` /// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the @@ -127,11 +129,13 @@ impl VirtualDisplay for KwinDisplay { } fn apply_position(&mut self, x: i32, y: i32) { - let Some(name) = self.last_name.clone() else { + // `last_name` holds the RESOLVED kscreen address (numeric output id, or the + // `Virtual-` fallback) — never re-derive from the name: during a supersede two + // outputs share it and the command would hit the old one (see `create`). + let Some(output) = self.last_name.clone() else { return; }; - let output = format!("Virtual-{name}"); - // kscreen-doctor position syntax: `output..position.,`. + // kscreen-doctor position syntax: `output..position.,`. let ok = std::process::Command::new("kscreen-doctor") .arg(format!("output.{output}.position.{x},{y}")) .status() @@ -161,32 +165,86 @@ impl VirtualDisplay for KwinDisplay { None => VOUT_NAME.to_string(), }; self.last_name = Some(name.clone()); // for apply_position (registry-driven §6.2 layout) - let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); - let stop = Arc::new(AtomicBool::new(false)); - let stop_thread = stop.clone(); let (width, height) = (mode.width, mode.height); - let name_thread = name.clone(); - thread::Builder::new() - .name("punktfunk-kwin-vout".into()) - .spawn(move || virtual_output_thread(width, height, name_thread, setup_tx, stop_thread)) - .context("spawn KWin virtual-output thread")?; - - let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) { - Ok(Ok(v)) => v, - Ok(Err(e)) => bail!("KWin virtual output failed: {e}"), - Err(_) => bail!("timed out creating the KWin virtual output"), + let spawn_vout = |w: u32, h: u32| -> Result<(u32, Arc)> { + let (setup_tx, setup_rx) = std::sync::mpsc::channel::>(); + let stop = Arc::new(AtomicBool::new(false)); + let stop_thread = stop.clone(); + let name_thread = name.clone(); + thread::Builder::new() + .name("punktfunk-kwin-vout".into()) + .spawn(move || virtual_output_thread(w, h, name_thread, setup_tx, stop_thread)) + .context("spawn KWin virtual-output thread")?; + match setup_rx.recv_timeout(Duration::from_secs(20)) { + Ok(Ok(v)) => Ok((v, stop)), + Ok(Err(e)) => bail!("KWin virtual output failed: {e}"), + Err(_) => bail!("timed out creating the KWin virtual output"), + } }; - tracing::info!(node_id, width, height, "KWin virtual output ready"); - // KWin creates virtual outputs at a hardcoded 60 Hz and `stream_virtual_output` has no - // refresh argument, so above 60 Hz we install + select a custom mode (supported on virtual - // outputs since KWin 6.6) before capture connects PipeWire, so the stream negotiates at the - // higher rate. First cut shells out to kscreen-doctor; the in-process - // kde_output_management_v2 client is a follow-up. `set_custom_refresh` reads back and - // returns what KWin *actually* achieved so the encoder paces to the real source rate (a - // rejected custom mode leaves the output at 60 Hz). At ≤60 Hz there's nothing to install — - // the source runs 60 Hz and the encoder downsamples — so carry the requested rate through. - let achieved_hz = if mode.refresh_hz > 60 { - set_custom_refresh(width, height, mode.refresh_hz, &name) + // KWin creates virtual outputs at a hardcoded 60 Hz, `stream_virtual_output` has no + // refresh argument — and its screencast stream builds its PipeWire format offer, INCLUDING + // the `maxFramerate` cap it actively throttles delivery to, ONCE at stream creation + // (screencaststream.cpp `buildFormats`, verified against the live offer with `pw-dump`: + // the offer stays `max=60` after a kscreen mode change, and consumers connecting later + // still negotiate against it). The ONLY path that rebuilds the offer is the stream's own + // resize handling: when the source's texture size changes while recording, KWin re-runs + // `buildFormats` — picking up the output's CURRENT refresh — and renegotiates the live + // stream via `pw_stream_update_params`. So above 60 Hz the output is born at a + // SACRIFICIAL height: installing + selecting the real `WxH@hz` custom mode (supported on + // virtual outputs since KWin 6.6) then changes the SIZE, and the first buffers recorded + // after the consumer connects trigger KWin's resize → a renegotiation to `WxH@hz`. The + // capturer holds frames until that lands (`expect_exact_dims`), so the pipeline never + // builds against the birth mode. First cut shells out to kscreen-doctor; the in-process + // kde_output_management_v2 client is a follow-up. `set_custom_refresh` reads back what + // KWin *actually* gave so the encoder paces to the real source rate. At ≤60 Hz there's + // nothing to install — the output is born at the real size and 60 Hz is the offer anyway. + let want_high = mode.refresh_hz > 60; + let birth_h = if want_high { height + 16 } else { height }; + let (mut node_id, mut stop) = spawn_vout(width, birth_h)?; + tracing::info!(node_id, width, height, birth_h, "KWin virtual output ready"); + // ⚠️ ADDRESS BY NUMERIC KSCREEN ID, NEVER BY NAME: a supersede (mode switch) creates the + // replacement output — SAME per-slot name, deliberately, for KWin's per-name config + // persistence — while the superseded sibling is still alive (create-before-drop). Every + // name-addressed kscreen command then hits the FIRST match = the OLD output: on-glass this + // resized the LIVE session's display out from under it (wrong-res/black), read back the + // OLD output as "custom mode applied", made the OLD output primary, and positioned it — + // while the new output never left its birth mode and the capturer's dims gate starved. + // Resolve OUR output's kscreen id once (match: managed-prefix name AND current mode == + // the just-created birth size; newest id wins), and use it for every kscreen operation. + let mut addr = resolve_kscreen_addr(&name, width, birth_h); + self.last_name = Some(addr.clone()); // for apply_position (registry-driven §6.2 layout) + let mut expect_exact_dims = false; + let achieved_hz = if want_high { + let (achieved, size_applied) = + set_custom_refresh(width, height, mode.refresh_hz, &addr); + if size_applied { + // Real mode selected: the recording stream will renegotiate to it (see above). + expect_exact_dims = true; + achieved + } else { + // Custom-mode install/select rejected (pre-6.6 KWin / stale kscreen-doctor): the + // output is STUCK at the sacrificial birth size — unusable. Recreate plain at the + // real size (the pre-sacrifice behavior: correct size, KWin's native 60 Hz). + tracing::warn!( + "KWin rejected the custom mode — recreating the virtual output at the real \ + size (60 Hz ceiling on this KWin)" + ); + stop.store(true, Ordering::Relaxed); + // Let KWin retire the doomed output before re-using its name. + std::thread::sleep(Duration::from_millis(300)); + let (nid, st) = spawn_vout(width, height)?; + node_id = nid; + stop = st; + addr = resolve_kscreen_addr(&name, width, height); + self.last_name = Some(addr.clone()); + tracing::info!( + node_id, + width, + height, + "KWin virtual output ready (fallback)" + ); + 60 + } } else { mode.refresh_hz }; @@ -197,9 +255,9 @@ impl VirtualDisplay for KwinDisplay { // bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean). use crate::policy::Topology; let disabled = match crate::effective_topology() { - Topology::Exclusive => apply_virtual_primary(&name), + Topology::Exclusive => apply_virtual_primary(&addr), Topology::Primary => { - apply_virtual_primary_only(&name); + apply_virtual_primary_only(&addr); Vec::new() // nothing disabled → nothing to restore } Topology::Extend | Topology::Auto => Vec::new(), @@ -215,11 +273,13 @@ impl VirtualDisplay for KwinDisplay { }); // Layout position (§6.2) is applied by the registry via `apply_position` right after create // (it owns the display group, so it computes auto-row / manual placement over the whole group). - Ok(VirtualOutput::owned( + let mut out = VirtualOutput::owned( node_id, Some((mode.width, mode.height, achieved_hz)), Box::new(StopGuard { stop }), - )) + ); + out.expect_exact_dims = expect_exact_dims; + Ok(out) } } @@ -257,14 +317,88 @@ fn reenable_outputs(outputs: &[(String, String)]) { tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs at their captured modes (group empty)"); } -/// Best-effort: raise the just-created virtual output's refresh above KWin's default 60 Hz by -/// installing + selecting a custom mode via `kscreen-doctor` (the output is `Virtual-`, -/// refresh given in mHz), then **read back the active mode** and return the refresh KWin actually -/// gave us. The apply command can report success yet leave the output at 60 Hz (mode rejected), -/// and a silent rate mismatch surfaces downstream as judder / duplicated frames — so the caller -/// paces the encoder to the *achieved* rate, not the requested one. -fn set_custom_refresh(width: u32, height: u32, hz: u32, name: &str) -> u32 { - let output = format!("Virtual-{name}"); +/// Resolve the kscreen address of the virtual output the host JUST created: the managed-prefix +/// name alone is ambiguous during a supersede (the replacement deliberately reuses the per-slot +/// name while the superseded sibling is still alive), so match on the birth mode's size too — +/// only the just-created output sits at the sacrificial `(w, h)` — and prefer the HIGHEST output +/// id (the newest) if several match. Returns the numeric id as a string (kscreen-doctor accepts +/// `output..…`), falling back to the ambiguous `Virtual-` if the output hasn't reached +/// kscreen's model yet after a few tries (single-output sessions are unambiguous anyway). +fn resolve_kscreen_addr(name: &str, w: u32, h: u32) -> String { + let fallback = format!("Virtual-{name}"); + for attempt in 0..3 { + if attempt > 0 { + std::thread::sleep(Duration::from_millis(150)); + } + let Some(doc) = kscreen_json() else { continue }; + let Some(outputs) = doc.get("outputs").and_then(|o| o.as_array()) else { + continue; + }; + let best = outputs + .iter() + .filter(|o| { + o.get("name") + .and_then(|n| n.as_str()) + .is_some_and(|n| n.starts_with(&fallback)) + && output_active_size(o) == Some((w, h)) + }) + .filter_map(|o| o.get("id").and_then(|i| i.as_u64())) + .max(); + if let Some(id) = best { + tracing::info!(id, name, w, h, "KWin: resolved the new output's kscreen id"); + return id.to_string(); + } + } + tracing::warn!( + name, + w, + h, + "KWin: could not resolve the new output's kscreen id — falling back to name addressing \ + (ambiguous during a mode-switch supersede)" + ); + fallback +} + +/// `kscreen-doctor -j` parsed, `None` on any failure. +fn kscreen_json() -> Option { + let out = std::process::Command::new("kscreen-doctor") + .arg("-j") + .output() + .ok()?; + serde_json::from_slice(&out.stdout).ok() +} + +/// The `(width, height)` of an output's CURRENT mode from its `kscreen-doctor -j` entry. +fn output_active_size(o: &serde_json::Value) -> Option<(u32, u32)> { + let as_id = |v: &serde_json::Value| -> Option { + v.as_str() + .map(|s| s.to_string()) + .or_else(|| v.as_u64().map(|n| n.to_string())) + }; + let current = o.get("currentModeId").and_then(as_id)?; + let mode = o + .get("modes")? + .as_array()? + .iter() + .find(|m| m.get("id").and_then(as_id).as_deref() == Some(current.as_str()))?; + let size = mode.get("size")?; + Some(( + size.get("width").and_then(|v| v.as_u64())? as u32, + size.get("height").and_then(|v| v.as_u64())? as u32, + )) +} + +/// Best-effort: install + select the `width`x`height`@`hz` custom mode on the just-created +/// virtual output via `kscreen-doctor` (`output` is the RESOLVED kscreen address — numeric id or +/// name, see [`resolve_kscreen_addr`] — refresh given in mHz), then **read back the active mode** +/// and return `(achieved_hz, size_applied)`. The apply command can report success yet leave the +/// output on its old mode (rejected), and a silent rate mismatch surfaces downstream as judder / +/// duplicated frames — so the caller paces the encoder to the *achieved* rate, not the requested +/// one. `size_applied` tells the sacrificial-birth caller (see `create`) whether the SIZE half of +/// the mode actually landed — that, not the refresh, is what triggers KWin's stream +/// renegotiation. +fn set_custom_refresh(width: u32, height: u32, hz: u32, output: &str) -> (u32, bool) { + let output = output.to_string(); let mhz = hz.saturating_mul(1000); let run = |arg: String| { std::process::Command::new("kscreen-doctor") @@ -278,26 +412,29 @@ fn set_custom_refresh(width: u32, height: u32, hz: u32, name: &str) -> u32 { "output.{output}.addCustomMode.{width}.{height}.{mhz}.full" )); let applied = run(format!("output.{output}.mode.{width}x{height}@{hz}")); - match read_active_refresh(&output) { - Some(achieved) if achieved >= hz => { - tracing::info!( - output, - requested = hz, - achieved, - "KWin virtual output: custom refresh applied" - ); - achieved - } - Some(achieved) => { - tracing::warn!( - output, - requested = hz, - achieved, - applied, - "KWin virtual output refresh below requested — pacing the encoder to the achieved \ - rate (custom-mode install rejected? is kscreen-doctor up to date?)" - ); - achieved.max(1) + match read_active_mode(&output) { + Some((w, h, achieved)) => { + let size_applied = (w, h) == (width, height); + if achieved >= hz && size_applied { + tracing::info!( + output, + requested = hz, + achieved, + "KWin virtual output: custom refresh applied" + ); + } else { + tracing::warn!( + output, + requested = hz, + achieved, + active_w = w, + active_h = h, + applied, + "KWin virtual output mode below requested — pacing the encoder to the \ + achieved rate (custom-mode install rejected? is kscreen-doctor up to date?)" + ); + } + (achieved.max(1), size_applied) } None => { tracing::warn!( @@ -307,38 +444,37 @@ fn set_custom_refresh(width: u32, height: u32, hz: u32, name: &str) -> u32 { "could not read back KWin virtual output refresh — assuming 60 Hz (is \ kscreen-doctor installed?)" ); - 60 + (60, false) } } } -/// Read the active refresh (Hz, rounded) of `output` from `kscreen-doctor -j`. `None` if the -/// tool, the output, or its current mode can't be found. Mode/output ids come through as either -/// JSON strings or numbers depending on the KWin version, so both are accepted. -fn read_active_refresh(output: &str) -> Option { - let out = std::process::Command::new("kscreen-doctor") - .arg("-j") - .output() - .ok()?; - let doc: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?; +/// Read the active mode (`(width, height, refresh_hz)`, Hz rounded) of `output` — a RESOLVED +/// kscreen address (numeric id or name, see [`resolve_kscreen_addr`]) — from `kscreen-doctor -j`. +/// `None` if the tool, the output, or its current mode can't be found. Mode/output ids come +/// through as either JSON strings or numbers depending on the KWin version, so both are accepted. +fn read_active_mode(output: &str) -> Option<(u32, u32, u32)> { + let doc = kscreen_json()?; let as_id = |v: &serde_json::Value| -> Option { v.as_str() .map(|s| s.to_string()) .or_else(|| v.as_u64().map(|n| n.to_string())) }; - let o = doc - .get("outputs")? - .as_array()? - .iter() - .find(|o| o.get("name").and_then(|n| n.as_str()) == Some(output))?; + let o = doc.get("outputs")?.as_array()?.iter().find(|o| { + o.get("name").and_then(|n| n.as_str()) == Some(output) + || o.get("id").and_then(as_id).as_deref() == Some(output) + })?; let current = o.get("currentModeId").and_then(as_id)?; let mode = o .get("modes")? .as_array()? .iter() .find(|m| m.get("id").and_then(as_id).as_deref() == Some(current.as_str()))?; + let size = mode.get("size")?; + let w = size.get("width").and_then(|v| v.as_u64())? as u32; + let h = size.get("height").and_then(|v| v.as_u64())? as u32; let hz = mode.get("refreshRate").and_then(|r| r.as_f64())?; - Some(hz.round() as u32) + Some((w, h, hz.round() as u32)) } /// The prefix EVERY managed KWin output shares — Stage 3 names them `punktfunk` / `punktfunk-`, @@ -377,7 +513,7 @@ fn output_current_mode_spec(o: &serde_json::Value) -> Option { /// bare re-enable drops a 120 Hz panel to KWin's default ~60 Hz). /// **Group-aware (§6.1):** excludes the WHOLE managed family (the [`MANAGED_PREFIX`]), not just this /// session's own output — so a 2nd `exclusive` session (with a distinct per-slot name) never disables -/// the 1st session's live output. Parsed from `kscreen-doctor -j` (same source as [`read_active_refresh`]). +/// the 1st session's live output. Parsed from `kscreen-doctor -j` (same source as [`read_active_mode`]). fn other_enabled_outputs() -> Vec<(String, String)> { let out = match std::process::Command::new("kscreen-doctor") .arg("-j") @@ -440,12 +576,12 @@ fn a_managed_output_is_primary() -> bool { .unwrap_or(false) } -/// Set `Virtual-punktfunk` primary and disable the bootstrap output(s) so the managed group becomes -/// the sole desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for +/// Set our output primary and disable the bootstrap output(s) so the managed group becomes +/// the sole desktop (KWin re-homes plasmashell + windows onto it). `ours` is the RESOLVED kscreen +/// address (numeric id or name, see [`resolve_kscreen_addr`]). Returns the disabled outputs for /// the keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly /// showing only the wallpaper) rather than failing the session. -fn apply_virtual_primary(name: &str) -> Vec<(String, String)> { - let ours = format!("Virtual-{name}"); +fn apply_virtual_primary(ours: &str) -> Vec<(String, String)> { let kscreen = |args: &[String]| { std::process::Command::new("kscreen-doctor") .args(args) @@ -483,8 +619,7 @@ fn apply_virtual_primary(name: &str) -> Vec<(String, String)> { /// **Primary** (Stage 2): make the streamed output the primary but KEEP the other outputs enabled /// (don't disable the bootstrap/physical) — so the shell re-homes onto the streamed surface while a /// physical screen stays usable. Nothing to restore on teardown (we disabled nothing). -fn apply_virtual_primary_only(name: &str) { - let ours = format!("Virtual-{name}"); +fn apply_virtual_primary_only(ours: &str) { let ok = std::process::Command::new("kscreen-doctor") .arg(format!("output.{ours}.primary")) .status() diff --git a/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs b/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs index a67e5709..d732c2dd 100644 --- a/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs +++ b/crates/pf-vdisplay/src/vdisplay/linux/wlroots.rs @@ -136,6 +136,7 @@ impl VirtualDisplay for WlrootsDisplay { ownership: DisplayOwnership::Owned, reused_gen: None, pool_gen: None, + expect_exact_dims: false, }) } } diff --git a/crates/pf-vdisplay/src/vdisplay/registry.rs b/crates/pf-vdisplay/src/vdisplay/registry.rs index 0912f510..5fbf4967 100644 --- a/crates/pf-vdisplay/src/vdisplay/registry.rs +++ b/crates/pf-vdisplay/src/vdisplay/registry.rs @@ -584,6 +584,11 @@ mod linux { let node_id = real.node_id; let preferred_mode = real.preferred_mode; + // Fresh creates only: the backend may have birthed the output at a sacrificial mode whose + // stream must renegotiate before frames count (KWin >60 Hz — see backend.rs). A REUSED kept + // display already renegotiated in its prior session (the producer's rebuilt offer persists + // across consumer reconnects), so the reuse path above correctly leaves the flag off. + let expect_exact_dims = real.expect_exact_dims; // The backend's topology-restore action (KWin `exclusive` → re-enable the disabled physicals), // lifted into the group so it runs once when the group's last member drops (§6.1), not at this // session's teardown. `None` for non-exclusive / non-first / backends whose topology auto-reverts. @@ -647,7 +652,9 @@ mod linux { if (position.x, position.y) != (0, 0) { vd.apply_position(position.x, position.y); } - Ok(output_for(node_id, preferred_mode, gen, quit, false)) + let mut out = output_for(node_id, preferred_mode, gen, quit, false); + out.expect_exact_dims = expect_exact_dims; + Ok(out) } /// The linger a releasing session actually gets. A deliberate quit (`force_immediate` — the diff --git a/crates/punktfunk-host/src/capture.rs b/crates/punktfunk-host/src/capture.rs index 31afcf06..a0af452c 100644 --- a/crates/punktfunk-host/src/capture.rs +++ b/crates/punktfunk-host/src/capture.rs @@ -108,6 +108,7 @@ pub fn capture_virtual_output( want.gpu, want.chroma_444, zero_copy_policy(want.pyrowave, want.nv12_native), + vout.expect_exact_dims, ) }