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:
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user