use crate::video_support::env_u32; #[path = "hevc_mjpeg_guard/mjpeg_frame_inspection.rs"] mod mjpeg_frame_inspection; pub(super) use mjpeg_frame_inspection::{inspect_mjpeg_frame, looks_like_complete_jpeg}; 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; /// 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 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)] #[path = "hevc_mjpeg_guard/tests.rs"] mod tests;