From 117323a10a6b019eed010ad826785fb8d7428f3d Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sat, 9 May 2026 21:48:57 -0300 Subject: [PATCH] media: cap uvc mjpeg frames --- Cargo.lock | 6 ++-- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- scripts/install/server.sh | 4 ++- server/Cargo.toml | 2 +- server/src/bin/lesavka-uvc.real.inc | 36 +++++++++++++++++++ server/src/bin/lesavka_uvc/coverage_model.rs | 4 +++ .../src/bin/lesavka_uvc/coverage_startup.rs | 26 ++++++++++++++ server/src/bin/tests/lesavka_uvc.rs | 30 +++++++++++++++- server/src/video_sinks/hevc_mjpeg_guard.rs | 8 ++--- 10 files changed, 108 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9d19d66..723bb0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.21.14" +version = "0.21.15" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.21.14" +version = "0.21.15" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.21.14" +version = "0.21.15" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index a882f47..181cedb 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.21.14" +version = "0.21.15" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 0e0e917..61bff1c 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.21.14" +version = "0.21.15" edition = "2024" build = "build.rs" diff --git a/scripts/install/server.sh b/scripts/install/server.sh index 7347213..e0855eb 100755 --- a/scripts/install/server.sh +++ b/scripts/install/server.sh @@ -234,6 +234,8 @@ LESAVKA_UVC_CODEC=${INSTALL_UVC_CODEC} LESAVKA_UVC_BLOCKING=${LESAVKA_UVC_BLOCKING:-1} LESAVKA_UVC_CONTROL_READ_ONLY=${LESAVKA_UVC_CONTROL_READ_ONLY:-0} LESAVKA_UVC_MAXBURST=${LESAVKA_UVC_MAXBURST:-0} +LESAVKA_UVC_FRAME_MAX_BYTES=${LESAVKA_UVC_FRAME_MAX_BYTES:-0} +LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=${LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC:-10000000} EOF } @@ -1148,7 +1150,7 @@ fi printf 'LESAVKA_UPSTREAM_PAIR_SLACK_US=%s\n' "${LESAVKA_UPSTREAM_PAIR_SLACK_US:-80000}" printf 'LESAVKA_UPSTREAM_AUDIO_MASTER_WAIT_GRACE_MS=%s\n' "${LESAVKA_UPSTREAM_AUDIO_MASTER_WAIT_GRACE_MS:-350}" printf 'LESAVKA_UPSTREAM_STALE_DROP_MS=%s\n' "${LESAVKA_UPSTREAM_STALE_DROP_MS:-80}" - printf 'LESAVKA_UVC_HEVC_JPEG_QUALITY=%s\n' "${LESAVKA_UVC_HEVC_JPEG_QUALITY:-82}" + printf 'LESAVKA_UVC_HEVC_JPEG_QUALITY=%s\n' "${LESAVKA_UVC_HEVC_JPEG_QUALITY:-72}" printf 'LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP=%s\n' "${LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP:-1}" printf 'LESAVKA_UVC_HEVC_SIZE_DROP_PCT=%s\n' "${LESAVKA_UVC_HEVC_SIZE_DROP_PCT:-45}" printf 'LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES=%s\n' "${LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES:-65536}" diff --git a/server/Cargo.toml b/server/Cargo.toml index 165a781..4c712b7 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -10,7 +10,7 @@ bench = false [package] name = "lesavka_server" -version = "0.21.14" +version = "0.21.15" edition = "2024" autobins = false diff --git a/server/src/bin/lesavka-uvc.real.inc b/server/src/bin/lesavka-uvc.real.inc index 2722e93..9283ef0 100644 --- a/server/src/bin/lesavka-uvc.real.inc +++ b/server/src/bin/lesavka-uvc.real.inc @@ -50,6 +50,7 @@ const EMPTY_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9]; const DEFAULT_UVC_BUFFER_COUNT: u32 = 2; const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2; const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000; +const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000; #[repr(C)] struct V4l2EventSubscription { @@ -239,6 +240,7 @@ struct UvcVideoStream { buffers: Vec, frame_path: std::path::PathBuf, latest_frame: Vec, + frame_max_bytes: usize, streaming: bool, } @@ -249,12 +251,14 @@ impl UvcVideoStream { buffers: Vec::new(), frame_path: frame_spool_path(), latest_frame: EMPTY_MJPEG_FRAME.to_vec(), + frame_max_bytes: MAX_MJPEG_FRAME_BYTES, streaming: false, } } fn start(&mut self, cfg: UvcConfig) -> Result<()> { self.stop(); + self.frame_max_bytes = uvc_frame_max_bytes(cfg); self.set_format(cfg)?; self.request_buffers(uvc_buffer_count())?; for index in 0..self.buffers.len() { @@ -406,6 +410,11 @@ impl UvcVideoStream { unsafe { std::ptr::copy_nonoverlapping(frame.as_ptr(), buffer.ptr, bytes); } + if bytes < buffer.len { + unsafe { + std::ptr::write_bytes(buffer.ptr.add(bytes), 0, buffer.len - bytes); + } + } } else { unsafe { std::ptr::write_bytes(buffer.ptr, 0, buffer.len); @@ -446,6 +455,7 @@ impl UvcVideoStream { .map(|buffer| buffer.len) .min() .unwrap_or(MAX_MJPEG_FRAME_BYTES) + .min(self.frame_max_bytes) } fn frame_for_buffer(&self, buffer_len: usize) -> &[u8] { @@ -515,6 +525,32 @@ fn uvc_idle_pump_sleep() -> Duration { )) } +/// Bound MJPEG frames before they enter the USB UVC helper. +/// +/// Inputs: UVC mode plus optional `LESAVKA_UVC_FRAME_MAX_BYTES` or +/// `LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC`. Output: maximum accepted JPEG +/// byte length. Why: oversized frames are the most common cause of browser +/// half-frame grey smears, and freezing the last good frame is preferable to +/// queueing a frame that is likely to arrive incomplete. +fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize { + if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") { + return if limit == 0 { + MAX_MJPEG_FRAME_BYTES + } else { + limit as usize + }; + } + + let fps = cfg.fps.max(1); + let budget_per_sec = env_u32( + "LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", + DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC, + ) + .max(1); + let per_frame = (budget_per_sec / fps).max(64 * 1024); + per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize +} + fn frame_spool_max_age() -> Option { match env_u64( "LESAVKA_UVC_FRAME_MAX_AGE_MS", diff --git a/server/src/bin/lesavka_uvc/coverage_model.rs b/server/src/bin/lesavka_uvc/coverage_model.rs index f5bd781..90281d1 100644 --- a/server/src/bin/lesavka_uvc/coverage_model.rs +++ b/server/src/bin/lesavka_uvc/coverage_model.rs @@ -53,6 +53,10 @@ const DEFAULT_UVC_BUFFER_COUNT: u32 = 2; const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2; #[cfg(coverage)] const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000; +#[cfg(coverage)] +const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000; +#[cfg(coverage)] +const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024; #[cfg(coverage)] #[repr(C)] diff --git a/server/src/bin/lesavka_uvc/coverage_startup.rs b/server/src/bin/lesavka_uvc/coverage_startup.rs index c7f1fb4..52c8362 100644 --- a/server/src/bin/lesavka_uvc/coverage_startup.rs +++ b/server/src/bin/lesavka_uvc/coverage_startup.rs @@ -151,6 +151,32 @@ fn uvc_idle_pump_sleep() -> std::time::Duration { )) } +#[cfg(coverage)] +/// Bound MJPEG frames before they enter the USB UVC helper. +/// +/// Inputs: UVC mode plus optional `LESAVKA_UVC_FRAME_MAX_BYTES` or +/// `LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC`. Output: maximum accepted JPEG +/// byte length. Why: coverage tests should lock the artifact-prevention budget +/// that turns oversized UVC frames into freezes instead of grey half-frames. +fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize { + if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") { + return if limit == 0 { + MAX_MJPEG_FRAME_BYTES + } else { + limit as usize + }; + } + + let fps = cfg.fps.max(1); + let budget_per_sec = env_u32( + "LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", + DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC, + ) + .max(1); + let per_frame = (budget_per_sec / fps).max(64 * 1024); + per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize +} + #[cfg(coverage)] /// Returns the optional maximum age for a spooled MJPEG frame. /// diff --git a/server/src/bin/tests/lesavka_uvc.rs b/server/src/bin/tests/lesavka_uvc.rs index 71e6372..1551a66 100644 --- a/server/src/bin/tests/lesavka_uvc.rs +++ b/server/src/bin/tests/lesavka_uvc.rs @@ -1,7 +1,7 @@ use super::*; use serial_test::serial; use std::fs; -use temp_env::with_var; +use temp_env::{with_var, with_vars}; use tempfile::NamedTempFile; fn sample_cfg() -> UvcConfig { @@ -69,6 +69,34 @@ fn io_helpers_cover_empty_and_missing_sources() { assert_eq!(read_fifo_min(&missing), None); } +#[test] +fn uvc_frame_max_bytes_defaults_to_freshness_budget_and_allows_override() { + with_vars( + [ + ("LESAVKA_UVC_FRAME_MAX_BYTES", None::<&str>), + ("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", None::<&str>), + ], + || { + let cfg = sample_cfg(); + assert_eq!(uvc_frame_max_bytes(cfg), 400_000); + }, + ); + + with_vars( + [ + ("LESAVKA_UVC_FRAME_MAX_BYTES", Some("123456")), + ("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")), + ], + || { + assert_eq!(uvc_frame_max_bytes(sample_cfg()), 123_456); + }, + ); + + with_var("LESAVKA_UVC_FRAME_MAX_BYTES", Some("0"), || { + assert_eq!(uvc_frame_max_bytes(sample_cfg()), MAX_MJPEG_FRAME_BYTES); + }); +} + #[test] #[serial] fn main_coverage_mode_returns_error_for_non_uvc_node() { diff --git a/server/src/video_sinks/hevc_mjpeg_guard.rs b/server/src/video_sinks/hevc_mjpeg_guard.rs index 70ccc7e..315fb3c 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard.rs @@ -1,6 +1,6 @@ use crate::video_support::env_u32; -const DEFAULT_HEVC_JPEG_QUALITY: u32 = 82; +const DEFAULT_HEVC_JPEG_QUALITY: u32 = 72; const DEFAULT_HEVC_SIZE_DROP_PCT: u32 = 45; const DEFAULT_HEVC_MIN_REFERENCE_BYTES: u32 = 64 * 1024; @@ -8,8 +8,8 @@ const DEFAULT_HEVC_MIN_REFERENCE_BYTES: u32 = 64 * 1024; /// /// Inputs: optional `LESAVKA_UVC_HEVC_JPEG_QUALITY`, clamped to 1..=100. /// Output: the `jpegenc` quality value. Why: HEVC ingress must become MJPEG -/// for the existing UVC gadget path, and a slightly smaller JPEG lowers USB -/// and browser pressure without changing the calibrated A/V timing model. +/// for the existing UVC gadget path, and smaller JPEGs avoid UVC/browser +/// partial-frame smears without changing the calibrated A/V timing model. pub(super) fn hevc_jpeg_quality() -> u32 { env_u32("LESAVKA_UVC_HEVC_JPEG_QUALITY", DEFAULT_HEVC_JPEG_QUALITY).clamp(1, 100) } @@ -76,7 +76,7 @@ mod tests { #[test] fn hevc_jpeg_quality_defaults_to_moderate_transport_pressure() { temp_env::with_var_unset("LESAVKA_UVC_HEVC_JPEG_QUALITY", || { - assert_eq!(super::hevc_jpeg_quality(), 82); + assert_eq!(super::hevc_jpeg_quality(), 72); }); temp_env::with_var("LESAVKA_UVC_HEVC_JPEG_QUALITY", Some("101"), || {