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
+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 },