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:
+48
-8
@@ -25,13 +25,25 @@ pub struct Cli {
|
||||
#[arg(long, value_enum)]
|
||||
pub display_server: Option<DisplayServerArg>,
|
||||
|
||||
/// Encode bitrate in kbps.
|
||||
#[arg(long, default_value_t = 6000)]
|
||||
pub bitrate: u32,
|
||||
/// Quality preset. Bundles a max video height, bitrate, and framerate.
|
||||
/// `auto` derives them from the saved bandwidth pre-flight (falls back to
|
||||
/// `medium` when no measurement exists). Defaults to `auto`; in the
|
||||
/// interactive menu, omitting this shows a picker instead.
|
||||
#[arg(long, value_enum)]
|
||||
pub quality: Option<Quality>,
|
||||
|
||||
/// Capture framerate.
|
||||
#[arg(long, default_value_t = 30)]
|
||||
pub framerate: u32,
|
||||
/// Cap the encoded video height (px); width follows the source aspect.
|
||||
/// Power-user override — takes precedence over the preset's height.
|
||||
#[arg(long, value_name = "N")]
|
||||
pub max_height: Option<u32>,
|
||||
|
||||
/// Encode bitrate in kbps. Overrides the quality preset's bitrate.
|
||||
#[arg(long)]
|
||||
pub bitrate: Option<u32>,
|
||||
|
||||
/// Capture framerate. Overrides the quality preset's framerate.
|
||||
#[arg(long)]
|
||||
pub framerate: Option<u32>,
|
||||
|
||||
/// Disable VAAPI HW encode; force software x264.
|
||||
#[arg(long)]
|
||||
@@ -71,13 +83,37 @@ pub enum DisplayServerArg {
|
||||
X11,
|
||||
}
|
||||
|
||||
/// Quality preset. Each fixed preset bundles a (max-height, bitrate, fps)
|
||||
/// tuple — resolution is a quality-per-bitrate knob, so the three only make
|
||||
/// sense together. `Auto` has no fixed tuple; it picks one of the others from
|
||||
/// the bandwidth pre-flight at host startup. See `host::quality`.
|
||||
#[derive(ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Quality {
|
||||
/// Native source resolution, 6000 kbps, 30 fps (no downscale).
|
||||
Source,
|
||||
/// Up to 1080p, 4000 kbps, 30 fps.
|
||||
High,
|
||||
/// Up to 720p, 2500 kbps, 30 fps.
|
||||
Medium,
|
||||
/// Up to 480p, 1000 kbps, 30 fps.
|
||||
Low,
|
||||
/// Derive from the measured upstream; falls back to `medium` when unmeasured.
|
||||
Auto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HostOpts {
|
||||
pub window: bool,
|
||||
pub app: Option<String>,
|
||||
pub display_server: Option<DisplayServerArg>,
|
||||
pub bitrate: u32,
|
||||
pub framerate: u32,
|
||||
/// Chosen preset (Auto = derive at startup). Defaults to Auto.
|
||||
pub quality: Quality,
|
||||
/// Raw `--bitrate` override (kbps); None = use the preset's bitrate.
|
||||
pub bitrate: Option<u32>,
|
||||
/// Raw `--framerate` override; None = use the preset's framerate.
|
||||
pub framerate: Option<u32>,
|
||||
/// Raw `--max-height` override (px); None = use the preset's height.
|
||||
pub max_height: Option<u32>,
|
||||
pub no_hwencode: bool,
|
||||
pub max_viewers: Option<u32>,
|
||||
pub interactive: bool,
|
||||
@@ -95,8 +131,12 @@ impl Cli {
|
||||
window: self.window,
|
||||
app: self.app,
|
||||
display_server: self.display_server,
|
||||
// No `--quality` and nothing picked interactively → the documented
|
||||
// default, Auto.
|
||||
quality: self.quality.unwrap_or(Quality::Auto),
|
||||
bitrate: self.bitrate,
|
||||
framerate: self.framerate,
|
||||
max_height: self.max_height,
|
||||
no_hwencode: self.no_hwencode,
|
||||
max_viewers: self.max_viewers,
|
||||
interactive,
|
||||
|
||||
@@ -16,6 +16,10 @@ pub fn check_host_binaries(display: DisplayServer, opts: &HostOpts) -> Result<()
|
||||
require("gst-launch-1.0")?;
|
||||
require("gst-inspect-1.0")?;
|
||||
require("pactl")?;
|
||||
// videoscale (downscale for the quality presets) lives in plugins-base,
|
||||
// the same package the gst tools need, so this rarely fails on its own —
|
||||
// but check it for a clear error if a partial install is missing it.
|
||||
require_gst_element("videoscale")?;
|
||||
require_gst_element("h264parse")?;
|
||||
require_gst_element("mpegtsmux")?;
|
||||
require_gst_element("pulsesrc")?;
|
||||
@@ -136,6 +140,13 @@ fn install_hint_for_gst_element(name: &str) -> String {
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good",
|
||||
_ => "the GStreamer X11 plugin (plugins-good)",
|
||||
},
|
||||
"videoscale" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-base",
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-base",
|
||||
Some("fedora" | "nobara") => "gstreamer1-plugins-base",
|
||||
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-base",
|
||||
_ => "the GStreamer plugins-base set",
|
||||
},
|
||||
"h264parse" | "mpegtsmux" | "aacparse" => match distro.as_deref() {
|
||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-bad",
|
||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-plugins-bad",
|
||||
|
||||
+8
-3
@@ -9,12 +9,17 @@ use anyhow::Result;
|
||||
use crate::cli::HostOpts;
|
||||
use crate::common::display::DisplayServer;
|
||||
use crate::host::pipeline::CaptureHandle;
|
||||
use crate::host::quality::EffectiveQuality;
|
||||
use crate::host::{wayland, x11};
|
||||
|
||||
pub async fn spawn(display: DisplayServer, opts: &HostOpts) -> Result<CaptureHandle> {
|
||||
pub async fn spawn(
|
||||
display: DisplayServer,
|
||||
opts: &HostOpts,
|
||||
quality: &EffectiveQuality,
|
||||
) -> Result<CaptureHandle> {
|
||||
match display {
|
||||
DisplayServer::Wayland => wayland::start(opts).await,
|
||||
DisplayServer::X11 => x11::start(opts).await,
|
||||
DisplayServer::Wayland => wayland::start(opts, quality).await,
|
||||
DisplayServer::X11 => x11::start(opts, quality).await,
|
||||
DisplayServer::Unknown => unreachable!("caller guarantees display != Unknown"),
|
||||
}
|
||||
}
|
||||
|
||||
+26
-7
@@ -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 },
|
||||
|
||||
+38
-9
@@ -14,6 +14,7 @@ use tokio::process::{Child, Command};
|
||||
use tokio::time::timeout;
|
||||
|
||||
use super::audio::Routing;
|
||||
use super::quality::EffectiveQuality;
|
||||
use super::serve::Serve;
|
||||
use crate::cli::HostOpts;
|
||||
|
||||
@@ -70,11 +71,12 @@ impl Drop for CaptureHandle {
|
||||
/// the pipewire fd it leaked into the child; X11 passes a no-op.
|
||||
pub async fn spawn(
|
||||
opts: &HostOpts,
|
||||
quality: &EffectiveQuality,
|
||||
source_args: Vec<String>,
|
||||
after_spawn: impl FnOnce(),
|
||||
) -> Result<CaptureHandle> {
|
||||
let (audio_routing, audio_device) = setup_audio(opts).await?;
|
||||
let args = build_args(&source_args, &audio_device, opts);
|
||||
let args = build_args(&source_args, &audio_device, opts, quality);
|
||||
|
||||
let mut gst_cmd = Command::new("gst-launch-1.0");
|
||||
gst_cmd
|
||||
@@ -135,14 +137,21 @@ async fn setup_audio(opts: &HostOpts) -> Result<(Option<Routing>, String)> {
|
||||
}
|
||||
|
||||
/// Build the full gst-launch argument vector: MPEG-TS mux + fdsink, then the
|
||||
/// video branch (caller's `source` → videorate cap → encoder → h264parse →
|
||||
/// mux.), then the audio branch (pulsesrc → AAC → mux.). The encoder and the
|
||||
/// `videoconvert` target format are selected by `opts.no_hwencode`:
|
||||
/// hardware VAAPI wants NV12, software x264 wants I420.
|
||||
fn build_args(source: &[String], audio_device: &str, opts: &HostOpts) -> Vec<String> {
|
||||
let key_interval = (opts.framerate * 2).to_string();
|
||||
let bitrate = opts.bitrate.to_string();
|
||||
let framerate_caps = format!("video/x-raw,framerate={}/1", opts.framerate);
|
||||
/// video branch (caller's `source` → videorate cap → optional downscale →
|
||||
/// encoder → h264parse → mux.), then the audio branch (pulsesrc → AAC → mux.).
|
||||
/// Bitrate, framerate, and the downscale height come from the resolved
|
||||
/// [`EffectiveQuality`]; the encoder and the `videoconvert` target format are
|
||||
/// selected by `opts.no_hwencode` (hardware VAAPI wants NV12, software x264
|
||||
/// wants I420).
|
||||
fn build_args(
|
||||
source: &[String],
|
||||
audio_device: &str,
|
||||
opts: &HostOpts,
|
||||
quality: &EffectiveQuality,
|
||||
) -> Vec<String> {
|
||||
let key_interval = (quality.framerate * 2).to_string();
|
||||
let bitrate = quality.bitrate.to_string();
|
||||
let framerate_caps = format!("video/x-raw,framerate={}/1", quality.framerate);
|
||||
|
||||
let (raw_format, encoder_args): (&str, Vec<String>) = if opts.no_hwencode {
|
||||
(
|
||||
@@ -188,6 +197,26 @@ fn build_args(source: &[String], audio_device: &str, opts: &HostOpts) -> Vec<Str
|
||||
"!".into(),
|
||||
framerate_caps,
|
||||
"!".into(),
|
||||
]);
|
||||
// Optional downscale (quality presets). Omitted entirely for the native
|
||||
// "Source" preset. `pixel-aspect-ratio=1/1` forces a *proportional* scale:
|
||||
// screen capture always has square pixels, and without pinning PAR videoscale
|
||||
// keeps the full source width and just squashes PAR to preserve display
|
||||
// aspect (e.g. 1920x480 @ PAR 4/9 — no bandwidth win at all). With square
|
||||
// pixels fixed, width follows the source DAR. H.264 4:2:0 needs even
|
||||
// dimensions, so we pin height to an even value (preset heights already are;
|
||||
// a raw --max-height override is rounded down) and constrain width to a
|
||||
// stepped even range — verified 1920x1080→852x480 and 1366x768→1280x720.
|
||||
if let Some(h) = quality.max_height {
|
||||
let h = (h & !1).max(2);
|
||||
args.extend([
|
||||
"videoscale".into(),
|
||||
"!".into(),
|
||||
format!("video/x-raw,height={h},pixel-aspect-ratio=1/1,width=[2,8192,2]"),
|
||||
"!".into(),
|
||||
]);
|
||||
}
|
||||
args.extend([
|
||||
"queue".into(),
|
||||
"!".into(),
|
||||
"videoconvert".into(),
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -16,9 +16,10 @@ use nix::unistd::close;
|
||||
use std::os::fd::{AsFd, IntoRawFd, OwnedFd, RawFd};
|
||||
|
||||
use super::pipeline::{self, CaptureHandle};
|
||||
use super::quality::EffectiveQuality;
|
||||
use crate::cli::HostOpts;
|
||||
|
||||
pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
|
||||
pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<CaptureHandle> {
|
||||
// 1. Negotiate the screencast session with the portal.
|
||||
let proxy = Screencast::new()
|
||||
.await
|
||||
@@ -69,7 +70,7 @@ pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
|
||||
"do-timestamp=true".to_string(),
|
||||
];
|
||||
|
||||
pipeline::spawn(opts, source_args, move || {
|
||||
pipeline::spawn(opts, quality, source_args, move || {
|
||||
// Parent no longer needs the pipewire fd — gst inherited its own copy.
|
||||
let _ = close(raw_fd);
|
||||
})
|
||||
|
||||
+3
-2
@@ -11,9 +11,10 @@ use x11rb::connection::Connection;
|
||||
use x11rb::protocol::xproto::ConnectionExt;
|
||||
|
||||
use super::pipeline::{self, CaptureHandle};
|
||||
use super::quality::EffectiveQuality;
|
||||
use crate::cli::HostOpts;
|
||||
|
||||
pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
|
||||
pub async fn start(opts: &HostOpts, quality: &EffectiveQuality) -> Result<CaptureHandle> {
|
||||
let xid = if opts.window {
|
||||
Some(pick_window().await?)
|
||||
} else {
|
||||
@@ -41,7 +42,7 @@ pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
|
||||
}
|
||||
|
||||
// X11 has no leaked fd to clean up, so the post-spawn hook is a no-op.
|
||||
pipeline::spawn(opts, source_args, || {}).await
|
||||
pipeline::spawn(opts, quality, source_args, || {}).await
|
||||
}
|
||||
|
||||
/// Run `xwininfo` and let the user click the window they want to share, then
|
||||
|
||||
+40
-1
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user