use crate::video_support::env_u32; const DEFAULT_HEVC_JPEG_QUALITY: u32 = 72; const DEFAULT_HEVC_SIZE_DROP_PCT: u32 = 45; const DEFAULT_HEVC_MIN_REFERENCE_BYTES: u32 = 64 * 1024; const DEFAULT_HEVC_MIN_PAYLOAD_DISTINCT_BYTES: u32 = 12; const DEFAULT_HEVC_DOMINANT_BYTE_PCT: u32 = 92; const DEFAULT_DIRECT_MJPEG_SIZE_DROP_PCT: u32 = 18; const DEFAULT_DIRECT_MJPEG_MIN_REFERENCE_BYTES: u32 = 48 * 1024; const DEFAULT_DIRECT_MJPEG_PROFILE_MISMATCH_REJECT: bool = false; const DEFAULT_DIRECT_MJPEG_NORMALIZE: bool = false; const DEFAULT_DIRECT_MJPEG_JPEG_QUALITY: u32 = 72; const DEFAULT_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS: u32 = 25; const DEFAULT_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT: u32 = 30; const DEFAULT_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB: u32 = 768; /// Summarizes one compressed MJPEG frame without fully decoding pixels. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(super) struct MjpegFrameInspection { pub bytes: usize, pub complete: bool, pub width: Option, pub height: Option, pub entropy_bytes: usize, pub entropy_distinct_bytes: u16, pub entropy_dominant_pct: u8, pub entropy_max_run: usize, } /// Explains why a direct MJPEG frame was frozen before UVC handoff. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(super) enum DirectMjpegRejectReason { Incomplete, Oversized { max_bytes: usize, }, ProfileMismatch { expected_width: u16, expected_height: u16, actual_width: u16, actual_height: u16, }, FlatPayload, SizeCollapse { threshold_bytes: u64, }, } /// Resolve the JPEG quality used after HEVC decode. /// /// 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 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) } /// Decide whether suspicious decoded-frame freezing is enabled. /// /// Inputs: optional `LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP`. Output: true unless /// explicitly disabled. Why: when one damaged decoded MJPEG frame appears, a /// short freeze is less disruptive than showing a grey slab or torn half-frame. pub(super) fn freeze_on_size_drop_enabled() -> bool { std::env::var("LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP") .ok() .map(|value| { let trimmed = value.trim(); !(trimmed.eq_ignore_ascii_case("0") || trimmed.eq_ignore_ascii_case("false") || trimmed.eq_ignore_ascii_case("no") || trimmed.eq_ignore_ascii_case("off")) }) .unwrap_or(true) } /// Resolve the frame-size drop percentage that triggers a freeze. /// /// Inputs: optional `LESAVKA_UVC_HEVC_SIZE_DROP_PCT`, clamped to 1..=95. /// Output: next-frame size percentage of the last good frame. Why: the guard /// should catch sudden damaged-frame collapses while still allowing normal /// bitrate variation from scene motion and encoder decisions. pub(super) fn size_drop_pct() -> u32 { env_u32("LESAVKA_UVC_HEVC_SIZE_DROP_PCT", DEFAULT_HEVC_SIZE_DROP_PCT).clamp(1, 95) } /// Resolve the minimum reference frame size for collapse detection. /// /// Inputs: optional `LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES`. Output: byte count. /// Why: tiny synthetic or blank frames should not establish a baseline that /// causes later healthy low-detail frames to be frozen. pub(super) fn min_reference_bytes() -> u32 { env_u32( "LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES", DEFAULT_HEVC_MIN_REFERENCE_BYTES, ) .max(1) } /// Resolve the minimum compressed-payload byte variety for decoded MJPEG. /// /// Inputs: optional `LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES`. Output: /// distinct byte count threshold. Why: grey/black smear failures can arrive as /// complete JPEG buffers, so the guard needs a conservative flat-payload check /// in addition to simple size-collapse detection. pub(super) fn min_payload_distinct_bytes() -> u32 { env_u32( "LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES", DEFAULT_HEVC_MIN_PAYLOAD_DISTINCT_BYTES, ) .clamp(1, 64) } /// Resolve the dominant-byte percentage that marks a payload as too flat. /// /// Inputs: optional `LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT`, clamped to 50..=99. /// Output: percentage threshold. Why: a single repeated byte occupying almost /// all entropy-coded JPEG payload is more likely a damaged decode/transfer /// artifact than useful webcam video, and freezing is the safer conference UX. pub(super) fn dominant_byte_pct() -> u32 { env_u32( "LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT", DEFAULT_HEVC_DOMINANT_BYTE_PCT, ) .clamp(50, 99) } /// Decide whether direct MJPEG visual filtering is enabled. /// /// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_VISUAL_GUARD`. Output: true /// unless explicitly disabled. Why: the direct MJPEG path can still receive /// complete but visually useless black/collapsed frames, and repeating the last /// good conference frame is safer than exposing those frames to Google Meet. pub(super) fn direct_mjpeg_visual_guard_enabled() -> bool { std::env::var("LESAVKA_UVC_DIRECT_MJPEG_VISUAL_GUARD") .ok() .map(|value| { let trimmed = value.trim(); !(trimmed.eq_ignore_ascii_case("0") || trimmed.eq_ignore_ascii_case("false") || trimmed.eq_ignore_ascii_case("no") || trimmed.eq_ignore_ascii_case("off")) }) .unwrap_or(true) } /// Resolve the direct-MJPEG size-collapse threshold. /// /// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_SIZE_DROP_PCT`, clamped to /// 1..=60. Output: next-frame size percentage of the last good direct MJPEG. /// Why: direct camera MJPEG naturally varies more than decoded HEVC output, so /// this guard is deliberately conservative and only catches dramatic collapses. pub(super) fn direct_mjpeg_size_drop_pct() -> u32 { env_u32( "LESAVKA_UVC_DIRECT_MJPEG_SIZE_DROP_PCT", DEFAULT_DIRECT_MJPEG_SIZE_DROP_PCT, ) .clamp(1, 60) } /// Resolve the direct-MJPEG baseline required before visual freezing. /// /// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_MIN_REFERENCE_BYTES`. Output: /// byte count. Why: tiny startup frames should not become the last-good /// baseline that causes healthy frames to be classified as suspicious. pub(super) fn direct_mjpeg_min_reference_bytes() -> u32 { env_u32( "LESAVKA_UVC_DIRECT_MJPEG_MIN_REFERENCE_BYTES", DEFAULT_DIRECT_MJPEG_MIN_REFERENCE_BYTES, ) .max(1) } /// Decide whether direct MJPEG frames must match the active UVC dimensions. /// /// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_REJECT_PROFILE_MISMATCH`. /// Output: false unless explicitly enabled. Why: current field debugging needs /// profile-mismatch telemetry first; rejecting mismatches by default could /// accidentally freeze every frame on an attached gadget with stale descriptors. pub(super) fn direct_mjpeg_reject_profile_mismatch_enabled() -> bool { std::env::var("LESAVKA_UVC_DIRECT_MJPEG_REJECT_PROFILE_MISMATCH") .ok() .map(|value| { let trimmed = value.trim(); trimmed.eq_ignore_ascii_case("1") || trimmed.eq_ignore_ascii_case("true") || trimmed.eq_ignore_ascii_case("yes") || trimmed.eq_ignore_ascii_case("on") }) .unwrap_or(DEFAULT_DIRECT_MJPEG_PROFILE_MISMATCH_REJECT) } /// Decide whether direct MJPEG should be normalized before UVC spool. /// /// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE`. Output: true unless /// explicitly enabled. Why: this path runs native GStreamer JPEG decode/encode /// inside `lesavka-server`; it is useful for lab comparisons, but field /// evidence showed long-running RSS growth from the native allocator/buffer /// pools, so guarded passthrough is the production-safe default. pub(super) fn direct_mjpeg_normalize_enabled() -> bool { std::env::var("LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE") .ok() .map(|value| { let trimmed = value.trim(); trimmed.eq_ignore_ascii_case("1") || trimmed.eq_ignore_ascii_case("true") || trimmed.eq_ignore_ascii_case("yes") || trimmed.eq_ignore_ascii_case("on") }) .unwrap_or(DEFAULT_DIRECT_MJPEG_NORMALIZE) } /// Resolve JPEG quality for normalized direct MJPEG frames. /// /// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY`, clamped to /// 1..=100. Output: the `jpegenc` quality value. Why: direct MJPEG /// normalization should reduce browser-facing bitstream complexity without /// creating a new hidden bandwidth spike. pub(super) fn direct_mjpeg_jpeg_quality() -> u32 { env_u32( "LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY", DEFAULT_DIRECT_MJPEG_JPEG_QUALITY, ) .clamp(1, 100) } /// Bound how long direct MJPEG normalization may wait for a fresh sample. /// /// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS`, /// clamped to 0..=50. Output: timeout in milliseconds. Why: normalization is /// safer than raw passthrough, but it must not build a live webcam backlog. pub(super) fn direct_mjpeg_normalize_pull_timeout_ms() -> u32 { env_u32( "LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS", DEFAULT_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS, ) .min(50) } /// Bound how many consecutive normalization misses are allowed before bypass. /// /// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT`, clamped /// to 1..=300. Output: miss count. Why: the direct-MJPEG normalizer is an /// optional sanitizer, not a reason to freeze the conference camera forever if /// GStreamer negotiation starves on one host. pub(super) fn direct_mjpeg_normalize_miss_limit() -> u32 { env_u32( "LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT", DEFAULT_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT, ) .clamp(1, 300) } /// Resolve the RSS ceiling for the optional direct-MJPEG normalizer. /// /// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB`. /// Output: `None` when set to zero, otherwise a kilobyte ceiling. Why: if an /// operator enables the native JPEG normalizer for lab diagnostics, the server /// should still self-disable that branch before allocator retention threatens /// the Pi. pub(super) fn direct_mjpeg_normalize_rss_limit_kb() -> Option { let mb = env_u32( "LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB", DEFAULT_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB, ); (mb > 0).then_some(u64::from(mb) * 1024) } /// Return whether a decoded buffer looks like one complete JPEG image. /// /// Inputs: decoded MJPEG bytes. Output: true when SOI, SOS, and EOI markers /// are present. Why: incomplete JPEGs can still be non-empty buffers; freezing /// them is safer than letting the UVC helper expose a partially decoded frame. fn looks_like_complete_jpeg(bytes: &[u8]) -> bool { bytes.len() >= 4 && bytes.starts_with(&[0xff, 0xd8]) && bytes.ends_with(&[0xff, 0xd9]) && bytes.windows(2).any(|pair| pair == [0xff, 0xda]) } fn marker_has_length(marker: u8) -> bool { !matches!(marker, 0x01 | 0xd0..=0xd9) } fn is_start_of_frame_marker(marker: u8) -> bool { matches!( marker, 0xc0 | 0xc1 | 0xc2 | 0xc3 | 0xc5 | 0xc6 | 0xc7 | 0xc9 | 0xca | 0xcb | 0xcd | 0xce | 0xcf ) } fn read_be_u16(bytes: &[u8], offset: usize) -> Option { bytes .get(offset..offset + 2) .map(|value| u16::from_be_bytes([value[0], value[1]])) } fn jpeg_entropy_range(bytes: &[u8]) -> Option> { let mut idx = 2; while idx + 4 <= bytes.len() { if bytes[idx] != 0xff { idx += 1; continue; } while idx < bytes.len() && bytes[idx] == 0xff { idx += 1; } let marker = *bytes.get(idx)?; idx += 1; if marker == 0xd9 { return None; } if !marker_has_length(marker) { continue; } let segment_len = usize::from(read_be_u16(bytes, idx)?); if marker == 0xda && (segment_len < 2 || idx + segment_len > bytes.len()) { let start = idx.min(bytes.len()); let end = bytes.len().saturating_sub(2); return (end > start).then_some(start..end); } if segment_len < 2 || idx + segment_len > bytes.len() { return None; } if marker == 0xda { let start = idx + segment_len; let end = bytes.len().saturating_sub(2); return (end > start).then_some(start..end); } idx += segment_len; } None } /// Inspect one MJPEG payload without full pixel decode. /// /// Inputs: compressed JPEG bytes. Output: dimensions and entropy-shape metrics. /// Why: these metrics make the guard explainable and cheap enough to run on /// every UVC-bound frame, while still catching black slabs, truncation, and /// profile mismatches that byte-size checks alone cannot distinguish. pub(super) fn inspect_mjpeg_frame(bytes: &[u8]) -> MjpegFrameInspection { let complete = looks_like_complete_jpeg(bytes); let mut width = None; let mut height = None; let mut idx = 2; while idx + 4 <= bytes.len() { if bytes[idx] != 0xff { idx += 1; continue; } while idx < bytes.len() && bytes[idx] == 0xff { idx += 1; } let Some(marker) = bytes.get(idx).copied() else { break; }; idx += 1; if marker == 0xda || marker == 0xd9 { break; } if !marker_has_length(marker) { continue; } let Some(segment_len) = read_be_u16(bytes, idx).map(usize::from) else { break; }; if segment_len < 2 || idx + segment_len > bytes.len() { break; } if is_start_of_frame_marker(marker) && segment_len >= 7 && let (Some(frame_height), Some(frame_width)) = (read_be_u16(bytes, idx + 3), read_be_u16(bytes, idx + 5)) { height = Some(frame_height); width = Some(frame_width); break; } idx += segment_len; } let mut inspection = MjpegFrameInspection { bytes: bytes.len(), complete, width, height, ..MjpegFrameInspection::default() }; let Some(range) = jpeg_entropy_range(bytes) else { return inspection; }; let payload = &bytes[range]; if payload.is_empty() { return inspection; } let mut counts = [0u32; 256]; let mut max_run = 1usize; let mut current_run = 0usize; let mut previous = None; for byte in payload { counts[*byte as usize] += 1; if previous == Some(*byte) { current_run += 1; } else { current_run = 1; previous = Some(*byte); } max_run = max_run.max(current_run); } let distinct = counts.iter().filter(|count| **count > 0).count() as u16; let dominant = counts.iter().copied().max().unwrap_or(0) as u64; let total = payload.len() as u64; inspection.entropy_bytes = payload.len(); inspection.entropy_distinct_bytes = distinct; inspection.entropy_dominant_pct = ((dominant.saturating_mul(100) + total.saturating_sub(1)) / total) as u8; inspection.entropy_max_run = max_run; inspection } /// Return whether one complete JPEG has an implausibly flat payload. /// /// Inputs: decoded MJPEG bytes. Output: true for dominant-byte or very low /// variety payloads. Why: the visible failure mode is often a grey/black slab /// that is syntactically present but not meaningful webcam video. fn suspiciously_flat_payload(bytes: &[u8]) -> bool { if bytes.len() < min_reference_bytes() as usize / 4 { return false; } let inspection = inspect_mjpeg_frame(bytes); if inspection.entropy_bytes < 512 { return false; } u32::from(inspection.entropy_distinct_bytes) < min_payload_distinct_bytes() || u32::from(inspection.entropy_dominant_pct) >= dominant_byte_pct() } /// Decide whether a decoded HEVC-to-MJPEG frame should be frozen out. /// /// Inputs: byte length of the last successfully spooled decoded MJPEG and the /// next decoded MJPEG. Output: true when the next frame looks like a damaged /// collapse. Why: keeping the last good frame preserves freshness and sync /// better than forwarding a syntactically valid but visually corrupted JPEG. pub(super) fn should_freeze_decoded_mjpeg(previous_bytes: u64, next_bytes: usize) -> bool { if !freeze_on_size_drop_enabled() || previous_bytes < u64::from(min_reference_bytes()) { return false; } let next_bytes = next_bytes as u64; let threshold_bytes = previous_bytes.saturating_mul(u64::from(size_drop_pct())) / 100; next_bytes < threshold_bytes } /// Decide whether a decoded MJPEG payload should be frozen before UVC spool. /// /// Inputs: byte length of the last successfully spooled decoded MJPEG and the /// decoded MJPEG bytes. Output: true when the payload is incomplete, collapsed, /// or suspiciously flat. Why: users prefer a short freeze over grey slabs, /// mostly black frames, or torn images that conferencing apps may otherwise /// display as if they were valid webcam frames. pub(super) fn should_freeze_decoded_mjpeg_frame(previous_bytes: u64, decoded_mjpeg: &[u8]) -> bool { if !freeze_on_size_drop_enabled() { return false; } !looks_like_complete_jpeg(decoded_mjpeg) || suspiciously_flat_payload(decoded_mjpeg) || should_freeze_decoded_mjpeg(previous_bytes, decoded_mjpeg.len()) } /// Decide whether a direct MJPEG camera frame is unsafe to publish. /// /// Inputs: the byte length of the last successfully spooled direct MJPEG and /// the next MJPEG bytes. Output: true when the next frame is incomplete, /// implausibly flat, or a dramatic size collapse. Why: direct MJPEG should be /// less aggressive than decoded HEVC filtering, but complete black/collapsed /// frames are still worse than a short last-good-frame freeze. #[allow(dead_code)] pub(super) fn should_reject_direct_mjpeg_frame(previous_bytes: u64, mjpeg: &[u8]) -> bool { direct_mjpeg_reject_reason(previous_bytes, None, None, mjpeg).is_some() } /// Return the concrete direct-MJPEG freeze reason, if any. /// /// Inputs: last accepted frame size, optional UVC byte budget/profile, and the /// next MJPEG payload. Output: a rejection reason or `None`. Why: the UVC path /// needs operator-visible evidence for freezes; this avoids another round of /// opaque threshold guessing when the RCT preview jumps or tears. pub(super) fn direct_mjpeg_reject_reason( previous_bytes: u64, max_bytes: Option, expected_profile: Option<(u16, u16)>, mjpeg: &[u8], ) -> Option { let inspection = inspect_mjpeg_frame(mjpeg); if !looks_like_complete_jpeg(mjpeg) { return Some(DirectMjpegRejectReason::Incomplete); } if let Some(max_bytes) = max_bytes && mjpeg.len() > max_bytes { return Some(DirectMjpegRejectReason::Oversized { max_bytes }); } if direct_mjpeg_reject_profile_mismatch_enabled() && let (Some((expected_width, expected_height)), Some(actual_width), Some(actual_height)) = (expected_profile, inspection.width, inspection.height) && (actual_width, actual_height) != (expected_width, expected_height) { return Some(DirectMjpegRejectReason::ProfileMismatch { expected_width, expected_height, actual_width, actual_height, }); } if !direct_mjpeg_visual_guard_enabled() || previous_bytes < u64::from(direct_mjpeg_min_reference_bytes()) { return None; } let threshold_bytes = previous_bytes.saturating_mul(u64::from(direct_mjpeg_size_drop_pct())) / 100; if suspiciously_flat_payload(mjpeg) { return Some(DirectMjpegRejectReason::FlatPayload); } if (mjpeg.len() as u64) < threshold_bytes { return Some(DirectMjpegRejectReason::SizeCollapse { threshold_bytes }); } None } #[cfg(test)] 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(), 72); }); temp_env::with_var("LESAVKA_UVC_HEVC_JPEG_QUALITY", Some("101"), || { assert_eq!(super::hevc_jpeg_quality(), 100); }); temp_env::with_var("LESAVKA_UVC_HEVC_JPEG_QUALITY", Some("0"), || { assert_eq!(super::hevc_jpeg_quality(), 1); }); } #[test] fn direct_mjpeg_normalization_defaults_off_and_clamps_tuning() { temp_env::with_vars( [ ("LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE", None::<&str>), ("LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY", None::<&str>), ( "LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS", None::<&str>, ), ( "LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT", None::<&str>, ), ( "LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB", None::<&str>, ), ], || { assert!(!super::direct_mjpeg_normalize_enabled()); assert_eq!(super::direct_mjpeg_jpeg_quality(), 72); assert_eq!(super::direct_mjpeg_normalize_pull_timeout_ms(), 25); assert_eq!(super::direct_mjpeg_normalize_miss_limit(), 30); assert_eq!( super::direct_mjpeg_normalize_rss_limit_kb(), Some(768 * 1024) ); }, ); temp_env::with_vars( [ ("LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE", Some("on")), ("LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY", Some("101")), ( "LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS", Some("999"), ), ( "LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT", Some("999"), ), ( "LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB", Some("0"), ), ], || { assert!(super::direct_mjpeg_normalize_enabled()); assert_eq!(super::direct_mjpeg_jpeg_quality(), 100); assert_eq!(super::direct_mjpeg_normalize_pull_timeout_ms(), 50); assert_eq!(super::direct_mjpeg_normalize_miss_limit(), 300); assert_eq!(super::direct_mjpeg_normalize_rss_limit_kb(), None); }, ); temp_env::with_var("LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY", Some("0"), || { assert_eq!(super::direct_mjpeg_jpeg_quality(), 1); }); temp_env::with_var( "LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT", Some("0"), || { assert_eq!(super::direct_mjpeg_normalize_miss_limit(), 1); }, ); } #[test] fn freeze_guard_catches_large_decoded_frame_collapses() { temp_env::with_vars( [ ("LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP", Some("1")), ("LESAVKA_UVC_HEVC_SIZE_DROP_PCT", Some("45")), ("LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES", Some("65536")), ], || { assert!(super::should_freeze_decoded_mjpeg(200_000, 80_000)); assert!(!super::should_freeze_decoded_mjpeg(200_000, 110_000)); assert!(!super::should_freeze_decoded_mjpeg(20_000, 1_000)); }, ); } #[test] fn freeze_guard_can_be_disabled_for_diagnostics() { temp_env::with_vars( [ ("LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP", Some("0")), ("LESAVKA_UVC_HEVC_SIZE_DROP_PCT", Some("95")), ("LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES", Some("1")), ], || { assert!(!super::should_freeze_decoded_mjpeg(200_000, 1_000)); }, ); } #[test] /// Verifies flat or incomplete HEVC-decoded MJPEG frames freeze out. /// /// Inputs: synthetic JPEG-like payloads. Output: assertions only. Why: /// the source-level guard should keep the same behavior as the root /// taxonomy tests even when this helper is compiled inside the server crate. fn freeze_guard_rejects_incomplete_or_flat_decoded_payloads() { /// Build a minimal JPEG-like buffer for guard heuristic tests. /// /// Inputs: entropy-coded payload bytes. Output: synthetic SOI/SOS/EOI /// wrapper. Why: the guard tests need deterministic payload shape /// without depending on slow image encoding. fn jpeg_with_payload(payload: &[u8]) -> Vec { let mut bytes = vec![0xff, 0xd8, 0xff, 0xda]; bytes.extend_from_slice(payload); bytes.extend_from_slice(&[0xff, 0xd9]); bytes } temp_env::with_vars( [ ("LESAVKA_UVC_HEVC_FREEZE_ON_SIZE_DROP", Some("1")), ("LESAVKA_UVC_HEVC_SIZE_DROP_PCT", Some("45")), ("LESAVKA_UVC_HEVC_MIN_REFERENCE_BYTES", Some("65536")), ( "LESAVKA_UVC_HEVC_MIN_PAYLOAD_DISTINCT_BYTES", Some("12"), ), ("LESAVKA_UVC_HEVC_DOMINANT_BYTE_PCT", Some("92")), ], || { let healthy_payload: Vec = (0..120_000).map(|idx| (idx % 251) as u8).collect(); let flat_payload = vec![0x80; 120_000]; let mut truncated = jpeg_with_payload(&healthy_payload); truncated.pop(); assert!(!super::should_freeze_decoded_mjpeg_frame( 200_000, &jpeg_with_payload(&healthy_payload), )); assert!(super::should_freeze_decoded_mjpeg_frame( 200_000, &jpeg_with_payload(&flat_payload), )); assert!(super::should_freeze_decoded_mjpeg_frame( 200_000, &truncated, )); assert!(super::should_freeze_decoded_mjpeg_frame( 0, &jpeg_with_payload(&flat_payload), )); }, ); } #[test] fn direct_mjpeg_guard_rejects_incomplete_and_obvious_bad_frames_after_baseline() { fn jpeg_with_payload(payload: &[u8]) -> Vec { let mut bytes = vec![0xff, 0xd8, 0xff, 0xda]; bytes.extend_from_slice(payload); bytes.extend_from_slice(&[0xff, 0xd9]); bytes } let flat = jpeg_with_payload(&vec![0x80; 120_000]); let varied = jpeg_with_payload(&(0..120_000).map(|idx| (idx % 251) as u8).collect::>()); let tiny = jpeg_with_payload(&vec![0x42; 4_000]); let mut truncated = varied.clone(); truncated.pop(); temp_env::with_vars( [ ("LESAVKA_UVC_DIRECT_MJPEG_VISUAL_GUARD", Some("1")), ("LESAVKA_UVC_DIRECT_MJPEG_SIZE_DROP_PCT", Some("18")), ("LESAVKA_UVC_DIRECT_MJPEG_MIN_REFERENCE_BYTES", Some("49152")), ], || { assert!(!super::should_reject_direct_mjpeg_frame(0, &flat)); assert!(!super::should_reject_direct_mjpeg_frame(180_000, &varied)); assert!(super::should_reject_direct_mjpeg_frame(180_000, &flat)); assert!(super::should_reject_direct_mjpeg_frame(180_000, &tiny)); assert!(super::should_reject_direct_mjpeg_frame(180_000, &truncated)); }, ); } #[test] fn direct_mjpeg_guard_reports_oversize_and_profile_mismatch_when_configured() { fn jpeg_with_sof(width: u16, height: u16, payload: &[u8]) -> Vec { let mut bytes = vec![ 0xff, 0xd8, // SOI 0xff, 0xc0, 0x00, 0x11, 0x08, // SOF0 len + precision (height >> 8) as u8, height as u8, (width >> 8) as u8, width as u8, 0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00, 0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00, ]; bytes.extend_from_slice(payload); bytes.extend_from_slice(&[0xff, 0xd9]); bytes } let healthy_payload: Vec = (0..8_000).map(|idx| (idx % 251) as u8).collect(); let frame = jpeg_with_sof(1920, 1080, &healthy_payload); let inspection = super::inspect_mjpeg_frame(&frame); assert_eq!(inspection.width, Some(1920)); assert_eq!(inspection.height, Some(1080)); assert!(inspection.entropy_distinct_bytes > 64); temp_env::with_var( "LESAVKA_UVC_DIRECT_MJPEG_REJECT_PROFILE_MISMATCH", Some("1"), || { assert_eq!( super::direct_mjpeg_reject_reason( 0, Some(frame.len() + 1), Some((1280, 720)), &frame, ), Some(super::DirectMjpegRejectReason::ProfileMismatch { expected_width: 1280, expected_height: 720, actual_width: 1920, actual_height: 1080, }) ); }, ); assert_eq!( super::direct_mjpeg_reject_reason(0, Some(frame.len() - 1), Some((1920, 1080)), &frame), Some(super::DirectMjpegRejectReason::Oversized { max_bytes: frame.len() - 1, }) ); } #[test] fn direct_mjpeg_visual_guard_can_be_disabled_without_allowing_truncation() { let mut complete = vec![0xff, 0xd8, 0xff, 0xda]; complete.extend_from_slice(&vec![0x80; 120_000]); complete.extend_from_slice(&[0xff, 0xd9]); let mut truncated = complete.clone(); truncated.pop(); temp_env::with_var("LESAVKA_UVC_DIRECT_MJPEG_VISUAL_GUARD", Some("0"), || { assert!(!super::should_reject_direct_mjpeg_frame(180_000, &complete)); assert!(super::should_reject_direct_mjpeg_frame(180_000, &truncated)); }); } }