From e62023573cdb4d275391a47cf5263da940218845 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Tue, 19 May 2026 13:34:00 -0300 Subject: [PATCH] diagnostics: audit rejected UVC HEVC frames --- Cargo.lock | 6 +- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- server/Cargo.toml | 2 +- server/src/video_sinks/hevc_mjpeg_guard.rs | 61 ++++++++++++++++--- .../src/video_sinks/hevc_mjpeg_guard/tests.rs | 15 +++++ server/src/video_sinks/mjpeg_spool.rs | 47 ++++++++++++++ server/src/video_sinks/mjpeg_spool/audit.rs | 52 ++++++++++++++++ .../video_sinks/webcam_sink/frame_handoff.rs | 12 +++- 9 files changed, 185 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a8285e8..c2c4915 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.26.2" +version = "0.26.3" dependencies = [ "anyhow", "async-stream", @@ -1692,7 +1692,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.26.2" +version = "0.26.3" dependencies = [ "anyhow", "base64", @@ -1704,7 +1704,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.26.2" +version = "0.26.3" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index 1affe8a..dedde0b 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.26.2" +version = "0.26.3" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 29d832b..73e10b7 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.26.2" +version = "0.26.3" edition = "2024" build = "build.rs" diff --git a/server/Cargo.toml b/server/Cargo.toml index 085b3e8..279d75c 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.26.2" +version = "0.26.3" edition = "2024" autobins = false diff --git a/server/src/video_sinks/hevc_mjpeg_guard.rs b/server/src/video_sinks/hevc_mjpeg_guard.rs index b87c3a3..0599f23 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard.rs @@ -44,6 +44,19 @@ pub(super) enum DirectMjpegRejectReason { }, } +/// 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. @@ -287,6 +300,7 @@ fn suspiciously_flat_payload(bytes: &[u8]) -> bool { /// 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; @@ -297,6 +311,44 @@ pub(super) fn should_freeze_decoded_mjpeg(previous_bytes: u64, next_bytes: usize next_bytes < threshold_bytes } +fn decoded_size_collapse_reason( + previous_bytes: u64, + next_bytes: usize, +) -> Option { + 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 { + 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 @@ -304,14 +356,9 @@ pub(super) fn should_freeze_decoded_mjpeg(previous_bytes: u64, next_bytes: usize /// 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 { - if !freeze_on_size_drop_enabled() { - return false; - } - !looks_like_complete_jpeg(decoded_mjpeg) - || suspiciously_flat_payload(decoded_mjpeg) - || mjpeg_visual_guard::mjpeg_visual_artifact_reason(decoded_mjpeg).is_some() - || should_freeze_decoded_mjpeg(previous_bytes, decoded_mjpeg.len()) + decoded_mjpeg_reject_reason(previous_bytes, decoded_mjpeg).is_some() } /// Decide whether a direct MJPEG camera frame is unsafe to publish. diff --git a/server/src/video_sinks/hevc_mjpeg_guard/tests.rs b/server/src/video_sinks/hevc_mjpeg_guard/tests.rs index ac907e7..cbdfa79 100644 --- a/server/src/video_sinks/hevc_mjpeg_guard/tests.rs +++ b/server/src/video_sinks/hevc_mjpeg_guard/tests.rs @@ -148,13 +148,28 @@ fn freeze_guard_rejects_incomplete_or_flat_decoded_payloads() { 200_000, &jpeg_with_payload(&healthy_payload), )); + assert_eq!( + super::decoded_mjpeg_reject_reason( + 200_000, + &jpeg_with_payload(&healthy_payload), + ), + None + ); assert!(super::should_freeze_decoded_mjpeg_frame( 200_000, &jpeg_with_payload(&flat_payload), )); + assert_eq!( + super::decoded_mjpeg_reject_reason(200_000, &jpeg_with_payload(&flat_payload)), + Some(super::DecodedMjpegRejectReason::FlatPayload) + ); assert!(super::should_freeze_decoded_mjpeg_frame( 200_000, &truncated, )); + assert_eq!( + super::decoded_mjpeg_reject_reason(200_000, &truncated), + Some(super::DecodedMjpegRejectReason::Incomplete) + ); assert!(super::should_freeze_decoded_mjpeg_frame( 0, &jpeg_with_payload(&flat_payload), diff --git a/server/src/video_sinks/mjpeg_spool.rs b/server/src/video_sinks/mjpeg_spool.rs index 1281027..79a67b6 100644 --- a/server/src/video_sinks/mjpeg_spool.rs +++ b/server/src/video_sinks/mjpeg_spool.rs @@ -66,6 +66,23 @@ impl MjpegSpoolTiming { decoded_pts_us, } } + + /// Build metadata for decoded HEVC frames frozen by the safety guard. + /// + /// Inputs: upstream packet PTS plus the decoded appsink buffer PTS. + /// Output: timing metadata labeled as rejected HEVC-decoded MJPEG. Why: + /// rejected-frame audits need to line up with accepted UVC-bound frames + /// without implying the rejected payload reached the gadget. + pub(super) fn rejected_hevc_decoded_mjpeg( + source_pts_us: u64, + decoded_pts_us: Option, + ) -> Self { + Self { + profile: "hevc-decoded-mjpeg-rejected", + source_pts_us: Some(source_pts_us), + decoded_pts_us, + } + } } /// Decide whether the UVC helper file-spool path should own MJPEG emission. @@ -391,6 +408,36 @@ pub(super) fn spool_mjpeg_frame_with_timing( Ok(()) } +/// Preserve a guard-rejected MJPEG frame in the optional boundary audit. +/// +/// Inputs: rejected JPEG bytes, timing metadata, and a human-readable reason. +/// Output: best-effort filesystem write. Why: when the live guard freezes a +/// frame, we need the exact payload that did not reach UVC so detector tuning +/// can separate false positives from real distortion. +pub(super) fn audit_rejected_mjpeg_frame( + data: &[u8], + timing: MjpegSpoolTiming, + reason: &str, +) { + let Some(dir) = audit::mjpeg_spool_audit_dir() else { + return; + }; + let sequence = SPOOL_SEQUENCE.fetch_add(1, Ordering::Relaxed); + let Some(slot) = audit::claim_spool_audit_frame(sequence) else { + return; + }; + if let Err(err) = + audit::write_mjpeg_spool_rejected_audit_frame(&dir, sequence, slot, data, timing, reason) + { + tracing::warn!( + target: "lesavka_server::video", + %err, + audit_dir = %dir.display(), + "failed to write rejected UVC MJPEG boundary audit frame" + ); + } +} + #[cfg(test)] #[path = "mjpeg_spool/tests.rs"] mod tests; diff --git a/server/src/video_sinks/mjpeg_spool/audit.rs b/server/src/video_sinks/mjpeg_spool/audit.rs index 0ef14b3..6c08c5c 100644 --- a/server/src/video_sinks/mjpeg_spool/audit.rs +++ b/server/src/video_sinks/mjpeg_spool/audit.rs @@ -199,6 +199,31 @@ fn format_audit_record( ) } +fn format_rejected_audit_record( + sequence: u64, + slot: u64, + data: &[u8], + timing: MjpegSpoolTiming, + frame_file: &str, + reason: &str, +) -> String { + let source_pts_us = timing.source_pts_us; + let decoded_pts_us = timing.decoded_pts_us; + format!( + "{{\"schema\":\"lesavka.uvc-mjpeg-spool-audit.v1\",\"sequence\":{},\"slot\":{},\"profile\":\"{}\",\"rejected\":true,\"reason\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"spool_unix_ns\":{},\"fnv1a64\":\"{}\",\"file\":\"{}\"}}\n", + sequence, + slot, + json_escape(timing.profile), + json_escape(reason), + data.len(), + json_number_or_null(source_pts_us), + json_number_or_null(decoded_pts_us), + unix_now_ns(), + fnv1a64_hex(data), + json_escape(frame_file) + ) +} + /// Save one exact UVC-bound MJPEG frame and append its JSONL index row. /// /// Inputs: audit directory, sequence, JPEG bytes, and timing. Output: success @@ -224,3 +249,30 @@ pub(super) fn write_mjpeg_spool_audit_frame( let record = format_audit_record(sequence, slot, data, timing, &frame_file); append_record(&mjpeg_spool_audit_log_path(dir), &record) } + +/// Save one guard-rejected MJPEG frame and append its JSONL index row. +/// +/// Inputs: audit directory, sequence, rejected JPEG bytes, timing, and reason. +/// Output: success or filesystem error. Why: rejected-frame payloads are the +/// missing evidence for tuning the guard against real RCT-visible distortion. +pub(super) fn write_mjpeg_spool_rejected_audit_frame( + dir: &Path, + sequence: u64, + slot: u64, + data: &[u8], + timing: MjpegSpoolTiming, + reason: &str, +) -> anyhow::Result<()> { + fs::create_dir_all(dir)?; + let frame_file = format!("rejected-frame-{slot:012}.mjpg"); + let tmp = dir.join(format!( + ".{frame_file}.{}.{}.tmp", + std::process::id(), + AUDIT_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed) + )); + fs::write(&tmp, data)?; + fs::rename(&tmp, dir.join(&frame_file))?; + + let record = format_rejected_audit_record(sequence, slot, data, timing, &frame_file, reason); + append_record(&mjpeg_spool_audit_log_path(dir), &record) +} diff --git a/server/src/video_sinks/webcam_sink/frame_handoff.rs b/server/src/video_sinks/webcam_sink/frame_handoff.rs index c91c268..596e1ec 100644 --- a/server/src/video_sinks/webcam_sink/frame_handoff.rs +++ b/server/src/video_sinks/webcam_sink/frame_handoff.rs @@ -107,10 +107,20 @@ impl WebcamSink { let timing = MjpegSpoolTiming::hevc_decoded_mjpeg(pkt.pts, decoded_pts_us); let previous_bytes = self.last_decoded_mjpeg_bytes.load(Ordering::Relaxed); let decoded_bytes = map.as_slice().len(); - if hevc_mjpeg_guard::should_freeze_decoded_mjpeg_frame(previous_bytes, map.as_slice()) + if let Some(reason) = + hevc_mjpeg_guard::decoded_mjpeg_reject_reason(previous_bytes, map.as_slice()) { + let rejected_timing = + MjpegSpoolTiming::rejected_hevc_decoded_mjpeg(pkt.pts, decoded_pts_us); + let reason_text = format!("{reason:?}"); + super::mjpeg_spool::audit_rejected_mjpeg_frame( + map.as_slice(), + rejected_timing, + &reason_text, + ); warn!( target:"lesavka_server::video", + ?reason, previous_bytes, next_bytes = decoded_bytes, "freezing suspicious decoded HEVC->MJPEG frame"