//! Display-server-agnostic capture pipeline. The video *source* element is the //! only part that differs between Wayland (`pipewiresrc`, after a portal //! handshake) and X11 (`ximagesrc`); everything downstream — the videorate cap, //! the encoder, `h264parse`, `mpegtsmux`, the whole audio branch, the gst spawn, //! the [`Serve`] fanout binding, and the [`CaptureHandle`] lifecycle — is shared //! and lives here. Backends call [`spawn`] with just their source-element args. use anyhow::{Context, Result, bail}; use nix::sys::signal::{Signal, kill}; use nix::unistd::Pid; use std::process::Stdio; use std::time::Duration; use tokio::process::{Child, Command}; use tokio::time::timeout; use super::audio::Routing; use super::quality::EffectiveQuality; use super::serve::Serve; use crate::cli::HostOpts; pub struct CaptureHandle { gst: Option, audio: Option, serve: Option, } impl CaptureHandle { pub fn local_port(&self) -> u16 { self.serve .as_ref() .expect("serve is always Some until shutdown") .local_port() } /// Graceful teardown: SIGTERM gst, give it ~1s to exit, then SIGKILL, /// unload audio routing (if any), then tear down the serve layer. /// The serve reader will see EOF on gst stdout and exit on its own; /// serve.shutdown() is the backstop. pub async fn shutdown(mut self) { if let Some(child) = self.gst.as_mut() && let Some(pid) = child.id() { let _ = kill(Pid::from_raw(pid as i32), Signal::SIGTERM); } if let Some(child) = self.gst.as_mut() { let _ = timeout(Duration::from_millis(1000), child.wait()).await; let _ = child.start_kill(); } if let Some(audio) = self.audio.take() { audio.shutdown(); } if let Some(serve) = self.serve.take() { serve.shutdown().await; } } } impl Drop for CaptureHandle { fn drop(&mut self) { if let Some(child) = self.gst.as_mut() { let _ = child.start_kill(); } // Routing's and Serve's own Drop impls handle the rest. } } /// Spawn the shared gst pipeline for a backend that supplies `source_args` /// (the video-source element + its properties, e.g. `["pipewiresrc", "fd=7", /// …]` or `["ximagesrc", "use-damage=false", …]`). `source_dims` is the source /// pixel size when the backend knows it (Wayland from the portal, X11 from /// root/window geometry); it lets a downscale preset compute an exact even /// target resolution and skip scaling when the source is already small enough. /// `after_spawn` runs once, immediately after the gst child is launched — /// Wayland uses it to `close` the pipewire fd it leaked into the child; X11 /// passes a no-op. pub async fn spawn( opts: &HostOpts, quality: &EffectiveQuality, source_dims: Option<(u32, u32)>, source_args: Vec, after_spawn: impl FnOnce(), ) -> Result { let (audio_routing, audio_device) = setup_audio(opts).await?; let args = build_args(&source_args, &audio_device, opts, quality, source_dims); let mut gst_cmd = Command::new("gst-launch-1.0"); gst_cmd .args(&args) .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::inherit()); if std::env::var_os("PIXELPASS_GST_DEBUG").is_some() { gst_cmd.env("GST_DEBUG", "3"); } let mut gst = gst_cmd.spawn().context("failed to spawn gst-launch-1.0")?; // Backend-specific post-spawn cleanup (Wayland closes its leaked pw fd here, // once gst has inherited its own copy). after_spawn(); let gst_stdout = gst .stdout .take() .context("gst-launch-1.0 stdout pipe unavailable")?; // Hand stdout to the serve layer, which binds the localhost HTTP listener // and runs the broadcast fanout. No demux/remux, no codec assumptions. let serve = Serve::bind(gst_stdout).await?; Ok(CaptureHandle { gst: Some(gst), audio: audio_routing, serve: Some(serve), }) } /// Decide whether per-app audio routing is active and produce the `device=…` /// argument for `pulsesrc`. Routing activates when either `--app` is set /// (per-stream rerouting to a per-PID null-sink) or `PIXELPASS_AUDIO_VIA_NULL_SINK=1` /// is set (no app filter — captures everything via the null-sink, used for /// dogfooding the loopback path). Otherwise we capture the default sink's /// monitor (system audio out), not the default source (the mic). async fn setup_audio(opts: &HostOpts) -> Result<(Option, String)> { let routing_requested = opts.app.is_some() || std::env::var_os("PIXELPASS_AUDIO_VIA_NULL_SINK").is_some(); let audio_routing = if routing_requested { Some( Routing::start(opts) .await .context("audio routing setup failed")?, ) } else { None }; let audio_device = if let Some(r) = &audio_routing { format!("device={}.monitor", r.sink_name()) } else { let default = default_audio_monitor().await?; format!("device={default}") }; Ok((audio_routing, audio_device)) } /// Build the full gst-launch argument vector: MPEG-TS mux + fdsink, then the /// video branch (caller's `source` → videorate cap → optional downscale → /// encoder → h264parse → mux.), then the audio branch (pulsesrc → AAC → mux.). /// Bitrate, framerate, and the downscale height come from the resolved /// [`EffectiveQuality`]; the encoder and the `videoconvert` target format are /// selected by `opts.no_hwencode` (hardware VAAPI wants NV12, software x264 /// wants I420). fn build_args( source: &[String], audio_device: &str, opts: &HostOpts, quality: &EffectiveQuality, source_dims: Option<(u32, u32)>, ) -> Vec { let key_interval = (quality.framerate * 2).to_string(); let bitrate = quality.bitrate.to_string(); let framerate_caps = format!("video/x-raw,framerate={}/1", quality.framerate); let (raw_format, encoder_args): (&str, Vec) = if opts.no_hwencode { ( "video/x-raw,format=I420", vec![ "x264enc".into(), "tune=zerolatency".into(), "speed-preset=ultrafast".into(), format!("bitrate={bitrate}"), format!("key-int-max={key_interval}"), ], ) } else { ( "video/x-raw,format=NV12", vec![ "vah264enc".into(), "rate-control=cbr".into(), format!("bitrate={bitrate}"), format!("key-int-max={key_interval}"), ], ) }; // muxer + sink let mut args: Vec = vec![ "mpegtsmux".into(), "name=mux".into(), "!".into(), "queue".into(), "!".into(), ]; // Debug A/V-drift tap: when PIXELPASS_TS_DUMP= is set, tee the exact // muxed TS both to fd=1 (normal serve path, unchanged) and to a file, so the // host-side stream can be ffprobe'd for capture-side audio/video PTS drift. // Each tee branch has its own queue so the disk sink can't backpressure the // live serve branch. No effect when unset. (Mirrors PIXELPASS_GST_DEBUG.) if let Some(dump) = std::env::var_os("PIXELPASS_TS_DUMP") { let path = dump.to_string_lossy().into_owned(); args.extend([ "tee".into(), "name=dbgtee".into(), "!".into(), "queue".into(), "!".into(), "fdsink".into(), "fd=1".into(), "dbgtee.".into(), "!".into(), "queue".into(), "!".into(), "filesink".into(), format!("location={path}"), ]); } else { args.extend(["fdsink".into(), "fd=1".into()]); } // Downscale step for the quality presets. `None` = encode at native size // (the "Source" preset, or a source already at/below the target height — we // never upscale). When the source dimensions are known we pin an exact even // WxH preserving the source aspect; H.264 4:2:0 needs even dims, so width is // rounded to even and height is forced even (preset heights already are; a // raw --max-height override is rounded down). When dims are unknown (a rare // X11 geometry-read failure) we fall back to height-only + square pixels + // an even-stepped width range and let videoscale negotiate. let scale_caps: Option = match quality.max_height { None => { tracing::info!(preset = %quality.label, "encoding at native resolution (no downscale)"); None } Some(max_h) => { let h = (max_h & !1).max(2); match source_dims { Some((sw, sh)) if sh > h => { let w = ((sw as u64 * h as u64 + sh as u64 / 2) / sh as u64) as u32; let w = (w & !1).max(2); tracing::info!( preset = %quality.label, from = %format!("{sw}x{sh}"), to = %format!("{w}x{h}"), "downscaling video" ); Some(format!("{raw_format},width={w},height={h}")) } Some((sw, sh)) => { tracing::info!( preset = %quality.label, source = %format!("{sw}x{sh}"), max_height = h, "source already at/below preset height — encoding native (no upscale)" ); None } None => { tracing::info!( preset = %quality.label, max_height = h, "downscaling to max height (source size unknown — width follows negotiation)" ); Some(format!( "{raw_format},height={h},pixel-aspect-ratio=1/1,width=[2,8192,2]" )) } } } }; // video branch — videorate caps to the target fps so we don't ship at the // monitor's refresh rate (e.g. 180Hz) and pile up frames in the demuxer // queue faster than realtime. videoscale (when scaling) runs *after* // videoconvert so it operates on system-memory NV12/I420: scaling // pipewiresrc's raw output directly can hit a format/memory (e.g. DMABuf) // that software videoscale won't negotiate. args.extend(source.iter().cloned()); args.extend([ "!".into(), "videorate".into(), "!".into(), framerate_caps, "!".into(), "queue".into(), "!".into(), "videoconvert".into(), "!".into(), raw_format.into(), "!".into(), ]); if let Some(caps) = scale_caps { args.extend(["videoscale".into(), "!".into(), caps, "!".into()]); } args.extend(encoder_args); args.extend([ "!".into(), "h264parse".into(), "config-interval=-1".into(), "!".into(), "video/x-h264,stream-format=byte-stream,alignment=au".into(), "!".into(), "mux.".into(), ]); // audio branch — capture the default sink's MONITOR (system audio out), // not the default source (which is the mic). args.extend([ "pulsesrc".into(), audio_device.to_string(), "do-timestamp=true".into(), "!".into(), "queue".into(), "!".into(), "audioconvert".into(), "!".into(), "audioresample".into(), "!".into(), "audio/x-raw,rate=48000,channels=2".into(), "!".into(), "avenc_aac".into(), "bitrate=128000".into(), "!".into(), "aacparse".into(), "!".into(), "mux.".into(), ]); args } async fn default_audio_monitor() -> Result { let output = Command::new("pactl") .arg("get-default-sink") .output() .await .context( "failed to run `pactl get-default-sink` (install pulseaudio-utils or pipewire-pulse)", )?; if !output.status.success() { bail!( "pactl get-default-sink failed: {}", String::from_utf8_lossy(&output.stderr).trim() ); } let sink = String::from_utf8(output.stdout) .context("default sink name was not UTF-8")? .trim() .to_string(); if sink.is_empty() { bail!("pactl get-default-sink returned no name (is a sound server running?)"); } Ok(format!("{sink}.monitor")) }