feat(quality): resolution/quality presets + Auto from pre-flight

Add a host-global quality knob (Discord-style) so the sharer can trade
resolution + bitrate for upload bandwidth. Quality is host-global by
design: one encode pipeline fans out to every viewer, so per-viewer
quality is out of scope (it would kill the broadcast fanout).

- New `--quality source|high|medium|low|auto` (ValueEnum) bundling a
  (max-height, bitrate, fps) tuple per preset; `auto` derives the preset
  from the saved bandwidth pre-flight (safe_mbps / viewer cap), falling
  back to `medium` when unmeasured. Default is auto; the interactive
  Host branch shows a picker when --quality is omitted (mirrors pick_app).
- `--max-height N` raw override; `--bitrate`/`--framerate` changed to
  Option so an explicit flag overrides just that field of the preset
  (precedence rule), leaving the rest of the preset intact.
- host/quality.rs: Preset table + resolve(); pure resolve_auto() split
  from the config read for testability. 5 unit tests lock preset
  pass-through, the Auto ladder, the unmeasured fallback, and override
  precedence.
- pipeline::build_args inserts `videoscale ! video/x-raw,height=N,
  pixel-aspect-ratio=1/1,width=[2,8192,2]` only for non-Source presets.
  PAR 1/1 forces a proportional downscale (without it videoscale keeps
  full width and squashes PAR — no bandwidth win); the even-stepped width
  range + even-rounded height satisfy H.264 4:2:0. EffectiveQuality is
  threaded capture -> wayland/x11 -> pipeline; max_viewers is now sized
  against the effective (post-preset) bitrate.
- Banner gains a quality line (preset label + ≤Np/kbps/fps + provenance).
- deps.rs checks `videoscale`; smoke-pipeline.sh adds a 1080->480
  downscale check asserting an even width below source.
- README: --quality preset table, Auto behavior, host-global note,
  --max-height/--bitrate/--framerate override precedence.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 15:03:14 -04:00
parent 45e5d7ef37
commit 7483b9aae8
11 changed files with 503 additions and 33 deletions
+48 -8
View File
@@ -25,13 +25,25 @@ pub struct Cli {
#[arg(long, value_enum)]
pub display_server: Option<DisplayServerArg>,
/// 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<Quality>,
/// 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<u32>,
/// Encode bitrate in kbps. Overrides the quality preset's bitrate.
#[arg(long)]
pub bitrate: Option<u32>,
/// Capture framerate. Overrides the quality preset's framerate.
#[arg(long)]
pub framerate: Option<u32>,
/// 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<String>,
pub display_server: Option<DisplayServerArg>,
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<u32>,
/// Raw `--framerate` override; None = use the preset's framerate.
pub framerate: Option<u32>,
/// Raw `--max-height` override (px); None = use the preset's height.
pub max_height: Option<u32>,
pub no_hwencode: bool,
pub max_viewers: Option<u32>,
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,
+11
View File
@@ -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",
+8 -3
View File
@@ -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<CaptureHandle> {
pub async fn spawn(
display: DisplayServer,
opts: &HostOpts,
quality: &EffectiveQuality,
) -> Result<CaptureHandle> {
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"),
}
}
+26 -7
View File
@@ -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::<SupervisorMsg>(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<SupervisorMsg>,
@@ -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 },
+38 -9
View File
@@ -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<String>,
after_spawn: impl FnOnce(),
) -> Result<CaptureHandle> {
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<Routing>, 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<String> {
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<String> {
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<String>) = if opts.no_hwencode {
(
@@ -188,6 +197,26 @@ fn build_args(source: &[String], audio_device: &str, opts: &HostOpts) -> Vec<Str
"!".into(),
framerate_caps,
"!".into(),
]);
// Optional downscale (quality presets). Omitted entirely for the native
// "Source" preset. `pixel-aspect-ratio=1/1` forces a *proportional* scale:
// screen capture always has square pixels, and without pinning PAR videoscale
// keeps the full source width and just squashes PAR to preserve display
// aspect (e.g. 1920x480 @ PAR 4/9 — no bandwidth win at all). With square
// pixels fixed, width follows the source DAR. H.264 4:2:0 needs even
// dimensions, so we pin height to an even value (preset heights already are;
// a raw --max-height override is rounded down) and constrain width to a
// stepped even range — verified 1920x1080→852x480 and 1366x768→1280x720.
if let Some(h) = quality.max_height {
let h = (h & !1).max(2);
args.extend([
"videoscale".into(),
"!".into(),
format!("video/x-raw,height={h},pixel-aspect-ratio=1/1,width=[2,8192,2]"),
"!".into(),
]);
}
args.extend([
"queue".into(),
"!".into(),
"videoconvert".into(),
+257
View File
@@ -0,0 +1,257 @@
//! Resolution / quality presets. A preset bundles `(max_height, bitrate, fps)`
//! because resolution is a *quality-per-bitrate* knob, not a standalone one —
//! the three are only useful together. Quality is **host-global**: one encode
//! pipeline fans out to every viewer over the broadcast channel, so the sharer
//! picks one quality for everyone (per-viewer quality would need per-viewer
//! encodes, which kills the fanout).
//!
//! [`resolve`] turns the raw CLI/picker choice into a concrete
//! [`EffectiveQuality`] the pipeline encodes at, applying — in order — the
//! chosen preset (or an Auto derivation from the bandwidth pre-flight), then
//! any explicit `--bitrate` / `--framerate` / `--max-height` field overrides.
use crate::cli::{HostOpts, Quality};
use crate::common::{config, config::BandwidthStatus};
/// A fixed preset's concrete settings. `max_height = None` means encode at the
/// native source resolution (no `videoscale` element is inserted at all).
#[derive(Debug, Clone, Copy)]
struct Preset {
max_height: Option<u32>,
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<Preset> {
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<u32>,
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<f64>, 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<f64> {
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<u32>) -> HostOpts {
HostOpts {
window: false,
app: None,
display_server: None::<DisplayServerArg>,
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"));
}
}
+3 -2
View File
@@ -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<CaptureHandle> {
pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<CaptureHandle> {
// 1. Negotiate the screencast session with the portal.
let proxy = Screencast::new()
.await
@@ -69,7 +70,7 @@ pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
"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);
})
+3 -2
View File
@@ -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<CaptureHandle> {
pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<CaptureHandle> {
let xid = if opts.window {
Some(pick_window().await?)
} else {
@@ -41,7 +42,7 @@ pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
}
// 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
+40 -1
View File
@@ -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<Option<String>> {
}
}
/// 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<Quality> {
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).