feat: M2 — KWin virtual-output backend behind a VirtualDisplay trait (native client resolution)

Honor the client's requested resolution by rendering a compositor virtual output at
exactly that size — native, headless, no scaling. There is no cross-compositor Wayland
protocol for this, so it's a per-compositor backend behind the (previously stubbed)
VirtualDisplay trait.

- vdisplay.rs: VirtualDisplay::create(mode) now returns a live VirtualOutput
  { node_id, remote_fd: Option<OwnedFd>, keepalive } with RAII teardown (drop releases
  the output) instead of an inert OutputHandle + explicit destroy. Add compositor
  detect() (LUMEN_COMPOSITOR / XDG_CURRENT_DESKTOP).
- vdisplay/kwin.rs: the KWin backend — the zkde_screencast_unstable_v1 stream_virtual_output
  client (vendored protocol XML + wayland-scanner codegen). Creates a WxH output, returns
  its PipeWire node (default daemon, remote_fd=None); a keepalive thread holds the Wayland
  connection until dropped. (Moved here from capture/kwin.rs — it's a vdisplay backend, not
  capture.)
- capture: generalize the PipeWire consumer to Option<OwnedFd> (portal remote vs. default
  daemon) and add capture_virtual_output(vout), compositor-agnostic, owning the keepalive.
- gamestream/stream.rs: LUMEN_VIDEO_SOURCE=virtual creates a virtual display sized to the
  client's cfg and captures it (self-contained, not pooled — a reconnect at a new
  resolution gets a fresh output).
- m0: --source kwin-virtual goes through the trait.

Verified end-to-end against the running headless KWin: the request reaches the compositor
and is handled cleanly. Native creation needs a backend implementing createVirtualOutput —
the DRM backend, or the VirtualBackend since KWin 6.5.6; on this box's --virtual 6.4.5 it
returns "Could not find output" (expected; validates after the KWin upgrade). wlroots/Mutter
backends are the next ones to land on the same seam.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 17:30:02 +00:00
parent 6508980564
commit 7d08e43c16
10 changed files with 581 additions and 84 deletions
+57 -22
View File
@@ -31,6 +31,10 @@ use std::time::Duration;
pub struct PortalCapturer {
frames: Receiver<CapturedFrame>,
active: Arc<AtomicBool>,
/// 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>>,
}
impl PortalCapturer {
@@ -60,28 +64,52 @@ impl PortalCapturer {
node_id,
"ScreenCast portal session started; connecting PipeWire"
);
// Frames flow from the pipewire thread over a small bounded channel.
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8);
let active = Arc::new(AtomicBool::new(false));
let active_cb = active.clone();
let zerocopy = crate::zerocopy::enabled();
thread::Builder::new()
.name("lumen-pipewire".into())
.spawn(move || {
if let Err(e) =
pipewire::pipewire_thread(fd, node_id, frame_tx, active_cb, zerocopy)
{
tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed");
}
})
.context("spawn pipewire thread")?;
let (frames, active) = spawn_pipewire(Some(fd), node_id)?;
Ok(PortalCapturer {
frames: frame_rx,
frames,
active,
_keepalive: None,
})
}
/// Build a capturer from an already-created virtual output ([`crate::vdisplay::VirtualOutput`]):
/// connect PipeWire to its node (`remote_fd` selects portal-remote vs. default-daemon) and
/// take ownership of its keepalive so the output lives exactly as long as this capturer. This
/// is how the client's requested resolution becomes the captured resolution without scaling.
pub fn from_virtual_output(vout: crate::vdisplay::VirtualOutput) -> Result<PortalCapturer> {
tracing::info!(
node_id = vout.node_id,
"connecting PipeWire to virtual output"
);
let (frames, active) = spawn_pipewire(vout.remote_fd, vout.node_id)?;
Ok(PortalCapturer {
frames,
active,
_keepalive: Some(vout.keepalive),
})
}
}
/// Spawn the PipeWire consumer thread for `node_id` (fd `Some` = portal remote, `None` =
/// default daemon) and return the frame channel + the activation flag it gates on.
fn spawn_pipewire(
fd: Option<OwnedFd>,
node_id: u32,
) -> Result<(Receiver<CapturedFrame>, Arc<AtomicBool>)> {
// Frames flow from the pipewire thread over a small bounded channel.
let (frame_tx, frame_rx) = sync_channel::<CapturedFrame>(8);
let active = Arc::new(AtomicBool::new(false));
let active_cb = active.clone();
let zerocopy = crate::zerocopy::enabled();
thread::Builder::new()
.name("lumen-pipewire".into())
.spawn(move || {
if let Err(e) = pipewire::pipewire_thread(fd, node_id, frame_tx, active_cb, zerocopy) {
tracing::error!(error = %format!("{e:#}"), "pipewire capture thread failed");
}
})
.context("spawn pipewire thread")?;
Ok((frame_rx, active))
}
impl Capturer for PortalCapturer {
@@ -419,7 +447,7 @@ mod pipewire {
}
pub fn pipewire_thread(
fd: OwnedFd,
fd: Option<OwnedFd>,
node_id: u32,
tx: SyncSender<CapturedFrame>,
active: Arc<AtomicBool>,
@@ -429,9 +457,16 @@ mod pipewire {
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw MainLoop")?;
let context = pw::context::ContextRc::new(&mainloop, None).context("pw Context")?;
let core = context
.connect_fd_rc(fd, None)
.context("pw connect_fd (portal remote)")?;
// A portal source hands us an fd to a (sandboxed) PipeWire remote; the KWin
// virtual-output source has no fd — its node lives on the user's default daemon.
let core = match fd {
Some(fd) => context
.connect_fd_rc(fd, None)
.context("pw connect_fd (portal remote)")?,
None => context
.connect_rc(None)
.context("pw connect (default daemon)")?,
};
// Build the EGL→CUDA importer up front; if it fails, log and fall back to the CPU path
// (we simply won't request dmabuf below).