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:
+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(),
|
||||
|
||||
Reference in New Issue
Block a user