diagnostics: audit rejected UVC HEVC frames
This commit is contained in:
parent
cb75cb1ca2
commit
e62023573c
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.26.2"
|
||||
version = "0.26.3"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.26.2"
|
||||
version = "0.26.3"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -16,7 +16,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.26.2"
|
||||
version = "0.26.3"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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<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
|
||||
@ -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.
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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<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.
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user