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
+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).