From 25da3137aaf638cecc9c7870a34e95a75443fb4d Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 19 Apr 2026 03:28:23 -0300 Subject: [PATCH] lesavka: pivot eye streams to native source modes --- client/Cargo.toml | 2 +- client/src/app.rs | 4 +- client/src/bin/lesavka-relayctl.rs | 128 +++++ client/src/launcher/diagnostics.rs | 197 ++++++-- client/src/launcher/preview.rs | 425 ++++++++++++---- client/src/launcher/state.rs | 500 ++++++++++--------- client/src/launcher/ui.rs | 271 +++++++--- client/src/launcher/ui_components.rs | 207 +++++--- common/Cargo.toml | 2 +- common/proto/lesavka.proto | 2 +- common/src/cli.rs | 2 +- common/src/eye_source.rs | 99 ++++ common/src/lib.rs | 1 + scripts/manual/probe-eye-capabilities.sh | 72 +++ server/Cargo.toml | 2 +- server/src/main.rs | 8 +- server/src/video.rs | 151 ++---- testing/tests/server_main_binary_contract.rs | 4 +- testing/tests/server_main_rpc_contract.rs | 8 +- 19 files changed, 1462 insertions(+), 623 deletions(-) create mode 100644 client/src/bin/lesavka-relayctl.rs create mode 100644 common/src/eye_source.rs create mode 100755 scripts/manual/probe-eye-capabilities.sh diff --git a/client/Cargo.toml b/client/Cargo.toml index 2cc2431..b24116e 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.11" +version = "0.11.12" edition = "2024" [dependencies] diff --git a/client/src/app.rs b/client/src/app.rs index 002035b..e6f5bb3 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -425,7 +425,7 @@ impl LesavkaClientApp { requested_width: 0, requested_height: 0, requested_fps: 0, - prefer_reencode: false, + source_id: None, }; match cli.capture_video(Request::new(req)).await { Ok(mut stream) => { @@ -469,7 +469,7 @@ impl LesavkaClientApp { requested_width: 0, requested_height: 0, requested_fps: 0, - prefer_reencode: false, + source_id: None, }; match cli.capture_audio(Request::new(req)).await { Ok(mut stream) => { diff --git a/client/src/bin/lesavka-relayctl.rs b/client/src/bin/lesavka-relayctl.rs new file mode 100644 index 0000000..ef747d6 --- /dev/null +++ b/client/src/bin/lesavka-relayctl.rs @@ -0,0 +1,128 @@ +use anyhow::{Context, Result, bail}; +use lesavka_common::lesavka::{ + CapturePowerCommand, Empty, SetCapturePowerRequest, relay_client::RelayClient, +}; +use tonic::{Request, transport::Channel}; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CommandKind { + Status, + Auto, + On, + Off, +} + +impl CommandKind { + fn parse(value: &str) -> Option { + match value { + "status" | "get" => Some(Self::Status), + "auto" => Some(Self::Auto), + "on" | "force-on" => Some(Self::On), + "off" | "force-off" => Some(Self::Off), + _ => None, + } + } +} + +struct Config { + server: String, + command: CommandKind, +} + +fn usage() -> &'static str { + "Usage: lesavka-relayctl [--server http://HOST:50051] " +} + +fn parse_args() -> Result { + let mut args = std::env::args().skip(1); + let mut server = "http://127.0.0.1:50051".to_string(); + let mut command = None; + + while let Some(arg) = args.next() { + match arg.as_str() { + "--server" => { + server = args + .next() + .context("missing value after --server")? + .trim() + .to_string(); + } + "--help" | "-h" => { + println!("{}", usage()); + std::process::exit(0); + } + _ if command.is_none() => { + command = CommandKind::parse(arg.as_str()); + if command.is_none() { + bail!("unknown command `{arg}`\n{}", usage()); + } + } + _ => bail!("unexpected argument `{arg}`\n{}", usage()), + } + } + + Ok(Config { + server, + command: command.unwrap_or(CommandKind::Status), + }) +} + +async fn connect(server_addr: &str) -> Result> { + let channel = Channel::from_shared(server_addr.to_string()) + .context("invalid relay server address")? + .tcp_nodelay(true) + .connect() + .await + .with_context(|| format!("connecting to relay at {server_addr}"))?; + Ok(RelayClient::new(channel)) +} + +fn print_state(state: lesavka_common::lesavka::CapturePowerState) { + println!("available={}", state.available); + println!("enabled={}", state.enabled); + println!("mode={}", state.mode); + println!("active_leases={}", state.active_leases); + println!("unit={}", state.unit); + println!("detail={}", state.detail); +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<()> { + let config = parse_args()?; + let mut client = connect(config.server.as_str()).await?; + + let reply = match config.command { + CommandKind::Status => client + .get_capture_power(Request::new(Empty {})) + .await + .context("querying capture power state")? + .into_inner(), + CommandKind::Auto => client + .set_capture_power(Request::new(SetCapturePowerRequest { + enabled: false, + command: CapturePowerCommand::Auto as i32, + })) + .await + .context("setting capture power to auto")? + .into_inner(), + CommandKind::On => client + .set_capture_power(Request::new(SetCapturePowerRequest { + enabled: true, + command: CapturePowerCommand::ForceOn as i32, + })) + .await + .context("forcing capture power on")? + .into_inner(), + CommandKind::Off => client + .set_capture_power(Request::new(SetCapturePowerRequest { + enabled: false, + command: CapturePowerCommand::ForceOff as i32, + })) + .await + .context("forcing capture power off")? + .into_inner(), + }; + + print_state(reply); + Ok(()) +} diff --git a/client/src/launcher/diagnostics.rs b/client/src/launcher/diagnostics.rs index e72c3fd..9b579e9 100644 --- a/client/src/launcher/diagnostics.rs +++ b/client/src/launcher/diagnostics.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use std::collections::VecDeque; use std::fmt::Write as _; -use super::state::{InputRouting, LauncherState, ViewMode}; +use super::state::{CaptureSizeChoice, FeedSourcePreset, InputRouting, LauncherState, ViewMode}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PerformanceSample { @@ -28,6 +28,7 @@ pub struct PerformanceSample { pub left_decoder_label: String, pub left_stream_caps_label: String, pub left_decoded_caps_label: String, + pub left_rendered_caps_label: String, pub right_receive_fps: f32, pub right_present_fps: f32, pub right_server_fps: f32, @@ -43,6 +44,7 @@ pub struct PerformanceSample { pub right_decoder_label: String, pub right_stream_caps_label: String, pub right_decoded_caps_label: String, + pub right_rendered_caps_label: String, pub dropped_frames: u64, pub queue_depth: u32, } @@ -100,6 +102,7 @@ pub struct SnapshotReport { pub preview_source: String, pub client_display_limit: String, pub left_surface: String, + pub left_feed_source: String, pub left_capture_profile: String, pub left_capture_transport: String, pub left_breakout_profile: String, @@ -115,7 +118,9 @@ pub struct SnapshotReport { pub left_server_encoder_label: String, pub left_stream_caps_label: String, pub left_decoded_caps_label: String, + pub left_rendered_caps_label: String, pub right_surface: String, + pub right_feed_source: String, pub right_capture_profile: String, pub right_capture_transport: String, pub right_breakout_profile: String, @@ -131,6 +136,7 @@ pub struct SnapshotReport { pub right_server_encoder_label: String, pub right_stream_caps_label: String, pub right_decoded_caps_label: String, + pub right_rendered_caps_label: String, pub selected_camera: Option, pub selected_microphone: Option, pub selected_speaker: Option, @@ -150,6 +156,12 @@ impl SnapshotReport { let left_breakout = state.breakout_size_choice(0); let right_breakout = state.breakout_size_choice(1); let latest = log.latest(); + let left_stream_caps = latest + .map(|sample| sample.left_stream_caps_label.clone()) + .unwrap_or_default(); + let right_stream_caps = latest + .map(|sample| sample.right_stream_caps_label.clone()) + .unwrap_or_default(); Self { client_version: crate::VERSION.to_string(), server_version: state.server_version.clone(), @@ -178,14 +190,12 @@ impl SnapshotReport { state.breakout_display.width, state.breakout_display.height ), left_surface: state.display_surface(0).label().to_string(), - left_capture_profile: format!( - "{} | {}x{} | {} fps | {} kbit", - left_capture.preset.label(), - left_capture.width, - left_capture.height, - left_capture.fps, - left_capture.max_bitrate_kbit - ), + left_feed_source: match state.feed_source_preset(0) { + super::state::FeedSourcePreset::ThisEye => "Left Eye".to_string(), + super::state::FeedSourcePreset::OtherEye => "Right Eye (mirrored)".to_string(), + super::state::FeedSourcePreset::Off => "Off".to_string(), + }, + left_capture_profile: capture_profile_label(&left_capture, &left_stream_caps), left_capture_transport: left_capture.preset.transport_label().to_string(), left_breakout_profile: format!( "{} | {}x{}", @@ -249,15 +259,22 @@ impl SnapshotReport { } }) .unwrap_or_else(|| "pending".to_string()), + left_rendered_caps_label: latest + .map(|sample| { + if sample.left_rendered_caps_label.is_empty() { + "pending".to_string() + } else { + sample.left_rendered_caps_label.clone() + } + }) + .unwrap_or_else(|| "pending".to_string()), right_surface: state.display_surface(1).label().to_string(), - right_capture_profile: format!( - "{} | {}x{} | {} fps | {} kbit", - right_capture.preset.label(), - right_capture.width, - right_capture.height, - right_capture.fps, - right_capture.max_bitrate_kbit - ), + right_feed_source: match state.feed_source_preset(1) { + super::state::FeedSourcePreset::ThisEye => "Right Eye".to_string(), + super::state::FeedSourcePreset::OtherEye => "Left Eye (mirrored)".to_string(), + super::state::FeedSourcePreset::Off => "Off".to_string(), + }, + right_capture_profile: capture_profile_label(&right_capture, &right_stream_caps), right_capture_transport: right_capture.preset.transport_label().to_string(), right_breakout_profile: format!( "{} | {}x{}", @@ -321,6 +338,15 @@ impl SnapshotReport { } }) .unwrap_or_else(|| "pending".to_string()), + right_rendered_caps_label: latest + .map(|sample| { + if sample.right_rendered_caps_label.is_empty() { + "pending".to_string() + } else { + sample.right_rendered_caps_label.clone() + } + }) + .unwrap_or_else(|| "pending".to_string()), selected_camera: state.devices.camera.clone(), selected_microphone: state.devices.microphone.clone(), selected_speaker: state.devices.speaker.clone(), @@ -364,6 +390,7 @@ impl SnapshotReport { let _ = writeln!(text); let _ = writeln!(text, "left eye"); let _ = writeln!(text, " surface: {}", self.left_surface); + let _ = writeln!(text, " source: {}", self.left_feed_source); let _ = writeln!(text, " capture: {}", self.left_capture_profile); let _ = writeln!(text, " transport: {}", self.left_capture_transport); let _ = writeln!(text, " breakout: {}", self.left_breakout_profile); @@ -379,6 +406,7 @@ impl SnapshotReport { ); let _ = writeln!(text, " stream caps: {}", self.left_stream_caps_label); let _ = writeln!(text, " decoded caps: {}", self.left_decoded_caps_label); + let _ = writeln!(text, " rendered caps: {}", self.left_rendered_caps_label); let _ = writeln!( text, " server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}", @@ -390,6 +418,7 @@ impl SnapshotReport { ); let _ = writeln!(text, "right eye"); let _ = writeln!(text, " surface: {}", self.right_surface); + let _ = writeln!(text, " source: {}", self.right_feed_source); let _ = writeln!(text, " capture: {}", self.right_capture_profile); let _ = writeln!(text, " transport: {}", self.right_capture_transport); let _ = writeln!(text, " breakout: {}", self.right_breakout_profile); @@ -405,6 +434,7 @@ impl SnapshotReport { ); let _ = writeln!(text, " stream caps: {}", self.right_stream_caps_label); let _ = writeln!(text, " decoded caps: {}", self.right_decoded_caps_label); + let _ = writeln!(text, " rendered caps: {}", self.right_rendered_caps_label); let _ = writeln!( text, " server: encoder={} cpu={:.1}% gaps={:.0}/{:.0}ms queue-peak={}", @@ -510,6 +540,50 @@ pub fn quality_probe_command() -> &'static str { "scripts/ci/hygiene_gate.sh && scripts/ci/quality_gate.sh" } +fn capture_profile_label(capture: &CaptureSizeChoice, stream_caps_label: &str) -> String { + if let Some((width, height, fps)) = parse_stream_caps_profile(stream_caps_label) { + return format!( + "{} | observed {}x{} @ {} fps | bitrate est ~{} kbit", + capture.preset.label(), + width, + height, + fps, + capture.max_bitrate_kbit + ); + } + format!( + "{} | {}x{} | {} fps | bitrate est ~{} kbit", + capture.preset.label(), + capture.width, + capture.height, + capture.fps, + capture.max_bitrate_kbit + ) +} + +fn parse_stream_caps_profile(caps: &str) -> Option<(u32, u32, u32)> { + let width = parse_caps_u32(caps, "width=(int)")?; + let height = parse_caps_u32(caps, "height=(int)")?; + let fps = parse_caps_fraction_numerator(caps, "framerate=(fraction)")?; + Some((width, height, fps)) +} + +fn parse_caps_u32(caps: &str, needle: &str) -> Option { + let start = caps.find(needle)? + needle.len(); + let tail = &caps[start..]; + let end = tail.find([',', ';']).unwrap_or(tail.len()); + tail[..end].trim().parse::().ok() +} + +fn parse_caps_fraction_numerator(caps: &str, needle: &str) -> Option { + let start = caps.find(needle)? + needle.len(); + let tail = &caps[start..]; + let end = tail.find([',', ';']).unwrap_or(tail.len()); + let value = tail[..end].trim(); + let numerator = value.split('/').next()?; + numerator.parse::().ok() +} + fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec { let mut items = Vec::new(); if !state.server_available { @@ -533,7 +607,7 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec= 2.0 || sample.dropped_frames > 0 { items.push( - "Video packets are arriving with gaps or server-side drops. Try 900p or 720p capture first, then watch whether dropped frames and video-loss fall." + "Video packets are arriving with gaps or server-side drops. Stay on device H.264 pass-through for now and reduce concurrent load before trying more invasive changes." .to_string(), ); } @@ -595,32 +669,18 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec= 85.0 { items.push( - "Server process CPU is high. That makes re-encode stalls more likely, so compare Source against lighter re-encode profiles before assuming the WAN is the bottleneck." + "Server process CPU is high. On current hardware that is a strong reason to stay on device H.264 pass-through and avoid any server-side eye transcoding." .to_string(), ); } } - let heavy_capture = state.capture_sizes.iter().any(|preset| { - matches!( - preset, - super::state::CaptureSizePreset::Source - | super::state::CaptureSizePreset::P1080 - | super::state::CaptureSizePreset::P1440 - ) - }); - if heavy_capture { - items.push( - "If motion artifacting spikes, try a 900p or 720p capture profile before shrinking the breakout window; that usually lowers WAN pressure faster." - .to_string(), - ); - } let source_passthrough = state - .capture_sizes + .feed_sources .iter() - .any(|preset| matches!(preset, super::state::CaptureSizePreset::Source)); + .any(|preset| !matches!(preset, FeedSourcePreset::Off)); if source_passthrough { items.push( - "Source capture uses the HDMI device's own H.264 stream. If motion damage lingers for seconds, switch that eye to 1080p, 900p, or 720p so the server re-encodes with a tighter keyframe cadence." + "Device H.264 pass-through is active. If we need it cheaper, use the eye device's real 1080p/720p/576p/480p/VGA source modes before considering codec-aware thinning." .to_string(), ); } @@ -631,7 +691,7 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec 6.0) { items.push( - "Receive fps is well below the target without packet loss. That usually points at source cadence or local decode pressure more than WAN loss, so compare Source against 1080p/900p and watch which side stays steadier." + "Receive fps is well below the target without packet loss. That usually points at source cadence or local decode pressure more than WAN loss." .to_string(), ); } @@ -662,7 +722,7 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec Vec PerformanceSample { PerformanceSample { @@ -705,9 +765,13 @@ mod tests { left_server_queue_peak: n as u32 + 1, left_server_encoder_label: "x264enc".to_string(), left_decoder_label: "decodebin".to_string(), - left_stream_caps_label: "video/x-h264, width=(int)1920, height=(int)1080".to_string(), + left_stream_caps_label: + "video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1" + .to_string(), left_decoded_caps_label: "video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(), + left_rendered_caps_label: + "video/x-raw, format=(string)RGBA, width=(int)1920, height=(int)1080".to_string(), right_receive_fps: 30.0, right_present_fps: 28.0, right_server_fps: 30.0, @@ -721,9 +785,13 @@ mod tests { right_server_queue_peak: n as u32 + 1, right_server_encoder_label: "source-pass-through".to_string(), right_decoder_label: "decodebin".to_string(), - right_stream_caps_label: "video/x-h264, width=(int)1920, height=(int)1080".to_string(), + right_stream_caps_label: + "video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1" + .to_string(), right_decoded_caps_label: "video/x-raw, format=(string)NV12, width=(int)1920, height=(int)1080".to_string(), + right_rendered_caps_label: + "video/x-raw, format=(string)RGBA, width=(int)1920, height=(int)1080".to_string(), dropped_frames: n, queue_depth: n as u32, } @@ -781,11 +849,17 @@ mod tests { assert_eq!(report.notes, vec!["first note".to_string()]); assert!(report.status.contains("mode=remote")); assert!(report.client_version.starts_with("0.")); - assert!(report.left_capture_profile.contains("fps")); - assert_eq!(report.left_capture_transport, "server re-encode"); + assert_eq!(report.left_feed_source, "Left Eye"); + assert!( + report + .left_capture_profile + .contains("observed 1920x1080 @ 60 fps") + ); + assert_eq!(report.left_capture_transport, "device H.264 pass-through"); assert_eq!(report.left_decoder_label, "decodebin"); assert!(report.left_stream_caps_label.contains("video/x-h264")); assert!(report.left_decoded_caps_label.contains("video/x-raw")); + assert!(report.left_rendered_caps_label.contains("video/x-raw")); } #[test] @@ -812,10 +886,12 @@ mod tests { assert!(text.contains("Lesavka Diagnostics")); assert!(text.contains("client: v")); assert!(text.contains("left eye")); + assert!(text.contains("source:")); assert!(text.contains("transport:")); assert!(text.contains("live: decoder=")); assert!(text.contains("stream caps:")); assert!(text.contains("decoded caps:")); + assert!(text.contains("rendered caps:")); assert!(text.contains("recommendations")); } @@ -825,4 +901,39 @@ mod tests { assert!(cmd.contains("hygiene_gate.sh")); assert!(cmd.contains("quality_gate.sh")); } + + #[test] + fn source_capture_profile_prefers_observed_stream_caps_when_available() { + let capture = CaptureSizeChoice { + preset: CaptureSizePreset::P1080, + width: 1920, + height: 1080, + fps: 60, + max_bitrate_kbit: 18_000, + }; + let label = capture_profile_label( + &capture, + "video/x-h264, width=(int)1920, height=(int)1080, framerate=(fraction)60/1", + ); + assert_eq!( + label, + "1080p | observed 1920x1080 @ 60 fps | bitrate est ~18000 kbit" + ); + } + + #[test] + fn capture_profile_falls_back_when_stream_caps_are_incomplete() { + let capture = CaptureSizeChoice { + preset: CaptureSizePreset::P1080, + width: 1920, + height: 1080, + fps: 60, + max_bitrate_kbit: 18_000, + }; + let label = capture_profile_label(&capture, "video/x-h264, width=(int)1920"); + assert_eq!( + label, + "1080p | 1920x1080 | 60 fps | bitrate est ~18000 kbit" + ); + } } diff --git a/client/src/launcher/preview.rs b/client/src/launcher/preview.rs index a779b65..a0c5b0b 100644 --- a/client/src/launcher/preview.rs +++ b/client/src/launcher/preview.rs @@ -12,7 +12,6 @@ use gstreamer_app as gst_app; use gtk::prelude::WidgetExt; #[cfg(not(coverage))] use gtk::{gdk, glib}; -#[cfg(not(coverage))] use lesavka_common::lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient}; #[cfg(not(coverage))] use std::collections::VecDeque; @@ -32,18 +31,22 @@ const PREVIEW_WIDTH: i32 = 960; #[cfg(not(coverage))] const PREVIEW_HEIGHT: i32 = 540; #[cfg(not(coverage))] -const INLINE_PREVIEW_REQUEST_WIDTH: i32 = 960; +const INLINE_PREVIEW_REQUEST_WIDTH: i32 = DEFAULT_EYE_SOURCE_WIDTH; #[cfg(not(coverage))] -const INLINE_PREVIEW_REQUEST_HEIGHT: i32 = 540; +const INLINE_PREVIEW_REQUEST_HEIGHT: i32 = DEFAULT_EYE_SOURCE_HEIGHT; #[cfg(not(coverage))] -const INLINE_PREVIEW_REQUEST_FPS: u32 = 24; +const INLINE_PREVIEW_REQUEST_FPS: u32 = DEFAULT_EYE_SOURCE_FPS; #[cfg(not(coverage))] -const INLINE_PREVIEW_MAX_KBIT: u32 = 4_000; +const INLINE_PREVIEW_MAX_KBIT: u32 = DEFAULT_EYE_SOURCE_MAX_KBIT; #[cfg(not(coverage))] const DEFAULT_EYE_SOURCE_WIDTH: i32 = 1920; #[cfg(not(coverage))] const DEFAULT_EYE_SOURCE_HEIGHT: i32 = 1080; #[cfg(not(coverage))] +const DEFAULT_EYE_SOURCE_FPS: u32 = 60; +#[cfg(not(coverage))] +const DEFAULT_EYE_SOURCE_MAX_KBIT: u32 = 18_000; +#[cfg(not(coverage))] const PREVIEW_IDLE_STATUS: &str = "Connect relay to preview."; #[cfg(not(coverage))] const TELEMETRY_WINDOW: Duration = Duration::from_secs(5); @@ -92,18 +95,19 @@ pub struct PreviewMetricsSnapshot { pub decoder_label: String, pub stream_caps_label: String, pub decoded_caps_label: String, + pub rendered_caps_label: String, } #[cfg(not(coverage))] #[derive(Clone, Copy, Debug)] struct PreviewProfile { + source_monitor_id: u32, display_width: i32, display_height: i32, requested_width: i32, requested_height: i32, requested_fps: u32, max_bitrate_kbit: u32, - prefer_reencode: bool, } #[cfg(not(coverage))] @@ -111,6 +115,7 @@ impl PreviewSurface { fn profile(self) -> PreviewProfile { match self { Self::Inline => PreviewProfile { + source_monitor_id: 0, display_width: preview_dimension("LESAVKA_PREVIEW_WIDTH", PREVIEW_WIDTH), display_height: preview_dimension("LESAVKA_PREVIEW_HEIGHT", PREVIEW_HEIGHT), requested_width: preview_dimension( @@ -129,9 +134,9 @@ impl PreviewSurface { "LESAVKA_PREVIEW_MAX_KBIT", INLINE_PREVIEW_MAX_KBIT, ), - prefer_reencode: true, }, Self::Window => PreviewProfile { + source_monitor_id: 0, display_width: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_WIDTH", 1280), display_height: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_HEIGHT", 720), requested_width: preview_dimension( @@ -142,9 +147,14 @@ impl PreviewSurface { "LESAVKA_BREAKOUT_REQUEST_HEIGHT", DEFAULT_EYE_SOURCE_HEIGHT, ), - requested_fps: preview_bitrate("LESAVKA_BREAKOUT_REQUEST_FPS", 30), - max_bitrate_kbit: preview_bitrate("LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT", 12_000), - prefer_reencode: true, + requested_fps: preview_bitrate( + "LESAVKA_BREAKOUT_REQUEST_FPS", + DEFAULT_EYE_SOURCE_FPS, + ), + max_bitrate_kbit: preview_bitrate( + "LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT", + DEFAULT_EYE_SOURCE_MAX_KBIT, + ), }, } } @@ -277,34 +287,32 @@ impl LauncherPreview { pub fn set_capture_profile( &self, monitor_id: usize, + source_monitor_id: usize, requested_width: i32, requested_height: i32, requested_fps: u32, max_bitrate_kbit: u32, - prefer_reencode: bool, ) { let ( inline_requested_width, inline_requested_height, inline_requested_fps, inline_max_bitrate_kbit, - inline_prefer_reencode, - ) = adapt_inline_preview_request( + ) = sanitize_preview_request( requested_width, requested_height, requested_fps, max_bitrate_kbit, - prefer_reencode, ); self.rebuild_feed( &self.inline_feeds, monitor_id, Some(( + source_monitor_id, inline_requested_width, inline_requested_height, inline_requested_fps, inline_max_bitrate_kbit, - inline_prefer_reencode, )), None, ); @@ -312,11 +320,11 @@ impl LauncherPreview { &self.window_feeds, monitor_id, Some(( + source_monitor_id, requested_width, requested_height, requested_fps, max_bitrate_kbit, - prefer_reencode, )), None, ); @@ -331,23 +339,36 @@ impl LauncherPreview { &self, monitor_id: usize, surface: PreviewSurface, - ) -> Option<(i32, i32, i32, i32, u32, u32, bool)> { + ) -> Option<(u32, i32, i32, i32, i32, u32, u32)> { let feed = match surface { PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(), PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(), }?; let profile = feed.profile(); Some(( + profile.source_monitor_id, profile.display_width, profile.display_height, profile.requested_width, profile.requested_height, profile.requested_fps, profile.max_bitrate_kbit, - profile.prefer_reencode, )) } + #[cfg(test)] + pub(crate) fn feed_disabled_for_test( + &self, + monitor_id: usize, + surface: PreviewSurface, + ) -> Option { + let feed = match surface { + PreviewSurface::Inline => self.inline_feeds.lock().ok()?.get(monitor_id).cloned(), + PreviewSurface::Window => self.window_feeds.lock().ok()?.get(monitor_id).cloned(), + }?; + Some(feed.is_disabled()) + } + #[cfg(test)] pub(crate) fn activate_surface_for_test(&self, monitor_id: usize, surface: PreviewSurface) { let feed = match surface { @@ -372,7 +393,7 @@ impl LauncherPreview { &self, feeds: &Arc>, monitor_id: usize, - requested: Option<(i32, i32, u32, u32, bool)>, + requested: Option<(usize, i32, i32, u32, u32)>, display: Option<(i32, i32)>, ) { let Ok(mut feeds) = feeds.lock() else { @@ -382,43 +403,95 @@ impl LauncherPreview { return; }; let was_active = existing.is_active(); + let keep_disabled = existing.is_disabled(); let mut profile = existing.profile(); if let Some(( + source_monitor_id, requested_width, requested_height, requested_fps, max_bitrate_kbit, - prefer_reencode, )) = requested { + profile.source_monitor_id = source_monitor_id as u32; profile.requested_width = requested_width.max(2); profile.requested_height = requested_height.max(2); profile.requested_fps = requested_fps.max(1); profile.max_bitrate_kbit = max_bitrate_kbit.max(800); - profile.prefer_reencode = prefer_reencode; } if let Some((display_width, display_height)) = display { profile.display_width = display_width.max(2); profile.display_height = display_height.max(2); } - match PreviewFeed::spawn( - Arc::clone(&self.server_addr), - monitor_id as u32, - profile, - Arc::clone(&self.log_sink), - ) { - Ok(feed) => { - if was_active { - feed.set_active(true); + let next_feed = if keep_disabled { + Some(PreviewFeed::spawn_disabled(profile)) + } else { + match PreviewFeed::spawn( + Arc::clone(&self.server_addr), + monitor_id as u32, + profile, + Arc::clone(&self.log_sink), + ) { + Ok(feed) => Some(feed), + Err(err) => { + warn!(monitor_id, ?err, "could not rebuild preview feed"); + None } - existing.shutdown(); - feeds[monitor_id] = feed; } - Err(err) => { - warn!(monitor_id, ?err, "could not rebuild preview feed"); + }; + if let Some(feed) = next_feed { + if was_active { + feed.set_active(true); } + existing.shutdown(); + feeds[monitor_id] = feed; } } + + pub fn set_monitor_enabled(&self, monitor_id: usize, enabled: bool) { + self.set_feed_enabled(&self.inline_feeds, monitor_id, enabled); + self.set_feed_enabled(&self.window_feeds, monitor_id, enabled); + } + + fn set_feed_enabled( + &self, + feeds: &Arc>, + monitor_id: usize, + enabled: bool, + ) { + let Ok(mut feeds) = feeds.lock() else { + return; + }; + let Some(existing) = feeds.get(monitor_id).cloned() else { + return; + }; + if existing.is_disabled() == !enabled { + return; + } + let was_active = existing.is_active(); + let profile = existing.profile(); + let replacement = if enabled { + match PreviewFeed::spawn( + Arc::clone(&self.server_addr), + monitor_id as u32, + profile, + Arc::clone(&self.log_sink), + ) { + Ok(feed) => feed, + Err(err) => { + warn!(monitor_id, ?err, "could not enable preview feed"); + return; + } + } + } else { + PreviewFeed::spawn_disabled(profile) + }; + if was_active { + replacement.set_active(true); + } + existing.shutdown(); + feeds[monitor_id] = replacement; + } } #[cfg(not(coverage))] @@ -463,6 +536,7 @@ struct PreviewFeed { active_bindings: Arc, running: Arc, profile: PreviewProfile, + disabled: bool, } #[cfg(not(coverage))] @@ -542,6 +616,7 @@ struct PreviewTelemetry { decoder_label: String, stream_caps_label: String, decoded_caps_label: String, + rendered_caps_label: String, } #[cfg(not(coverage))] @@ -655,6 +730,12 @@ impl PreviewTelemetry { } } + fn note_rendered_caps(&mut self, caps_label: &str) { + if !caps_label.is_empty() { + self.rendered_caps_label = caps_label.to_string(); + } + } + fn snapshot(&mut self) -> PreviewMetricsSnapshot { self.snapshot_at(Instant::now()) } @@ -700,6 +781,7 @@ impl PreviewTelemetry { decoder_label: self.decoder_label.clone(), stream_caps_label: self.stream_caps_label.clone(), decoded_caps_label: self.decoded_caps_label.clone(), + rendered_caps_label: self.rendered_caps_label.clone(), } } @@ -750,27 +832,54 @@ impl PreviewFeed { active_bindings, running, profile, + disabled: false, }) } + fn spawn_disabled(profile: PreviewProfile) -> Self { + let shared = Arc::new(Mutex::new(SharedPreviewState::new())); + if let Ok(mut slot) = shared.lock() { + slot.set_status("Feed disabled.", true); + } + Self { + shared, + session_active: Arc::new(AtomicBool::new(false)), + active_bindings: Arc::new(AtomicUsize::new(0)), + running: Arc::new(AtomicBool::new(false)), + profile, + disabled: true, + } + } + fn profile(&self) -> PreviewProfile { self.profile } + fn is_disabled(&self) -> bool { + self.disabled + } + fn is_active(&self) -> bool { self.session_active.load(Ordering::Relaxed) } fn set_active(&self, active: bool) { self.session_active.store(active, Ordering::Relaxed); - if !active { + if !active && !self.disabled { self.replace_status(PREVIEW_IDLE_STATUS, true); } } fn shutdown(&self) { self.running.store(false, Ordering::Relaxed); - self.replace_status(PREVIEW_IDLE_STATUS, true); + self.replace_status( + if self.disabled { + "Feed disabled." + } else { + PREVIEW_IDLE_STATUS + }, + true, + ); } fn replace_status(&self, status: impl Into, clear_picture: bool) { @@ -907,6 +1016,14 @@ fn run_preview_feed( if let Some(decoder) = decoder.as_ref() { record_preview_caps(&shared, decoder, "src", PreviewCapsKind::Decoded); } + if let Some(caps) = sample.caps() { + let caps_label = preview_caps_summary(&caps); + if !caps_label.is_empty() + && let Ok(mut slot) = shared.lock() + { + slot.telemetry.note_rendered_caps(&caps_label); + } + } if let Some(frame) = sample_to_frame(&sample) { if let Ok(mut slot) = shared.lock() { slot.push_frame(frame); @@ -1022,7 +1139,7 @@ fn run_preview_feed( requested_width: profile.requested_width.max(0) as u32, requested_height: profile.requested_height.max(0) as u32, requested_fps: profile.requested_fps, - prefer_reencode: profile.prefer_reencode, + source_id: Some(profile.source_monitor_id), }; match cli.capture_video(Request::new(req)).await { Ok(mut stream) => { @@ -1289,16 +1406,15 @@ fn looks_like_preview_problem(status: &str) -> bool { #[cfg(not(coverage))] fn build_preview_pipeline( - profile: PreviewProfile, + _profile: PreviewProfile, ) -> Result<(gst::Pipeline, gst_app::AppSrc, gst_app::AppSink, String)> { let decoder_name = pick_h264_decoder(); let desc = format!( "appsrc name=src is-live=true format=time do-timestamp=true block=false ! \ queue max-size-buffers=6 max-size-time=0 max-size-bytes=0 leaky=downstream ! \ - h264parse name=preview_parse disable-passthrough=true ! {decoder_name} name=decoder ! videoconvert ! videoscale ! \ - video/x-raw,format=RGBA,width={},height={},pixel-aspect-ratio=1/1 ! \ + h264parse name=preview_parse disable-passthrough=true ! {decoder_name} name=decoder ! videoconvert ! \ + video/x-raw,format=RGBA,pixel-aspect-ratio=1/1 ! \ appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true", - profile.display_width, profile.display_height ); let pipeline = gst::parse::launch(&desc)? .downcast::() @@ -1325,8 +1441,7 @@ fn build_preview_pipeline( appsink.set_caps(Some( &gst::Caps::builder("video/x-raw") .field("format", &"RGBA") - .field("width", &profile.display_width) - .field("height", &profile.display_height) + .field("pixel-aspect-ratio", &gst::Fraction::new(1, 1)) .build(), )); @@ -1375,10 +1490,8 @@ fn record_preview_caps( } #[cfg(not(coverage))] -fn preview_caps_summary(caps: &gst::Caps) -> String { - caps.structure(0) - .map(|structure| structure.to_string()) - .unwrap_or_else(|| caps.to_string()) +fn preview_caps_summary(caps: &impl std::fmt::Display) -> String { + caps.to_string() } #[cfg(not(coverage))] @@ -1444,39 +1557,17 @@ fn preview_dimension(var: &str, default: i32) -> i32 { } #[cfg(not(coverage))] -fn adapt_inline_preview_request( +fn sanitize_preview_request( requested_width: i32, requested_height: i32, requested_fps: u32, max_bitrate_kbit: u32, - prefer_reencode: bool, -) -> (i32, i32, u32, u32, bool) { - let inline_width = requested_width.max(2); - let inline_height = requested_height.max(2); - if !prefer_reencode { - return ( - inline_width, - inline_height, - requested_fps.max(1), - max_bitrate_kbit.max(800), - false, - ); - } - let inline_fps = requested_fps.max(1).min(preview_bitrate( - "LESAVKA_PREVIEW_REQUEST_FPS", - INLINE_PREVIEW_REQUEST_FPS, - )); - let inline_bitrate = max_bitrate_kbit.max(800).min(preview_bitrate( - "LESAVKA_PREVIEW_MAX_KBIT", - INLINE_PREVIEW_MAX_KBIT, - )); - let adapted = inline_fps != requested_fps.max(1) || inline_bitrate != max_bitrate_kbit.max(800); +) -> (i32, i32, u32, u32) { ( - inline_width, - inline_height, - inline_fps, - inline_bitrate, - prefer_reencode || adapted, + requested_width.max(2), + requested_height.max(2), + requested_fps.max(1), + max_bitrate_kbit.max(800), ) } @@ -1539,7 +1630,7 @@ mod tests { DEFAULT_EYE_SOURCE_HEIGHT, DEFAULT_EYE_SOURCE_WIDTH, INLINE_PREVIEW_MAX_KBIT, INLINE_PREVIEW_REQUEST_FPS, INLINE_PREVIEW_REQUEST_HEIGHT, INLINE_PREVIEW_REQUEST_WIDTH, LauncherPreview, PREVIEW_HEIGHT, PREVIEW_WIDTH, PreviewSurface, PreviewTelemetry, - adapt_inline_preview_request, + sanitize_preview_request, }; use crate::launcher::state::{CaptureSizePreset, LauncherState}; use futures::stream; @@ -1691,20 +1782,20 @@ mod tests { assert_eq!(profile.display_height, 720); assert_eq!(profile.requested_width, DEFAULT_EYE_SOURCE_WIDTH); assert_eq!(profile.requested_height, DEFAULT_EYE_SOURCE_HEIGHT); - assert_eq!(profile.requested_fps, 30); - assert_eq!(profile.max_bitrate_kbit, 12_000); + assert_eq!(profile.requested_fps, 60); + assert_eq!(profile.max_bitrate_kbit, 18_000); } #[test] - fn inline_preview_request_caps_large_full_quality_streams() { - let adapted = adapt_inline_preview_request(1920, 1080, 30, 12_000, true); - assert_eq!(adapted, (1920, 1080, 24, 4_000, true)); + fn preview_request_sanitizer_keeps_requested_source_geometry() { + let adapted = sanitize_preview_request(1920, 1080, 60, 18_000); + assert_eq!(adapted, (1920, 1080, 60, 18_000)); } #[test] - fn inline_preview_request_keeps_source_stream_honest() { - let adapted = adapt_inline_preview_request(1920, 1080, 30, 12_000, false); - assert_eq!(adapted, (1920, 1080, 30, 12_000, false)); + fn preview_request_sanitizer_clamps_invalid_values() { + let adapted = sanitize_preview_request(0, 0, 0, 0); + assert_eq!(adapted, (2, 2, 1, 800)); } #[test] @@ -1762,7 +1853,7 @@ mod tests { #[test] #[serial] - fn inline_preview_requests_selected_reencode_profile_on_wire() { + fn inline_preview_requests_selected_source_profile_on_wire() { let relay = ProbeRelay::default(); let requests = relay.requests.clone(); let rt = tokio::runtime::Runtime::new().expect("runtime"); @@ -1783,23 +1874,24 @@ mod tests { let state = LauncherState::default(); let capture = state.capture_size_choice(1); preview.set_capture_profile( + 1, 1, capture.width, capture.height, capture.fps, capture.max_bitrate_kbit, - capture.preset != CaptureSizePreset::Source, ); preview.activate_surface_for_test(1, PreviewSurface::Inline); let deadline = Instant::now() + Duration::from_secs(5); while Instant::now() < deadline { if let Some(request) = requests.lock().unwrap().last().cloned() { + assert_eq!(request.id, 1); + assert_eq!(request.source_id, Some(1)); assert_eq!(request.requested_width, 1920); assert_eq!(request.requested_height, 1080); - assert_eq!(request.requested_fps, 24); - assert_eq!(request.max_bitrate, 4_000); - assert!(request.prefer_reencode); + assert_eq!(request.requested_fps, 60); + assert_eq!(request.max_bitrate, 18_000); preview.shutdown_all(); return; } @@ -1831,26 +1923,27 @@ mod tests { let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); let mut state = LauncherState::default(); - state.set_capture_size_preset(1, CaptureSizePreset::Source); + state.set_capture_size_preset(1, CaptureSizePreset::P1080); let capture = state.capture_size_choice(1); preview.set_capture_profile( + 1, 1, capture.width, capture.height, capture.fps, capture.max_bitrate_kbit, - capture.preset != CaptureSizePreset::Source, ); preview.activate_surface_for_test(1, PreviewSurface::Inline); let deadline = Instant::now() + Duration::from_secs(5); while Instant::now() < deadline { if let Some(request) = requests.lock().unwrap().last().cloned() { + assert_eq!(request.id, 1); + assert_eq!(request.source_id, Some(1)); assert_eq!(request.requested_width, 1920); assert_eq!(request.requested_height, 1080); - assert_eq!(request.requested_fps, 30); - assert_eq!(request.max_bitrate, 12_000); - assert!(!request.prefer_reencode); + assert_eq!(request.requested_fps, 60); + assert_eq!(request.max_bitrate, 18_000); preview.shutdown_all(); return; } @@ -1860,4 +1953,148 @@ mod tests { preview.shutdown_all(); panic!("preview did not issue a source capture request within timeout"); } + + #[test] + #[serial] + fn inline_preview_requests_native_720p_source_mode_on_wire() { + let relay = ProbeRelay::default(); + let requests = relay.requests.clone(); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let addr = rt.block_on(async move { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(relay)) + .serve(addr) + .await; + }); + addr + }); + + let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); + let mut state = LauncherState::default(); + state.set_capture_size_preset(1, CaptureSizePreset::P720); + let capture = state.capture_size_choice(1); + preview.set_capture_profile( + 1, + 1, + capture.width, + capture.height, + capture.fps, + capture.max_bitrate_kbit, + ); + preview.activate_surface_for_test(1, PreviewSurface::Inline); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if let Some(request) = requests.lock().unwrap().last().cloned() { + assert_eq!(request.id, 1); + assert_eq!(request.source_id, Some(1)); + assert_eq!(request.requested_width, 1280); + assert_eq!(request.requested_height, 720); + assert_eq!(request.requested_fps, 60); + assert_eq!(request.max_bitrate, 12_000); + preview.shutdown_all(); + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + + preview.shutdown_all(); + panic!("preview did not issue a 720p source capture request within timeout"); + } + + #[test] + #[serial] + fn inline_preview_requests_native_480p_source_mode_on_wire() { + let relay = ProbeRelay::default(); + let requests = relay.requests.clone(); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let addr = rt.block_on(async move { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(relay)) + .serve(addr) + .await; + }); + addr + }); + + let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); + let mut state = LauncherState::default(); + state.set_capture_size_preset(1, CaptureSizePreset::P480); + let capture = state.capture_size_choice(1); + preview.set_capture_profile( + 1, + 1, + capture.width, + capture.height, + capture.fps, + capture.max_bitrate_kbit, + ); + preview.activate_surface_for_test(1, PreviewSurface::Inline); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if let Some(request) = requests.lock().unwrap().last().cloned() { + assert_eq!(request.id, 1); + assert_eq!(request.source_id, Some(1)); + assert_eq!(request.requested_width, 720); + assert_eq!(request.requested_height, 480); + assert_eq!(request.requested_fps, 60); + assert_eq!(request.max_bitrate, 2_500); + preview.shutdown_all(); + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + + preview.shutdown_all(); + panic!("preview did not issue a 480p source capture request within timeout"); + } + + #[test] + #[serial] + fn preview_can_request_other_eye_as_a_distinct_stream() { + let relay = ProbeRelay::default(); + let requests = relay.requests.clone(); + let rt = tokio::runtime::Runtime::new().expect("runtime"); + let addr = rt.block_on(async move { + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind"); + let addr = listener.local_addr().expect("addr"); + drop(listener); + tokio::spawn(async move { + let _ = tonic::transport::Server::builder() + .add_service(RelayServer::new(relay)) + .serve(addr) + .await; + }); + addr + }); + + let preview = LauncherPreview::new(format!("http://{addr}")).expect("preview"); + preview.set_capture_profile(0, 1, 1920, 1080, 30, 12_000); + preview.activate_surface_for_test(0, PreviewSurface::Inline); + + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if let Some(request) = requests.lock().unwrap().last().cloned() { + assert_eq!(request.id, 0); + assert_eq!(request.source_id, Some(1)); + assert_eq!(request.requested_width, 1920); + assert_eq!(request.requested_height, 1080); + preview.shutdown_all(); + return; + } + std::thread::sleep(Duration::from_millis(50)); + } + + preview.shutdown_all(); + panic!("preview did not issue a mirrored capture request within timeout"); + } } diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index ab9ea53..afdb7d8 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -1,8 +1,7 @@ -use std::collections::BTreeSet; - use serde::{Deserialize, Serialize}; use super::devices::DeviceCatalog; +use lesavka_common::eye_source::{EyeSourceMode, default_eye_source_mode, native_eye_source_modes}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum InputRouting { @@ -49,6 +48,43 @@ impl DisplaySurface { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum FeedSourcePreset { + ThisEye, + OtherEye, + Off, +} + +impl FeedSourcePreset { + pub fn as_id(self) -> &'static str { + match self { + Self::ThisEye => "self", + Self::OtherEye => "other", + Self::Off => "off", + } + } + + pub fn from_id(raw: &str) -> Option { + match raw { + "self" => Some(Self::ThisEye), + "other" => Some(Self::OtherEye), + "off" => Some(Self::Off), + _ => None, + } + } + + pub fn label(self, monitor_id: usize) -> &'static str { + match (monitor_id, self) { + (_, Self::Off) => "Off", + (0, Self::ThisEye) => "Left Eye", + (0, Self::OtherEye) => "Right Eye", + (1, Self::ThisEye) => "Right Eye", + (1, Self::OtherEye) => "Left Eye", + _ => "This Eye", + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum BreakoutSizePreset { P360, @@ -105,57 +141,69 @@ impl BreakoutSizePreset { #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum CaptureSizePreset { - P360, - P540, + #[serde(alias = "P360")] + Vga, + #[serde(alias = "P540")] + P480, + P576, P720, - P900, + #[serde(alias = "P900", alias = "P1440", alias = "Source")] P1080, - P1440, - Source, } impl CaptureSizePreset { pub fn as_id(self) -> &'static str { match self { - Self::P360 => "360p", - Self::P540 => "540p", + Self::Vga => "vga", + Self::P480 => "480p", + Self::P576 => "576p", Self::P720 => "720p", - Self::P900 => "900p", Self::P1080 => "1080p", - Self::P1440 => "1440p", - Self::Source => "source", } } pub fn from_id(raw: &str) -> Option { match raw { - "360p" => Some(Self::P360), - "540p" => Some(Self::P540), + "vga" | "360p" => Some(Self::Vga), + "480p" | "540p" => Some(Self::P480), + "576p" => Some(Self::P576), "720p" => Some(Self::P720), - "900p" => Some(Self::P900), - "1080p" => Some(Self::P1080), - "1440p" => Some(Self::P1440), - "source" => Some(Self::Source), + "900p" | "1080p" | "1440p" | "source" => Some(Self::P1080), _ => None, } } pub fn label(self) -> &'static str { match self { - Self::P360 => "360p", - Self::P540 => "540p", + Self::Vga => "VGA", + Self::P480 => "480p", + Self::P576 => "576p", Self::P720 => "720p", - Self::P900 => "900p", Self::P1080 => "1080p", - Self::P1440 => "1440p", - Self::Source => "Source", } } pub fn transport_label(self) -> &'static str { + "device H.264 pass-through" + } + + pub fn source_mode(self) -> EyeSourceMode { match self { - Self::Source => "source pass-through", - _ => "server re-encode", + Self::Vga => native_eye_source_modes()[4], + Self::P480 => native_eye_source_modes()[3], + Self::P576 => native_eye_source_modes()[2], + Self::P720 => native_eye_source_modes()[1], + Self::P1080 => native_eye_source_modes()[0], + } + } + + pub fn from_source_mode(mode: EyeSourceMode) -> Self { + match (mode.width, mode.height, mode.fps) { + (640, 480, 60) => Self::Vga, + (720, 480, 60) => Self::P480, + (720, 576, 50) => Self::P576, + (1280, 720, 60) => Self::P720, + _ => Self::P1080, } } } @@ -169,10 +217,11 @@ pub struct PreviewSourceSize { impl Default for PreviewSourceSize { fn default() -> Self { + let mode = default_eye_source_mode(); Self { - width: 1920, - height: 1080, - fps: 30, + width: mode.width, + height: mode.height, + fps: mode.fps, } } } @@ -193,6 +242,12 @@ pub struct CaptureSizeChoice { pub max_bitrate_kbit: u32, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FeedSourceChoice { + pub preset: FeedSourcePreset, + pub label: &'static str, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CaptureFpsChoice { pub fps: u32, @@ -242,6 +297,7 @@ pub struct LauncherState { pub routing: InputRouting, pub view_mode: ViewMode, pub displays: [DisplaySurface; 2], + pub feed_sources: [FeedSourcePreset; 2], pub preview_source: PreviewSourceSize, pub breakout_limit: PreviewSourceSize, pub breakout_display: PreviewSourceSize, @@ -266,12 +322,13 @@ impl Default for LauncherState { routing: InputRouting::Remote, view_mode: ViewMode::Unified, displays: [DisplaySurface::Preview, DisplaySurface::Preview], + feed_sources: [FeedSourcePreset::ThisEye, FeedSourcePreset::ThisEye], preview_source: PreviewSourceSize::default(), breakout_limit: PreviewSourceSize::default(), breakout_display: PreviewSourceSize::default(), capture_sizes: [CaptureSizePreset::P1080, CaptureSizePreset::P1080], - capture_fps: [30, 30], - capture_bitrates_kbit: [12_000, 12_000], + capture_fps: [60, 60], + capture_bitrates_kbit: [18_000, 18_000], breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source], devices: DeviceSelection::default(), swap_key: "pause".to_string(), @@ -323,6 +380,44 @@ impl LauncherState { .unwrap_or(DisplaySurface::Preview) } + pub fn feed_source_preset(&self, monitor_id: usize) -> FeedSourcePreset { + self.feed_sources + .get(monitor_id) + .copied() + .unwrap_or(FeedSourcePreset::ThisEye) + } + + pub fn set_feed_source_preset(&mut self, monitor_id: usize, preset: FeedSourcePreset) { + if let Some(slot) = self.feed_sources.get_mut(monitor_id) { + *slot = preset; + } + } + + pub fn feed_source_options(&self, monitor_id: usize) -> Vec { + vec![ + FeedSourceChoice { + preset: FeedSourcePreset::ThisEye, + label: FeedSourcePreset::ThisEye.label(monitor_id), + }, + FeedSourceChoice { + preset: FeedSourcePreset::OtherEye, + label: FeedSourcePreset::OtherEye.label(monitor_id), + }, + FeedSourceChoice { + preset: FeedSourcePreset::Off, + label: FeedSourcePreset::Off.label(monitor_id), + }, + ] + } + + pub fn resolved_feed_monitor_id(&self, monitor_id: usize) -> Option { + match self.feed_source_preset(monitor_id) { + FeedSourcePreset::ThisEye => Some(monitor_id.min(1)), + FeedSourcePreset::OtherEye => Some(1_usize.saturating_sub(monitor_id.min(1))), + FeedSourcePreset::Off => None, + } + } + pub fn set_display_surface(&mut self, monitor_id: usize, surface: DisplaySurface) { if let Some(slot) = self.displays.get_mut(monitor_id) { *slot = surface; @@ -391,13 +486,21 @@ impl LauncherState { } pub fn capture_size_preset(&self, monitor_id: usize) -> CaptureSizePreset { - self.capture_sizes - .get(monitor_id) - .copied() - .unwrap_or(CaptureSizePreset::Source) + normalize_capture_size_preset( + self.capture_sizes + .get(monitor_id) + .copied() + .unwrap_or(CaptureSizePreset::P1080), + ) + } + + pub fn display_capture_size_preset(&self, monitor_id: usize) -> Option { + self.resolved_feed_monitor_id(monitor_id) + .map(|source_id| self.capture_size_preset(source_id)) } pub fn set_capture_size_preset(&mut self, monitor_id: usize, preset: CaptureSizePreset) { + let preset = normalize_capture_size_preset(preset); if let Some(slot) = self.capture_sizes.get_mut(monitor_id) { *slot = preset; } @@ -410,10 +513,15 @@ impl LauncherState { self.capture_fps .get(monitor_id) .copied() - .unwrap_or(30) + .unwrap_or(default_eye_source_mode().fps) .max(1) } + pub fn display_capture_fps(&self, monitor_id: usize) -> Option { + self.resolved_feed_monitor_id(monitor_id) + .map(|source_id| self.capture_fps(source_id)) + } + pub fn set_capture_fps(&mut self, monitor_id: usize, fps: u32) { if let Some(slot) = self.capture_fps.get_mut(monitor_id) { *slot = fps.max(1); @@ -424,10 +532,19 @@ impl LauncherState { self.capture_bitrates_kbit .get(monitor_id) .copied() - .unwrap_or(12_000) + .unwrap_or(estimate_source_bitrate_kbit( + default_eye_source_mode().width as i32, + default_eye_source_mode().height as i32, + default_eye_source_mode().fps, + )) .max(800) } + pub fn display_capture_bitrate_kbit(&self, monitor_id: usize) -> Option { + self.resolved_feed_monitor_id(monitor_id) + .map(|source_id| self.capture_bitrate_kbit(source_id)) + } + pub fn set_capture_bitrate_kbit(&mut self, monitor_id: usize, max_bitrate_kbit: u32) { if let Some(slot) = self.capture_bitrates_kbit.get_mut(monitor_id) { *slot = max_bitrate_kbit.max(800); @@ -443,6 +560,11 @@ impl LauncherState { ) } + pub fn display_capture_size_choice(&self, monitor_id: usize) -> Option { + self.resolved_feed_monitor_id(monitor_id) + .map(|source_id| self.capture_size_choice(source_id)) + } + pub fn capture_size_options(&self) -> Vec { capture_size_options(self.preview_source) } @@ -571,7 +693,7 @@ impl LauncherState { pub fn status_line(&self) -> String { format!( - "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} camera={} mic={} speaker={} kbd={} mouse={} swap={}", + "server={} mode={} view={} active={} power={} source={}x{} d1={} d2={} s1={} s2={} camera={} mic={} speaker={} kbd={} mouse={} swap={}", self.server_available, match self.routing { InputRouting::Local => "local", @@ -591,6 +713,8 @@ impl LauncherState { self.preview_source.height, self.displays[0].label(), self.displays[1].label(), + self.feed_source_preset(0).as_id(), + self.feed_source_preset(1).as_id(), self.devices.camera.as_deref().unwrap_or("auto"), self.devices.microphone.as_deref().unwrap_or("auto"), self.devices.speaker.as_deref().unwrap_or("auto"), @@ -679,76 +803,20 @@ fn breakout_size_options( } fn capture_size_choice( - source: PreviewSourceSize, + _source: PreviewSourceSize, preset: CaptureSizePreset, selected_fps: u32, selected_bitrate_kbit: u32, ) -> CaptureSizeChoice { - let source_width = source.width.max(1) as i32; - let source_height = source.height.max(1) as i32; - let source_fps = source.fps.max(1); - let (width, height, fps, max_bitrate_kbit) = match preset { - CaptureSizePreset::P360 => { - let (width, height) = fit_standard_dimensions(source_width, source_height, 640, 360); - ( - width, - height, - source_fps.min(selected_fps.max(1)), - selected_bitrate_kbit.max(800), - ) - } - CaptureSizePreset::P540 => { - let (width, height) = fit_standard_dimensions(source_width, source_height, 960, 540); - ( - width, - height, - source_fps.min(selected_fps.max(1)), - selected_bitrate_kbit.max(800), - ) - } - CaptureSizePreset::P720 => { - let (width, height) = fit_standard_dimensions(source_width, source_height, 1280, 720); - ( - width, - height, - source_fps.min(selected_fps.max(1)), - selected_bitrate_kbit.max(800), - ) - } - CaptureSizePreset::P900 => { - let (width, height) = fit_standard_dimensions(source_width, source_height, 1600, 900); - ( - width, - height, - source_fps.min(selected_fps.max(1)), - selected_bitrate_kbit.max(800), - ) - } - CaptureSizePreset::P1080 => { - let (width, height) = fit_standard_dimensions(source_width, source_height, 1920, 1080); - ( - width, - height, - source_fps.min(selected_fps.max(1)), - selected_bitrate_kbit.max(800), - ) - } - CaptureSizePreset::P1440 => { - let (width, height) = fit_standard_dimensions(source_width, source_height, 2560, 1440); - ( - width, - height, - source_fps.min(selected_fps.max(1)), - selected_bitrate_kbit.max(800), - ) - } - CaptureSizePreset::Source => ( - source_width, - source_height, - source_fps, - estimate_source_bitrate_kbit(source_width, source_height, source_fps), - ), - }; + let preset = normalize_capture_size_preset(preset); + let mode = preset.source_mode(); + let _ = (selected_fps, selected_bitrate_kbit); + let (width, height, fps, max_bitrate_kbit) = ( + mode.width as i32, + mode.height as i32, + mode.fps, + estimate_source_bitrate_kbit(mode.width as i32, mode.height as i32, mode.fps), + ); CaptureSizeChoice { preset, width, @@ -769,106 +837,46 @@ fn estimate_source_bitrate_kbit(width: i32, height: i32, fps: u32) -> u32 { } fn capture_size_options(source: PreviewSourceSize) -> Vec { - let mut options = Vec::new(); - for preset in [ - CaptureSizePreset::Source, - CaptureSizePreset::P360, - CaptureSizePreset::P540, - CaptureSizePreset::P720, - CaptureSizePreset::P900, - CaptureSizePreset::P1080, - CaptureSizePreset::P1440, - ] { - let defaults = default_profile_for_preset(source, preset); - let choice = capture_size_choice(source, preset, defaults.fps, defaults.max_bitrate_kbit); - if options.iter().any(|existing: &CaptureSizeChoice| { - let existing_transport = existing.preset.transport_label(); - let current_transport = choice.preset.transport_label(); - existing_transport == current_transport - && existing.width == choice.width - && existing.height == choice.height - && existing.fps == choice.fps - && existing.max_bitrate_kbit == choice.max_bitrate_kbit - }) { - continue; - } - options.push(choice); - } - options + native_eye_source_modes() + .iter() + .copied() + .filter(|mode| mode.width <= source.width && mode.height <= source.height) + .map(CaptureSizePreset::from_source_mode) + .map(|preset| { + let defaults = default_profile_for_preset(source, preset); + capture_size_choice(source, preset, defaults.fps, defaults.max_bitrate_kbit) + }) + .collect() } fn capture_fps_options(source: PreviewSourceSize) -> Vec { - let mut values = BTreeSet::new(); - for fps in [15, 20, 24, 30, 48, 60, source.fps.max(1)] { - if fps <= source.fps.max(1) || fps == source.fps.max(1) { - values.insert(fps.max(1)); - } - } - values - .into_iter() - .map(|fps| CaptureFpsChoice { fps }) - .collect() + vec![CaptureFpsChoice { + fps: source.fps.max(1), + }] } fn capture_bitrate_options(source: PreviewSourceSize) -> Vec { - let mut values = BTreeSet::new(); - for bitrate in [ - 2_500, - 4_000, - 6_000, - 8_500, - 12_000, - 18_000, - 24_000, - estimate_source_bitrate_kbit(source.width as i32, source.height as i32, source.fps), - ] { - values.insert(bitrate.max(800)); - } - values - .into_iter() - .map(|max_bitrate_kbit| CaptureBitrateChoice { max_bitrate_kbit }) - .collect() + vec![CaptureBitrateChoice { + max_bitrate_kbit: estimate_source_bitrate_kbit( + source.width as i32, + source.height as i32, + source.fps, + ), + }] } fn default_profile_for_preset( - source: PreviewSourceSize, + _source: PreviewSourceSize, preset: CaptureSizePreset, ) -> CaptureSizeChoice { - let source_width = source.width.max(1) as i32; - let source_height = source.height.max(1) as i32; - let source_fps = source.fps.max(1); - let (width, height, fps, max_bitrate_kbit) = match preset { - CaptureSizePreset::P360 => { - let (width, height) = fit_standard_dimensions(source_width, source_height, 640, 360); - (width, height, source_fps.min(15), 2_500) - } - CaptureSizePreset::P540 => { - let (width, height) = fit_standard_dimensions(source_width, source_height, 960, 540); - (width, height, source_fps.min(20), 4_000) - } - CaptureSizePreset::P720 => { - let (width, height) = fit_standard_dimensions(source_width, source_height, 1280, 720); - (width, height, source_fps.min(24), 6_000) - } - CaptureSizePreset::P900 => { - let (width, height) = fit_standard_dimensions(source_width, source_height, 1600, 900); - (width, height, source_fps.min(30), 8_500) - } - CaptureSizePreset::P1080 => { - let (width, height) = fit_standard_dimensions(source_width, source_height, 1920, 1080); - (width, height, source_fps.min(30), 12_000) - } - CaptureSizePreset::P1440 => { - let (width, height) = fit_standard_dimensions(source_width, source_height, 2560, 1440); - (width, height, source_fps.min(30), 18_000) - } - CaptureSizePreset::Source => ( - source_width, - source_height, - source_fps, - estimate_source_bitrate_kbit(source_width, source_height, source_fps), - ), - }; + let preset = normalize_capture_size_preset(preset); + let mode = preset.source_mode(); + let (width, height, fps, max_bitrate_kbit) = ( + mode.width as i32, + mode.height as i32, + mode.fps, + estimate_source_bitrate_kbit(mode.width as i32, mode.height as i32, mode.fps), + ); CaptureSizeChoice { preset, width, @@ -878,6 +886,10 @@ fn default_profile_for_preset( } } +fn normalize_capture_size_preset(preset: CaptureSizePreset) -> CaptureSizePreset { + preset +} + fn fit_standard_dimensions( limit_width: i32, limit_height: i32, @@ -973,6 +985,29 @@ mod tests { assert_eq!(state.display_surface(1), DisplaySurface::Window); } + #[test] + fn feed_sources_can_mirror_or_disable_a_pane() { + let mut state = LauncherState::new(); + state.set_capture_size_preset(1, CaptureSizePreset::P1080); + + assert_eq!(state.resolved_feed_monitor_id(0), Some(0)); + assert_eq!(state.resolved_feed_monitor_id(1), Some(1)); + + state.set_feed_source_preset(0, FeedSourcePreset::OtherEye); + assert_eq!(state.resolved_feed_monitor_id(0), Some(1)); + assert_eq!( + state.display_capture_size_choice(0), + Some(state.capture_size_choice(1)) + ); + + state.set_feed_source_preset(1, FeedSourcePreset::OtherEye); + assert_eq!(state.resolved_feed_monitor_id(1), Some(0)); + + state.set_feed_source_preset(0, FeedSourcePreset::Off); + assert_eq!(state.resolved_feed_monitor_id(0), None); + assert!(state.display_capture_size_choice(0).is_none()); + } + #[test] fn selecting_auto_or_blank_clears_explicit_device() { let mut state = LauncherState::new(); @@ -1076,21 +1111,21 @@ mod tests { #[test] fn breakout_size_choices_track_the_negotiated_source_size() { let mut state = LauncherState::new(); - state.set_preview_source_profile(1920, 1080, 30); + state.set_preview_source_profile(1920, 1080, 60); state.set_breakout_limit_size(2560, 1440); let source = state.capture_size_choice(0); assert_eq!(source.width, 1920); assert_eq!(source.height, 1080); - assert_eq!(source.fps, 30); - assert_eq!(source.max_bitrate_kbit, 12_000); + assert_eq!(source.fps, 60); + assert_eq!(source.max_bitrate_kbit, 18_000); - state.set_capture_size_preset(0, CaptureSizePreset::P540); + state.set_capture_size_preset(0, CaptureSizePreset::P480); let compact_capture = state.capture_size_choice(0); - assert_eq!(compact_capture.width, 960); - assert_eq!(compact_capture.height, 540); - assert_eq!(compact_capture.fps, 20); - assert_eq!(compact_capture.max_bitrate_kbit, 4_000); + assert_eq!(compact_capture.width, 720); + assert_eq!(compact_capture.height, 480); + assert_eq!(compact_capture.fps, 60); + assert_eq!(compact_capture.max_bitrate_kbit, 2_500); let display = state.breakout_size_choice(0); assert_eq!(display.width, 1920); @@ -1107,17 +1142,12 @@ mod tests { assert_eq!(compact.height, 540); let capture_options = state.capture_size_options(); - assert!(capture_options.len() >= 5); - assert!(capture_options.iter().any(|choice| { - choice.preset == CaptureSizePreset::Source - && choice.width == 1920 - && choice.height == 1080 - })); - assert!(capture_options.iter().any(|choice| { - choice.preset == CaptureSizePreset::P1080 - && choice.width == 1920 - && choice.height == 1080 - })); + assert_eq!(capture_options.len(), 5); + assert_eq!(capture_options[0].preset, CaptureSizePreset::P1080); + assert_eq!(capture_options[0].width, 1920); + assert_eq!(capture_options[0].height, 1080); + assert_eq!(capture_options[0].fps, 60); + assert_eq!(capture_options[0].max_bitrate_kbit, 18_000); let breakout_options = state.breakout_size_options(); assert!(breakout_options.len() >= 5); @@ -1181,61 +1211,73 @@ mod tests { } #[test] - fn source_capture_profile_uses_source_fps_and_scaled_profiles_cap_it() { + fn capture_size_presets_map_to_real_device_modes() { let mut state = LauncherState::new(); state.set_preview_source_profile(1920, 1080, 60); - state.set_capture_size_preset(0, CaptureSizePreset::Source); + state.set_capture_size_preset(0, CaptureSizePreset::P1080); let source = state.capture_size_choice(0); assert_eq!(source.width, 1920); assert_eq!(source.height, 1080); assert_eq!(source.fps, 60); - assert!(source.max_bitrate_kbit >= 12_000); + assert!(source.max_bitrate_kbit >= 18_000); state.set_capture_size_preset(0, CaptureSizePreset::P720); let hd = state.capture_size_choice(0); - assert_eq!(hd.fps, 24); + assert_eq!(hd.preset, CaptureSizePreset::P720); + assert_eq!(hd.width, 1280); + assert_eq!(hd.height, 720); + assert_eq!(hd.fps, 60); - state.set_capture_size_preset(0, CaptureSizePreset::P540); + state.set_capture_size_preset(0, CaptureSizePreset::P576); let compact = state.capture_size_choice(0); - assert_eq!(compact.fps, 20); + assert_eq!(compact.preset, CaptureSizePreset::P576); + assert_eq!(compact.width, 720); + assert_eq!(compact.height, 576); + assert_eq!(compact.fps, 50); - state.set_capture_size_preset(0, CaptureSizePreset::P360); + state.set_capture_size_preset(0, CaptureSizePreset::Vga); let small = state.capture_size_choice(0); - assert_eq!(small.fps, 15); + assert_eq!(small.preset, CaptureSizePreset::Vga); + assert_eq!(small.width, 640); + assert_eq!(small.height, 480); + assert_eq!(small.fps, 60); } #[test] - fn split_capture_controls_apply_custom_fps_and_bitrate() { + fn source_capture_knobs_follow_the_selected_native_mode() { let mut state = LauncherState::new(); - state.set_preview_source_profile(1920, 1080, 30); + state.set_preview_source_profile(1920, 1080, 60); state.set_capture_size_preset(1, CaptureSizePreset::P1080); let defaults = state.capture_size_choice(1); assert_eq!(defaults.width, 1920); assert_eq!(defaults.height, 1080); - assert_eq!(defaults.fps, 30); - assert_eq!(defaults.max_bitrate_kbit, 12_000); + assert_eq!(defaults.fps, 60); + assert_eq!(defaults.max_bitrate_kbit, 18_000); state.set_capture_fps(1, 24); state.set_capture_bitrate_kbit(1, 8_500); let tuned = state.capture_size_choice(1); + assert_eq!(tuned.preset, CaptureSizePreset::P1080); assert_eq!(tuned.width, 1920); assert_eq!(tuned.height, 1080); - assert_eq!(tuned.fps, 24); - assert_eq!(tuned.max_bitrate_kbit, 8_500); + assert_eq!(tuned.fps, 60); + assert_eq!(tuned.max_bitrate_kbit, 18_000); } #[test] fn source_capture_ignores_manual_fps_and_bitrate_knobs() { let mut state = LauncherState::new(); - state.set_preview_source_profile(1920, 1080, 25); - state.set_capture_size_preset(0, CaptureSizePreset::Source); + state.set_preview_source_profile(1920, 1080, 60); + state.set_capture_size_preset(0, CaptureSizePreset::P720); state.set_capture_fps(0, 60); state.set_capture_bitrate_kbit(0, 24_000); let source = state.capture_size_choice(0); - assert_eq!(source.preset, CaptureSizePreset::Source); - assert_eq!(source.fps, 25); + assert_eq!(source.preset, CaptureSizePreset::P720); + assert_eq!(source.width, 1280); + assert_eq!(source.height, 720); + assert_eq!(source.fps, 60); assert_eq!(source.max_bitrate_kbit, 12_000); } } diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index 319a79b..ac839d1 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -10,8 +10,8 @@ use { super::launcher_focus_signal_path, super::power::{fetch_capture_power, set_capture_power_mode}, super::state::{ - BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface, InputRouting, - LauncherState, + BreakoutSizePreset, CapturePowerStatus, CaptureSizePreset, DisplaySurface, + FeedSourcePreset, InputRouting, LauncherState, }, super::ui_components::build_launcher_view, super::ui_runtime::{ @@ -207,25 +207,43 @@ fn refresh_eye_feed_controls( state: &LauncherState, ) { for monitor_id in 0..2 { - super::ui_components::sync_capture_resolution_combo( - &widgets.display_panes[monitor_id].capture_resolution_combo, - state.capture_size_options(), - state.capture_size_preset(monitor_id), - ); - super::ui_components::sync_capture_fps_combo( - &widgets.display_panes[monitor_id].capture_fps_combo, - state.capture_fps_options(), - state.capture_fps(monitor_id), - state.capture_size_preset(monitor_id) == CaptureSizePreset::Source, - state.capture_size_choice(monitor_id).fps, - ); - super::ui_components::sync_capture_bitrate_combo( - &widgets.display_panes[monitor_id].capture_bitrate_combo, - state.capture_bitrate_options(), - state.capture_bitrate_kbit(monitor_id), - state.capture_size_preset(monitor_id) == CaptureSizePreset::Source, - state.capture_size_choice(monitor_id).max_bitrate_kbit, + super::ui_components::sync_feed_source_combo( + &widgets.display_panes[monitor_id].feed_source_combo, + state.feed_source_options(monitor_id), + state.feed_source_preset(monitor_id), ); + if state.feed_source_preset(monitor_id) != FeedSourcePreset::Off { + let choice = state.capture_size_choice(monitor_id); + super::ui_components::sync_capture_resolution_combo( + &widgets.display_panes[monitor_id].capture_resolution_combo, + state.capture_size_options(), + state.capture_size_preset(monitor_id), + ); + super::ui_components::sync_capture_fps_combo( + &widgets.display_panes[monitor_id].capture_fps_combo, + state.capture_fps_options(), + state.capture_fps(monitor_id), + true, + choice.fps, + ); + super::ui_components::sync_capture_bitrate_combo( + &widgets.display_panes[monitor_id].capture_bitrate_combo, + state.capture_bitrate_options(), + state.capture_bitrate_kbit(monitor_id), + true, + choice.max_bitrate_kbit, + ); + } else { + super::ui_components::sync_capture_resolution_disabled( + &widgets.display_panes[monitor_id].capture_resolution_combo, + ); + super::ui_components::sync_capture_fps_disabled( + &widgets.display_panes[monitor_id].capture_fps_combo, + ); + super::ui_components::sync_capture_bitrate_disabled( + &widgets.display_panes[monitor_id].capture_bitrate_combo, + ); + } super::ui_components::sync_breakout_size_combo( &widgets.display_panes[monitor_id].breakout_combo, state.breakout_size_options(), @@ -254,25 +272,31 @@ fn record_diagnostics_sample( ) { let left_metrics = preview .and_then(|preview| { - preview.snapshot_metrics( - 0, - match state.display_surface(0) { - DisplaySurface::Preview => super::preview::PreviewSurface::Inline, - DisplaySurface::Window => super::preview::PreviewSurface::Window, - }, + (state.feed_source_preset(0) != FeedSourcePreset::Off).then_some( + preview.snapshot_metrics( + 0, + match state.display_surface(0) { + DisplaySurface::Preview => super::preview::PreviewSurface::Inline, + DisplaySurface::Window => super::preview::PreviewSurface::Window, + }, + ), ) }) + .flatten() .unwrap_or_default(); let right_metrics = preview .and_then(|preview| { - preview.snapshot_metrics( - 1, - match state.display_surface(1) { - DisplaySurface::Preview => super::preview::PreviewSurface::Inline, - DisplaySurface::Window => super::preview::PreviewSurface::Window, - }, + (state.feed_source_preset(1) != FeedSourcePreset::Off).then_some( + preview.snapshot_metrics( + 1, + match state.display_surface(1) { + DisplaySurface::Preview => super::preview::PreviewSurface::Inline, + DisplaySurface::Window => super::preview::PreviewSurface::Window, + }, + ), ) }) + .flatten() .unwrap_or_default(); widgets @@ -305,6 +329,7 @@ fn record_diagnostics_sample( left_decoder_label: left_metrics.decoder_label.clone(), left_stream_caps_label: left_metrics.stream_caps_label.clone(), left_decoded_caps_label: left_metrics.decoded_caps_label.clone(), + left_rendered_caps_label: left_metrics.rendered_caps_label.clone(), right_receive_fps: right_metrics.receive_fps, right_present_fps: right_metrics.present_fps, right_server_fps: right_metrics.server_fps, @@ -320,6 +345,7 @@ fn record_diagnostics_sample( right_decoder_label: right_metrics.decoder_label.clone(), right_stream_caps_label: right_metrics.stream_caps_label.clone(), right_decoded_caps_label: right_metrics.decoded_caps_label.clone(), + right_rendered_caps_label: right_metrics.rendered_caps_label.clone(), dropped_frames: left_metrics .dropped_frames .saturating_add(right_metrics.dropped_frames), @@ -419,6 +445,7 @@ fn normalize_breakout_limit(width: u32, height: u32) -> (u32, u32) { fn rebind_inline_preview( preview: &super::preview::LauncherPreview, widgets: &super::ui_components::LauncherWidgets, + state: &LauncherState, monitor_id: usize, ) { if let Some(binding) = widgets.display_panes[monitor_id] @@ -428,6 +455,15 @@ fn rebind_inline_preview( { binding.close(); } + if state.feed_source_preset(monitor_id) == FeedSourcePreset::Off { + widgets.display_panes[monitor_id] + .picture + .set_paintable(Option::<>k::gdk::Paintable>::None); + widgets.display_panes[monitor_id] + .stream_status + .set_text("Feed disabled."); + return; + } let binding = preview.install_on_picture( monitor_id, super::preview::PreviewSurface::Inline, @@ -443,6 +479,7 @@ fn rebind_inline_preview( fn rebind_popout_preview( preview: &super::preview::LauncherPreview, popouts: &Rc; 2]>>, + state: &LauncherState, monitor_id: usize, ) { let mut popouts = popouts.borrow_mut(); @@ -450,6 +487,13 @@ fn rebind_popout_preview( return; }; handle.binding.close(); + if state.feed_source_preset(monitor_id) == FeedSourcePreset::Off { + handle + .picture + .set_paintable(Option::<>k::gdk::Paintable>::None); + handle.status_label.set_text("Feed disabled."); + return; + } if let Some(binding) = preview.install_on_picture( monitor_id, super::preview::PreviewSurface::Window, @@ -463,15 +507,20 @@ fn rebind_popout_preview( #[cfg(not(coverage))] fn apply_preview_profiles(preview: &super::preview::LauncherPreview, state: &LauncherState) { for monitor_id in 0..2 { + let enabled = state.feed_source_preset(monitor_id) != FeedSourcePreset::Off; + preview.set_monitor_enabled(monitor_id, enabled); let capture = state.capture_size_choice(monitor_id); + let source_monitor_id = state + .resolved_feed_monitor_id(monitor_id) + .unwrap_or(monitor_id); let breakout = state.breakout_size_choice(monitor_id); preview.set_capture_profile( monitor_id, + source_monitor_id, capture.width, capture.height, capture.fps, capture.max_bitrate_kbit, - capture.preset != CaptureSizePreset::Source, ); preview.set_breakout_profile(monitor_id, breakout.width, breakout.height); } @@ -486,8 +535,8 @@ fn sync_preview_profiles( ) { apply_preview_profiles(preview, state); for monitor_id in 0..2 { - rebind_inline_preview(preview, widgets, monitor_id); - rebind_popout_preview(preview, popouts, monitor_id); + rebind_inline_preview(preview, widgets, state, monitor_id); + rebind_popout_preview(preview, popouts, state, monitor_id); } } @@ -796,6 +845,34 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { }); } + for monitor_id in 0..2 { + let state = Rc::clone(&state); + let widgets = widgets.clone(); + let popouts = Rc::clone(&popouts); + let child_proc = Rc::clone(&child_proc); + let preview = preview.clone(); + let feed_source_combo = widgets.display_panes[monitor_id].feed_source_combo.clone(); + feed_source_combo.connect_changed(move |combo| { + let Some(active_id) = combo.active_id() else { + return; + }; + let Some(preset) = FeedSourcePreset::from_id(active_id.as_str()) else { + return; + }; + if state.borrow().feed_source_preset(monitor_id) == preset { + return; + } + { + let mut state = state.borrow_mut(); + state.set_feed_source_preset(monitor_id, preset); + } + if let Some(preview) = preview.as_ref() { + sync_preview_profiles(preview, &widgets, &popouts, &state.borrow()); + } + refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); + }); + } + for monitor_id in 0..2 { let state = Rc::clone(&state); let widgets = widgets.clone(); @@ -811,6 +888,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let Some(preset) = CaptureSizePreset::from_id(active_id.as_str()) else { return; }; + if state.borrow().feed_source_preset(monitor_id) == FeedSourcePreset::Off { + return; + } if state.borrow().capture_size_preset(monitor_id) == preset { return; } @@ -820,16 +900,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { } if let Some(preview) = preview.as_ref() { let choice = state.borrow().capture_size_choice(monitor_id); + let source_monitor_id = state + .borrow() + .resolved_feed_monitor_id(monitor_id) + .unwrap_or(monitor_id); preview.set_capture_profile( monitor_id, + source_monitor_id, choice.width, choice.height, choice.fps, choice.max_bitrate_kbit, - choice.preset != CaptureSizePreset::Source, ); - rebind_inline_preview(preview, &widgets, monitor_id); - rebind_popout_preview(preview, &popouts, monitor_id); + sync_preview_profiles(preview, &widgets, &popouts, &state.borrow()); } refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); }); @@ -852,6 +935,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let Ok(fps) = active_id.as_str().parse::() else { return; }; + if state.borrow().feed_source_preset(monitor_id) == FeedSourcePreset::Off { + return; + } if state.borrow().capture_fps(monitor_id) == fps { return; } @@ -861,16 +947,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { } if let Some(preview) = preview.as_ref() { let choice = state.borrow().capture_size_choice(monitor_id); + let source_monitor_id = state + .borrow() + .resolved_feed_monitor_id(monitor_id) + .unwrap_or(monitor_id); preview.set_capture_profile( monitor_id, + source_monitor_id, choice.width, choice.height, choice.fps, choice.max_bitrate_kbit, - choice.preset != CaptureSizePreset::Source, ); - rebind_inline_preview(preview, &widgets, monitor_id); - rebind_popout_preview(preview, &popouts, monitor_id); + sync_preview_profiles(preview, &widgets, &popouts, &state.borrow()); } refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); }); @@ -894,6 +983,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let Ok(max_bitrate_kbit) = active_id.as_str().parse::() else { return; }; + if state.borrow().feed_source_preset(monitor_id) == FeedSourcePreset::Off { + return; + } if state.borrow().capture_bitrate_kbit(monitor_id) == max_bitrate_kbit { return; } @@ -903,16 +995,19 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { } if let Some(preview) = preview.as_ref() { let choice = state.borrow().capture_size_choice(monitor_id); + let source_monitor_id = state + .borrow() + .resolved_feed_monitor_id(monitor_id) + .unwrap_or(monitor_id); preview.set_capture_profile( monitor_id, + source_monitor_id, choice.width, choice.height, choice.fps, choice.max_bitrate_kbit, - choice.preset != CaptureSizePreset::Source, ); - rebind_inline_preview(preview, &widgets, monitor_id); - rebind_popout_preview(preview, &popouts, monitor_id); + sync_preview_profiles(preview, &widgets, &popouts, &state.borrow()); } refresh_launcher_ui(&widgets, &state.borrow(), child_proc.borrow().is_some()); }); @@ -952,7 +1047,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { }; if popout_open { if let Some(preview) = preview.as_ref() { - rebind_popout_preview(preview, &popouts, monitor_id); + rebind_popout_preview(preview, &popouts, &state.borrow(), monitor_id); } if let Some(handle) = popouts .borrow() @@ -1833,7 +1928,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { if let (Some(width), Some(height)) = (caps.eye_width, caps.eye_height) { - let fps = caps.eye_fps.unwrap_or(30); + let fps = caps + .eye_fps + .unwrap_or(crate::launcher::state::PreviewSourceSize::default().fps); { let mut state = state.borrow_mut(); state.set_preview_source_profile(width, height, fps); @@ -1911,7 +2008,7 @@ pub fn run_gui_launcher(_server_addr: String) -> Result<()> { mod tests { use super::apply_preview_profiles; use crate::launcher::preview::{LauncherPreview, PreviewSurface}; - use crate::launcher::state::{CaptureSizePreset, LauncherState}; + use crate::launcher::state::{CaptureSizePreset, FeedSourcePreset, LauncherState}; #[test] fn fresh_preview_bootstrap_is_overridden_by_launcher_state_profiles() { @@ -1919,25 +2016,27 @@ mod tests { let state = LauncherState::default(); let bootstrap = preview.profile_for_test(1, PreviewSurface::Inline).unwrap(); - assert_eq!(bootstrap.2, 960); - assert_eq!(bootstrap.3, 540); - assert_eq!(bootstrap.4, 24); + assert_eq!(bootstrap.0, 0); + assert_eq!(bootstrap.3, 1920); + assert_eq!(bootstrap.4, 1080); + assert_eq!(bootstrap.5, 60); + assert_eq!(bootstrap.6, 18_000); apply_preview_profiles(&preview, &state); let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap(); - assert_eq!(inline.2, 1920); - assert_eq!(inline.3, 1080); - assert_eq!(inline.4, 24); - assert_eq!(inline.5, 4_000); - assert!(inline.6); + assert_eq!(inline.0, 1); + assert_eq!(inline.3, 1920); + assert_eq!(inline.4, 1080); + assert_eq!(inline.5, 60); + assert_eq!(inline.6, 18_000); let window = preview.profile_for_test(1, PreviewSurface::Window).unwrap(); - assert_eq!(window.2, 1920); - assert_eq!(window.3, 1080); - assert_eq!(window.4, 30); - assert_eq!(window.5, 12_000); - assert!(window.6); + assert_eq!(window.0, 1); + assert_eq!(window.3, 1920); + assert_eq!(window.4, 1080); + assert_eq!(window.5, 60); + assert_eq!(window.6, 18_000); preview.shutdown_all(); } @@ -1946,16 +2045,56 @@ mod tests { fn source_preview_profile_stays_honest_after_apply() { let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap(); let mut state = LauncherState::default(); - state.set_capture_size_preset(1, CaptureSizePreset::Source); + state.set_capture_size_preset(1, CaptureSizePreset::P1080); apply_preview_profiles(&preview, &state); let inline = preview.profile_for_test(1, PreviewSurface::Inline).unwrap(); - assert_eq!(inline.2, 1920); - assert_eq!(inline.3, 1080); - assert_eq!(inline.4, 30); - assert_eq!(inline.5, 12_000); - assert!(!inline.6); + assert_eq!(inline.0, 1); + assert_eq!(inline.3, 1920); + assert_eq!(inline.4, 1080); + assert_eq!(inline.5, 60); + assert_eq!(inline.6, 18_000); + + preview.shutdown_all(); + } + + #[test] + fn mirrored_preview_profile_keeps_its_own_feed_id_but_uses_the_other_source() { + let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap(); + let mut state = LauncherState::default(); + state.set_feed_source_preset(0, FeedSourcePreset::OtherEye); + + apply_preview_profiles(&preview, &state); + + let inline = preview.profile_for_test(0, PreviewSurface::Inline).unwrap(); + let window = preview.profile_for_test(0, PreviewSurface::Window).unwrap(); + assert_eq!(inline.0, 1); + assert_eq!(window.0, 1); + + preview.shutdown_all(); + } + + #[test] + fn off_preview_profile_disables_both_surfaces_instead_of_leaving_idle_feeds_running() { + let preview = LauncherPreview::new("http://127.0.0.1:1".to_string()).unwrap(); + let mut state = LauncherState::default(); + state.set_feed_source_preset(0, FeedSourcePreset::Off); + + apply_preview_profiles(&preview, &state); + + assert_eq!( + preview.feed_disabled_for_test(0, PreviewSurface::Inline), + Some(true) + ); + assert_eq!( + preview.feed_disabled_for_test(0, PreviewSurface::Window), + Some(true) + ); + assert_eq!( + preview.feed_disabled_for_test(1, PreviewSurface::Inline), + Some(false) + ); preview.shutdown_all(); } diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index e9bd4e7..becc760 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -9,7 +9,7 @@ use super::{ preview::{LauncherPreview, PreviewBinding, PreviewSurface}, state::{ BreakoutSizeChoice, BreakoutSizePreset, CaptureBitrateChoice, CaptureFpsChoice, - CaptureSizeChoice, CaptureSizePreset, LauncherState, + CaptureSizeChoice, CaptureSizePreset, FeedSourceChoice, FeedSourcePreset, LauncherState, }, }; @@ -31,6 +31,7 @@ pub struct DisplayPaneWidgets { pub picture: gtk::Picture, pub stream_status: gtk::Label, pub placeholder: gtk::Label, + pub feed_source_combo: gtk::ComboBoxText, pub capture_resolution_combo: gtk::ComboBoxText, pub capture_fps_combo: gtk::ComboBoxText, pub capture_bitrate_combo: gtk::ComboBoxText, @@ -529,60 +530,94 @@ pub fn build_launcher_view( let left_pane = left_pane; let right_pane = right_pane; if let Some(preview) = preview.as_ref() { - *left_pane.preview_binding.borrow_mut() = preview.install_on_picture( - 0, - PreviewSurface::Inline, - &left_pane.picture, - &left_pane.stream_status, - ); - *right_pane.preview_binding.borrow_mut() = preview.install_on_picture( - 1, - PreviewSurface::Inline, - &right_pane.picture, - &right_pane.stream_status, - ); + *left_pane.preview_binding.borrow_mut() = + if state.feed_source_preset(0) == FeedSourcePreset::Off { + None + } else { + preview.install_on_picture( + 0, + PreviewSurface::Inline, + &left_pane.picture, + &left_pane.stream_status, + ) + }; + *right_pane.preview_binding.borrow_mut() = + if state.feed_source_preset(1) == FeedSourcePreset::Off { + None + } else { + preview.install_on_picture( + 1, + PreviewSurface::Inline, + &right_pane.picture, + &right_pane.stream_status, + ) + }; } else { left_pane.stream_status.set_text("Preview unavailable"); right_pane.stream_status.set_text("Preview unavailable"); } - sync_capture_resolution_combo( - &left_pane.capture_resolution_combo, - state.capture_size_options(), - state.capture_size_preset(0), + sync_feed_source_combo( + &left_pane.feed_source_combo, + state.feed_source_options(0), + state.feed_source_preset(0), ); - sync_capture_fps_combo( - &left_pane.capture_fps_combo, - state.capture_fps_options(), - state.capture_fps(0), - state.capture_size_preset(0) == CaptureSizePreset::Source, - state.capture_size_choice(0).fps, - ); - sync_capture_bitrate_combo( - &left_pane.capture_bitrate_combo, - state.capture_bitrate_options(), - state.capture_bitrate_kbit(0), - state.capture_size_preset(0) == CaptureSizePreset::Source, - state.capture_size_choice(0).max_bitrate_kbit, - ); - sync_capture_resolution_combo( - &right_pane.capture_resolution_combo, - state.capture_size_options(), - state.capture_size_preset(1), - ); - sync_capture_fps_combo( - &right_pane.capture_fps_combo, - state.capture_fps_options(), - state.capture_fps(1), - state.capture_size_preset(1) == CaptureSizePreset::Source, - state.capture_size_choice(1).fps, - ); - sync_capture_bitrate_combo( - &right_pane.capture_bitrate_combo, - state.capture_bitrate_options(), - state.capture_bitrate_kbit(1), - state.capture_size_preset(1) == CaptureSizePreset::Source, - state.capture_size_choice(1).max_bitrate_kbit, + sync_feed_source_combo( + &right_pane.feed_source_combo, + state.feed_source_options(1), + state.feed_source_preset(1), ); + if state.feed_source_preset(0) != FeedSourcePreset::Off { + let choice = state.capture_size_choice(0); + sync_capture_resolution_combo( + &left_pane.capture_resolution_combo, + state.capture_size_options(), + state.capture_size_preset(0), + ); + sync_capture_fps_combo( + &left_pane.capture_fps_combo, + state.capture_fps_options(), + state.capture_fps(0), + true, + choice.fps, + ); + sync_capture_bitrate_combo( + &left_pane.capture_bitrate_combo, + state.capture_bitrate_options(), + state.capture_bitrate_kbit(0), + true, + choice.max_bitrate_kbit, + ); + } else { + sync_capture_resolution_disabled(&left_pane.capture_resolution_combo); + sync_capture_fps_disabled(&left_pane.capture_fps_combo); + sync_capture_bitrate_disabled(&left_pane.capture_bitrate_combo); + } + if state.feed_source_preset(1) != FeedSourcePreset::Off { + let choice = state.capture_size_choice(1); + sync_capture_resolution_combo( + &right_pane.capture_resolution_combo, + state.capture_size_options(), + state.capture_size_preset(1), + ); + sync_capture_fps_combo( + &right_pane.capture_fps_combo, + state.capture_fps_options(), + state.capture_fps(1), + true, + choice.fps, + ); + sync_capture_bitrate_combo( + &right_pane.capture_bitrate_combo, + state.capture_bitrate_options(), + state.capture_bitrate_kbit(1), + true, + choice.max_bitrate_kbit, + ); + } else { + sync_capture_resolution_disabled(&right_pane.capture_resolution_combo); + sync_capture_fps_disabled(&right_pane.capture_fps_combo); + sync_capture_bitrate_disabled(&right_pane.capture_bitrate_combo); + } sync_breakout_size_combo( &left_pane.breakout_combo, state.breakout_size_options(), @@ -853,30 +888,45 @@ fn stabilize_chip(chip: >k::Box, width: i32) { chip.set_size_request(width, -1); } +pub fn sync_feed_source_combo( + combo: >k::ComboBoxText, + options: Vec, + selected: FeedSourcePreset, +) { + combo.remove_all(); + for option in options { + combo.append(Some(option.preset.as_id()), option.label); + } + combo.set_active_id(Some(selected.as_id())); + combo.set_sensitive(true); +} + pub fn sync_capture_resolution_combo( combo: >k::ComboBoxText, options: Vec, selected: CaptureSizePreset, ) { combo.remove_all(); + let option_count = options.len(); for option in options { - let label = match option.preset { - CaptureSizePreset::Source => format!( - "{} • {}x{} (Pass-through)", - option.preset.label(), - option.width, - option.height, - ), - _ => format!( - "{} • {}x{} (Re-encode)", - option.preset.label(), - option.width, - option.height, - ), - }; + let label = format!( + "{} • {}x{} @ {} fps (Device H.264)", + option.preset.label(), + option.width, + option.height, + option.fps, + ); combo.append(Some(option.preset.as_id()), &label); } combo.set_active_id(Some(selected.as_id())); + combo.set_sensitive(option_count > 1); +} + +pub fn sync_capture_resolution_disabled(combo: >k::ComboBoxText) { + combo.remove_all(); + combo.append(Some("off"), "Feed disabled"); + combo.set_active_id(Some("off")); + combo.set_sensitive(false); } pub fn sync_capture_fps_combo( @@ -888,7 +938,7 @@ pub fn sync_capture_fps_combo( ) { combo.remove_all(); if locked_to_source { - combo.append(Some("source"), &format!("{source_fps} fps (Source)")); + combo.append(Some("source"), &format!("{source_fps} fps (Device mode)")); combo.set_active_id(Some("source")); combo.set_sensitive(false); return; @@ -903,6 +953,13 @@ pub fn sync_capture_fps_combo( combo.set_sensitive(true); } +pub fn sync_capture_fps_disabled(combo: >k::ComboBoxText) { + combo.remove_all(); + combo.append(Some("off"), "Feed disabled"); + combo.set_active_id(Some("off")); + combo.set_sensitive(false); +} + pub fn sync_capture_bitrate_combo( combo: >k::ComboBoxText, options: Vec, @@ -914,7 +971,7 @@ pub fn sync_capture_bitrate_combo( if locked_to_source { combo.append( Some("source"), - &format!("{source_bitrate_kbit} kbit (Source estimate)"), + &format!("~{source_bitrate_kbit} kbit (Estimated, device-managed)"), ); combo.set_active_id(Some("source")); combo.set_sensitive(false); @@ -930,6 +987,13 @@ pub fn sync_capture_bitrate_combo( combo.set_sensitive(true); } +pub fn sync_capture_bitrate_disabled(combo: >k::ComboBoxText) { + combo.remove_all(); + combo.append(Some("off"), "Feed disabled"); + combo.set_active_id(Some("off")); + combo.set_sensitive(false); +} + pub fn sync_breakout_size_combo( combo: >k::ComboBoxText, options: Vec, @@ -1105,19 +1169,24 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { stream_status.set_single_line_mode(true); stream_status.set_max_width_chars(24); stream_status.set_tooltip_text(Some("Connect relay to preview.")); + let feed_source_combo = gtk::ComboBoxText::new(); + feed_source_combo.set_tooltip_text(Some( + "Choose which physical eye feed appears in this pane. Off disables the pane; the opposite-eye option mirrors the other physical feed while preserving a separate stream load for realistic validation.", + )); + feed_source_combo.set_size_request(150, -1); let capture_resolution_combo = gtk::ComboBoxText::new(); capture_resolution_combo.set_tooltip_text(Some( - "Choose the server-side capture resolution for this eye feed. Source keeps the HDMI device's own H.264 stream; the standard sizes force a server re-encode.", + "Choose the eye-stream source mode for this feed. Source keeps the HDMI device's own H.264 stream; cheaper source-device modes will appear here once the hardware proves it supports them.", )); capture_resolution_combo.set_size_request(240, -1); let capture_fps_combo = gtk::ComboBoxText::new(); capture_fps_combo.set_tooltip_text(Some( - "Choose the target server-side fps for re-encoded eye feeds. Source pass-through uses the HDMI device's own cadence.", + "Source pass-through uses the HDMI device's own cadence. This control will wake back up if we add a proven source-side fps option later.", )); capture_fps_combo.set_size_request(120, -1); let capture_bitrate_combo = gtk::ComboBoxText::new(); capture_bitrate_combo.set_tooltip_text(Some( - "Choose the target server-side bitrate for re-encoded eye feeds. Source pass-through uses an estimated hardware bitrate tier.", + "Source pass-through uses the eye device's own H.264 bitrate behavior. This control stays disabled until the hardware exposes a real source-side bitrate mode we can trust.", )); capture_bitrate_combo.set_size_request(170, -1); let breakout_combo = gtk::ComboBoxText::new(); @@ -1129,6 +1198,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { stabilize_button(&action_button, 104); action_button.set_halign(gtk::Align::End); let capture_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + capture_row.append(&feed_source_combo); capture_row.append(&capture_resolution_combo); capture_row.append(&capture_fps_combo); capture_row.append(&capture_bitrate_combo); @@ -1146,6 +1216,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { picture, stream_status, placeholder, + feed_source_combo, capture_resolution_combo, capture_fps_combo, capture_bitrate_combo, diff --git a/common/Cargo.toml b/common/Cargo.toml index 64029ec..5e72e99 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.11" +version = "0.11.12" edition = "2024" build = "build.rs" diff --git a/common/proto/lesavka.proto b/common/proto/lesavka.proto index 8085544..666685d 100644 --- a/common/proto/lesavka.proto +++ b/common/proto/lesavka.proto @@ -11,7 +11,7 @@ message MonitorRequest { uint32 requested_width = 3; uint32 requested_height = 4; uint32 requested_fps = 5; - bool prefer_reencode = 6; + optional uint32 source_id = 6; } message VideoPacket { uint32 id = 1; diff --git a/common/src/cli.rs b/common/src/cli.rs index 73ea571..17599ac 100644 --- a/common/src/cli.rs +++ b/common/src/cli.rs @@ -17,6 +17,6 @@ mod tests { #[test] fn banner_includes_version() { - assert_eq!(banner("0.11.11"), "lesavka-common CLI (v0.11.11)"); + assert_eq!(banner("0.11.12"), "lesavka-common CLI (v0.11.12)"); } } diff --git a/common/src/eye_source.rs b/common/src/eye_source.rs new file mode 100644 index 0000000..067ef5f --- /dev/null +++ b/common/src/eye_source.rs @@ -0,0 +1,99 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EyeSourceMode { + pub width: u32, + pub height: u32, + pub fps: u32, +} + +const GC311_H264_SOURCE_MODES: [EyeSourceMode; 5] = [ + EyeSourceMode { + width: 1920, + height: 1080, + fps: 60, + }, + EyeSourceMode { + width: 1280, + height: 720, + fps: 60, + }, + EyeSourceMode { + width: 720, + height: 576, + fps: 50, + }, + EyeSourceMode { + width: 720, + height: 480, + fps: 60, + }, + EyeSourceMode { + width: 640, + height: 480, + fps: 60, + }, +]; + +pub fn native_eye_source_modes() -> &'static [EyeSourceMode] { + &GC311_H264_SOURCE_MODES +} + +pub fn default_eye_source_mode() -> EyeSourceMode { + GC311_H264_SOURCE_MODES[0] +} + +pub fn eye_source_mode_for_request(requested_width: u32, requested_height: u32) -> EyeSourceMode { + if requested_width == 0 || requested_height == 0 { + return default_eye_source_mode(); + } + + native_eye_source_modes() + .iter() + .copied() + .find(|mode| mode.width <= requested_width && mode.height <= requested_height) + .unwrap_or_else(|| *native_eye_source_modes().last().expect("source mode list")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_to_1080p60() { + assert_eq!( + default_eye_source_mode(), + EyeSourceMode { + width: 1920, + height: 1080, + fps: 60, + } + ); + } + + #[test] + fn picks_the_highest_supported_mode_that_fits_the_request() { + assert_eq!( + eye_source_mode_for_request(1920, 1080), + EyeSourceMode { + width: 1920, + height: 1080, + fps: 60, + } + ); + assert_eq!( + eye_source_mode_for_request(1600, 900), + EyeSourceMode { + width: 1280, + height: 720, + fps: 60, + } + ); + assert_eq!( + eye_source_mode_for_request(960, 540), + EyeSourceMode { + width: 720, + height: 480, + fps: 60, + } + ); + } +} diff --git a/common/src/lib.rs b/common/src/lib.rs index f34b6ac..cf58858 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -3,6 +3,7 @@ // common/src/lib.rs pub mod cli; +pub mod eye_source; pub mod hid; pub mod paste; pub mod process_metrics; diff --git a/scripts/manual/probe-eye-capabilities.sh b/scripts/manual/probe-eye-capabilities.sh new file mode 100755 index 0000000..1662600 --- /dev/null +++ b/scripts/manual/probe-eye-capabilities.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +DEVICE="${1:-/dev/lesavka_r_eye}" +OUTDIR="${2:-/tmp/lesavka-eye-capabilities}" +WAIT_SECONDS="${LESAVKA_EYE_CAP_WAIT_SECONDS:-3600}" +POLL_SECONDS="${LESAVKA_EYE_CAP_POLL_SECONDS:-1}" + +mkdir -p "$OUTDIR" + +deadline=$((SECONDS + WAIT_SECONDS)) +while [[ ! -e "$DEVICE" ]]; do + if (( SECONDS >= deadline )); then + echo "eye capability probe timed out waiting for $DEVICE" >&2 + exit 124 + fi + sleep "$POLL_SECONDS" +done + +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +device_name="$(basename "$DEVICE")" +resolved_device="$(readlink -f "$DEVICE" 2>/dev/null || printf "%s" "$DEVICE")" +prefix="$OUTDIR/${timestamp}-${device_name}" +sysfs_base="/sys/class/video4linux/$(basename "$resolved_device" 2>/dev/null || basename "$DEVICE")" + +echo "capturing eye capabilities for $DEVICE (${resolved_device}) into ${prefix}-*.txt" + +run_with_optional_sudo() { + if "$@"; then + return 0 + fi + + local status=$? + if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then + echo + echo "# direct access failed with status ${status}; retrying under sudo -n" + sudo -n "$@" + return $? + fi + + echo + echo "# command exited with status ${status}" + if [[ -e "$resolved_device" && ! -r "$resolved_device" ]]; then + echo "# ${resolved_device} is not readable by user $(id -un)" + echo "# groups: $(id -Gn)" + fi + echo "# passwordless sudo is unavailable; root or video-group access is required for full V4L2 inspection" + return $status +} + +run_probe() { + local label="$1" + shift + local outfile="${prefix}-${label}.txt" + { + printf "# %s\n" "$label" + printf "# device: %s\n" "$DEVICE" + printf "# resolved_device: %s\n" "$resolved_device" + printf "# timestamp_utc: %s\n\n" "$timestamp" + "$@" + } >"$outfile" 2>&1 || true +} + +run_probe path-info bash -lc "printf 'device=%s\nresolved=%s\n' '$DEVICE' '$resolved_device'; printf 'ls -l: '; ls -l '$resolved_device' 2>/dev/null || true; printf 'user=%s\ngroups=%s\n' \"\$(id -un)\" \"\$(id -Gn)\"" +run_probe sysfs-summary bash -lc "printf 'sysfs_base=%s\n' '$sysfs_base'; if [[ -d '$sysfs_base' ]]; then for path in name device/modalias device/uevent; do file='$sysfs_base/'\"\$path\"; if [[ -f \"\$file\" ]]; then printf -- '\n## %s\n' \"\$path\"; sed -n '1,200p' \"\$file\"; fi; done; else echo 'missing sysfs path'; fi" +run_probe v4l2-device run_with_optional_sudo v4l2-ctl --device "$DEVICE" --info +run_probe v4l2-all run_with_optional_sudo v4l2-ctl --device "$DEVICE" --all +run_probe v4l2-formats run_with_optional_sudo v4l2-ctl --device "$DEVICE" --list-formats-ext +run_probe v4l2-ctrls run_with_optional_sudo v4l2-ctl --device "$DEVICE" --list-ctrls-menus +run_probe udev udevadm info --query=all --name "$DEVICE" + +echo "eye capability artifacts written under $OUTDIR" diff --git a/server/Cargo.toml b/server/Cargo.toml index b4bdadc..996d26f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.11" +version = "0.11.12" edition = "2024" autobins = false diff --git a/server/src/main.rs b/server/src/main.rs index e57331f..494f05e 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -98,10 +98,11 @@ impl Handler { req: MonitorRequest, ) -> Result, Status> { let id = req.id; - let dev = match id { + let source_id = req.source_id.unwrap_or(id); + let dev = match source_id { 0 => "/dev/lesavka_l_eye", 1 => "/dev/lesavka_r_eye", - _ => return Err(Status::invalid_argument("monitor id must be 0 or 1")), + _ => return Err(Status::invalid_argument("source id must be 0 or 1")), }; #[cfg(not(coverage))] @@ -110,11 +111,11 @@ impl Handler { info!( rpc_id, id, + source_id, max_bitrate = req.max_bitrate, requested_width = req.requested_width, requested_height = req.requested_height, requested_fps = req.requested_fps, - prefer_reencode = req.prefer_reencode, "🎥 capture_video opened" ); debug!(rpc_id, "🎥 streaming {dev}"); @@ -128,7 +129,6 @@ impl Handler { req.requested_width, req.requested_height, req.requested_fps, - req.prefer_reencode, ) .await .map_err(|e| Status::internal(format!("{e:#}")))?; diff --git a/server/src/video.rs b/server/src/video.rs index 0ac72aa..51946ce 100644 --- a/server/src/video.rs +++ b/server/src/video.rs @@ -6,6 +6,7 @@ use gst::MessageView::*; use gst::prelude::*; use gstreamer as gst; use gstreamer_app as gst_app; +use lesavka_common::eye_source::{default_eye_source_mode, eye_source_mode_for_request}; use lesavka_common::lesavka::VideoPacket; use lesavka_common::process_metrics::ProcessCpuSampler; use std::os::unix::fs::FileTypeExt; @@ -193,10 +194,8 @@ fn eye_device_wait_poll() -> Duration { } pub fn eye_source_profile() -> (u32, u32, u32) { - let width = round_down_even_u32(env_u32("LESAVKA_EYE_SOURCE_WIDTH", 1920).max(320)); - let height = round_down_even_u32(env_u32("LESAVKA_EYE_SOURCE_HEIGHT", 1080).max(180)); - let fps = env_u32("LESAVKA_EYE_SOURCE_FPS", 30).max(1); - (width, height, fps) + let mode = default_eye_source_mode(); + (mode.width, mode.height, mode.fps) } fn round_down_even_u32(value: u32) -> u32 { @@ -227,53 +226,24 @@ fn reset_stream_telemetry_window( #[derive(Clone, Copy, Debug)] struct EyeCaptureRequest { - source_width: u32, - source_height: u32, - requested_width: u32, - requested_height: u32, - requested_fps: u32, + width: u32, + height: u32, + fps: u32, max_bitrate_kbit: u32, - reencode: bool, } fn normalize_eye_capture_request( requested_width: u32, requested_height: u32, - requested_fps: u32, + _requested_fps: u32, max_bitrate_kbit: u32, - prefer_reencode: bool, ) -> EyeCaptureRequest { - let (source_width, source_height, source_fps) = eye_source_profile(); - let requested_width = if requested_width == 0 { - source_width - } else { - round_down_even_u32(requested_width.min(source_width).max(320)) - }; - let requested_height = if requested_height == 0 { - source_height - } else { - round_down_even_u32(requested_height.min(source_height).max(180)) - }; - let requested_fps = if requested_fps == 0 { - source_fps.max(1) - } else { - requested_fps.max(1).min(source_fps.max(1)) - }; - let max_bitrate_kbit = max_bitrate_kbit.max(800); - let downscale = requested_width < source_width || requested_height < source_height; - let baseline_source_bitrate_kbit = 12_000; - let reencode = prefer_reencode - || downscale - || requested_fps != source_fps.max(1) - || max_bitrate_kbit != baseline_source_bitrate_kbit; + let source_mode = eye_source_mode_for_request(requested_width, requested_height); EyeCaptureRequest { - source_width, - source_height, - requested_width, - requested_height, - requested_fps, + width: round_down_even_u32(source_mode.width.max(320)), + height: round_down_even_u32(source_mode.height.max(180)), + fps: source_mode.fps.max(1), max_bitrate_kbit, - reencode, } } @@ -337,7 +307,7 @@ async fn wait_for_eye_device(dev: &str, eye: &str) -> anyhow::Result<()> { /// frames before they build up in gRPC queues and destabilize downstream playback. #[cfg(coverage)] pub async fn eye_ball(dev: &str, id: u32, _max_bitrate_kbit: u32) -> anyhow::Result { - eye_ball_with_request(dev, id, _max_bitrate_kbit, 0, 0, 0, false).await + eye_ball_with_request(dev, id, _max_bitrate_kbit, 0, 0, 0).await } #[cfg(coverage)] @@ -348,7 +318,6 @@ pub async fn eye_ball_with_request( _requested_width: u32, _requested_height: u32, _requested_fps: u32, - _prefer_reencode: bool, ) -> anyhow::Result { let _ = EYE_ID[id as usize]; if dev.contains('"') { @@ -384,7 +353,7 @@ pub async fn eye_ball_with_request( #[cfg(not(coverage))] pub async fn eye_ball(dev: &str, id: u32, max_bitrate_kbit: u32) -> anyhow::Result { - eye_ball_with_request(dev, id, max_bitrate_kbit, 0, 0, 0, false).await + eye_ball_with_request(dev, id, max_bitrate_kbit, 0, 0, 0).await } #[cfg(not(coverage))] @@ -395,7 +364,6 @@ pub async fn eye_ball_with_request( requested_width: u32, requested_height: u32, requested_fps: u32, - prefer_reencode: bool, ) -> anyhow::Result { let eye = EYE_ID[id as usize]; gst::init().context("gst init")?; @@ -405,10 +373,9 @@ pub async fn eye_ball_with_request( requested_height, requested_fps, max_bitrate_kbit, - prefer_reencode, ); let target_fps = if requested_fps > 0 { - request.requested_fps + request.fps } else { env_u32("LESAVKA_EYE_FPS", default_eye_fps(max_bitrate_kbit)).max(1) }; @@ -420,11 +387,12 @@ pub async fn eye_ball_with_request( target: "lesavka_server::video", eye = %eye, max_bitrate_kbit, - source_width = request.source_width, - source_height = request.source_height, - requested_width = request.requested_width, - requested_height = request.requested_height, - requested_fps = request.requested_fps, + source_width = request.width, + source_height = request.height, + source_fps = request.fps, + requested_width = request.width, + requested_height = request.height, + requested_fps = request.fps, target_fps, min_fps, adaptive, @@ -447,17 +415,12 @@ pub async fn eye_ball_with_request( let queue_buffers = env_u32("LESAVKA_EYE_QUEUE_BUFFERS", 4).max(1); let appsink_buffers = env_u32("LESAVKA_EYE_APPSINK_BUFFERS", 4).max(1); - let keyframe_interval = env_u32( - "LESAVKA_EYE_KEYFRAME_INTERVAL", - request.requested_fps.max(1).min(5), - ) - .clamp(1, request.requested_fps.max(1)); + let keyframe_interval = env_u32("LESAVKA_EYE_KEYFRAME_INTERVAL", request.fps.max(1).min(5)) + .clamp(1, request.fps.max(1)); let use_test_src = dev.eq_ignore_ascii_case("testsrc") || dev.eq_ignore_ascii_case("videotestsrc"); let server_encoder_label = if use_test_src { "x264enc(testsrc)".to_string() - } else if request.reencode { - "x264enc".to_string() } else { "source-pass-through".to_string() }; @@ -476,36 +439,17 @@ pub async fn eye_ball_with_request( h264parse disable-passthrough=true config-interval=-1 ! \ video/x-h264,stream-format=byte-stream,alignment=au ! \ appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true", - request.requested_width, - request.requested_height, - request.requested_fps, - request.requested_width, - request.requested_height, - request.requested_fps, - ) - } else if request.reencode { - format!( - "v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \ - queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \ - h264parse disable-passthrough=true config-interval=-1 ! avdec_h264 ! videoconvert ! \ - videoscale add-borders=false ! videorate ! video/x-raw,format=I420,width={},height={},framerate={}/1,pixel-aspect-ratio=1/1 ! \ - x264enc tune=zerolatency speed-preset=faster bitrate={} key-int-max={} option-string=sar=1/1 ! \ - h264parse disable-passthrough=true config-interval=-1 ! \ - video/x-h264,stream-format=byte-stream,alignment=au ! \ - appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true", - request.requested_width, - request.requested_height, - request.requested_fps, - request.max_bitrate_kbit, - keyframe_interval, + request.width, request.height, request.fps, request.width, request.height, request.fps, ) } else { format!( "v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \ + video/x-h264,width={},height={},framerate={}/1 ! \ queue max-size-buffers={queue_buffers} max-size-time=0 max-size-bytes=0 leaky=downstream ! \ h264parse disable-passthrough=true config-interval=-1 ! \ video/x-h264,stream-format=byte-stream,alignment=au ! \ - appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true" + appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true", + request.width, request.height, request.fps, ) }; @@ -718,31 +662,24 @@ mod tests { #[test] fn source_profile_stays_pass_through_without_explicit_reencode_request() { - let request = normalize_eye_capture_request(1920, 1080, 30, 12_000, false); + let request = normalize_eye_capture_request(1920, 1080, 30, 12_000); - assert_eq!(request.requested_width, 1920); - assert_eq!(request.requested_height, 1080); - assert_eq!(request.requested_fps, 30); - assert!(!request.reencode); + assert_eq!(request.width, 1920); + assert_eq!(request.height, 1080); + assert_eq!(request.fps, 60); } #[test] - fn explicit_reencode_preference_forces_same_size_reencode() { - let request = normalize_eye_capture_request(1920, 1080, 30, 12_000, true); + fn source_mode_selection_prefers_native_modes_without_reencode() { + let bitrate_request = normalize_eye_capture_request(1920, 1080, 30, 2_500); + let fps_request = normalize_eye_capture_request(1920, 1080, 24, 12_000); + let smaller_mode_request = normalize_eye_capture_request(1600, 900, 30, 12_000); - assert_eq!(request.requested_width, 1920); - assert_eq!(request.requested_height, 1080); - assert_eq!(request.requested_fps, 30); - assert!(request.reencode); - } - - #[test] - fn bitrate_or_fps_change_forces_reencode_even_at_source_size() { - let bitrate_request = normalize_eye_capture_request(1920, 1080, 30, 2_500, false); - let fps_request = normalize_eye_capture_request(1920, 1080, 24, 12_000, false); - - assert!(bitrate_request.reencode); - assert!(fps_request.reencode); + assert_eq!(bitrate_request.fps, 60); + assert_eq!(fps_request.fps, 60); + assert_eq!(smaller_mode_request.width, 1280); + assert_eq!(smaller_mode_request.height, 720); + assert_eq!(smaller_mode_request.fps, 60); } fn marker_frame(width: i32, height: i32) -> Vec { @@ -824,9 +761,9 @@ mod tests { let mut buffer = gst::Buffer::from_mut_slice(marker_frame(width, height)); if let Some(buf) = buffer.get_mut() { buf.set_pts(Some(gst::ClockTime::ZERO)); - buf.set_duration(Some( - gst::ClockTime::from_nseconds(1_000_000_000_u64 / input_fps.max(1) as u64), - )); + buf.set_duration(Some(gst::ClockTime::from_nseconds( + 1_000_000_000_u64 / input_fps.max(1) as u64, + ))); } appsrc .push_buffer(buffer) @@ -838,7 +775,9 @@ mod tests { let sample = appsink .try_pull_sample(gst::ClockTime::from_seconds(5)) .ok_or_else(|| anyhow::anyhow!("timed out pulling reencoded frame"))?; - let caps = sample.caps().ok_or_else(|| anyhow::anyhow!("missing sample caps"))?; + let caps = sample + .caps() + .ok_or_else(|| anyhow::anyhow!("missing sample caps"))?; let structure = caps .structure(0) .ok_or_else(|| anyhow::anyhow!("missing caps structure"))?; diff --git a/testing/tests/server_main_binary_contract.rs b/testing/tests/server_main_binary_contract.rs index 849113a..d8b4ea6 100644 --- a/testing/tests/server_main_binary_contract.rs +++ b/testing/tests/server_main_binary_contract.rs @@ -152,7 +152,7 @@ mod server_main_binary { requested_width: 0, requested_height: 0, requested_fps: 0, - prefer_reencode: false, + source_id: None, })) .await }); @@ -206,7 +206,7 @@ mod server_main_binary { requested_width: 0, requested_height: 0, requested_fps: 0, - prefer_reencode: false, + source_id: None, }; let rt = tokio::runtime::Runtime::new().expect("runtime"); diff --git a/testing/tests/server_main_rpc_contract.rs b/testing/tests/server_main_rpc_contract.rs index 29eb627..c88dc00 100644 --- a/testing/tests/server_main_rpc_contract.rs +++ b/testing/tests/server_main_rpc_contract.rs @@ -81,7 +81,7 @@ mod server_main_rpc { requested_width: 0, requested_height: 0, requested_fps: 0, - prefer_reencode: false, + source_id: None, })) .await }); @@ -105,7 +105,7 @@ mod server_main_rpc { requested_width: 0, requested_height: 0, requested_fps: 0, - prefer_reencode: false, + source_id: None, })) .await }); @@ -135,7 +135,7 @@ mod server_main_rpc { requested_width: 0, requested_height: 0, requested_fps: 0, - prefer_reencode: false, + source_id: None, })) .await }) @@ -209,7 +209,7 @@ mod server_main_rpc { requested_width: 0, requested_height: 0, requested_fps: 0, - prefer_reencode: false, + source_id: None, }; let rt = tokio::runtime::Runtime::new().expect("runtime");