diagnostics: audit rejected UVC HEVC frames

This commit is contained in:
Brad Stein 2026-05-19 13:34:00 -03:00
parent cb75cb1ca2
commit e62023573c
9 changed files with 185 additions and 14 deletions

6
Cargo.lock generated
View File

@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.26.2" version = "0.26.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1692,7 +1692,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.26.2" version = "0.26.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1704,7 +1704,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.26.2" version = "0.26.3"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",

View File

@ -4,7 +4,7 @@ path = "src/main.rs"
[package] [package]
name = "lesavka_client" name = "lesavka_client"
version = "0.26.2" version = "0.26.3"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

@ -1,6 +1,6 @@
[package] [package]
name = "lesavka_common" name = "lesavka_common"
version = "0.26.2" version = "0.26.3"
edition = "2024" edition = "2024"
build = "build.rs" build = "build.rs"

View File

@ -16,7 +16,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.26.2" version = "0.26.3"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -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. /// Resolve the JPEG quality used after HEVC decode.
/// ///
/// Inputs: optional `LESAVKA_UVC_HEVC_JPEG_QUALITY`, clamped to 1..=100. /// 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 /// next decoded MJPEG. Output: true when the next frame looks like a damaged
/// collapse. Why: keeping the last good frame preserves freshness and sync /// collapse. Why: keeping the last good frame preserves freshness and sync
/// better than forwarding a syntactically valid but visually corrupted JPEG. /// 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 { 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()) { if !freeze_on_size_drop_enabled() || previous_bytes < u64::from(min_reference_bytes()) {
return false; return false;
@ -297,6 +311,44 @@ pub(super) fn should_freeze_decoded_mjpeg(previous_bytes: u64, next_bytes: usize
next_bytes < threshold_bytes next_bytes < threshold_bytes
} }
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. /// Decide whether a decoded MJPEG payload should be frozen before UVC spool.
/// ///
/// Inputs: byte length of the last successfully spooled decoded MJPEG and the /// 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, /// or suspiciously flat. Why: users prefer a short freeze over grey slabs,
/// mostly black frames, or torn images that conferencing apps may otherwise /// mostly black frames, or torn images that conferencing apps may otherwise
/// display as if they were valid webcam frames. /// 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 { pub(super) fn should_freeze_decoded_mjpeg_frame(previous_bytes: u64, decoded_mjpeg: &[u8]) -> bool {
if !freeze_on_size_drop_enabled() { decoded_mjpeg_reject_reason(previous_bytes, decoded_mjpeg).is_some()
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())
} }
/// Decide whether a direct MJPEG camera frame is unsafe to publish. /// Decide whether a direct MJPEG camera frame is unsafe to publish.

View File

@ -148,13 +148,28 @@ fn freeze_guard_rejects_incomplete_or_flat_decoded_payloads() {
200_000, 200_000,
&jpeg_with_payload(&healthy_payload), &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( assert!(super::should_freeze_decoded_mjpeg_frame(
200_000, 200_000,
&jpeg_with_payload(&flat_payload), &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( assert!(super::should_freeze_decoded_mjpeg_frame(
200_000, &truncated, 200_000, &truncated,
)); ));
assert_eq!(
super::decoded_mjpeg_reject_reason(200_000, &truncated),
Some(super::DecodedMjpegRejectReason::Incomplete)
);
assert!(super::should_freeze_decoded_mjpeg_frame( assert!(super::should_freeze_decoded_mjpeg_frame(
0, 0,
&jpeg_with_payload(&flat_payload), &jpeg_with_payload(&flat_payload),

View File

@ -66,6 +66,23 @@ impl MjpegSpoolTiming {
decoded_pts_us, 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<u64>,
) -> 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. /// 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(()) 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)] #[cfg(test)]
#[path = "mjpeg_spool/tests.rs"] #[path = "mjpeg_spool/tests.rs"]
mod tests; mod tests;

View File

@ -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. /// Save one exact UVC-bound MJPEG frame and append its JSONL index row.
/// ///
/// Inputs: audit directory, sequence, JPEG bytes, and timing. Output: success /// 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); let record = format_audit_record(sequence, slot, data, timing, &frame_file);
append_record(&mjpeg_spool_audit_log_path(dir), &record) 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)
}

View File

@ -107,10 +107,20 @@ impl WebcamSink {
let timing = MjpegSpoolTiming::hevc_decoded_mjpeg(pkt.pts, decoded_pts_us); let timing = MjpegSpoolTiming::hevc_decoded_mjpeg(pkt.pts, decoded_pts_us);
let previous_bytes = self.last_decoded_mjpeg_bytes.load(Ordering::Relaxed); let previous_bytes = self.last_decoded_mjpeg_bytes.load(Ordering::Relaxed);
let decoded_bytes = map.as_slice().len(); 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!( warn!(
target:"lesavka_server::video", target:"lesavka_server::video",
?reason,
previous_bytes, previous_bytes,
next_bytes = decoded_bytes, next_bytes = decoded_bytes,
"freezing suspicious decoded HEVC->MJPEG frame" "freezing suspicious decoded HEVC->MJPEG frame"