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:
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user