diff --git a/README.md b/README.md index 350c236..0b76ae7 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ Lesavka is a remote-control and remote-presence client/server pair built to make ## Current Capabilities - KDE launcher integration for the local client install - Session console with copy and breakout support -- Adjustable capture and breakout sizing for each eye feed with standard-size profiles +- Adjustable per-eye server-side capture controls for resolution, fps, and bitrate +- Adjustable breakout sizing for each eye feed with standard client-side display profiles - Automatic redocking of broken-out eye windows when the relay disconnects - Modifier-aware keyboard relay that now supports `Shift+a -> A` - Client and server semver visible in the launcher @@ -41,8 +42,9 @@ ssh theia 'cd /var/src/lesavka && git pull --ff-only && sudo LESAVKA_REF=master These install scripts are intended to be the trusted, repeatable delivery path. They pull the requested ref, ensure the environment is ready, build the correct binaries, install them into sensible system paths, and refresh the launched application or service. ## Capture / Display Profiles -- `Source` capture keeps the HDMI device's own H.264 stream and asks the server to pace it. That is the lowest-overhead path, but its keyframe cadence comes from the capture hardware. -- Standard capture profiles such as `360p`, `540p`, `720p`, `900p`, and `1080p` force the server to re-encode the eye feed at a known resolution, fps, and bitrate tier. +- Each eye now has separate server-side capture controls for `resolution`, `fps`, and `bitrate`. +- `Source` resolution keeps the HDMI device's own H.264 stream and asks the server to pace it. That is the lowest-overhead path, but its keyframe cadence comes from the capture hardware. +- Standard capture resolutions such as `360p`, `540p`, `720p`, `900p`, and `1080p` force the server to re-encode the eye feed. Once you choose one of those, fps and bitrate become explicit knobs instead of being hidden inside a single preset. - Breakout display profiles use standard client-side sizes plus `Source Size` and `Display Size`, so the popout window size is explicit instead of implied. ## Versioning diff --git a/client/Cargo.toml b/client/Cargo.toml index b0dacfe..9aa5cc8 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.7.2" +version = "0.8.0" edition = "2024" [dependencies] diff --git a/client/src/input/camera.rs b/client/src/input/camera.rs index 3c20d1c..095a5ac 100644 --- a/client/src/input/camera.rs +++ b/client/src/input/camera.rs @@ -90,7 +90,7 @@ impl CameraCapture { .map(|cfg| cfg.fps) .unwrap_or_else(|| env_u32("LESAVKA_CAM_FPS", 25)) .max(1); - let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(15)).clamp(1, fps); + let keyframe_interval = env_u32("LESAVKA_CAM_KEYFRAME_INTERVAL", fps.min(10)).clamp(1, fps); let (enc, kf_prop) = if use_mjpg_source && !output_mjpeg { ("x264enc", "key-int-max") } else { diff --git a/client/src/launcher/diagnostics.rs b/client/src/launcher/diagnostics.rs index 8e54b4b..12dbe10 100644 --- a/client/src/launcher/diagnostics.rs +++ b/client/src/launcher/diagnostics.rs @@ -7,7 +7,7 @@ use super::state::{InputRouting, LauncherState, ViewMode}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct PerformanceSample { pub rtt_ms: f32, - pub jitter_ms: f32, + pub probe_spread_ms: f32, pub input_latency_ms: f32, pub probe_loss_pct: f32, pub video_loss_pct: f32, @@ -231,15 +231,15 @@ impl SnapshotReport { if self.recent_samples.is_empty() { let _ = writeln!( text, - " no live RTT/jitter/loss samples yet; this report is currently a launcher state snapshot." + " no live RTT/probe-spread/loss samples yet; this report is currently a launcher state snapshot." ); } else { for sample in &self.recent_samples { let _ = writeln!( text, - " rtt={:.1}ms jitter={:.1}ms input-floor={:.1}ms probe-loss={:.1}% video-loss={:.1}% left={:.1}/{:.1}/{:.1}fps right={:.1}/{:.1}/{:.1}fps dropped={} queue={}", + " rtt={:.1}ms probe-spread={:.1}ms input-floor={:.1}ms probe-loss={:.1}% video-loss={:.1}% left={:.1}/{:.1}/{:.1}fps right={:.1}/{:.1}/{:.1}fps dropped={} queue={}", sample.rtt_ms, - sample.jitter_ms, + sample.probe_spread_ms, sample.input_latency_ms, sample.probe_loss_pct, sample.video_loss_pct, @@ -287,14 +287,14 @@ fn recommendations_for(state: &LauncherState, log: &DiagnosticsLog) -> Vec= 3.0 || sample.jitter_ms >= 18.0 { + if sample.probe_loss_pct >= 3.0 || sample.probe_spread_ms >= 18.0 { items.push( - "Probe jitter/loss is elevated. A wired client connection or a gentler capture profile will usually help more than changing the breakout size." + "Control-plane probe spread or loss is elevated. That can come from the network or from server stalls, so compare it against the eye fps before blaming the WAN." .to_string(), ); } @@ -374,7 +374,7 @@ mod tests { fn sample(n: u64) -> PerformanceSample { PerformanceSample { rtt_ms: 20.0 + n as f32, - jitter_ms: 3.0 + n as f32, + probe_spread_ms: 3.0 + n as f32, input_latency_ms: 10.0 + n as f32, probe_loss_pct: n as f32, video_loss_pct: (n as f32) * 0.5, diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index 9101197..ffbe3e0 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeSet; + use serde::{Deserialize, Serialize}; use super::devices::DeviceCatalog; @@ -191,6 +193,16 @@ pub struct CaptureSizeChoice { pub max_bitrate_kbit: u32, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CaptureFpsChoice { + pub fps: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CaptureBitrateChoice { + pub max_bitrate_kbit: u32, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CapturePowerStatus { pub available: bool, @@ -234,6 +246,8 @@ pub struct LauncherState { pub breakout_limit: PreviewSourceSize, pub breakout_display: PreviewSourceSize, pub capture_sizes: [CaptureSizePreset; 2], + pub capture_fps: [u32; 2], + pub capture_bitrates_kbit: [u32; 2], pub breakout_sizes: [BreakoutSizePreset; 2], pub devices: DeviceSelection, pub swap_key: String, @@ -256,6 +270,8 @@ impl Default for LauncherState { breakout_limit: PreviewSourceSize::default(), breakout_display: PreviewSourceSize::default(), capture_sizes: [CaptureSizePreset::Source, CaptureSizePreset::Source], + capture_fps: [30, 30], + capture_bitrates_kbit: [12_000, 12_000], breakout_sizes: [BreakoutSizePreset::Source, BreakoutSizePreset::Source], devices: DeviceSelection::default(), swap_key: "pause".to_string(), @@ -385,16 +401,60 @@ impl LauncherState { if let Some(slot) = self.capture_sizes.get_mut(monitor_id) { *slot = preset; } + let defaults = default_profile_for_preset(self.preview_source, preset); + self.set_capture_fps(monitor_id, defaults.fps); + self.set_capture_bitrate_kbit(monitor_id, defaults.max_bitrate_kbit); + } + + pub fn capture_fps(&self, monitor_id: usize) -> u32 { + self.capture_fps + .get(monitor_id) + .copied() + .unwrap_or(30) + .max(1) + } + + 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); + } + } + + pub fn capture_bitrate_kbit(&self, monitor_id: usize) -> u32 { + self.capture_bitrates_kbit + .get(monitor_id) + .copied() + .unwrap_or(12_000) + .max(800) + } + + 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); + } } pub fn capture_size_choice(&self, monitor_id: usize) -> CaptureSizeChoice { - capture_size_choice(self.preview_source, self.capture_size_preset(monitor_id)) + capture_size_choice( + self.preview_source, + self.capture_size_preset(monitor_id), + self.capture_fps(monitor_id), + self.capture_bitrate_kbit(monitor_id), + ) } pub fn capture_size_options(&self) -> Vec { capture_size_options(self.preview_source) } + pub fn capture_fps_options(&self) -> Vec { + capture_fps_options(self.preview_source) + } + + pub fn capture_bitrate_options(&self) -> Vec { + capture_bitrate_options(self.preview_source) + } + pub fn breakout_size_preset(&self, monitor_id: usize) -> BreakoutSizePreset { self.breakout_sizes .get(monitor_id) @@ -618,7 +678,162 @@ fn breakout_size_options( options } -fn capture_size_choice(source: PreviewSourceSize, preset: CaptureSizePreset) -> CaptureSizeChoice { +fn capture_size_choice( + 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), + ), + }; + CaptureSizeChoice { + preset, + width, + height, + fps, + max_bitrate_kbit, + } +} + +fn estimate_source_bitrate_kbit(width: i32, height: i32, fps: u32) -> u32 { + let pixels_per_second = width.max(1) as u64 * height.max(1) as u64 * fps.max(1) as u64; + match pixels_per_second { + p if p >= 1920_u64 * 1080 * 50 => 18_000, + p if p >= 1920_u64 * 1080 * 24 => 12_000, + p if p >= 1280_u64 * 720 * 24 => 6_000, + _ => 2_500, + } +} + +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 +} + +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() +} + +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() +} + +fn default_profile_for_preset( + 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); @@ -663,44 +878,6 @@ fn capture_size_choice(source: PreviewSourceSize, preset: CaptureSizePreset) -> } } -fn estimate_source_bitrate_kbit(width: i32, height: i32, fps: u32) -> u32 { - let pixels_per_second = width.max(1) as u64 * height.max(1) as u64 * fps.max(1) as u64; - match pixels_per_second { - p if p >= 1920_u64 * 1080 * 50 => 18_000, - p if p >= 1920_u64 * 1080 * 24 => 12_000, - p if p >= 1280_u64 * 720 * 24 => 6_000, - _ => 2_500, - } -} - -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 choice = capture_size_choice(source, preset); - 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 -} - fn fit_standard_dimensions( limit_width: i32, limit_height: i32, @@ -1025,4 +1202,38 @@ mod tests { let small = state.capture_size_choice(0); assert_eq!(small.fps, 15); } + + #[test] + fn split_capture_controls_apply_custom_fps_and_bitrate() { + let mut state = LauncherState::new(); + state.set_preview_source_profile(1920, 1080, 30); + + 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); + + state.set_capture_fps(1, 24); + state.set_capture_bitrate_kbit(1, 8_500); + let tuned = state.capture_size_choice(1); + assert_eq!(tuned.width, 1920); + assert_eq!(tuned.height, 1080); + assert_eq!(tuned.fps, 24); + assert_eq!(tuned.max_bitrate_kbit, 8_500); + } + + #[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_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.max_bitrate_kbit, 12_000); + } } diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index f604f60..39debb4 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -71,7 +71,7 @@ struct NetworkTelemetry { #[derive(Clone, Copy, Debug, Default)] struct NetworkSnapshot { rtt_ms: f32, - jitter_ms: f32, + probe_spread_ms: f32, probe_loss_pct: f32, } @@ -92,7 +92,7 @@ impl NetworkTelemetry { let now = Instant::now(); self.trim(now); let rtt_ms = self.rtt_samples.back().map(|(_, rtt)| *rtt).unwrap_or(0.0); - let jitter_ms = network_jitter_ms(&self.rtt_samples); + let probe_spread_ms = network_spread_ms(&self.rtt_samples); let probe_count = self.rtt_samples.len() + self.failures.len(); let probe_loss_pct = if probe_count == 0 { 0.0 @@ -101,7 +101,7 @@ impl NetworkTelemetry { }; NetworkSnapshot { rtt_ms, - jitter_ms, + probe_spread_ms, probe_loss_pct, } } @@ -125,16 +125,19 @@ impl NetworkTelemetry { } #[cfg(not(coverage))] -fn network_jitter_ms(samples: &VecDeque<(Instant, f32)>) -> f32 { +fn network_spread_ms(samples: &VecDeque<(Instant, f32)>) -> f32 { if samples.len() < 2 { return 0.0; } - let mean = samples.iter().map(|(_, value)| *value).sum::() / samples.len() as f32; - samples - .iter() - .map(|(_, value)| (value - mean).abs()) - .sum::() - / samples.len() as f32 + let mut values = samples.iter().map(|(_, value)| *value).collect::>(); + values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + let median = values[values.len() / 2]; + let mut deviations = values + .into_iter() + .map(|value| (value - median).abs()) + .collect::>(); + deviations.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + deviations[deviations.len() / 2] } #[cfg(not(coverage))] @@ -203,11 +206,25 @@ fn refresh_eye_feed_controls( state: &LauncherState, ) { for monitor_id in 0..2 { - super::ui_components::sync_capture_size_combo( - &widgets.display_panes[monitor_id].capture_combo, + 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_breakout_size_combo( &widgets.display_panes[monitor_id].breakout_combo, state.breakout_size_options(), @@ -261,7 +278,7 @@ fn record_diagnostics_sample( .borrow_mut() .record(PerformanceSample { rtt_ms: network.rtt_ms, - jitter_ms: network.jitter_ms, + probe_spread_ms: network.probe_spread_ms, input_latency_ms: network.rtt_ms * 0.5, probe_loss_pct: network.probe_loss_pct, video_loss_pct: left_metrics @@ -667,8 +684,9 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { let popouts = Rc::clone(&popouts); let child_proc = Rc::clone(&child_proc); let preview = preview.clone(); - let capture_combo = widgets.display_panes[monitor_id].capture_combo.clone(); - capture_combo.connect_changed(move |combo| { + let resolution_combo = + widgets.display_panes[monitor_id].capture_resolution_combo.clone(); + resolution_combo.connect_changed(move |combo| { let Some(active_id) = combo.active_id() else { return; }; @@ -698,6 +716,87 @@ 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 fps_combo = widgets.display_panes[monitor_id].capture_fps_combo.clone(); + fps_combo.connect_changed(move |combo| { + let Some(active_id) = combo.active_id() else { + return; + }; + if active_id.as_str() == "source" { + return; + } + let Ok(fps) = active_id.as_str().parse::() else { + return; + }; + if state.borrow().capture_fps(monitor_id) == fps { + return; + } + { + let mut state = state.borrow_mut(); + state.set_capture_fps(monitor_id, fps); + } + if let Some(preview) = preview.as_ref() { + let choice = state.borrow().capture_size_choice(monitor_id); + preview.set_capture_profile( + monitor_id, + choice.width, + choice.height, + choice.fps, + choice.max_bitrate_kbit, + ); + rebind_inline_preview(preview, &widgets, monitor_id); + rebind_popout_preview(preview, &popouts, monitor_id); + } + 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(); + let popouts = Rc::clone(&popouts); + let child_proc = Rc::clone(&child_proc); + let preview = preview.clone(); + let bitrate_combo = + widgets.display_panes[monitor_id].capture_bitrate_combo.clone(); + bitrate_combo.connect_changed(move |combo| { + let Some(active_id) = combo.active_id() else { + return; + }; + if active_id.as_str() == "source" { + return; + } + let Ok(max_bitrate_kbit) = active_id.as_str().parse::() else { + return; + }; + if state.borrow().capture_bitrate_kbit(monitor_id) == max_bitrate_kbit { + return; + } + { + let mut state = state.borrow_mut(); + state.set_capture_bitrate_kbit(monitor_id, max_bitrate_kbit); + } + if let Some(preview) = preview.as_ref() { + let choice = state.borrow().capture_size_choice(monitor_id); + preview.set_capture_profile( + monitor_id, + choice.width, + choice.height, + choice.fps, + choice.max_bitrate_kbit, + ); + rebind_inline_preview(preview, &widgets, monitor_id); + rebind_popout_preview(preview, &popouts, monitor_id); + } + 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(); diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 8d5b7fb..2ace925 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -8,7 +8,8 @@ use super::{ diagnostics::DiagnosticsLog, preview::{LauncherPreview, PreviewBinding, PreviewSurface}, state::{ - BreakoutSizeChoice, BreakoutSizePreset, CaptureSizeChoice, CaptureSizePreset, LauncherState, + BreakoutSizeChoice, BreakoutSizePreset, CaptureBitrateChoice, CaptureFpsChoice, + CaptureSizeChoice, CaptureSizePreset, LauncherState, }, }; @@ -30,7 +31,9 @@ pub struct DisplayPaneWidgets { pub picture: gtk::Picture, pub stream_status: gtk::Label, pub placeholder: gtk::Label, - pub capture_combo: gtk::ComboBoxText, + pub capture_resolution_combo: gtk::ComboBoxText, + pub capture_fps_combo: gtk::ComboBoxText, + pub capture_bitrate_combo: gtk::ComboBoxText, pub breakout_combo: gtk::ComboBoxText, pub action_button: gtk::Button, pub preview_binding: Rc>>, @@ -542,16 +545,44 @@ pub fn build_launcher_view( left_pane.stream_status.set_text("Preview unavailable"); right_pane.stream_status.set_text("Preview unavailable"); } - sync_capture_size_combo( - &left_pane.capture_combo, + sync_capture_resolution_combo( + &left_pane.capture_resolution_combo, state.capture_size_options(), state.capture_size_preset(0), ); - sync_capture_size_combo( - &right_pane.capture_combo, + 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_breakout_size_combo( &left_pane.breakout_combo, state.breakout_size_options(), @@ -822,7 +853,7 @@ fn stabilize_chip(chip: >k::Box, width: i32) { chip.set_size_request(width, -1); } -pub fn sync_capture_size_combo( +pub fn sync_capture_resolution_combo( combo: >k::ComboBoxText, options: Vec, selected: CaptureSizePreset, @@ -831,20 +862,16 @@ pub fn sync_capture_size_combo( for option in options { let label = match option.preset { CaptureSizePreset::Source => format!( - "{} • {}x{} @ {} fps • {} kbit (Pass-through)", + "{} • {}x{} (Pass-through)", option.preset.label(), option.width, option.height, - option.fps, - option.max_bitrate_kbit ), _ => format!( - "{} • {}x{} @ {} fps • {} kbit (Re-encode)", + "{} • {}x{} (Re-encode)", option.preset.label(), option.width, option.height, - option.fps, - option.max_bitrate_kbit ), }; combo.append(Some(option.preset.as_id()), &label); @@ -852,6 +879,57 @@ pub fn sync_capture_size_combo( combo.set_active_id(Some(selected.as_id())); } +pub fn sync_capture_fps_combo( + combo: >k::ComboBoxText, + options: Vec, + selected: u32, + locked_to_source: bool, + source_fps: u32, +) { + combo.remove_all(); + if locked_to_source { + combo.append(Some("source"), &format!("{source_fps} fps (Source)")); + combo.set_active_id(Some("source")); + combo.set_sensitive(false); + return; + } + for option in options { + combo.append( + Some(&option.fps.to_string()), + &format!("{} fps", option.fps), + ); + } + combo.set_active_id(Some(&selected.max(1).to_string())); + combo.set_sensitive(true); +} + +pub fn sync_capture_bitrate_combo( + combo: >k::ComboBoxText, + options: Vec, + selected: u32, + locked_to_source: bool, + source_bitrate_kbit: u32, +) { + combo.remove_all(); + if locked_to_source { + combo.append( + Some("source"), + &format!("{source_bitrate_kbit} kbit (Source estimate)"), + ); + combo.set_active_id(Some("source")); + combo.set_sensitive(false); + return; + } + for option in options { + combo.append( + Some(&option.max_bitrate_kbit.to_string()), + &format!("{} kbit", option.max_bitrate_kbit), + ); + } + combo.set_active_id(Some(&selected.max(800).to_string())); + combo.set_sensitive(true); +} + pub fn sync_breakout_size_combo( combo: >k::ComboBoxText, options: Vec, @@ -1005,7 +1083,6 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { stack.set_visible_child_name("preview"); root.append(&stack); - let footer = gtk::Box::new(gtk::Orientation::Horizontal, 8); let stream_status = gtk::Label::new(Some("Connect relay to preview.")); stream_status.set_halign(gtk::Align::Start); stream_status.set_hexpand(true); @@ -1013,11 +1090,21 @@ 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 capture_combo = gtk::ComboBoxText::new(); - capture_combo.set_tooltip_text(Some( - "Choose the server-side capture profile for this eye feed. Source keeps the HDMI device's own H.264 stream; the standard sizes force a server re-encode at a known resolution, fps, and bitrate.", + 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.", )); - capture_combo.set_size_request(360, -1); + 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.", + )); + 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.", + )); + capture_bitrate_combo.set_size_request(170, -1); let breakout_combo = gtk::ComboBoxText::new(); breakout_combo.set_tooltip_text(Some( "Choose the client-side breakout window size for this eye feed. Source Size preserves the feed's own dimensions; Display Size fills the effective monitor size.", @@ -1026,10 +1113,17 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { let action_button = gtk::Button::with_label("Break Out"); stabilize_button(&action_button, 104); action_button.set_halign(gtk::Align::End); - footer.append(&capture_combo); - footer.append(&breakout_combo); - footer.append(&action_button); - root.append(&footer); + let capture_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + capture_row.append(&capture_resolution_combo); + capture_row.append(&capture_fps_combo); + capture_row.append(&capture_bitrate_combo); + let breakout_row = gtk::Box::new(gtk::Orientation::Horizontal, 8); + breakout_row.append(&breakout_combo); + breakout_row.append(&action_button); + let footer_shell = gtk::Box::new(gtk::Orientation::Vertical, 6); + footer_shell.append(&capture_row); + footer_shell.append(&breakout_row); + root.append(&footer_shell); DisplayPaneWidgets { root, @@ -1037,7 +1131,9 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { picture, stream_status, placeholder, - capture_combo, + capture_resolution_combo, + capture_fps_combo, + capture_bitrate_combo, breakout_combo, action_button, preview_binding: Rc::new(RefCell::new(None)), diff --git a/common/Cargo.toml b/common/Cargo.toml index d84cd40..89aa4f7 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.7.2" +version = "0.8.0" edition = "2024" build = "build.rs" diff --git a/common/src/cli.rs b/common/src/cli.rs index fe7ef83..b4cdbe1 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.7.2"), "lesavka-common CLI (v0.7.2)"); + assert_eq!(banner("0.8.0"), "lesavka-common CLI (v0.8.0)"); } } diff --git a/server/Cargo.toml b/server/Cargo.toml index 928ab17..e86e411 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.7.2" +version = "0.8.0" edition = "2024" autobins = false diff --git a/server/src/video.rs b/server/src/video.rs index 80d41ad..2167bf9 100644 --- a/server/src/video.rs +++ b/server/src/video.rs @@ -393,7 +393,7 @@ pub async fn eye_ball_with_request( let appsink_buffers = env_u32("LESAVKA_EYE_APPSINK_BUFFERS", 8).max(1); let keyframe_interval = env_u32( "LESAVKA_EYE_KEYFRAME_INTERVAL", - request.requested_fps.max(1).min(15), + request.requested_fps.max(1).min(10), ) .clamp(1, request.requested_fps.max(1)); let use_test_src =