A newer rustfmt wraps over-long match arms and call expressions that the version main was last formatted with left on one line. Pure formatting, no semantic change — split out so the friends-list feature commits stay focused on real changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
283 lines
10 KiB
Rust
283 lines
10 KiB
Rust
//! 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,
|
|
relay: None,
|
|
}
|
|
}
|
|
|
|
#[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"));
|
|
}
|
|
}
|