2026-05-09 18:52:14 -03:00
|
|
|
use crate::video_support::env_u32;
|
|
|
|
|
|
2026-05-09 21:48:57 -03:00
|
|
|
const DEFAULT_HEVC_JPEG_QUALITY: u32 = 72;
|
2026-05-09 18:52:14 -03:00
|
|
|
const DEFAULT_HEVC_SIZE_DROP_PCT: u32 = 45;
|
|
|
|
|
const DEFAULT_HEVC_MIN_REFERENCE_BYTES: u32 = 64 * 1024;
|
2026-05-10 23:14:15 -03:00
|
|
|
const DEFAULT_HEVC_MIN_PAYLOAD_DISTINCT_BYTES: u32 = 12;
|
|
|
|
|
const DEFAULT_HEVC_DOMINANT_BYTE_PCT: u32 = 92;
|
2026-05-14 01:42:14 -03:00
|
|
|
const DEFAULT_DIRECT_MJPEG_SIZE_DROP_PCT: u32 = 18;
|
|
|
|
|
const DEFAULT_DIRECT_MJPEG_MIN_REFERENCE_BYTES: u32 = 48 * 1024;
|
2026-05-14 05:18:36 -03:00
|
|
|
const DEFAULT_DIRECT_MJPEG_PROFILE_MISMATCH_REJECT: bool = false;
|
2026-05-16 02:53:49 -03:00
|
|
|
const DEFAULT_DIRECT_MJPEG_NORMALIZE: bool = true;
|
|
|
|
|
const DEFAULT_DIRECT_MJPEG_JPEG_QUALITY: u32 = 72;
|
|
|
|
|
const DEFAULT_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS: u32 = 25;
|
2026-05-14 05:18:36 -03:00
|
|
|
|
|
|
|
|
/// 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<u16>,
|
|
|
|
|
pub height: Option<u16>,
|
|
|
|
|
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,
|
|
|
|
|
},
|
|
|
|
|
}
|
2026-05-09 18:52:14 -03:00
|
|
|
|
|
|
|
|
/// 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
|
2026-05-09 21:48:57 -03:00
|
|
|
/// for the existing UVC gadget path, and smaller JPEGs avoid UVC/browser
|
|
|
|
|
/// partial-frame smears without changing the calibrated A/V timing model.
|
2026-05-09 18:52:14 -03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:14:15 -03:00
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 01:42:14 -03:00
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 05:18:36 -03:00
|
|
|
/// 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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 02:53:49 -03:00
|
|
|
/// Decide whether direct MJPEG should be normalized before UVC spool.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE`. Output: true unless
|
|
|
|
|
/// explicitly disabled. Why: Google Meet/Firefox can expose lower-half grey
|
|
|
|
|
/// slabs from otherwise complete camera JPEGs; a local decode/re-encode gives
|
|
|
|
|
/// the RCT a simpler, freshly bounded MJPEG bitstream.
|
|
|
|
|
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("0")
|
|
|
|
|
|| trimmed.eq_ignore_ascii_case("false")
|
|
|
|
|
|| trimmed.eq_ignore_ascii_case("no")
|
|
|
|
|
|| trimmed.eq_ignore_ascii_case("off"))
|
|
|
|
|
})
|
|
|
|
|
.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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:14:15 -03:00
|
|
|
/// 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])
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 05:18:36 -03:00
|
|
|
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<u16> {
|
|
|
|
|
bytes
|
|
|
|
|
.get(offset..offset + 2)
|
|
|
|
|
.map(|value| u16::from_be_bytes([value[0], value[1]]))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn jpeg_entropy_range(bytes: &[u8]) -> Option<std::ops::Range<usize>> {
|
|
|
|
|
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.
|
2026-05-10 23:14:15 -03:00
|
|
|
///
|
2026-05-14 05:18:36 -03:00
|
|
|
/// 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;
|
2026-05-10 23:14:15 -03:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 05:18:36 -03:00
|
|
|
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;
|
2026-05-10 23:14:15 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut counts = [0u32; 256];
|
2026-05-14 05:18:36 -03:00
|
|
|
let mut max_run = 1usize;
|
|
|
|
|
let mut current_run = 0usize;
|
|
|
|
|
let mut previous = None;
|
2026-05-10 23:14:15 -03:00
|
|
|
for byte in payload {
|
|
|
|
|
counts[*byte as usize] += 1;
|
2026-05-14 05:18:36 -03:00
|
|
|
if previous == Some(*byte) {
|
|
|
|
|
current_run += 1;
|
|
|
|
|
} else {
|
|
|
|
|
current_run = 1;
|
|
|
|
|
previous = Some(*byte);
|
|
|
|
|
}
|
|
|
|
|
max_run = max_run.max(current_run);
|
2026-05-10 23:14:15 -03:00
|
|
|
}
|
|
|
|
|
|
2026-05-14 05:18:36 -03:00
|
|
|
let distinct = counts.iter().filter(|count| **count > 0).count() as u16;
|
2026-05-10 23:14:15 -03:00
|
|
|
let dominant = counts.iter().copied().max().unwrap_or(0) as u64;
|
|
|
|
|
let total = payload.len() as u64;
|
2026-05-14 05:18:36 -03:00
|
|
|
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;
|
|
|
|
|
}
|
2026-05-10 23:14:15 -03:00
|
|
|
|
2026-05-14 05:18:36 -03:00
|
|
|
u32::from(inspection.entropy_distinct_bytes) < min_payload_distinct_bytes()
|
|
|
|
|
|| u32::from(inspection.entropy_dominant_pct) >= dominant_byte_pct()
|
2026-05-10 23:14:15 -03:00
|
|
|
}
|
|
|
|
|
|
2026-05-09 18:52:14 -03:00
|
|
|
/// 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
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-10 23:14:15 -03:00
|
|
|
/// 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 {
|
2026-05-13 11:05:08 -03:00
|
|
|
if !freeze_on_size_drop_enabled() {
|
2026-05-10 23:14:15 -03:00
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
!looks_like_complete_jpeg(decoded_mjpeg)
|
|
|
|
|
|| suspiciously_flat_payload(decoded_mjpeg)
|
2026-05-13 11:05:08 -03:00
|
|
|
|| should_freeze_decoded_mjpeg(previous_bytes, decoded_mjpeg.len())
|
2026-05-10 23:14:15 -03:00
|
|
|
}
|
|
|
|
|
|
2026-05-13 17:43:00 -03:00
|
|
|
/// Decide whether a direct MJPEG camera frame is unsafe to publish.
|
|
|
|
|
///
|
2026-05-14 01:42:14 -03:00
|
|
|
/// 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.
|
2026-05-14 05:18:36 -03:00
|
|
|
#[allow(dead_code)]
|
2026-05-14 01:42:14 -03:00
|
|
|
pub(super) fn should_reject_direct_mjpeg_frame(previous_bytes: u64, mjpeg: &[u8]) -> bool {
|
2026-05-14 05:18:36 -03:00
|
|
|
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<usize>,
|
|
|
|
|
expected_profile: Option<(u16, u16)>,
|
|
|
|
|
mjpeg: &[u8],
|
|
|
|
|
) -> Option<DirectMjpegRejectReason> {
|
|
|
|
|
let inspection = inspect_mjpeg_frame(mjpeg);
|
2026-05-14 01:42:14 -03:00
|
|
|
if !looks_like_complete_jpeg(mjpeg) {
|
2026-05-14 05:18:36 -03:00
|
|
|
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,
|
|
|
|
|
});
|
2026-05-14 01:42:14 -03:00
|
|
|
}
|
|
|
|
|
if !direct_mjpeg_visual_guard_enabled()
|
|
|
|
|
|| previous_bytes < u64::from(direct_mjpeg_min_reference_bytes())
|
|
|
|
|
{
|
2026-05-14 05:18:36 -03:00
|
|
|
return None;
|
2026-05-14 01:42:14 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let threshold_bytes = previous_bytes.saturating_mul(u64::from(direct_mjpeg_size_drop_pct()))
|
|
|
|
|
/ 100;
|
2026-05-14 05:18:36 -03:00
|
|
|
if suspiciously_flat_payload(mjpeg) {
|
|
|
|
|
return Some(DirectMjpegRejectReason::FlatPayload);
|
|
|
|
|
}
|
|
|
|
|
if (mjpeg.len() as u64) < threshold_bytes {
|
|
|
|
|
return Some(DirectMjpegRejectReason::SizeCollapse { threshold_bytes });
|
|
|
|
|
}
|
|
|
|
|
None
|
2026-05-13 17:43:00 -03:00
|
|
|
}
|
|
|
|
|
|
2026-05-09 18:52:14 -03:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
#[test]
|
|
|
|
|
fn hevc_jpeg_quality_defaults_to_moderate_transport_pressure() {
|
|
|
|
|
temp_env::with_var_unset("LESAVKA_UVC_HEVC_JPEG_QUALITY", || {
|
2026-05-09 21:48:57 -03:00
|
|
|
assert_eq!(super::hevc_jpeg_quality(), 72);
|
2026-05-09 18:52:14 -03:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 02:53:49 -03:00
|
|
|
#[test]
|
|
|
|
|
fn direct_mjpeg_normalization_defaults_on_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>,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
|| {
|
|
|
|
|
assert!(super::direct_mjpeg_normalize_enabled());
|
|
|
|
|
assert_eq!(super::direct_mjpeg_jpeg_quality(), 72);
|
|
|
|
|
assert_eq!(super::direct_mjpeg_normalize_pull_timeout_ms(), 25);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
temp_env::with_vars(
|
|
|
|
|
[
|
|
|
|
|
("LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE", Some("off")),
|
|
|
|
|
("LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY", Some("101")),
|
|
|
|
|
(
|
|
|
|
|
"LESAVKA_UVC_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS",
|
|
|
|
|
Some("999"),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
|| {
|
|
|
|
|
assert!(!super::direct_mjpeg_normalize_enabled());
|
|
|
|
|
assert_eq!(super::direct_mjpeg_jpeg_quality(), 100);
|
|
|
|
|
assert_eq!(super::direct_mjpeg_normalize_pull_timeout_ms(), 50);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
temp_env::with_var("LESAVKA_UVC_DIRECT_MJPEG_JPEG_QUALITY", Some("0"), || {
|
|
|
|
|
assert_eq!(super::direct_mjpeg_jpeg_quality(), 1);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 18:52:14 -03:00
|
|
|
#[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));
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-10 23:14:15 -03:00
|
|
|
|
|
|
|
|
#[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<u8> {
|
|
|
|
|
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<u8> =
|
|
|
|
|
(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,
|
|
|
|
|
));
|
2026-05-13 11:05:08 -03:00
|
|
|
assert!(super::should_freeze_decoded_mjpeg_frame(
|
|
|
|
|
0,
|
|
|
|
|
&jpeg_with_payload(&flat_payload),
|
|
|
|
|
));
|
2026-05-10 23:14:15 -03:00
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-13 17:43:00 -03:00
|
|
|
|
|
|
|
|
#[test]
|
2026-05-14 01:42:14 -03:00
|
|
|
fn direct_mjpeg_guard_rejects_incomplete_and_obvious_bad_frames_after_baseline() {
|
2026-05-13 17:43:00 -03:00
|
|
|
fn jpeg_with_payload(payload: &[u8]) -> Vec<u8> {
|
|
|
|
|
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::<Vec<_>>());
|
2026-05-14 01:42:14 -03:00
|
|
|
let tiny = jpeg_with_payload(&vec![0x42; 4_000]);
|
2026-05-13 17:43:00 -03:00
|
|
|
let mut truncated = varied.clone();
|
|
|
|
|
truncated.pop();
|
|
|
|
|
|
2026-05-14 01:42:14 -03:00
|
|
|
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));
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 05:18:36 -03:00
|
|
|
#[test]
|
|
|
|
|
fn direct_mjpeg_guard_reports_oversize_and_profile_mismatch_when_configured() {
|
|
|
|
|
fn jpeg_with_sof(width: u16, height: u16, payload: &[u8]) -> Vec<u8> {
|
|
|
|
|
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<u8> = (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,
|
|
|
|
|
})
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 01:42:14 -03:00
|
|
|
#[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));
|
|
|
|
|
});
|
2026-05-13 17:43:00 -03:00
|
|
|
}
|
2026-05-09 18:52:14 -03:00
|
|
|
}
|