media: cap uvc mjpeg frames

This commit is contained in:
Brad Stein 2026-05-09 21:48:57 -03:00
parent c3adeb323f
commit 117323a10a
10 changed files with 108 additions and 12 deletions

6
Cargo.lock generated
View File

@ -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",

View File

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

View File

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

View File

@ -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}"

View File

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

View File

@ -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<MmapBuffer>,
frame_path: std::path::PathBuf,
latest_frame: Vec<u8>,
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<Duration> {
match env_u64(
"LESAVKA_UVC_FRAME_MAX_AGE_MS",

View File

@ -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)]

View File

@ -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.
///

View File

@ -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() {

View File

@ -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"), || {