lesavka/server/src/video_sinks/hevc_mjpeg_guard.rs

437 lines
17 KiB
Rust

use crate::video_support::env_u32;
#[path = "hevc_mjpeg_guard/mjpeg_frame_inspection.rs"]
mod mjpeg_frame_inspection;
#[path = "hevc_mjpeg_guard/mjpeg_visual_seams.rs"]
mod mjpeg_visual_seams;
#[path = "hevc_mjpeg_guard/mjpeg_visual_guard.rs"]
mod mjpeg_visual_guard;
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 = true;
const DEFAULT_DIRECT_MJPEG_NORMALIZE: bool = true;
const DEFAULT_DIRECT_MJPEG_JPEG_QUALITY: u32 = 60;
const DEFAULT_DIRECT_MJPEG_NORMALIZE_PULL_TIMEOUT_MS: u32 = 50;
const DEFAULT_DIRECT_MJPEG_NORMALIZE_MISS_LIMIT: u32 = 30;
const DEFAULT_DIRECT_MJPEG_NORMALIZE_RSS_LIMIT_MB: u32 = 384;
/// 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,
VisualArtifact {
reason: mjpeg_visual_guard::MjpegVisualArtifactReason,
},
SizeCollapse {
threshold_bytes: u64,
},
}
/// Explains why a decoded HEVC->MJPEG frame was frozen before UVC handoff.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum DecodedMjpegRejectReason {
Incomplete,
FlatPayload,
VisualArtifact {
reason: mjpeg_visual_guard::MjpegVisualArtifactReason,
},
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: true unless explicitly disabled. Why: the live UVC descriptor is the
/// browser-visible contract; spooling a mismatched JPEG can leave the host
/// showing stale or smeared frames instead of an honest unsupported-mode freeze.
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("0")
|| trimmed.eq_ignore_ascii_case("false")
|| trimmed.eq_ignore_ascii_case("no")
|| trimmed.eq_ignore_ascii_case("off"))
})
.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 disabled. Why: direct MJPEG camera frames can still stress the
/// high-speed isochronous UVC link; normalizing them to a simpler, smaller
/// JPEG bitstream before the gadget is safer than trusting passthrough bytes.
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<u64> {
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.
#[cfg(test)]
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
}
/// Return the size-collapse reason for a decoded HEVC->MJPEG frame.
///
/// Inputs: last accepted decoded frame size and next frame size. Output: the
/// collapse reason when the next frame is too small. Why: callers need the
/// exact threshold for operator-visible diagnostics instead of a boolean.
fn decoded_size_collapse_reason(
previous_bytes: u64,
next_bytes: usize,
) -> Option<DecodedMjpegRejectReason> {
if !freeze_on_size_drop_enabled() || previous_bytes < u64::from(min_reference_bytes()) {
return None;
}
let threshold_bytes = previous_bytes.saturating_mul(u64::from(size_drop_pct())) / 100;
((next_bytes as u64) < threshold_bytes)
.then_some(DecodedMjpegRejectReason::SizeCollapse { threshold_bytes })
}
/// Return the concrete decoded-MJPEG freeze reason, if any.
///
/// Inputs: byte length of the last accepted decoded MJPEG and the next decoded
/// MJPEG. Output: a rejection reason or `None`. Why: the live guard needs to
/// protect users from torn frames, but field tuning is impossible if every
/// freeze is logged as an opaque "suspicious frame".
pub(super) fn decoded_mjpeg_reject_reason(
previous_bytes: u64,
decoded_mjpeg: &[u8],
) -> Option<DecodedMjpegRejectReason> {
if !freeze_on_size_drop_enabled() {
return None;
}
if !looks_like_complete_jpeg(decoded_mjpeg) {
return Some(DecodedMjpegRejectReason::Incomplete);
}
if suspiciously_flat_payload(decoded_mjpeg) {
return Some(DecodedMjpegRejectReason::FlatPayload);
}
if let Some(reason) = mjpeg_visual_guard::mjpeg_visual_artifact_reason(decoded_mjpeg) {
return Some(DecodedMjpegRejectReason::VisualArtifact { reason });
}
decoded_size_collapse_reason(previous_bytes, decoded_mjpeg.len())
}
/// 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.
#[cfg(test)]
pub(super) fn should_freeze_decoded_mjpeg_frame(previous_bytes: u64, decoded_mjpeg: &[u8]) -> bool {
decoded_mjpeg_reject_reason(previous_bytes, decoded_mjpeg).is_some()
}
/// 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<usize>,
expected_profile: Option<(u16, u16)>,
mjpeg: &[u8],
) -> Option<DirectMjpegRejectReason> {
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 let Some(reason) = mjpeg_visual_guard::mjpeg_visual_artifact_reason(mjpeg) {
return Some(DirectMjpegRejectReason::VisualArtifact { reason });
}
if (mjpeg.len() as u64) < threshold_bytes {
return Some(DirectMjpegRejectReason::SizeCollapse { threshold_bytes });
}
None
}
#[cfg(test)]
#[path = "hevc_mjpeg_guard/tests/mod.rs"]
mod tests;