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:
@@ -31,6 +31,9 @@ Working:
|
|||||||
- First-run upstream bandwidth pre-flight, persisted to
|
- First-run upstream bandwidth pre-flight, persisted to
|
||||||
`~/.config/pixelpass/config.toml` and used to auto-size the default
|
`~/.config/pixelpass/config.toml` and used to auto-size the default
|
||||||
viewer cap
|
viewer cap
|
||||||
|
- Quality presets (`--quality source|high|medium|low|auto`) that trade
|
||||||
|
resolution + bitrate for upload bandwidth, plus an `Auto` mode that
|
||||||
|
derives quality from the bandwidth pre-flight
|
||||||
|
|
||||||
Not yet built (deferred, not blocking):
|
Not yet built (deferred, not blocking):
|
||||||
- Per-monitor selection on a multi-monitor X11 host — `ximagesrc` grabs the
|
- Per-monitor selection on a multi-monitor X11 host — `ximagesrc` grabs the
|
||||||
@@ -242,7 +245,37 @@ message and the host keeps running.
|
|||||||
|
|
||||||
For more viewers, drop the per-viewer bitrate: e.g. `pixelpass
|
For more viewers, drop the per-viewer bitrate: e.g. `pixelpass
|
||||||
--bitrate 2500 --max-viewers 4` fits four 2.5 Mbps streams in roughly
|
--bitrate 2500 --max-viewers 4` fits four 2.5 Mbps streams in roughly
|
||||||
12 Mbps of upstream.
|
12 Mbps of upstream. The `--quality` presets below are the friendlier
|
||||||
|
way to do the same thing.
|
||||||
|
|
||||||
|
## Quality
|
||||||
|
|
||||||
|
`--quality <preset>` bundles a max video height, bitrate, and framerate —
|
||||||
|
resolution is a quality-per-bitrate knob, so the three only make sense
|
||||||
|
together. Quality is **host-global**: one encode pipeline fans out to every
|
||||||
|
viewer, so the sharer picks one quality for everyone (per-viewer quality
|
||||||
|
would need per-viewer encodes, which kills the fanout).
|
||||||
|
|
||||||
|
| Preset | Max height | Bitrate | fps |
|
||||||
|
|----------|-------------------|-----------|-----|
|
||||||
|
| `source` | native (no scale) | 6000 kbps | 30 |
|
||||||
|
| `high` | 1080p | 4000 kbps | 30 |
|
||||||
|
| `medium` | 720p | 2500 kbps | 30 |
|
||||||
|
| `low` | 480p | 1000 kbps | 30 |
|
||||||
|
| `auto` | derived (below) | derived | 30 |
|
||||||
|
|
||||||
|
`auto` (the default) picks the highest preset whose bitrate fits your
|
||||||
|
measured safe upstream divided by the viewer cap — so quality is sized for
|
||||||
|
the worst case, since it's baked in when capture starts and can't drop when
|
||||||
|
a second viewer joins. With no `--max-viewers`, it sizes for a single
|
||||||
|
viewer. If there's no bandwidth measurement yet, `auto` falls back to
|
||||||
|
`medium` (run `pixelpass --reconfigure` to measure). In the interactive
|
||||||
|
menu, omitting `--quality` shows a picker instead of assuming `auto`.
|
||||||
|
|
||||||
|
Downscaling preserves the source aspect ratio with square pixels and snaps
|
||||||
|
to even dimensions (H.264 requires them). Power users can override
|
||||||
|
individual fields: `--max-height N`, `--bitrate N`, and `--framerate N`
|
||||||
|
each take precedence over the chosen preset's value for that field.
|
||||||
|
|
||||||
## Known limitations and gotchas
|
## Known limitations and gotchas
|
||||||
|
|
||||||
|
|||||||
@@ -51,3 +51,38 @@ else
|
|||||||
echo "$MPV_LOG" | tail -30 | sed 's/^/ /'
|
echo "$MPV_LOG" | tail -30 | sed 's/^/ /'
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── quality-preset downscale check ────────────────────────────────────────
|
||||||
|
# Mirrors the videoscale element host/pipeline.rs inserts for a non-Source
|
||||||
|
# preset. A 16:9 source @ 480p wants width 853.3 — the danger case for H.264,
|
||||||
|
# which needs even dimensions. Asserts videoscale negotiates an even width
|
||||||
|
# (the pixel-aspect-ratio=1/1 + stepped-even-range caps) and the encoder
|
||||||
|
# accepts it. Guards the "even-width caveat" the design plan flagged.
|
||||||
|
SCALED="${TMPDIR:-/tmp}/pixelpass-smoke-scaled-$$.ts"
|
||||||
|
trap 'rm -f "$OUT" "$SCALED"' EXIT
|
||||||
|
echo "[smoke] downscale check: 1920x1080 -> height 480 (videoscale, even-width)"
|
||||||
|
gst-launch-1.0 -q \
|
||||||
|
mpegtsmux name=mux ! queue ! filesink location="$SCALED" \
|
||||||
|
videotestsrc num-buffers=30 is-live=false \
|
||||||
|
! video/x-raw,width=1920,height=1080,framerate=30/1 \
|
||||||
|
! videorate ! video/x-raw,framerate=30/1 \
|
||||||
|
! videoscale ! "video/x-raw,height=480,pixel-aspect-ratio=1/1,width=[2,8192,2]" \
|
||||||
|
! videoconvert ! video/x-raw,format=NV12 \
|
||||||
|
! vah264enc rate-control=cbr bitrate=1000 key-int-max=60 \
|
||||||
|
! h264parse config-interval=-1 \
|
||||||
|
! video/x-h264,stream-format=byte-stream,alignment=au ! mux. \
|
||||||
|
audiotestsrc num-buffers=47 is-live=false \
|
||||||
|
! audioconvert ! audioresample ! audio/x-raw,rate=48000,channels=2 \
|
||||||
|
! avenc_aac bitrate=128000 ! aacparse ! mux.
|
||||||
|
|
||||||
|
SCALED_DIMS=$(ffprobe -v error -select_streams v:0 -show_entries stream=width,height \
|
||||||
|
-of csv=p=0 "$SCALED" | head -1)
|
||||||
|
SCALED_W=${SCALED_DIMS%,*}
|
||||||
|
SCALED_H=${SCALED_DIMS#*,}
|
||||||
|
echo " negotiated ${SCALED_W}x${SCALED_H}"
|
||||||
|
if [[ "$SCALED_H" == "480" && $((SCALED_W % 2)) -eq 0 && "$SCALED_W" -lt 1920 ]]; then
|
||||||
|
echo "[smoke] PASS — downscale produced an even width below source (proportional)"
|
||||||
|
else
|
||||||
|
echo "[smoke] FAIL: expected even width < 1920 at height 480, got ${SCALED_W}x${SCALED_H}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|||||||
+48
-8
@@ -25,13 +25,25 @@ pub struct Cli {
|
|||||||
#[arg(long, value_enum)]
|
#[arg(long, value_enum)]
|
||||||
pub display_server: Option<DisplayServerArg>,
|
pub display_server: Option<DisplayServerArg>,
|
||||||
|
|
||||||
/// Encode bitrate in kbps.
|
/// Quality preset. Bundles a max video height, bitrate, and framerate.
|
||||||
#[arg(long, default_value_t = 6000)]
|
/// `auto` derives them from the saved bandwidth pre-flight (falls back to
|
||||||
pub bitrate: u32,
|
/// `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.
|
/// Cap the encoded video height (px); width follows the source aspect.
|
||||||
#[arg(long, default_value_t = 30)]
|
/// Power-user override — takes precedence over the preset's height.
|
||||||
pub framerate: u32,
|
#[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.
|
/// Disable VAAPI HW encode; force software x264.
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -71,13 +83,37 @@ pub enum DisplayServerArg {
|
|||||||
X11,
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct HostOpts {
|
pub struct HostOpts {
|
||||||
pub window: bool,
|
pub window: bool,
|
||||||
pub app: Option<String>,
|
pub app: Option<String>,
|
||||||
pub display_server: Option<DisplayServerArg>,
|
pub display_server: Option<DisplayServerArg>,
|
||||||
pub bitrate: u32,
|
/// Chosen preset (Auto = derive at startup). Defaults to Auto.
|
||||||
pub framerate: u32,
|
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 no_hwencode: bool,
|
||||||
pub max_viewers: Option<u32>,
|
pub max_viewers: Option<u32>,
|
||||||
pub interactive: bool,
|
pub interactive: bool,
|
||||||
@@ -95,8 +131,12 @@ impl Cli {
|
|||||||
window: self.window,
|
window: self.window,
|
||||||
app: self.app,
|
app: self.app,
|
||||||
display_server: self.display_server,
|
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,
|
bitrate: self.bitrate,
|
||||||
framerate: self.framerate,
|
framerate: self.framerate,
|
||||||
|
max_height: self.max_height,
|
||||||
no_hwencode: self.no_hwencode,
|
no_hwencode: self.no_hwencode,
|
||||||
max_viewers: self.max_viewers,
|
max_viewers: self.max_viewers,
|
||||||
interactive,
|
interactive,
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ pub fn check_host_binaries(display: DisplayServer, opts: &HostOpts) -> Result<()
|
|||||||
require("gst-launch-1.0")?;
|
require("gst-launch-1.0")?;
|
||||||
require("gst-inspect-1.0")?;
|
require("gst-inspect-1.0")?;
|
||||||
require("pactl")?;
|
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("h264parse")?;
|
||||||
require_gst_element("mpegtsmux")?;
|
require_gst_element("mpegtsmux")?;
|
||||||
require_gst_element("pulsesrc")?;
|
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",
|
Some("opensuse" | "opensuse-tumbleweed" | "opensuse-leap") => "gstreamer-plugins-good",
|
||||||
_ => "the GStreamer X11 plugin (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() {
|
"h264parse" | "mpegtsmux" | "aacparse" => match distro.as_deref() {
|
||||||
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-bad",
|
Some("arch" | "cachyos" | "manjaro" | "endeavouros") => "gst-plugins-bad",
|
||||||
Some("debian" | "ubuntu" | "pop" | "linuxmint") => "gstreamer1.0-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::cli::HostOpts;
|
||||||
use crate::common::display::DisplayServer;
|
use crate::common::display::DisplayServer;
|
||||||
use crate::host::pipeline::CaptureHandle;
|
use crate::host::pipeline::CaptureHandle;
|
||||||
|
use crate::host::quality::EffectiveQuality;
|
||||||
use crate::host::{wayland, x11};
|
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 {
|
match display {
|
||||||
DisplayServer::Wayland => wayland::start(opts).await,
|
DisplayServer::Wayland => wayland::start(opts, quality).await,
|
||||||
DisplayServer::X11 => x11::start(opts).await,
|
DisplayServer::X11 => x11::start(opts, quality).await,
|
||||||
DisplayServer::Unknown => unreachable!("caller guarantees display != Unknown"),
|
DisplayServer::Unknown => unreachable!("caller guarantees display != Unknown"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+26
-7
@@ -1,6 +1,7 @@
|
|||||||
pub mod audio;
|
pub mod audio;
|
||||||
mod capture;
|
mod capture;
|
||||||
mod pipeline;
|
mod pipeline;
|
||||||
|
mod quality;
|
||||||
mod serve;
|
mod serve;
|
||||||
mod wayland;
|
mod wayland;
|
||||||
mod x11;
|
mod x11;
|
||||||
@@ -20,6 +21,7 @@ use crate::common::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use self::pipeline::CaptureHandle;
|
use self::pipeline::CaptureHandle;
|
||||||
|
use self::quality::EffectiveQuality;
|
||||||
|
|
||||||
/// Messages from per-viewer tasks to the capture supervisor.
|
/// Messages from per-viewer tasks to the capture supervisor.
|
||||||
enum SupervisorMsg {
|
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 {
|
if resolution.value == 0 {
|
||||||
bail!("--max-viewers must be at least 1");
|
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());
|
EndpointAddr::new(addr.id).with_addrs(addr.addrs.iter().filter(|a| a.is_relay()).cloned());
|
||||||
let ticket = EndpointTicket::new(relay_only);
|
let ticket = EndpointTicket::new(relay_only);
|
||||||
let clipboard_ok = opts.interactive && copy_to_clipboard(&ticket.to_string());
|
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 (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;
|
accept_loop(&endpoint, sup_tx.clone(), cancel.clone()).await;
|
||||||
|
|
||||||
@@ -187,6 +203,7 @@ async fn handle_peer(
|
|||||||
/// the count is already at the cap.
|
/// the count is already at the cap.
|
||||||
async fn supervise(
|
async fn supervise(
|
||||||
opts: HostOpts,
|
opts: HostOpts,
|
||||||
|
quality: EffectiveQuality,
|
||||||
display: DisplayServer,
|
display: DisplayServer,
|
||||||
max_viewers: u32,
|
max_viewers: u32,
|
||||||
mut rx: mpsc::Receiver<SupervisorMsg>,
|
mut rx: mpsc::Receiver<SupervisorMsg>,
|
||||||
@@ -206,7 +223,7 @@ async fn supervise(
|
|||||||
|
|
||||||
if handle.is_none() {
|
if handle.is_none() {
|
||||||
tracing::info!("first viewer arriving — spawning capture");
|
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),
|
Ok(h) => handle = Some(h),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = reply.send(Err(format!("capture spawn failed: {e:#}")));
|
let _ = reply.send(Err(format!("capture spawn failed: {e:#}")));
|
||||||
@@ -243,6 +260,7 @@ fn print_host_banner(
|
|||||||
ticket: &EndpointTicket,
|
ticket: &EndpointTicket,
|
||||||
display: DisplayServer,
|
display: DisplayServer,
|
||||||
opts: &HostOpts,
|
opts: &HostOpts,
|
||||||
|
quality: &EffectiveQuality,
|
||||||
resolution: &MaxViewersResolution,
|
resolution: &MaxViewersResolution,
|
||||||
clipboard_ok: bool,
|
clipboard_ok: bool,
|
||||||
) {
|
) {
|
||||||
@@ -250,7 +268,8 @@ fn print_host_banner(
|
|||||||
eprintln!("┌─ PixelPass · host ─────────────────────────────────────────");
|
eprintln!("┌─ PixelPass · host ─────────────────────────────────────────");
|
||||||
eprintln!("│ display server : {display:?}");
|
eprintln!("│ display server : {display:?}");
|
||||||
eprintln!("│ capture : {}", capture_summary(opts));
|
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!("│ hw encode : {}", if opts.no_hwencode { "off (software x264)" } else { "on (VAAPI H.264)" });
|
||||||
eprintln!("│ max viewers : {} ({})", resolution.value, resolution.source.label());
|
eprintln!("│ max viewers : {} ({})", resolution.value, resolution.source.label());
|
||||||
eprintln!("│");
|
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 {
|
if let Some(n) = opts.max_viewers {
|
||||||
return MaxViewersResolution {
|
return MaxViewersResolution {
|
||||||
value: n,
|
value: n,
|
||||||
@@ -313,7 +332,7 @@ fn resolve_max_viewers(opts: &HostOpts) -> MaxViewersResolution {
|
|||||||
&& cfg.bandwidth.status == BandwidthStatus::Measured
|
&& cfg.bandwidth.status == BandwidthStatus::Measured
|
||||||
&& let Some(upstream) = cfg.bandwidth.upstream_mbps
|
&& 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 {
|
return MaxViewersResolution {
|
||||||
value: n,
|
value: n,
|
||||||
source: MaxViewersSource::BandwidthMeasurement { safe_mbps: upstream },
|
source: MaxViewersSource::BandwidthMeasurement { safe_mbps: upstream },
|
||||||
|
|||||||
+38
-9
@@ -14,6 +14,7 @@ use tokio::process::{Child, Command};
|
|||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
|
|
||||||
use super::audio::Routing;
|
use super::audio::Routing;
|
||||||
|
use super::quality::EffectiveQuality;
|
||||||
use super::serve::Serve;
|
use super::serve::Serve;
|
||||||
use crate::cli::HostOpts;
|
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.
|
/// the pipewire fd it leaked into the child; X11 passes a no-op.
|
||||||
pub async fn spawn(
|
pub async fn spawn(
|
||||||
opts: &HostOpts,
|
opts: &HostOpts,
|
||||||
|
quality: &EffectiveQuality,
|
||||||
source_args: Vec<String>,
|
source_args: Vec<String>,
|
||||||
after_spawn: impl FnOnce(),
|
after_spawn: impl FnOnce(),
|
||||||
) -> Result<CaptureHandle> {
|
) -> Result<CaptureHandle> {
|
||||||
let (audio_routing, audio_device) = setup_audio(opts).await?;
|
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");
|
let mut gst_cmd = Command::new("gst-launch-1.0");
|
||||||
gst_cmd
|
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
|
/// Build the full gst-launch argument vector: MPEG-TS mux + fdsink, then the
|
||||||
/// video branch (caller's `source` → videorate cap → encoder → h264parse →
|
/// video branch (caller's `source` → videorate cap → optional downscale →
|
||||||
/// mux.), then the audio branch (pulsesrc → AAC → mux.). The encoder and the
|
/// encoder → h264parse → mux.), then the audio branch (pulsesrc → AAC → mux.).
|
||||||
/// `videoconvert` target format are selected by `opts.no_hwencode`:
|
/// Bitrate, framerate, and the downscale height come from the resolved
|
||||||
/// hardware VAAPI wants NV12, software x264 wants I420.
|
/// [`EffectiveQuality`]; the encoder and the `videoconvert` target format are
|
||||||
fn build_args(source: &[String], audio_device: &str, opts: &HostOpts) -> Vec<String> {
|
/// selected by `opts.no_hwencode` (hardware VAAPI wants NV12, software x264
|
||||||
let key_interval = (opts.framerate * 2).to_string();
|
/// wants I420).
|
||||||
let bitrate = opts.bitrate.to_string();
|
fn build_args(
|
||||||
let framerate_caps = format!("video/x-raw,framerate={}/1", opts.framerate);
|
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 {
|
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(),
|
"!".into(),
|
||||||
framerate_caps,
|
framerate_caps,
|
||||||
"!".into(),
|
"!".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(),
|
"queue".into(),
|
||||||
"!".into(),
|
"!".into(),
|
||||||
"videoconvert".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 std::os::fd::{AsFd, IntoRawFd, OwnedFd, RawFd};
|
||||||
|
|
||||||
use super::pipeline::{self, CaptureHandle};
|
use super::pipeline::{self, CaptureHandle};
|
||||||
|
use super::quality::EffectiveQuality;
|
||||||
use crate::cli::HostOpts;
|
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.
|
// 1. Negotiate the screencast session with the portal.
|
||||||
let proxy = Screencast::new()
|
let proxy = Screencast::new()
|
||||||
.await
|
.await
|
||||||
@@ -69,7 +70,7 @@ pub async fn start(opts: &HostOpts) -> Result<CaptureHandle> {
|
|||||||
"do-timestamp=true".to_string(),
|
"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.
|
// Parent no longer needs the pipewire fd — gst inherited its own copy.
|
||||||
let _ = close(raw_fd);
|
let _ = close(raw_fd);
|
||||||
})
|
})
|
||||||
|
|||||||
+3
-2
@@ -11,9 +11,10 @@ use x11rb::connection::Connection;
|
|||||||
use x11rb::protocol::xproto::ConnectionExt;
|
use x11rb::protocol::xproto::ConnectionExt;
|
||||||
|
|
||||||
use super::pipeline::{self, CaptureHandle};
|
use super::pipeline::{self, CaptureHandle};
|
||||||
|
use super::quality::EffectiveQuality;
|
||||||
use crate::cli::HostOpts;
|
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 {
|
let xid = if opts.window {
|
||||||
Some(pick_window().await?)
|
Some(pick_window().await?)
|
||||||
} else {
|
} 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.
|
// 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
|
/// 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 iroh_tickets::endpoint::EndpointTicket;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use crate::cli::Cli;
|
use crate::cli::{Cli, Quality};
|
||||||
use crate::common::{bandwidth, config};
|
use crate::common::{bandwidth, config};
|
||||||
use crate::{host, viewer};
|
use crate::{host, viewer};
|
||||||
|
|
||||||
@@ -27,6 +27,9 @@ pub async fn run(cli: Cli) -> Result<()> {
|
|||||||
if cli.app.is_none() {
|
if cli.app.is_none() {
|
||||||
cli.app = pick_app(&theme)?;
|
cli.app = pick_app(&theme)?;
|
||||||
}
|
}
|
||||||
|
if cli.quality.is_none() {
|
||||||
|
cli.quality = Some(pick_quality(&theme)?);
|
||||||
|
}
|
||||||
host::run(cli.into_host_opts(true)).await
|
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
|
/// `pixelpass --reconfigure` entry point: unconditionally re-run the
|
||||||
/// bandwidth pre-flight test, save the result, and return. Used to
|
/// bandwidth pre-flight test, save the result, and return. Used to
|
||||||
/// refresh a stale measurement (e.g. user moved house, changed ISP).
|
/// refresh a stale measurement (e.g. user moved house, changed ISP).
|
||||||
|
|||||||
Reference in New Issue
Block a user