fix(linux/vdisplay): KWin virtual outputs stream above 60 Hz via sacrificial-birth renegotiation
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 <noreply@anthropic.com>
This commit is contained in:
@@ -453,6 +453,7 @@ pub fn open_virtual_output(
|
||||
allow_zerocopy: bool,
|
||||
want_444: bool,
|
||||
policy: ZeroCopyPolicy,
|
||||
expect_exact_dims: bool,
|
||||
) -> Result<Box<dyn Capturer>> {
|
||||
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<dyn Capturer>)
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
@@ -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<PwHandles> {
|
||||
// Frames flow from the pipewire thread over a small bounded channel.
|
||||
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(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<std::sync::Mutex<Option<pf_frame::CursorOverlay>>>,
|
||||
/// `Some((w, h))` while the producer's negotiated size is a sacrificial birth mode and a
|
||||
/// renegotiation to these dims is guaranteed (KWin virtual outputs — kwin.rs `create`):
|
||||
/// `.process` skips whole buffers until the negotiated size matches, then clears this
|
||||
/// (self-disarming — later legitimate resizes are unaffected). `None` = no gating.
|
||||
expect_dims: Option<(u32, u32)>,
|
||||
/// Buffers skipped by the `expect_dims` gate (rate-limits its log).
|
||||
gate_skips: u64,
|
||||
/// When the gate first held a buffer — after [`GATE_DEADLINE`] with no renegotiation the
|
||||
/// gate disarms and accepts what the producer serves (degraded dims beat a session wedged
|
||||
/// into the first-frame-timeout retry loop; the promised renegotiation normally lands
|
||||
/// within a frame or two).
|
||||
gate_since: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
|
||||
@@ -70,6 +70,12 @@ pub struct VirtualOutput {
|
||||
/// Linux-only (the keep-alive pool is Linux).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub pool_gen: Option<u64>,
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +199,7 @@ impl VirtualDisplay for HyprlandDisplay {
|
||||
ownership: DisplayOwnership::Owned,
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<u32>,
|
||||
/// The base output name the last `create` used (`punktfunk` / `punktfunk-<id>`) — so
|
||||
/// [`apply_position`](VirtualDisplay::apply_position) can address the KWin output `Virtual-<name>`.
|
||||
/// The RESOLVED kscreen address of the last `create`'s output — the numeric kscreen output id
|
||||
/// when [`resolve_kscreen_addr`] found it, else the `Virtual-<name>` fallback — so
|
||||
/// [`apply_position`](VirtualDisplay::apply_position) addresses OUR output even while a
|
||||
/// superseded same-name sibling is still alive.
|
||||
last_name: Option<String>,
|
||||
/// 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-<name>` 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.<name>.position.<x>,<y>`.
|
||||
// kscreen-doctor position syntax: `output.<name-or-id>.position.<x>,<y>`.
|
||||
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::<Result<u32, String>>();
|
||||
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<AtomicBool>)> {
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
|
||||
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-<VOUT_NAME>`,
|
||||
/// 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.<id>.…`), falling back to the ambiguous `Virtual-<name>` 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<serde_json::Value> {
|
||||
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<String> {
|
||||
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<u32> {
|
||||
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<String> {
|
||||
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-<id>`,
|
||||
@@ -377,7 +513,7 @@ fn output_current_mode_spec(o: &serde_json::Value) -> Option<String> {
|
||||
/// 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()
|
||||
|
||||
@@ -136,6 +136,7 @@ impl VirtualDisplay for WlrootsDisplay {
|
||||
ownership: DisplayOwnership::Owned,
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
expect_exact_dims: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user