From 8273b83017692c7f4e8f5a89be29fbd3401a9337 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 19 Apr 2026 15:07:24 -0300 Subject: [PATCH] lesavka: fix sd source preview geometry --- client/Cargo.toml | 2 +- client/src/launcher/preview.rs | 17 +++++++-- client/src/launcher/state.rs | 13 ++++++- client/src/launcher/ui.rs | 21 +++++++++++ client/src/launcher/ui_components.rs | 3 ++ client/src/launcher/ui_runtime.rs | 5 +++ common/Cargo.toml | 2 +- common/src/cli.rs | 2 +- common/src/eye_source.rs | 54 ++++++++++++++++++++++++++++ server/Cargo.toml | 2 +- 10 files changed, 113 insertions(+), 8 deletions(-) diff --git a/client/Cargo.toml b/client/Cargo.toml index 820df98..b82595e 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.16" +version = "0.11.17" edition = "2024" [dependencies] diff --git a/client/src/launcher/preview.rs b/client/src/launcher/preview.rs index a0c5b0b..9d3750d 100644 --- a/client/src/launcher/preview.rs +++ b/client/src/launcher/preview.rs @@ -12,7 +12,10 @@ use gstreamer_app as gst_app; use gtk::prelude::WidgetExt; #[cfg(not(coverage))] use gtk::{gdk, glib}; -use lesavka_common::lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient}; +use lesavka_common::{ + eye_source::{display_size_for_source_mode, eye_source_mode_for_request}, + lesavka::{MonitorRequest, VideoPacket, relay_client::RelayClient}, +}; #[cfg(not(coverage))] use std::collections::VecDeque; #[cfg(not(coverage))] @@ -1406,14 +1409,20 @@ 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 source_mode = eye_source_mode_for_request( + profile.requested_width.max(2) as u32, + profile.requested_height.max(2) as u32, + ); + let (render_width, render_height) = display_size_for_source_mode(source_mode); 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 ! \ - video/x-raw,format=RGBA,pixel-aspect-ratio=1/1 ! \ + videoscale add-borders=false ! \ + video/x-raw,format=RGBA,width=(int){render_width},height=(int){render_height},pixel-aspect-ratio=1/1 ! \ appsink name=sink emit-signals=false sync=false max-buffers=1 drop=true", ); let pipeline = gst::parse::launch(&desc)? @@ -1441,6 +1450,8 @@ fn build_preview_pipeline( appsink.set_caps(Some( &gst::Caps::builder("video/x-raw") .field("format", &"RGBA") + .field("width", &(render_width as i32)) + .field("height", &(render_height as i32)) .field("pixel-aspect-ratio", &gst::Fraction::new(1, 1)) .build(), )); diff --git a/client/src/launcher/state.rs b/client/src/launcher/state.rs index e71d7c4..39fc093 100644 --- a/client/src/launcher/state.rs +++ b/client/src/launcher/state.rs @@ -1,7 +1,9 @@ use serde::{Deserialize, Serialize}; use super::devices::DeviceCatalog; -use lesavka_common::eye_source::{EyeSourceMode, default_eye_source_mode, native_eye_source_modes}; +use lesavka_common::eye_source::{ + EyeSourceMode, default_eye_source_mode, display_size_for_source_mode, native_eye_source_modes, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum InputRouting { @@ -206,6 +208,15 @@ impl CaptureSizePreset { _ => Self::P1080, } } + + pub fn display_size(self) -> (u32, u32) { + display_size_for_source_mode(self.source_mode()) + } + + pub fn display_aspect_ratio(self) -> f32 { + let (width, height) = self.display_size(); + width.max(1) as f32 / height.max(1) as f32 + } } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index e1ca644..058b5bf 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -239,9 +239,24 @@ fn refresh_eye_feed_controls( state.breakout_size_options(monitor_id), state.breakout_size_preset(monitor_id), ); + refresh_preview_frame_ratio(widgets, monitor_id, state); } } +#[cfg(not(coverage))] +fn refresh_preview_frame_ratio( + widgets: &super::ui_components::LauncherWidgets, + monitor_id: usize, + state: &LauncherState, +) { + let capture = state + .display_capture_size_choice(monitor_id) + .unwrap_or_else(|| state.capture_size_choice(monitor_id)); + widgets.display_panes[monitor_id] + .preview_frame + .set_ratio(capture.preset.display_aspect_ratio()); +} + #[cfg(not(coverage))] fn eye_caps_changed(state: &LauncherState, caps: &crate::handshake::PeerCaps) -> bool { let next_width = caps.eye_width.unwrap_or(state.preview_source.width); @@ -499,6 +514,12 @@ fn rebind_popout_preview( ) { handle.binding = binding; } + let capture = state + .display_capture_size_choice(monitor_id) + .unwrap_or_else(|| state.capture_size_choice(monitor_id)); + handle + .frame + .set_ratio(capture.preset.display_aspect_ratio()); } #[cfg(not(coverage))] diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index b40b399..a6a5993 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -28,6 +28,7 @@ pub struct SummaryWidgets { pub struct DisplayPaneWidgets { pub root: gtk::Box, pub stack: gtk::Stack, + pub preview_frame: gtk::AspectFrame, pub picture: gtk::Picture, pub stream_status: gtk::Label, pub placeholder: gtk::Label, @@ -41,6 +42,7 @@ pub struct DisplayPaneWidgets { pub struct PopoutWindowHandle { pub window: gtk::ApplicationWindow, + pub frame: gtk::AspectFrame, pub picture: gtk::Picture, pub status_label: gtk::Label, pub binding: PreviewBinding, @@ -1133,6 +1135,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { DisplayPaneWidgets { root, stack, + preview_frame, picture, stream_status, placeholder, diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index 47d3416..63f2ca9 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -307,6 +307,7 @@ pub fn open_popout_window( let mut popouts = popouts.borrow_mut(); popouts[monitor_id] = Some(PopoutWindowHandle { window: window.clone(), + frame: frame.clone(), picture: picture.clone(), status_label: stream_status.clone(), binding, @@ -1218,6 +1219,7 @@ mod tests { .application(&app) .title("Left") .build(), + frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), picture: gtk::Picture::new(), status_label: gtk::Label::new(None), binding: left_binding, @@ -1227,6 +1229,7 @@ mod tests { .application(&app) .title("Right") .build(), + frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), picture: gtk::Picture::new(), status_label: gtk::Label::new(None), binding: right_binding, @@ -1281,6 +1284,7 @@ mod tests { let mut slot = popouts.borrow_mut(); slot[0] = Some(PopoutWindowHandle { window, + frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), picture: gtk::Picture::new(), status_label: gtk::Label::new(None), binding: PreviewBinding::test_stub(), @@ -1328,6 +1332,7 @@ mod tests { .application(&app) .title("Left") .build(), + frame: gtk::AspectFrame::new(0.5, 0.5, 16.0 / 9.0, false), picture: gtk::Picture::new(), status_label: gtk::Label::new(None), binding: PreviewBinding::test_stub(), diff --git a/common/Cargo.toml b/common/Cargo.toml index cf7493a..0ce8daa 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.16" +version = "0.11.17" edition = "2024" build = "build.rs" diff --git a/common/src/cli.rs b/common/src/cli.rs index 173accb..44b3486 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.16"), "lesavka-common CLI (v0.11.16)"); + assert_eq!(banner("0.11.17"), "lesavka-common CLI (v0.11.17)"); } } diff --git a/common/src/eye_source.rs b/common/src/eye_source.rs index 067ef5f..768535b 100644 --- a/common/src/eye_source.rs +++ b/common/src/eye_source.rs @@ -41,6 +41,16 @@ pub fn default_eye_source_mode() -> EyeSourceMode { GC311_H264_SOURCE_MODES[0] } +pub fn display_size_for_source_mode(mode: EyeSourceMode) -> (u32, u32) { + match (mode.width, mode.height) { + // GC311 exposes SD widescreen source modes with non-square pixels. Render them + // into a square-pixel frame before the GTK preview consumes them. + (720, 576) => (1024, 576), + (720, 480) => (854, 480), + _ => (mode.width, mode.height), + } +} + 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(); @@ -96,4 +106,48 @@ mod tests { } ); } + + #[test] + fn reports_square_pixel_display_size_for_each_native_mode() { + assert_eq!( + display_size_for_source_mode(EyeSourceMode { + width: 1920, + height: 1080, + fps: 60, + }), + (1920, 1080) + ); + assert_eq!( + display_size_for_source_mode(EyeSourceMode { + width: 1280, + height: 720, + fps: 60, + }), + (1280, 720) + ); + assert_eq!( + display_size_for_source_mode(EyeSourceMode { + width: 720, + height: 576, + fps: 50, + }), + (1024, 576) + ); + assert_eq!( + display_size_for_source_mode(EyeSourceMode { + width: 720, + height: 480, + fps: 60, + }), + (854, 480) + ); + assert_eq!( + display_size_for_source_mode(EyeSourceMode { + width: 640, + height: 480, + fps: 60, + }), + (640, 480) + ); + } } diff --git a/server/Cargo.toml b/server/Cargo.toml index 82a85bd..062d81f 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.16" +version = "0.11.17" edition = "2024" autobins = false