//! 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, 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 { 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, 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, 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 { 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) -> HostOpts { HostOpts { window: false, app: None, display_server: None::, 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")); } }