diff --git a/client/Cargo.toml b/client/Cargo.toml index 4684712..c791da4 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.11.1" +version = "0.11.2" edition = "2024" [dependencies] diff --git a/client/src/app.rs b/client/src/app.rs index dabc6b1..002035b 100644 --- a/client/src/app.rs +++ b/client/src/app.rs @@ -425,6 +425,7 @@ impl LesavkaClientApp { requested_width: 0, requested_height: 0, requested_fps: 0, + prefer_reencode: false, }; match cli.capture_video(Request::new(req)).await { Ok(mut stream) => { @@ -468,6 +469,7 @@ impl LesavkaClientApp { requested_width: 0, requested_height: 0, requested_fps: 0, + prefer_reencode: false, }; match cli.capture_audio(Request::new(req)).await { Ok(mut stream) => { diff --git a/client/src/launcher/preview.rs b/client/src/launcher/preview.rs index 3ca39a9..b6742ae 100644 --- a/client/src/launcher/preview.rs +++ b/client/src/launcher/preview.rs @@ -93,6 +93,7 @@ struct PreviewProfile { requested_height: i32, requested_fps: u32, max_bitrate_kbit: u32, + prefer_reencode: bool, } #[cfg(not(coverage))] @@ -112,6 +113,7 @@ impl PreviewSurface { ), requested_fps: preview_bitrate("LESAVKA_PREVIEW_REQUEST_FPS", 30), max_bitrate_kbit: preview_bitrate("LESAVKA_PREVIEW_MAX_KBIT", 12_000), + prefer_reencode: true, }, Self::Window => PreviewProfile { display_width: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_WIDTH", 1280), @@ -126,6 +128,7 @@ impl PreviewSurface { ), requested_fps: preview_bitrate("LESAVKA_BREAKOUT_REQUEST_FPS", 30), max_bitrate_kbit: preview_bitrate("LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT", 12_000), + prefer_reencode: true, }, } } @@ -262,6 +265,7 @@ impl LauncherPreview { requested_height: i32, requested_fps: u32, max_bitrate_kbit: u32, + prefer_reencode: bool, ) { self.rebuild_feed( &self.inline_feeds, @@ -271,6 +275,7 @@ impl LauncherPreview { requested_height, requested_fps, max_bitrate_kbit, + prefer_reencode, )), None, ); @@ -282,6 +287,7 @@ impl LauncherPreview { requested_height, requested_fps, max_bitrate_kbit, + prefer_reencode, )), None, ); @@ -295,7 +301,7 @@ impl LauncherPreview { &self, feeds: &Arc>, monitor_id: usize, - requested: Option<(i32, i32, u32, u32)>, + requested: Option<(i32, i32, u32, u32, bool)>, display: Option<(i32, i32)>, ) { let Ok(mut feeds) = feeds.lock() else { @@ -306,13 +312,19 @@ impl LauncherPreview { }; let was_active = existing.is_active(); let mut profile = existing.profile(); - if let Some((requested_width, requested_height, requested_fps, max_bitrate_kbit)) = - requested + if let Some(( + requested_width, + requested_height, + requested_fps, + max_bitrate_kbit, + prefer_reencode, + )) = requested { 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); @@ -913,6 +925,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, }; match cli.capture_video(Request::new(req)).await { Ok(mut stream) => { diff --git a/client/src/launcher/ui.rs b/client/src/launcher/ui.rs index ceecd4e..3dab378 100644 --- a/client/src/launcher/ui.rs +++ b/client/src/launcher/ui.rs @@ -790,6 +790,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { 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); @@ -830,6 +831,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { 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); @@ -871,6 +873,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { 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); @@ -1811,6 +1814,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> { capture.height, capture.fps, capture.max_bitrate_kbit, + capture.preset != CaptureSizePreset::Source, ); preview.set_breakout_profile( monitor_id, diff --git a/client/src/launcher/ui_components.rs b/client/src/launcher/ui_components.rs index 6b6f9ed..91af304 100644 --- a/client/src/launcher/ui_components.rs +++ b/client/src/launcher/ui_components.rs @@ -1055,6 +1055,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets { picture.set_hexpand(true); picture.set_vexpand(true); picture.set_can_shrink(true); + picture.set_keep_aspect_ratio(true); picture.set_size_request(220, 124); let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 0); diff --git a/client/src/launcher/ui_runtime.rs b/client/src/launcher/ui_runtime.rs index 5b92b04..fa65a07 100644 --- a/client/src/launcher/ui_runtime.rs +++ b/client/src/launcher/ui_runtime.rs @@ -237,7 +237,7 @@ pub fn open_popout_window( let picture = gtk::Picture::new(); picture.set_hexpand(true); picture.set_vexpand(true); - picture.set_can_shrink(false); + picture.set_can_shrink(true); picture.set_keep_aspect_ratio(true); picture.set_size_request(breakout_size.width, breakout_size.height); let root = gtk::Box::new(gtk::Orientation::Vertical, 0); diff --git a/common/Cargo.toml b/common/Cargo.toml index 3f2ecc7..cbf031c 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.11.1" +version = "0.11.2" edition = "2024" build = "build.rs" diff --git a/common/proto/lesavka.proto b/common/proto/lesavka.proto index 3ac16c0..8085544 100644 --- a/common/proto/lesavka.proto +++ b/common/proto/lesavka.proto @@ -11,6 +11,7 @@ message MonitorRequest { uint32 requested_width = 3; uint32 requested_height = 4; uint32 requested_fps = 5; + bool prefer_reencode = 6; } message VideoPacket { uint32 id = 1; diff --git a/common/src/cli.rs b/common/src/cli.rs index 15f748c..34aa87e 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.1"), "lesavka-common CLI (v0.11.1)"); + assert_eq!(banner("0.11.2"), "lesavka-common CLI (v0.11.2)"); } } diff --git a/server/Cargo.toml b/server/Cargo.toml index c196581..ee9f859 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.11.1" +version = "0.11.2" edition = "2024" autobins = false diff --git a/server/src/main.rs b/server/src/main.rs index bd210e8..e57331f 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -114,6 +114,7 @@ impl Handler { 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}"); @@ -127,6 +128,7 @@ 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 feb80c3..4c42ebc 100644 --- a/server/src/video.rs +++ b/server/src/video.rs @@ -233,7 +233,7 @@ struct EyeCaptureRequest { requested_height: u32, requested_fps: u32, max_bitrate_kbit: u32, - downscale: bool, + reencode: bool, } fn normalize_eye_capture_request( @@ -241,6 +241,7 @@ fn normalize_eye_capture_request( requested_height: 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 { @@ -258,14 +259,21 @@ fn normalize_eye_capture_request( } 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; EyeCaptureRequest { source_width, source_height, requested_width, requested_height, requested_fps, - max_bitrate_kbit: max_bitrate_kbit.max(800), - downscale: requested_width < source_width || requested_height < source_height, + max_bitrate_kbit, + reencode, } } @@ -329,7 +337,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).await + eye_ball_with_request(dev, id, _max_bitrate_kbit, 0, 0, 0, false).await } #[cfg(coverage)] @@ -340,6 +348,7 @@ 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('"') { @@ -375,7 +384,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).await + eye_ball_with_request(dev, id, max_bitrate_kbit, 0, 0, 0, false).await } #[cfg(not(coverage))] @@ -386,6 +395,7 @@ 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")?; @@ -395,6 +405,7 @@ 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 @@ -445,7 +456,7 @@ pub async fn eye_ball_with_request( 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.downscale { + } else if request.reencode { "x264enc".to_string() } else { "source-pass-through".to_string() @@ -466,7 +477,7 @@ pub async fn eye_ball_with_request( appsink name=sink emit-signals=true max-buffers={appsink_buffers} drop=true", request.requested_width, request.requested_height, request.requested_fps, ) - } else if request.downscale { + } 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 ! \ @@ -693,3 +704,37 @@ pub async fn eye_ball_with_request( inner: ReceiverStream::new(rx), }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn source_profile_stays_pass_through_without_explicit_reencode_request() { + let request = normalize_eye_capture_request(1920, 1080, 30, 12_000, false); + + assert_eq!(request.requested_width, 1920); + assert_eq!(request.requested_height, 1080); + assert_eq!(request.requested_fps, 30); + assert!(!request.reencode); + } + + #[test] + fn explicit_reencode_preference_forces_same_size_reencode() { + let request = normalize_eye_capture_request(1920, 1080, 30, 12_000, true); + + 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); + } +} diff --git a/testing/tests/server_main_binary_contract.rs b/testing/tests/server_main_binary_contract.rs index 4c992e6..849113a 100644 --- a/testing/tests/server_main_binary_contract.rs +++ b/testing/tests/server_main_binary_contract.rs @@ -152,6 +152,7 @@ mod server_main_binary { requested_width: 0, requested_height: 0, requested_fps: 0, + prefer_reencode: false, })) .await }); @@ -205,6 +206,7 @@ mod server_main_binary { requested_width: 0, requested_height: 0, requested_fps: 0, + prefer_reencode: false, }; 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 60f5826..29eb627 100644 --- a/testing/tests/server_main_rpc_contract.rs +++ b/testing/tests/server_main_rpc_contract.rs @@ -81,6 +81,7 @@ mod server_main_rpc { requested_width: 0, requested_height: 0, requested_fps: 0, + prefer_reencode: false, })) .await }); @@ -104,6 +105,7 @@ mod server_main_rpc { requested_width: 0, requested_height: 0, requested_fps: 0, + prefer_reencode: false, })) .await }); @@ -133,6 +135,7 @@ mod server_main_rpc { requested_width: 0, requested_height: 0, requested_fps: 0, + prefer_reencode: false, })) .await }) @@ -206,6 +209,7 @@ mod server_main_rpc { requested_width: 0, requested_height: 0, requested_fps: 0, + prefer_reencode: false, }; let rt = tokio::runtime::Runtime::new().expect("runtime");