feat(host): X11 capture backend + shared pipeline extraction

Extract the display-agnostic encode/mux tail out of wayland.rs into a new
host/pipeline.rs: CaptureHandle + lifecycle, audio routing setup, the gst
arg builder, the spawn, and Serve::bind now live there. Backends supply
only their video-source element args plus a post-spawn hook (Wayland uses
it to close its leaked pipewire fd; X11 passes a no-op). capture.rs
collapses to a thin dispatcher; its CaptureHandle enum is gone.

Add host/x11.rs: ximagesrc (use-damage=false show-pointer=true), whole
root window by default or a single window via --window (xwininfo
click-picker → xid). x11rb reads geometry for an info log, justifying the
previously-vestigial dep. No portal, no fd dance — capture starts
silently when the first viewer connects (the ticket is the access
control). Viewer is display-agnostic and unchanged.

Wire --no-hwencode for real (was a no-op): the shared tail now selects
x264enc(tune=zerolatency,ultrafast)/I420 vs vah264enc/NV12 and switches
the videoconvert target format to match. Applies to both backends.

deps.rs: check_host_binaries now takes &HostOpts and checks shared
elements for both backends, encoder by --no-hwencode, source per backend
(pipewiresrc/ximagesrc), and xwininfo only when X11 + --window. Install
hints added for x264enc, ximagesrc, xwininfo.

Verified: warning-free build; smoke test still passes (tail unchanged);
ximagesrc + both encoder tails produce mpv-decodable H.264 against an
Xwayland root. Interactive cross-machine end-to-end pending.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 20:48:50 -04:00
co-authored by Claude Opus 4.7
parent 0c9d8eb9f9
commit cd127a9704
7 changed files with 474 additions and 247 deletions
+94
View File
@@ -0,0 +1,94 @@
//! X11 capture: `ximagesrc` → the shared encode/mux tail in [`super::pipeline`].
//! Unlike Wayland there's no portal and no fd hand-off — `ximagesrc` opens its
//! own X connection from `$DISPLAY`. The whole root window is captured by
//! default; `--window` resolves a single window's XID via an `xwininfo`
//! click-picker. The ticket is the access control, so capture starts silently
//! when the first viewer connects (no host-side consent prompt).
use anyhow::{Context, Result, bail};
use tokio::process::Command;
use x11rb::connection::Connection;
use x11rb::protocol::xproto::ConnectionExt;
use super::pipeline::{self, CaptureHandle};
use crate::cli::HostOpts;
pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
let xid = if opts.window {
Some(pick_window().await?)
} else {
None
};
// Geometry is informational (mirrors Wayland's portal-handshake log line);
// a failure here shouldn't abort capture — ximagesrc will surface a real
// error if the X connection is genuinely unusable.
match read_geometry(xid) {
Ok((w, h)) => tracing::info!(width = w, height = h, xid = ?xid, "X11 capture geometry"),
Err(e) => tracing::warn!("could not read X11 geometry (capture will still try): {e:#}"),
}
let mut source_args = vec![
"ximagesrc".to_string(),
// Full frames (no damage regions) to avoid partial-update artifacts;
// use-damage=true is a later CPU optimization. show-pointer matches
// Wayland's CursorMode::Embedded.
"use-damage=false".to_string(),
"show-pointer=true".to_string(),
];
if let Some(xid) = xid {
source_args.push(format!("xid={xid}"));
}
// X11 has no leaked fd to clean up, so the post-spawn hook is a no-op.
pipeline::spawn(opts, source_args, || {}).await
}
/// Run `xwininfo` and let the user click the window they want to share, then
/// parse the `Window id: 0x…` line out of its output. Returns the numeric XID.
async fn pick_window() -> Result<u32> {
eprintln!("[pixelpass] click the window you want to share…");
let output = Command::new("xwininfo")
.output()
.await
.context("failed to run `xwininfo` (install xorg-xwininfo)")?;
if !output.status.success() {
bail!(
"xwininfo failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let text = String::from_utf8_lossy(&output.stdout);
for line in text.lines() {
// e.g. "xwininfo: Window id: 0x3a00007 \"xterm\""
if let Some((_, rest)) = line.split_once("Window id: ") {
let token = rest.split_whitespace().next().unwrap_or("");
let hex = token.strip_prefix("0x").unwrap_or(token);
if let Ok(xid) = u32::from_str_radix(hex, 16) {
return Ok(xid);
}
}
}
bail!("could not parse a window id from xwininfo output");
}
/// Read pixel dimensions: the selected window's geometry when `--window` was
/// used, otherwise the root window of the screen named by `$DISPLAY`.
fn read_geometry(xid: Option<u32>) -> Result<(u16, u16)> {
let (conn, screen_num) =
x11rb::connect(None).context("could not connect to the X server (is DISPLAY set?)")?;
match xid {
Some(id) => {
let geo = conn
.get_geometry(id)
.context("GetGeometry request failed")?
.reply()
.context("GetGeometry reply failed")?;
Ok((geo.width, geo.height))
}
None => {
let screen = &conn.setup().roots[screen_num];
Ok((screen.width_in_pixels, screen.height_in_pixels))
}
}
}