diff --git a/README.md b/README.md index 047ade4..cb74977 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,9 @@ Working: - First-run upstream bandwidth pre-flight, persisted to `~/.config/pixelpass/config.toml` and used to auto-size the default viewer cap +- Quality presets (`--quality source|high|medium|low|auto`) that trade + resolution + bitrate for upload bandwidth, plus an `Auto` mode that + derives quality from the bandwidth pre-flight Not yet built (deferred, not blocking): - Per-monitor selection on a multi-monitor X11 host — `ximagesrc` grabs the @@ -242,7 +245,37 @@ message and the host keeps running. For more viewers, drop the per-viewer bitrate: e.g. `pixelpass --bitrate 2500 --max-viewers 4` fits four 2.5 Mbps streams in roughly -12 Mbps of upstream. +12 Mbps of upstream. The `--quality` presets below are the friendlier +way to do the same thing. + +## Quality + +`--quality ` bundles a max video height, bitrate, and framerate — +resolution is a quality-per-bitrate knob, so the three only make sense +together. Quality is **host-global**: one encode pipeline fans out to every +viewer, so the sharer picks one quality for everyone (per-viewer quality +would need per-viewer encodes, which kills the fanout). + +| Preset | Max height | Bitrate | fps | +|----------|-------------------|-----------|-----| +| `source` | native (no scale) | 6000 kbps | 30 | +| `high` | 1080p | 4000 kbps | 30 | +| `medium` | 720p | 2500 kbps | 30 | +| `low` | 480p | 1000 kbps | 30 | +| `auto` | derived (below) | derived | 30 | + +`auto` (the default) picks the highest preset whose bitrate fits your +measured safe upstream divided by the viewer cap — so quality is sized for +the worst case, since it's baked in when capture starts and can't drop when +a second viewer joins. With no `--max-viewers`, it sizes for a single +viewer. If there's no bandwidth measurement yet, `auto` falls back to +`medium` (run `pixelpass --reconfigure` to measure). In the interactive +menu, omitting `--quality` shows a picker instead of assuming `auto`. + +Downscaling preserves the source aspect ratio with square pixels and snaps +to even dimensions (H.264 requires them). Power users can override +individual fields: `--max-height N`, `--bitrate N`, and `--framerate N` +each take precedence over the chosen preset's value for that field. ## Known limitations and gotchas diff --git a/scripts/smoke-pipeline.sh b/scripts/smoke-pipeline.sh index cdacb8f..ce64522 100755 --- a/scripts/smoke-pipeline.sh +++ b/scripts/smoke-pipeline.sh @@ -51,3 +51,38 @@ else echo "$MPV_LOG" | tail -30 | sed 's/^/ /' exit 1 fi + +# ── quality-preset downscale check ──────────────────────────────────────── +# Mirrors the videoscale element host/pipeline.rs inserts for a non-Source +# preset. A 16:9 source @ 480p wants width 853.3 — the danger case for H.264, +# which needs even dimensions. Asserts videoscale negotiates an even width +# (the pixel-aspect-ratio=1/1 + stepped-even-range caps) and the encoder +# accepts it. Guards the "even-width caveat" the design plan flagged. +SCALED="${TMPDIR:-/tmp}/pixelpass-smoke-scaled-$$.ts" +trap 'rm -f "$OUT" "$SCALED"' EXIT +echo "[smoke] downscale check: 1920x1080 -> height 480 (videoscale, even-width)" +gst-launch-1.0 -q \ + mpegtsmux name=mux ! queue ! filesink location="$SCALED" \ + videotestsrc num-buffers=30 is-live=false \ + ! video/x-raw,width=1920,height=1080,framerate=30/1 \ + ! videorate ! video/x-raw,framerate=30/1 \ + ! videoscale ! "video/x-raw,height=480,pixel-aspect-ratio=1/1,width=[2,8192,2]" \ + ! videoconvert ! video/x-raw,format=NV12 \ + ! vah264enc rate-control=cbr bitrate=1000 key-int-max=60 \ + ! h264parse config-interval=-1 \ + ! video/x-h264,stream-format=byte-stream,alignment=au ! mux. \ + audiotestsrc num-buffers=47 is-live=false \ + ! audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 \ + ! avenc_aac bitrate=128000 ! aacparse ! mux. + +SCALED_DIMS=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height \ + -of csv=p=0 "$SCALED" | head -1) +SCALED_W=${SCALED_DIMS%,*} +SCALED_H=${SCALED_DIMS#*,} +echo " negotiated ${SCALED_W}x${SCALED_H}" +if [[ "$SCALED_H" == "480" && $((SCALED_W % 2)) -eq 0 && "$SCALED_W" -lt 1920 ]]; then + echo "[smoke] PASS — downscale produced an even width below source (proportional)" +else + echo "[smoke] FAIL: expected even width < 1920 at height 480, got ${SCALED_W}x${SCALED_H}" + exit 1 +fi diff --git a/src/cli.rs b/src/cli.rs index 9598c2a..41bb49b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -25,13 +25,25 @@ pub struct Cli { #[arg(long, value_enum)] pub display_server: Option, - /// Encode bitrate in kbps. - #[arg(long, default_value_t = 6000)] - pub bitrate: u32, + /// Quality preset. Bundles a max video height, bitrate, and framerate. + /// `auto` derives them from the saved bandwidth pre-flight (falls back to + /// `medium` when no measurement exists). Defaults to `auto`; in the + /// interactive menu, omitting this shows a picker instead. + #[arg(long, value_enum)] + pub quality: Option, - /// Capture framerate. - #[arg(long, default_value_t = 30)] - pub framerate: u32, + /// Cap the encoded video height (px); width follows the source aspect. + /// Power-user override — takes precedence over the preset's height. + #[arg(long, value_name = "N")] + pub max_height: Option, + + /// Encode bitrate in kbps. Overrides the quality preset's bitrate. + #[arg(long)] + pub bitrate: Option, + + /// Capture framerate. Overrides the quality preset's framerate. + #[arg(long)] + pub framerate: Option, /// Disable VAAPI HW encode; force software x264. #[arg(long)] @@ -71,13 +83,37 @@ pub enum DisplayServerArg { X11, } +/// Quality preset. Each fixed preset bundles a (max-height, bitrate, fps) +/// tuple — resolution is a quality-per-bitrate knob, so the three only make +/// sense together. `Auto` has no fixed tuple; it picks one of the others from +/// the bandwidth pre-flight at host startup. See `host::quality`. +#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)] +pub enum Quality { + /// Native source resolution, 6000 kbps, 30 fps (no downscale). + Source, + /// Up to 1080p, 4000 kbps, 30 fps. + High, + /// Up to 720p, 2500 kbps, 30 fps. + Medium, + /// Up to 480p, 1000 kbps, 30 fps. + Low, + /// Derive from the measured upstream; falls back to `medium` when unmeasured. + Auto, +} + #[derive(Debug, Clone)] pub struct HostOpts { pub window: bool, pub app: Option, pub display_server: Option, - pub bitrate: u32, - pub framerate: u32, + /// Chosen preset (Auto = derive at startup). Defaults to Auto. + pub quality: Quality, + /// Raw `--bitrate` override (kbps); None = use the preset's bitrate. + pub bitrate: Option, + /// Raw `--framerate` override; None = use the preset's framerate. + pub framerate: Option, + /// Raw `--max-height` override (px); None = use the preset's height. + pub max_height: Option, pub no_hwencode: bool, pub max_viewers: Option, pub interactive: bool, @@ -95,8 +131,12 @@ impl Cli { window: self.window, app: self.app, display_server: self.display_server, + // No `--quality` and nothing picked interactively → the documented + // default, Auto. + quality: self.quality.unwrap_or(Quality::Auto), bitrate: self.bitrate, framerate: self.framerate, + max_height: self.max_height, no_hwencode: self.no_hwencode, max_viewers: self.max_viewers, interactive, diff --git a/src/common/deps.rs b/src/common/deps.rs index 98e6f2d..1ea9932 100644 --- a/src/common/deps.rs +++ b/src/common/deps.rs @@ -16,6 +16,10 @@ pub fn check_host_binaries(display: DisplayServer, opts: &HostOpts) -> Result<() require("gst-launch-1.0")?; require("gst-inspect-1.0")?; require("pactl")?; + // videoscale (downscale for the quality presets) lives in plugins-base, + // the same package the gst tools need, so this rarely fails on its own — + // but check it for a clear error if a partial install is missing it. + require_gst_element("videoscale")?; require_gst_element("h264parse")?; require_gst_element("mpegtsmux")?; require_gst_element("pulsesrc")?; @@ -136,6 +140,13 @@ fn install_hint_for_gst_element(name: &str) -> String { Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good", _ => "the GStreamer X11 plugin (plugins-good)", }, + "videoscale" => match distro.as_deref() { + Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-base", + Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-base", + Some("fedora" | "nobara") => "gstreamer1-plugins-base", + Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-base", + _ => "the GStreamer plugins-base set", + }, "h264parse" | "mpegtsmux" | "aacparse" => match distro.as_deref() { Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-bad", Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad", diff --git a/src/host/capture.rs b/src/host/capture.rs index 1cbc30d..4635826 100644 --- a/src/host/capture.rs +++ b/src/host/capture.rs @@ -9,12 +9,17 @@ use anyhow::Result; use crate::cli::HostOpts; use crate::common::display::DisplayServer; use crate::host::pipeline::CaptureHandle; +use crate::host::quality::EffectiveQuality; use crate::host::{wayland, x11}; -pub async fn spawn(display: DisplayServer, opts: &HostOpts) -> Result { +pub async fn spawn( + display: DisplayServer, + opts: &HostOpts, + quality: &EffectiveQuality, +) -> Result { match display { - DisplayServer::Wayland => wayland::start(opts).await, - DisplayServer::X11 => x11::start(opts).await, + DisplayServer::Wayland => wayland::start(opts, quality).await, + DisplayServer::X11 => x11::start(opts, quality).await, DisplayServer::Unknown => unreachable!("caller guarantees display != Unknown"), } } diff --git a/src/host/mod.rs b/src/host/mod.rs index 549cfbd..4f42ac6 100644 --- a/src/host/mod.rs +++ b/src/host/mod.rs @@ -1,6 +1,7 @@ pub mod audio; mod capture; mod pipeline; +mod quality; mod serve; mod wayland; mod x11; @@ -20,6 +21,7 @@ use crate::common::{ }; use self::pipeline::CaptureHandle; +use self::quality::EffectiveQuality; /// Messages from per-viewer tasks to the capture supervisor. enum SupervisorMsg { @@ -43,7 +45,15 @@ pub async fn run(opts: HostOpts) -> Result<()> { ); } - let resolution = resolve_max_viewers(&opts); + // Resolve quality first: Auto sizes its bandwidth budget against the viewer + // cap the host will honor. To avoid a circular dependency (the auto-derived + // cap itself depends on bitrate), Auto sizes against the user's explicit + // --max-viewers when given, else a single viewer. The resulting effective + // bitrate then feeds the cap resolution below. + let sizing_viewers = opts.max_viewers.filter(|&n| n > 0).unwrap_or(1); + let quality = quality::resolve(&opts, sizing_viewers); + + let resolution = resolve_max_viewers(&opts, quality.bitrate); if resolution.value == 0 { bail!("--max-viewers must be at least 1"); } @@ -76,10 +86,16 @@ pub async fn run(opts: HostOpts) -> Result<()> { EndpointAddr::new(addr.id).with_addrs(addr.addrs.iter().filter(|a| a.is_relay()).cloned()); let ticket = EndpointTicket::new(relay_only); let clipboard_ok = opts.interactive && copy_to_clipboard(&ticket.to_string()); - print_host_banner(&ticket, display, &opts, &resolution, clipboard_ok); + print_host_banner(&ticket, display, &opts, &quality, &resolution, clipboard_ok); let (sup_tx, sup_rx) = mpsc::channel::(16); - let supervisor = tokio::spawn(supervise(opts.clone(), display, resolution.value, sup_rx)); + let supervisor = tokio::spawn(supervise( + opts.clone(), + quality, + display, + resolution.value, + sup_rx, + )); accept_loop(&endpoint, sup_tx.clone(), cancel.clone()).await; @@ -187,6 +203,7 @@ async fn handle_peer( /// the count is already at the cap. async fn supervise( opts: HostOpts, + quality: EffectiveQuality, display: DisplayServer, max_viewers: u32, mut rx: mpsc::Receiver, @@ -206,7 +223,7 @@ async fn supervise( if handle.is_none() { tracing::info!("first viewer arriving — spawning capture"); - match capture::spawn(display, &opts).await { + match capture::spawn(display, &opts, &quality).await { Ok(h) => handle = Some(h), Err(e) => { let _ = reply.send(Err(format!("capture spawn failed: {e:#}"))); @@ -243,6 +260,7 @@ fn print_host_banner( ticket: &EndpointTicket, display: DisplayServer, opts: &HostOpts, + quality: &EffectiveQuality, resolution: &MaxViewersResolution, clipboard_ok: bool, ) { @@ -250,7 +268,8 @@ fn print_host_banner( eprintln!("┌─ PixelPass · host ─────────────────────────────────────────"); eprintln!("│ display server : {display:?}"); eprintln!("│ capture : {}", capture_summary(opts)); - eprintln!("│ bitrate / fps : {} kbps @ {} fps", opts.bitrate, opts.framerate); + eprintln!("│ quality : {} — {}", quality.label, quality.dimensions_summary()); + eprintln!("│ ({})", quality.note); eprintln!("│ hw encode : {}", if opts.no_hwencode { "off (software x264)" } else { "on (VAAPI H.264)" }); eprintln!("│ max viewers : {} ({})", resolution.value, resolution.source.label()); eprintln!("│"); @@ -302,7 +321,7 @@ impl MaxViewersSource { } } -fn resolve_max_viewers(opts: &HostOpts) -> MaxViewersResolution { +fn resolve_max_viewers(opts: &HostOpts, effective_bitrate: u32) -> MaxViewersResolution { if let Some(n) = opts.max_viewers { return MaxViewersResolution { value: n, @@ -313,7 +332,7 @@ fn resolve_max_viewers(opts: &HostOpts) -> MaxViewersResolution { && cfg.bandwidth.status == BandwidthStatus::Measured && let Some(upstream) = cfg.bandwidth.upstream_mbps { - let n = bandwidth::recommended_max_viewers(upstream, opts.bitrate); + let n = bandwidth::recommended_max_viewers(upstream, effective_bitrate); return MaxViewersResolution { value: n, source: MaxViewersSource::BandwidthMeasurement { safe_mbps: upstream }, diff --git a/src/host/pipeline.rs b/src/host/pipeline.rs index b3ca789..894223c 100644 --- a/src/host/pipeline.rs +++ b/src/host/pipeline.rs @@ -14,6 +14,7 @@ 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; @@ -70,11 +71,12 @@ impl Drop for CaptureHandle { /// the pipewire fd it leaked into the child; X11 passes a no-op. pub async fn spawn( opts: &HostOpts, + quality: &EffectiveQuality, 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); + let args = build_args(&source_args, &audio_device, opts, quality); let mut gst_cmd = Command::new("gst-launch-1.0"); gst_cmd @@ -135,14 +137,21 @@ async fn setup_audio(opts: &HostOpts) -> Result<(Option, String)> { } /// Build the full gst-launch argument vector: MPEG-TS mux + fdsink, then the -/// video branch (caller's `source` → videorate cap → encoder → h264parse → -/// mux.), then the audio branch (pulsesrc → AAC → mux.). 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) -> Vec { - let key_interval = (opts.framerate * 2).to_string(); - let bitrate = opts.bitrate.to_string(); - let framerate_caps = format!("video/x-raw,framerate={}/1", opts.framerate); +/// 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, +) -> 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 { ( @@ -188,6 +197,26 @@ fn build_args(source: &[String], audio_device: &str, opts: &HostOpts) -> Vec, + bitrate: u32, // kbps + framerate: u32, +} + +impl Quality { + /// The fixed tuple for a preset. `Auto` returns `None` — it has no fixed + /// values and resolves to one of the others at runtime (see [`resolve_auto`]). + fn preset(self) -> Option { + let p = match self { + Quality::Source => Preset { max_height: None, bitrate: 6000, framerate: 30 }, + Quality::High => Preset { max_height: Some(1080), bitrate: 4000, framerate: 30 }, + Quality::Medium => Preset { max_height: Some(720), bitrate: 2500, framerate: 30 }, + Quality::Low => Preset { max_height: Some(480), bitrate: 1000, framerate: 30 }, + Quality::Auto => return None, + }; + Some(p) + } + + fn name(self) -> &'static str { + match self { + Quality::Source => "Source", + Quality::High => "High", + Quality::Medium => "Medium", + Quality::Low => "Low", + Quality::Auto => "Auto", + } + } +} + +/// Fixed presets in descending quality order — Auto walks this to find the +/// best one whose per-viewer bitrate fits the measured upstream budget. +const AUTO_LADDER: [Quality; 4] = [Quality::Source, Quality::High, Quality::Medium, Quality::Low]; + +/// Auto's fallback when there is no usable bandwidth measurement. +const AUTO_FALLBACK: Quality = Quality::Medium; + +/// Fully-resolved quality: the concrete values the pipeline will encode at, +/// plus human-readable strings for the host banner. +#[derive(Debug, Clone)] +pub struct EffectiveQuality { + /// `None` = native resolution (omit `videoscale`); `Some(h)` = scale to height `h`. + pub max_height: Option, + pub bitrate: u32, // kbps + pub framerate: u32, + /// Short label, e.g. `"High"` or `"Auto → Medium"`. + pub label: String, + /// Provenance note for the banner, e.g. `"user-specified"` or + /// `"auto: 8.8 Mbps safe ÷ 1 viewer"`. + pub note: String, +} + +impl EffectiveQuality { + /// `WxH-ish / bitrate / fps` summary for the banner. Width is unknown until + /// capture (the source dictates it), so height is shown as `?xN` / `native`. + pub fn dimensions_summary(&self) -> String { + let res = match self.max_height { + Some(h) => format!("≤{h}p"), + None => "native".to_string(), + }; + format!("{res} / {} kbps / {} fps", self.bitrate, self.framerate) + } +} + +/// Resolve the host's quality choice into concrete encode settings. +/// +/// `sizing_viewers` is the viewer count Auto sizes its budget against (the +/// resolved `--max-viewers` cap, so quality is chosen for the worst case — +/// quality is baked in at capture-spawn and can't drop when viewer #2 joins). +pub fn resolve(opts: &HostOpts, sizing_viewers: u32) -> EffectiveQuality { + // 1. Base preset: a fixed tuple, or an Auto derivation. + let (base, label, base_note) = match opts.quality { + Quality::Auto => resolve_auto(measured_safe_mbps(), sizing_viewers), + q => { + let p = q.preset().expect("non-Auto presets always have a tuple"); + (p, q.name().to_string(), "user-specified".to_string()) + } + }; + + let mut eff = EffectiveQuality { + max_height: base.max_height, + bitrate: base.bitrate, + framerate: base.framerate, + label, + note: base_note, + }; + + // 2. Per-field overrides win over the preset (precedence rule). + let mut overridden = Vec::new(); + if let Some(b) = opts.bitrate { + eff.bitrate = b; + overridden.push("bitrate"); + } + if let Some(f) = opts.framerate { + eff.framerate = f; + overridden.push("fps"); + } + if let Some(h) = opts.max_height { + eff.max_height = Some(h); + overridden.push("max-height"); + } + if !overridden.is_empty() { + eff.note = format!("{}; override: {}", eff.note, overridden.join(", ")); + } + + eff +} + +/// Auto: pick the highest preset whose per-viewer bitrate fits the measured +/// safe upstream divided by the viewer count. Falls back to [`AUTO_FALLBACK`] +/// when there's no usable measurement. Pure (no config I/O) so it's testable; +/// [`resolve`] supplies the measurement via [`measured_safe_mbps`]. +fn resolve_auto(safe_mbps: Option, sizing_viewers: u32) -> (Preset, String, String) { + match safe_mbps { + Some(safe_mbps) => { + let n = sizing_viewers.max(1); + let budget_mbps = safe_mbps / n as f64; + let chosen = AUTO_LADDER + .iter() + .copied() + .find(|q| { + let kbps = q.preset().expect("ladder is fixed presets").bitrate; + (kbps as f64) / 1000.0 <= budget_mbps + }) + .unwrap_or(Quality::Low); + let preset = chosen.preset().expect("ladder is fixed presets"); + ( + preset, + format!("Auto → {}", chosen.name()), + format!("auto: {safe_mbps:.1} Mbps safe ÷ {n} viewer(s) = {budget_mbps:.1} Mbps each"), + ) + } + None => { + let preset = AUTO_FALLBACK.preset().expect("fallback is a fixed preset"); + ( + preset, + format!("Auto → {}", AUTO_FALLBACK.name()), + "auto fallback — no bandwidth measurement (run `pixelpass --reconfigure`)".to_string(), + ) + } + } +} + +/// The saved safe-upstream figure, only when the pre-flight actually measured +/// one. Skipped/failed/unmeasured all return `None` so Auto falls back. +fn measured_safe_mbps() -> Option { + let cfg = config::load().ok()?; + if cfg.bandwidth.status == BandwidthStatus::Measured { + cfg.bandwidth.upstream_mbps + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::cli::{DisplayServerArg, HostOpts}; + + /// A HostOpts with no overrides, parameterized by quality + max_viewers. + fn opts(quality: Quality, max_viewers: Option) -> HostOpts { + HostOpts { + window: false, + app: None, + display_server: None::, + quality, + bitrate: None, + framerate: None, + max_height: None, + no_hwencode: false, + max_viewers, + interactive: false, + } + } + + #[test] + fn fixed_presets_pass_through_their_tuple() { + let e = resolve(&opts(Quality::Medium, None), 1); + assert_eq!(e.max_height, Some(720)); + assert_eq!(e.bitrate, 2500); + assert_eq!(e.framerate, 30); + assert_eq!(e.label, "Medium"); + assert_eq!(e.note, "user-specified"); + + // Source is the native (no-scale) preset. + assert_eq!(resolve(&opts(Quality::Source, None), 1).max_height, None); + } + + #[test] + fn auto_picks_highest_preset_that_fits_budget() { + // Ample upstream, single viewer → Source fits (6 Mbps <= 8.78). + let (p, label, _) = resolve_auto(Some(8.78), 1); + assert_eq!(p.bitrate, 6000); + assert_eq!(label, "Auto → Source"); + + // 10 Mbps split across 2 viewers = 5 each → Source(6) no, High(4) yes. + let (p, label, _) = resolve_auto(Some(10.0), 2); + assert_eq!(p.bitrate, 4000); + assert_eq!(label, "Auto → High"); + + // Tight budget falls to the bottom of the ladder, never below Low. + let (p, _, _) = resolve_auto(Some(0.3), 1); + assert_eq!(p.bitrate, 1000); // Low + } + + #[test] + fn auto_without_measurement_falls_back_to_medium() { + let (p, label, note) = resolve_auto(None, 1); + assert_eq!(p.bitrate, 2500); // Medium + assert_eq!(p.max_height, Some(720)); + assert_eq!(label, "Auto → Medium"); + assert!(note.contains("reconfigure")); + } + + #[test] + fn explicit_flags_override_preset_fields() { + let mut o = opts(Quality::High, None); + o.bitrate = Some(9000); + o.framerate = Some(60); + let e = resolve(&o, 1); + assert_eq!(e.bitrate, 9000); // override wins + assert_eq!(e.framerate, 60); // override wins + assert_eq!(e.max_height, Some(1080)); // untouched preset field + assert!(e.note.contains("override: bitrate, fps")); + } + + #[test] + fn max_height_override_is_rounded_even_and_applies_to_source() { + // Odd override rounds down to even in the pipeline; here we just assert + // the override replaces the (native) Source height with the raw value; + // the even-rounding happens in pipeline::build_args. + let mut o = opts(Quality::Source, None); + o.max_height = Some(900); + let e = resolve(&o, 1); + assert_eq!(e.max_height, Some(900)); + assert!(e.note.contains("override: max-height")); + } +} diff --git a/src/host/wayland.rs b/src/host/wayland.rs index 8ec15ea..b51ea5c 100644 --- a/src/host/wayland.rs +++ b/src/host/wayland.rs @@ -16,9 +16,10 @@ use nix::unistd::close; use std::os::fd::{AsFd, IntoRawFd, OwnedFd, RawFd}; use super::pipeline::{self, CaptureHandle}; +use super::quality::EffectiveQuality; use crate::cli::HostOpts; -pub async fn start(opts: &HostOpts) -> Result { +pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result { // 1. Negotiate the screencast session with the portal. let proxy = Screencast::new() .await @@ -69,7 +70,7 @@ pub async fn start(opts: &HostOpts) -> Result { "do-timestamp=true".to_string(), ]; - pipeline::spawn(opts, source_args, move || { + pipeline::spawn(opts, quality, source_args, move || { // Parent no longer needs the pipewire fd — gst inherited its own copy. let _ = close(raw_fd); }) diff --git a/src/host/x11.rs b/src/host/x11.rs index 5d68f95..85aa0aa 100644 --- a/src/host/x11.rs +++ b/src/host/x11.rs @@ -11,9 +11,10 @@ use x11rb::connection::Connection; use x11rb::protocol::xproto::ConnectionExt; use super::pipeline::{self, CaptureHandle}; +use super::quality::EffectiveQuality; use crate::cli::HostOpts; -pub async fn start(opts: &HostOpts) -> Result { +pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result { let xid = if opts.window { Some(pick_window().await?) } else { @@ -41,7 +42,7 @@ pub async fn start(opts: &HostOpts) -> Result { } // X11 has no leaked fd to clean up, so the post-spawn hook is a no-op. - pipeline::spawn(opts, source_args, || {}).await + pipeline::spawn(opts, quality, source_args, || {}).await } /// Run `xwininfo` and let the user click the window they want to share, then diff --git a/src/interactive.rs b/src/interactive.rs index 85b6e84..2b69489 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -3,7 +3,7 @@ use dialoguer::{Input, Select, theme::ColorfulTheme}; use iroh_tickets::endpoint::EndpointTicket; use std::str::FromStr; -use crate::cli::Cli; +use crate::cli::{Cli, Quality}; use crate::common::{bandwidth, config}; use crate::{host, viewer}; @@ -27,6 +27,9 @@ pub async fn run(cli: Cli) -> Result<()> { if cli.app.is_none() { cli.app = pick_app(&theme)?; } + if cli.quality.is_none() { + cli.quality = Some(pick_quality(&theme)?); + } host::run(cli.into_host_opts(true)).await } _ => { @@ -80,6 +83,42 @@ fn pick_app(theme: &ColorfulTheme) -> Result> { } } +/// Picker for the encode quality preset. Mirrors [`pick_app`]; bypassed when +/// `--quality` was given on the CLI. Quality is host-global (the same stream +/// fans out to every viewer), so this is the one choice that sets it for all. +fn pick_quality(theme: &ColorfulTheme) -> Result { + eprintln!(); + eprintln!("Quality"); + eprintln!("───────"); + eprintln!("Lower presets trade resolution + bitrate for less upload usage."); + eprintln!("The same quality is sent to every viewer."); + eprintln!(); + + // Order mirrors the labels below; index maps back to a Quality. + let choices = [ + Quality::Auto, + Quality::Source, + Quality::High, + Quality::Medium, + Quality::Low, + ]; + let items = [ + "Auto — pick from my measured upstream (recommended)", + "Source — native resolution, 6000 kbps", + "High — up to 1080p, 4000 kbps", + "Medium — up to 720p, 2500 kbps", + "Low — up to 480p, 1000 kbps", + ]; + + let choice = Select::with_theme(theme) + .with_prompt("What quality should the viewer(s) get?") + .items(&items) + .default(0) + .interact()?; + + Ok(choices[choice]) +} + /// `pixelpass --reconfigure` entry point: unconditionally re-run the /// bandwidth pre-flight test, save the result, and return. Used to /// refresh a stale measurement (e.g. user moved house, changed ISP).