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]]
|
[[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",
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user