lesavka: force reencode when requested

This commit is contained in:
Brad Stein 2026-04-17 11:51:19 -03:00
parent bb4921e7e9
commit 7cb0a4d655
14 changed files with 89 additions and 15 deletions

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package] [package]
name = "lesavka_client" name = "lesavka_client"
version = "0.11.1" version = "0.11.2"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -425,6 +425,7 @@ impl LesavkaClientApp {
requested_width: 0, requested_width: 0,
requested_height: 0, requested_height: 0,
requested_fps: 0, requested_fps: 0,
prefer_reencode: false,
}; };
match cli.capture_video(Request::new(req)).await { match cli.capture_video(Request::new(req)).await {
Ok(mut stream) => { Ok(mut stream) => {
@ -468,6 +469,7 @@ impl LesavkaClientApp {
requested_width: 0, requested_width: 0,
requested_height: 0, requested_height: 0,
requested_fps: 0, requested_fps: 0,
prefer_reencode: false,
}; };
match cli.capture_audio(Request::new(req)).await { match cli.capture_audio(Request::new(req)).await {
Ok(mut stream) => { Ok(mut stream) => {

View File

@ -93,6 +93,7 @@ struct PreviewProfile {
requested_height: i32, requested_height: i32,
requested_fps: u32, requested_fps: u32,
max_bitrate_kbit: u32, max_bitrate_kbit: u32,
prefer_reencode: bool,
} }
#[cfg(not(coverage))] #[cfg(not(coverage))]
@ -112,6 +113,7 @@ impl PreviewSurface {
), ),
requested_fps: preview_bitrate("LESAVKA_PREVIEW_REQUEST_FPS", 30), requested_fps: preview_bitrate("LESAVKA_PREVIEW_REQUEST_FPS", 30),
max_bitrate_kbit: preview_bitrate("LESAVKA_PREVIEW_MAX_KBIT", 12_000), max_bitrate_kbit: preview_bitrate("LESAVKA_PREVIEW_MAX_KBIT", 12_000),
prefer_reencode: true,
}, },
Self::Window => PreviewProfile { Self::Window => PreviewProfile {
display_width: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_WIDTH", 1280), display_width: preview_dimension("LESAVKA_BREAKOUT_PREVIEW_WIDTH", 1280),
@ -126,6 +128,7 @@ impl PreviewSurface {
), ),
requested_fps: preview_bitrate("LESAVKA_BREAKOUT_REQUEST_FPS", 30), requested_fps: preview_bitrate("LESAVKA_BREAKOUT_REQUEST_FPS", 30),
max_bitrate_kbit: preview_bitrate("LESAVKA_BREAKOUT_PREVIEW_MAX_KBIT", 12_000), 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_height: i32,
requested_fps: u32, requested_fps: u32,
max_bitrate_kbit: u32, max_bitrate_kbit: u32,
prefer_reencode: bool,
) { ) {
self.rebuild_feed( self.rebuild_feed(
&self.inline_feeds, &self.inline_feeds,
@ -271,6 +275,7 @@ impl LauncherPreview {
requested_height, requested_height,
requested_fps, requested_fps,
max_bitrate_kbit, max_bitrate_kbit,
prefer_reencode,
)), )),
None, None,
); );
@ -282,6 +287,7 @@ impl LauncherPreview {
requested_height, requested_height,
requested_fps, requested_fps,
max_bitrate_kbit, max_bitrate_kbit,
prefer_reencode,
)), )),
None, None,
); );
@ -295,7 +301,7 @@ impl LauncherPreview {
&self, &self,
feeds: &Arc<Mutex<[PreviewFeed; 2]>>, feeds: &Arc<Mutex<[PreviewFeed; 2]>>,
monitor_id: usize, monitor_id: usize,
requested: Option<(i32, i32, u32, u32)>, requested: Option<(i32, i32, u32, u32, bool)>,
display: Option<(i32, i32)>, display: Option<(i32, i32)>,
) { ) {
let Ok(mut feeds) = feeds.lock() else { let Ok(mut feeds) = feeds.lock() else {
@ -306,13 +312,19 @@ impl LauncherPreview {
}; };
let was_active = existing.is_active(); let was_active = existing.is_active();
let mut profile = existing.profile(); let mut profile = existing.profile();
if let Some((requested_width, requested_height, requested_fps, max_bitrate_kbit)) = if let Some((
requested requested_width,
requested_height,
requested_fps,
max_bitrate_kbit,
prefer_reencode,
)) = requested
{ {
profile.requested_width = requested_width.max(2); profile.requested_width = requested_width.max(2);
profile.requested_height = requested_height.max(2); profile.requested_height = requested_height.max(2);
profile.requested_fps = requested_fps.max(1); profile.requested_fps = requested_fps.max(1);
profile.max_bitrate_kbit = max_bitrate_kbit.max(800); profile.max_bitrate_kbit = max_bitrate_kbit.max(800);
profile.prefer_reencode = prefer_reencode;
} }
if let Some((display_width, display_height)) = display { if let Some((display_width, display_height)) = display {
profile.display_width = display_width.max(2); 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_width: profile.requested_width.max(0) as u32,
requested_height: profile.requested_height.max(0) as u32, requested_height: profile.requested_height.max(0) as u32,
requested_fps: profile.requested_fps, requested_fps: profile.requested_fps,
prefer_reencode: profile.prefer_reencode,
}; };
match cli.capture_video(Request::new(req)).await { match cli.capture_video(Request::new(req)).await {
Ok(mut stream) => { Ok(mut stream) => {

View File

@ -790,6 +790,7 @@ pub fn run_gui_launcher(server_addr: String) -> Result<()> {
choice.height, choice.height,
choice.fps, choice.fps,
choice.max_bitrate_kbit, choice.max_bitrate_kbit,
choice.preset != CaptureSizePreset::Source,
); );
rebind_inline_preview(preview, &widgets, monitor_id); rebind_inline_preview(preview, &widgets, monitor_id);
rebind_popout_preview(preview, &popouts, 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.height,
choice.fps, choice.fps,
choice.max_bitrate_kbit, choice.max_bitrate_kbit,
choice.preset != CaptureSizePreset::Source,
); );
rebind_inline_preview(preview, &widgets, monitor_id); rebind_inline_preview(preview, &widgets, monitor_id);
rebind_popout_preview(preview, &popouts, 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.height,
choice.fps, choice.fps,
choice.max_bitrate_kbit, choice.max_bitrate_kbit,
choice.preset != CaptureSizePreset::Source,
); );
rebind_inline_preview(preview, &widgets, monitor_id); rebind_inline_preview(preview, &widgets, monitor_id);
rebind_popout_preview(preview, &popouts, 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.height,
capture.fps, capture.fps,
capture.max_bitrate_kbit, capture.max_bitrate_kbit,
capture.preset != CaptureSizePreset::Source,
); );
preview.set_breakout_profile( preview.set_breakout_profile(
monitor_id, monitor_id,

View File

@ -1055,6 +1055,7 @@ fn build_display_pane(title: &str, capture_path: &str) -> DisplayPaneWidgets {
picture.set_hexpand(true); picture.set_hexpand(true);
picture.set_vexpand(true); picture.set_vexpand(true);
picture.set_can_shrink(true); picture.set_can_shrink(true);
picture.set_keep_aspect_ratio(true);
picture.set_size_request(220, 124); picture.set_size_request(220, 124);
let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 0); let preview_box = gtk::Box::new(gtk::Orientation::Vertical, 0);

View File

@ -237,7 +237,7 @@ pub fn open_popout_window(
let picture = gtk::Picture::new(); let picture = gtk::Picture::new();
picture.set_hexpand(true); picture.set_hexpand(true);
picture.set_vexpand(true); picture.set_vexpand(true);
picture.set_can_shrink(false); picture.set_can_shrink(true);
picture.set_keep_aspect_ratio(true); picture.set_keep_aspect_ratio(true);
picture.set_size_request(breakout_size.width, breakout_size.height); picture.set_size_request(breakout_size.width, breakout_size.height);
let root = gtk::Box::new(gtk::Orientation::Vertical, 0); let root = gtk::Box::new(gtk::Orientation::Vertical, 0);

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lesavka_common" name = "lesavka_common"
version = "0.11.1" version = "0.11.2"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"

View File

@ -11,6 +11,7 @@ message MonitorRequest {
uint32 requested_width = 3; uint32 requested_width = 3;
uint32 requested_height = 4; uint32 requested_height = 4;
uint32 requested_fps = 5; uint32 requested_fps = 5;
bool prefer_reencode = 6;
} }
message VideoPacket { message VideoPacket {
uint32 id = 1; uint32 id = 1;

View File

@ -17,6 +17,6 @@ mod tests {
#[test] #[test]
fn banner_includes_version() { 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)");
} }
} }

View File

@ -10,7 +10,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.11.1" version = "0.11.2"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -114,6 +114,7 @@ impl Handler {
requested_width = req.requested_width, requested_width = req.requested_width,
requested_height = req.requested_height, requested_height = req.requested_height,
requested_fps = req.requested_fps, requested_fps = req.requested_fps,
prefer_reencode = req.prefer_reencode,
"🎥 capture_video opened" "🎥 capture_video opened"
); );
debug!(rpc_id, "🎥 streaming {dev}"); debug!(rpc_id, "🎥 streaming {dev}");
@ -127,6 +128,7 @@ impl Handler {
req.requested_width, req.requested_width,
req.requested_height, req.requested_height,
req.requested_fps, req.requested_fps,
req.prefer_reencode,
) )
.await .await
.map_err(|e| Status::internal(format!("{e:#}")))?; .map_err(|e| Status::internal(format!("{e:#}")))?;

View File

@ -233,7 +233,7 @@ struct EyeCaptureRequest {
requested_height: u32, requested_height: u32,
requested_fps: u32, requested_fps: u32,
max_bitrate_kbit: u32, max_bitrate_kbit: u32,
downscale: bool, reencode: bool,
} }
fn normalize_eye_capture_request( fn normalize_eye_capture_request(
@ -241,6 +241,7 @@ fn normalize_eye_capture_request(
requested_height: u32, requested_height: u32,
requested_fps: u32, requested_fps: u32,
max_bitrate_kbit: u32, max_bitrate_kbit: u32,
prefer_reencode: bool,
) -> EyeCaptureRequest { ) -> EyeCaptureRequest {
let (source_width, source_height, source_fps) = eye_source_profile(); let (source_width, source_height, source_fps) = eye_source_profile();
let requested_width = if requested_width == 0 { let requested_width = if requested_width == 0 {
@ -258,14 +259,21 @@ fn normalize_eye_capture_request(
} else { } else {
requested_fps.max(1).min(source_fps.max(1)) 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 { EyeCaptureRequest {
source_width, source_width,
source_height, source_height,
requested_width, requested_width,
requested_height, requested_height,
requested_fps, requested_fps,
max_bitrate_kbit: max_bitrate_kbit.max(800), max_bitrate_kbit,
downscale: requested_width < source_width || requested_height < source_height, 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. /// frames before they build up in gRPC queues and destabilize downstream playback.
#[cfg(coverage)] #[cfg(coverage)]
pub async fn eye_ball(dev: &str, id: u32, _max_bitrate_kbit: u32) -> anyhow::Result<VideoStream> { pub async fn eye_ball(dev: &str, id: u32, _max_bitrate_kbit: u32) -> anyhow::Result<VideoStream> {
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)] #[cfg(coverage)]
@ -340,6 +348,7 @@ pub async fn eye_ball_with_request(
_requested_width: u32, _requested_width: u32,
_requested_height: u32, _requested_height: u32,
_requested_fps: u32, _requested_fps: u32,
_prefer_reencode: bool,
) -> anyhow::Result<VideoStream> { ) -> anyhow::Result<VideoStream> {
let _ = EYE_ID[id as usize]; let _ = EYE_ID[id as usize];
if dev.contains('"') { if dev.contains('"') {
@ -375,7 +384,7 @@ pub async fn eye_ball_with_request(
#[cfg(not(coverage))] #[cfg(not(coverage))]
pub async fn eye_ball(dev: &str, id: u32, max_bitrate_kbit: u32) -> anyhow::Result<VideoStream> { pub async fn eye_ball(dev: &str, id: u32, max_bitrate_kbit: u32) -> anyhow::Result<VideoStream> {
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))] #[cfg(not(coverage))]
@ -386,6 +395,7 @@ pub async fn eye_ball_with_request(
requested_width: u32, requested_width: u32,
requested_height: u32, requested_height: u32,
requested_fps: u32, requested_fps: u32,
prefer_reencode: bool,
) -> anyhow::Result<VideoStream> { ) -> anyhow::Result<VideoStream> {
let eye = EYE_ID[id as usize]; let eye = EYE_ID[id as usize];
gst::init().context("gst init")?; gst::init().context("gst init")?;
@ -395,6 +405,7 @@ pub async fn eye_ball_with_request(
requested_height, requested_height,
requested_fps, requested_fps,
max_bitrate_kbit, max_bitrate_kbit,
prefer_reencode,
); );
let target_fps = if requested_fps > 0 { let target_fps = if requested_fps > 0 {
request.requested_fps 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"); dev.eq_ignore_ascii_case("testsrc") || dev.eq_ignore_ascii_case("videotestsrc");
let server_encoder_label = if use_test_src { let server_encoder_label = if use_test_src {
"x264enc(testsrc)".to_string() "x264enc(testsrc)".to_string()
} else if request.downscale { } else if request.reencode {
"x264enc".to_string() "x264enc".to_string()
} else { } else {
"source-pass-through".to_string() "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", 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.downscale { } else if request.reencode {
format!( format!(
"v4l2src name=cam_{eye} device=\"{dev}\" io-mode=mmap do-timestamp=true ! \ "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 ! \ 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), 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);
}
}

View File

@ -152,6 +152,7 @@ mod server_main_binary {
requested_width: 0, requested_width: 0,
requested_height: 0, requested_height: 0,
requested_fps: 0, requested_fps: 0,
prefer_reencode: false,
})) }))
.await .await
}); });
@ -205,6 +206,7 @@ mod server_main_binary {
requested_width: 0, requested_width: 0,
requested_height: 0, requested_height: 0,
requested_fps: 0, requested_fps: 0,
prefer_reencode: false,
}; };
let rt = tokio::runtime::Runtime::new().expect("runtime"); let rt = tokio::runtime::Runtime::new().expect("runtime");

View File

@ -81,6 +81,7 @@ mod server_main_rpc {
requested_width: 0, requested_width: 0,
requested_height: 0, requested_height: 0,
requested_fps: 0, requested_fps: 0,
prefer_reencode: false,
})) }))
.await .await
}); });
@ -104,6 +105,7 @@ mod server_main_rpc {
requested_width: 0, requested_width: 0,
requested_height: 0, requested_height: 0,
requested_fps: 0, requested_fps: 0,
prefer_reencode: false,
})) }))
.await .await
}); });
@ -133,6 +135,7 @@ mod server_main_rpc {
requested_width: 0, requested_width: 0,
requested_height: 0, requested_height: 0,
requested_fps: 0, requested_fps: 0,
prefer_reencode: false,
})) }))
.await .await
}) })
@ -206,6 +209,7 @@ mod server_main_rpc {
requested_width: 0, requested_width: 0,
requested_height: 0, requested_height: 0,
requested_fps: 0, requested_fps: 0,
prefer_reencode: false,
}; };
let rt = tokio::runtime::Runtime::new().expect("runtime"); let rt = tokio::runtime::Runtime::new().expect("runtime");