media: add mjpeg uvc quality telemetry

This commit is contained in:
Brad Stein 2026-05-14 05:18:36 -03:00
parent dec332ea40
commit 22dd45aa39
16 changed files with 748 additions and 45 deletions

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.22.29" version = "0.22.30"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.22.29" version = "0.22.30"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.22.29" version = "0.22.30"
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.22.29" version = "0.22.30"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

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

View File

@ -330,8 +330,9 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `LESAVKA_UVC_FRAME_META_LOG_PATH` | UVC helper diagnostic override; when set with `LESAVKA_UVC_FRAME_META=1`, append every MJPEG spool timing record as JSONL for full-probe HEVC/RCT correlation; summarize with `scripts/manual/summarize_uvc_frame_meta_log.py` | | `LESAVKA_UVC_FRAME_META_LOG_PATH` | UVC helper diagnostic override; when set with `LESAVKA_UVC_FRAME_META=1`, append every MJPEG spool timing record as JSONL for full-probe HEVC/RCT correlation; summarize with `scripts/manual/summarize_uvc_frame_meta_log.py` |
| `LESAVKA_UVC_FRAME_META_PATH` | UVC helper diagnostic override; explicit path for the optional MJPEG spool metadata sidecar | | `LESAVKA_UVC_FRAME_META_PATH` | UVC helper diagnostic override; explicit path for the optional MJPEG spool metadata sidecar |
| `LESAVKA_UVC_FRAME_MAX_AGE_MS` | UVC helper freshness override; stale spooled MJPEG frames older than this are not replayed, defaults to `1000`; `0` disables TTL | | `LESAVKA_UVC_FRAME_MAX_AGE_MS` | UVC helper freshness override; stale spooled MJPEG frames older than this are not replayed, defaults to `1000`; `0` disables TTL |
| `LESAVKA_UVC_FRAME_MAX_BYTES` | UVC helper MJPEG frame-size guard; explicit maximum accepted frame bytes, where `0` disables the guard and otherwise oversized frames are frozen out | | `LESAVKA_UVC_FRAME_MAX_BYTES` | UVC helper MJPEG frame-size guard; explicit maximum accepted frame bytes. Unset or `0` uses the live-call byte budget so oversized frames freeze instead of tearing on the host |
| `LESAVKA_UVC_FRAME_SIZE` | server hardware/device override | | `LESAVKA_UVC_FRAME_SIZE` | server hardware/device override |
| `LESAVKA_UVC_FRAME_SIZE_GUARD` | UVC helper MJPEG frame-size guard toggle; defaults to `1`; set `0` only for diagnostics when oversized MJPEG frames must be allowed through |
| `LESAVKA_UVC_HEIGHT` | server hardware/device override | | `LESAVKA_UVC_HEIGHT` | server hardware/device override |
| `LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS` | server HEVC decode-to-MJPEG freshness override; appsink pull wait for decoded MJPEG handoff before publishing newest frame to the UVC helper, defaults to `20` and is capped at `50` | | `LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS` | server HEVC decode-to-MJPEG freshness override; appsink pull wait for decoded MJPEG handoff before publishing newest frame to the UVC helper, defaults to `20` and is capped at `50` |
| `LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS` | server HEVC decode-to-MJPEG branch queue depth; defaults to `2` and is capped at `4` so decode/JPEG scheduling jitter does not starve the UVC helper while stale frames still get dropped | | `LESAVKA_UVC_HEVC_FRESHNESS_QUEUE_BUFFERS` | server HEVC decode-to-MJPEG branch queue depth; defaults to `2` and is capped at `4` so decode/JPEG scheduling jitter does not starve the UVC helper while stale frames still get dropped |
@ -344,6 +345,7 @@ from `LESAVKA_CLIENT_PKI_SSH_SOURCE` over SSH. Runtime clients require the insta
| `LESAVKA_UVC_MJPEG` | server hardware/device override | | `LESAVKA_UVC_MJPEG` | server hardware/device override |
| `LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC` | UVC helper MJPEG budget guard; derives a per-frame byte cap from target FPS when `LESAVKA_UVC_FRAME_MAX_BYTES` is unset | | `LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC` | UVC helper MJPEG budget guard; derives a per-frame byte cap from target FPS when `LESAVKA_UVC_FRAME_MAX_BYTES` is unset |
| `LESAVKA_UVC_SKIP_UDEV` | server hardware/device override | | `LESAVKA_UVC_SKIP_UDEV` | server hardware/device override |
| `LESAVKA_UVC_STATS_INTERVAL_MS` | UVC helper telemetry interval for queued/reloaded/rejected MJPEG frame counters; defaults to `5000`, `0` disables |
| `LESAVKA_UVC_STREAMING_INTERVAL` | server hardware/device override | | `LESAVKA_UVC_STREAMING_INTERVAL` | server hardware/device override |
| `LESAVKA_UVC_STREAM_INTF` | server hardware/device override | | `LESAVKA_UVC_STREAM_INTF` | server hardware/device override |
| `LESAVKA_UVC_WIDTH` | server hardware/device override | | `LESAVKA_UVC_WIDTH` | server hardware/device override |

View File

@ -355,6 +355,7 @@ LESAVKA_UVC_CODEC=${INSTALL_UVC_CODEC}
LESAVKA_UVC_BLOCKING=$(uvc_env_value LESAVKA_UVC_BLOCKING 1) LESAVKA_UVC_BLOCKING=$(uvc_env_value LESAVKA_UVC_BLOCKING 1)
LESAVKA_UVC_CONTROL_READ_ONLY=$(uvc_env_value LESAVKA_UVC_CONTROL_READ_ONLY 0) LESAVKA_UVC_CONTROL_READ_ONLY=$(uvc_env_value LESAVKA_UVC_CONTROL_READ_ONLY 0)
LESAVKA_UVC_MAXBURST=$(uvc_env_value LESAVKA_UVC_MAXBURST 0) LESAVKA_UVC_MAXBURST=$(uvc_env_value LESAVKA_UVC_MAXBURST 0)
LESAVKA_UVC_FRAME_SIZE_GUARD=$(uvc_env_value LESAVKA_UVC_FRAME_SIZE_GUARD 1)
LESAVKA_UVC_FRAME_MAX_BYTES=$(uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0) LESAVKA_UVC_FRAME_MAX_BYTES=$(uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0)
LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=$(uvc_env_value LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC 10000000) LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC=$(uvc_env_value LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC 10000000)
EOF EOF

View File

@ -0,0 +1,129 @@
#!/usr/bin/env python3
"""Inspect a Lesavka UVC MJPEG spool frame for size/profile corruption clues."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from typing import Optional
MAX_MJPEG_FRAME_BYTES = 8 * 1024 * 1024
DEFAULT_BUDGET_BYTES_PER_SEC = 10_000_000
def read_be16(data: bytes, offset: int) -> Optional[int]:
if offset + 2 > len(data):
return None
return int.from_bytes(data[offset : offset + 2], "big")
def marker_has_length(marker: int) -> bool:
return marker != 0x01 and not 0xD0 <= marker <= 0xD9
def is_sof(marker: int) -> bool:
return marker in {0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF}
def inspect_jpeg(data: bytes) -> dict[str, object]:
complete = len(data) >= 4 and data.startswith(b"\xff\xd8") and data.endswith(b"\xff\xd9") and b"\xff\xda" in data
width = None
height = None
entropy_start = None
entropy_end = len(data) - 2 if len(data) >= 2 else len(data)
idx = 2
while idx + 4 <= len(data):
if data[idx] != 0xFF:
idx += 1
continue
while idx < len(data) and data[idx] == 0xFF:
idx += 1
if idx >= len(data):
break
marker = data[idx]
idx += 1
if marker == 0xDA:
seg_len = read_be16(data, idx)
if seg_len is not None and seg_len >= 2 and idx + seg_len <= len(data):
entropy_start = idx + seg_len
break
if marker == 0xD9:
break
if not marker_has_length(marker):
continue
seg_len = read_be16(data, idx)
if seg_len is None or seg_len < 2 or idx + seg_len > len(data):
break
if is_sof(marker) and seg_len >= 7:
height = read_be16(data, idx + 3)
width = read_be16(data, idx + 5)
idx += seg_len
payload = data[entropy_start:entropy_end] if entropy_start is not None and entropy_end > entropy_start else b""
counts = [0] * 256
max_run = 0
run = 0
prev = None
for byte in payload:
counts[byte] += 1
if byte == prev:
run += 1
else:
prev = byte
run = 1
max_run = max(max_run, run)
dominant = max(counts) if payload else 0
distinct = sum(1 for count in counts if count)
dominant_pct = round(dominant * 100 / len(payload), 2) if payload else 0.0
return {
"bytes": len(data),
"complete": complete,
"width": width,
"height": height,
"entropy_bytes": len(payload),
"entropy_distinct_bytes": distinct,
"entropy_dominant_pct": dominant_pct,
"entropy_max_run": max_run,
}
def derived_cap(fps: int, budget: int, explicit: Optional[int], guard: bool) -> int:
if not guard:
return MAX_MJPEG_FRAME_BYTES
if explicit and explicit > 0:
return min(explicit, MAX_MJPEG_FRAME_BYTES)
per_frame = max(budget // max(fps, 1), 64 * 1024)
return min(per_frame, MAX_MJPEG_FRAME_BYTES)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("frame", nargs="?", default=os.environ.get("LESAVKA_UVC_FRAME_PATH", "/run/lesavka-uvc-frame.mjpg"))
parser.add_argument("--fps", type=int, default=int(os.environ.get("LESAVKA_UVC_FPS", "30") or "30"))
parser.add_argument("--budget-bytes-per-sec", type=int, default=int(os.environ.get("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", str(DEFAULT_BUDGET_BYTES_PER_SEC)) or str(DEFAULT_BUDGET_BYTES_PER_SEC)))
parser.add_argument("--max-bytes", type=int, default=int(os.environ.get("LESAVKA_UVC_FRAME_MAX_BYTES", "0") or "0"))
parser.add_argument("--disable-size-guard", action="store_true")
args = parser.parse_args()
path = Path(args.frame)
data = path.read_bytes()
cap = derived_cap(args.fps, args.budget_bytes_per_sec, args.max_bytes, not args.disable_size_guard)
report = inspect_jpeg(data)
report.update(
{
"path": str(path),
"fps": args.fps,
"budget_bytes_per_sec": args.budget_bytes_per_sec,
"max_bytes": cap,
"over_budget": len(data) > cap,
}
)
print(json.dumps(report, indent=2, sort_keys=True))
return 1 if report["over_budget"] or not report["complete"] else 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -10,7 +10,7 @@ bench = false
[package] [package]
name = "lesavka_server" name = "lesavka_server"
version = "0.22.29" version = "0.22.30"
edition = "2024" edition = "2024"
autobins = false autobins = false

View File

@ -6,7 +6,7 @@ use std::fs::{File, OpenOptions};
use std::os::unix::fs::OpenOptionsExt; use std::os::unix::fs::OpenOptionsExt;
use std::os::unix::io::{AsRawFd, RawFd}; use std::os::unix::io::{AsRawFd, RawFd};
use std::thread; use std::thread;
use std::time::{Duration, SystemTime}; use std::time::{Duration, Instant, SystemTime};
const STREAM_CTRL_SIZE_11: usize = 26; const STREAM_CTRL_SIZE_11: usize = 26;
const STREAM_CTRL_SIZE_15: usize = 34; const STREAM_CTRL_SIZE_15: usize = 34;
@ -52,6 +52,7 @@ const DEFAULT_UVC_BUFFER_COUNT: u32 = 2;
const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2; const DEFAULT_UVC_IDLE_PUMP_MS: u64 = 2;
const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000; const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000;
const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000; const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000;
const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000;
#[repr(C)] #[repr(C)]
struct V4l2EventSubscription { struct V4l2EventSubscription {
@ -243,6 +244,19 @@ struct UvcVideoStream {
latest_frame: Vec<u8>, latest_frame: Vec<u8>,
frame_max_bytes: usize, frame_max_bytes: usize,
streaming: bool, streaming: bool,
stats: UvcVideoStats,
}
#[derive(Default)]
struct UvcVideoStats {
queued: u64,
reloaded: u64,
replayed_stale: u64,
rejected_oversize: u64,
rejected_invalid: u64,
fallback_idle: u64,
latest_bytes: usize,
last_report: Option<Instant>,
} }
impl UvcVideoStream { impl UvcVideoStream {
@ -254,6 +268,7 @@ impl UvcVideoStream {
latest_frame: IDLE_MJPEG_FRAME.to_vec(), latest_frame: IDLE_MJPEG_FRAME.to_vec(),
frame_max_bytes: MAX_MJPEG_FRAME_BYTES, frame_max_bytes: MAX_MJPEG_FRAME_BYTES,
streaming: false, streaming: false,
stats: UvcVideoStats::default(),
} }
} }
@ -431,21 +446,34 @@ impl UvcVideoStream {
if rc < 0 { if rc < 0 {
return Err(std::io::Error::last_os_error()).context("VIDIOC_QBUF"); return Err(std::io::Error::last_os_error()).context("VIDIOC_QBUF");
} }
self.stats.queued += 1;
self.stats.latest_bytes = bytes;
self.report_stats_if_due();
Ok(()) Ok(())
} }
fn refresh_latest_frame(&mut self) { fn refresh_latest_frame(&mut self) {
let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age()); let stale = frame_spool_is_stale(&self.frame_path, frame_spool_max_age());
if stale && looks_like_mjpeg_frame(&self.latest_frame) { if stale && looks_like_mjpeg_frame(&self.latest_frame) {
self.stats.replayed_stale += 1;
return; return;
} }
let max_frame_bytes = self.frame_payload_limit(); let max_frame_bytes = self.frame_payload_limit();
if let Ok(frame) = std::fs::read(&self.frame_path) match std::fs::read(&self.frame_path) {
&& frame.len() <= max_frame_bytes Ok(frame) if !looks_like_mjpeg_frame(&frame) => {
&& looks_like_mjpeg_frame(&frame) self.stats.rejected_invalid += 1;
{ }
self.latest_frame = frame; Ok(frame) if frame.len() > max_frame_bytes => {
} else if !looks_like_mjpeg_frame(&self.latest_frame) { self.stats.rejected_oversize += 1;
}
Ok(frame) => {
self.stats.reloaded += 1;
self.latest_frame = frame;
}
Err(_) => {}
}
if !looks_like_mjpeg_frame(&self.latest_frame) {
self.stats.fallback_idle += 1;
self.latest_frame = IDLE_MJPEG_FRAME.to_vec(); self.latest_frame = IDLE_MJPEG_FRAME.to_vec();
} }
} }
@ -468,6 +496,32 @@ impl UvcVideoStream {
MINIMAL_MJPEG_FRAME MINIMAL_MJPEG_FRAME
} }
} }
fn report_stats_if_due(&mut self) {
let Some(interval) = uvc_stats_interval() else {
return;
};
let now = Instant::now();
if self
.stats
.last_report
.is_some_and(|last| now.duration_since(last) < interval)
{
return;
}
self.stats.last_report = Some(now);
eprintln!(
"[lesavka-uvc] video stats queued={} reloaded={} stale_replay={} rejected_oversize={} rejected_invalid={} fallback_idle={} latest_bytes={} frame_cap={}",
self.stats.queued,
self.stats.reloaded,
self.stats.replayed_stale,
self.stats.rejected_oversize,
self.stats.rejected_invalid,
self.stats.fallback_idle,
self.stats.latest_bytes,
self.frame_payload_limit()
);
}
} }
impl Drop for UvcVideoStream { impl Drop for UvcVideoStream {
@ -536,14 +590,21 @@ fn uvc_idle_pump_sleep() -> Duration {
/// half-frame grey smears, and freezing the last good frame is preferable to /// half-frame grey smears, and freezing the last good frame is preferable to
/// queueing a frame that is likely to arrive incomplete. /// queueing a frame that is likely to arrive incomplete.
fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize { fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
if !uvc_frame_size_guard_enabled() {
return MAX_MJPEG_FRAME_BYTES;
}
if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") { if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") {
return if limit == 0 { return if limit == 0 {
MAX_MJPEG_FRAME_BYTES derived_uvc_frame_max_bytes(cfg)
} else { } else {
limit as usize (limit as usize).min(MAX_MJPEG_FRAME_BYTES)
}; };
} }
derived_uvc_frame_max_bytes(cfg)
}
fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
let fps = cfg.fps.max(1); let fps = cfg.fps.max(1);
let budget_per_sec = env_u32( let budget_per_sec = env_u32(
"LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", "LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC",
@ -554,6 +615,26 @@ fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize
} }
fn uvc_frame_size_guard_enabled() -> bool {
env::var("LESAVKA_UVC_FRAME_SIZE_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)
}
fn uvc_stats_interval() -> Option<Duration> {
match env_u64("LESAVKA_UVC_STATS_INTERVAL_MS", DEFAULT_UVC_STATS_INTERVAL_MS) {
0 => None,
value => Some(Duration::from_millis(value)),
}
}
fn frame_spool_max_age() -> Option<Duration> { fn frame_spool_max_age() -> Option<Duration> {
match env_u64( match env_u64(
"LESAVKA_UVC_FRAME_MAX_AGE_MS", "LESAVKA_UVC_FRAME_MAX_AGE_MS",

View File

@ -56,6 +56,8 @@ const DEFAULT_UVC_FRAME_MAX_AGE_MS: u64 = 1_000;
#[cfg(coverage)] #[cfg(coverage)]
const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000; const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000;
#[cfg(coverage)] #[cfg(coverage)]
const DEFAULT_UVC_STATS_INTERVAL_MS: u64 = 5_000;
#[cfg(coverage)]
const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024; const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024;
#[cfg(coverage)] #[cfg(coverage)]
const MINIMAL_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9]; const MINIMAL_MJPEG_FRAME: &[u8] = &[0xff, 0xd8, 0xff, 0xd9];

View File

@ -159,14 +159,22 @@ fn uvc_idle_pump_sleep() -> std::time::Duration {
/// byte length. Why: coverage tests should lock the artifact-prevention budget /// byte length. Why: coverage tests should lock the artifact-prevention budget
/// that turns oversized UVC frames into freezes instead of grey half-frames. /// that turns oversized UVC frames into freezes instead of grey half-frames.
fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize { fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
if !uvc_frame_size_guard_enabled() {
return MAX_MJPEG_FRAME_BYTES;
}
if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") { if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES") {
return if limit == 0 { return if limit == 0 {
MAX_MJPEG_FRAME_BYTES derived_uvc_frame_max_bytes(cfg)
} else { } else {
limit as usize (limit as usize).min(MAX_MJPEG_FRAME_BYTES)
}; };
} }
derived_uvc_frame_max_bytes(cfg)
}
#[cfg(coverage)]
fn derived_uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
let fps = cfg.fps.max(1); let fps = cfg.fps.max(1);
let budget_per_sec = env_u32( let budget_per_sec = env_u32(
"LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", "LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC",
@ -177,6 +185,28 @@ fn uvc_frame_max_bytes(cfg: UvcConfig) -> usize {
per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize
} }
#[cfg(coverage)]
fn uvc_frame_size_guard_enabled() -> bool {
env::var("LESAVKA_UVC_FRAME_SIZE_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)
}
#[cfg(coverage)]
fn uvc_stats_interval() -> Option<std::time::Duration> {
match env_u64("LESAVKA_UVC_STATS_INTERVAL_MS", DEFAULT_UVC_STATS_INTERVAL_MS) {
0 => None,
value => Some(std::time::Duration::from_millis(value)),
}
}
#[cfg(coverage)] #[cfg(coverage)]
/// Returns the optional maximum age for a spooled MJPEG frame. /// Returns the optional maximum age for a spooled MJPEG frame.
/// ///

View File

@ -73,6 +73,7 @@ fn io_helpers_cover_empty_and_missing_sources() {
fn uvc_frame_max_bytes_defaults_to_freshness_budget_and_allows_override() { fn uvc_frame_max_bytes_defaults_to_freshness_budget_and_allows_override() {
with_vars( with_vars(
[ [
("LESAVKA_UVC_FRAME_SIZE_GUARD", None::<&str>),
("LESAVKA_UVC_FRAME_MAX_BYTES", None::<&str>), ("LESAVKA_UVC_FRAME_MAX_BYTES", None::<&str>),
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", None::<&str>), ("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", None::<&str>),
], ],
@ -84,6 +85,7 @@ fn uvc_frame_max_bytes_defaults_to_freshness_budget_and_allows_override() {
with_vars( with_vars(
[ [
("LESAVKA_UVC_FRAME_SIZE_GUARD", Some("1")),
("LESAVKA_UVC_FRAME_MAX_BYTES", Some("123456")), ("LESAVKA_UVC_FRAME_MAX_BYTES", Some("123456")),
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")), ("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")),
], ],
@ -93,7 +95,21 @@ fn uvc_frame_max_bytes_defaults_to_freshness_budget_and_allows_override() {
); );
with_var("LESAVKA_UVC_FRAME_MAX_BYTES", Some("0"), || { with_var("LESAVKA_UVC_FRAME_MAX_BYTES", Some("0"), || {
assert_eq!(uvc_frame_max_bytes(sample_cfg()), MAX_MJPEG_FRAME_BYTES); assert_eq!(uvc_frame_max_bytes(sample_cfg()), 400_000);
});
with_vars(
[
("LESAVKA_UVC_FRAME_SIZE_GUARD", Some("0")),
("LESAVKA_UVC_FRAME_MAX_BYTES", Some("123456")),
],
|| {
assert_eq!(uvc_frame_max_bytes(sample_cfg()), MAX_MJPEG_FRAME_BYTES);
},
);
with_var("LESAVKA_UVC_STATS_INTERVAL_MS", Some("0"), || {
assert_eq!(uvc_stats_interval(), None);
}); });
} }

View File

@ -7,6 +7,39 @@ const DEFAULT_HEVC_MIN_PAYLOAD_DISTINCT_BYTES: u32 = 12;
const DEFAULT_HEVC_DOMINANT_BYTE_PCT: u32 = 92; const DEFAULT_HEVC_DOMINANT_BYTE_PCT: u32 = 92;
const DEFAULT_DIRECT_MJPEG_SIZE_DROP_PCT: u32 = 18; const DEFAULT_DIRECT_MJPEG_SIZE_DROP_PCT: u32 = 18;
const DEFAULT_DIRECT_MJPEG_MIN_REFERENCE_BYTES: u32 = 48 * 1024; const DEFAULT_DIRECT_MJPEG_MIN_REFERENCE_BYTES: u32 = 48 * 1024;
const DEFAULT_DIRECT_MJPEG_PROFILE_MISMATCH_REJECT: bool = false;
/// Summarizes one compressed MJPEG frame without fully decoding pixels.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(super) struct MjpegFrameInspection {
pub bytes: usize,
pub complete: bool,
pub width: Option<u16>,
pub height: Option<u16>,
pub entropy_bytes: usize,
pub entropy_distinct_bytes: u16,
pub entropy_dominant_pct: u8,
pub entropy_max_run: usize,
}
/// 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,
SizeCollapse {
threshold_bytes: u64,
},
}
/// Resolve the JPEG quality used after HEVC decode. /// Resolve the JPEG quality used after HEVC decode.
/// ///
@ -133,6 +166,25 @@ pub(super) fn direct_mjpeg_min_reference_bytes() -> u32 {
.max(1) .max(1)
} }
/// Decide whether direct MJPEG frames must match the active UVC dimensions.
///
/// Inputs: optional `LESAVKA_UVC_DIRECT_MJPEG_REJECT_PROFILE_MISMATCH`.
/// Output: false unless explicitly enabled. Why: current field debugging needs
/// profile-mismatch telemetry first; rejecting mismatches by default could
/// accidentally freeze every frame on an attached gadget with stale descriptors.
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("1")
|| trimmed.eq_ignore_ascii_case("true")
|| trimmed.eq_ignore_ascii_case("yes")
|| trimmed.eq_ignore_ascii_case("on")
})
.unwrap_or(DEFAULT_DIRECT_MJPEG_PROFILE_MISMATCH_REJECT)
}
/// Return whether a decoded buffer looks like one complete JPEG image. /// Return whether a decoded buffer looks like one complete JPEG image.
/// ///
/// Inputs: decoded MJPEG bytes. Output: true when SOI, SOS, and EOI markers /// Inputs: decoded MJPEG bytes. Output: true when SOI, SOS, and EOI markers
@ -145,6 +197,149 @@ fn looks_like_complete_jpeg(bytes: &[u8]) -> bool {
&& bytes.windows(2).any(|pair| pair == [0xff, 0xda]) && bytes.windows(2).any(|pair| pair == [0xff, 0xda])
} }
fn marker_has_length(marker: u8) -> bool {
!matches!(marker, 0x01 | 0xd0..=0xd9)
}
fn is_start_of_frame_marker(marker: u8) -> bool {
matches!(
marker,
0xc0 | 0xc1 | 0xc2 | 0xc3 | 0xc5 | 0xc6 | 0xc7 | 0xc9 | 0xca | 0xcb | 0xcd | 0xce
| 0xcf
)
}
fn read_be_u16(bytes: &[u8], offset: usize) -> Option<u16> {
bytes
.get(offset..offset + 2)
.map(|value| u16::from_be_bytes([value[0], value[1]]))
}
fn jpeg_entropy_range(bytes: &[u8]) -> Option<std::ops::Range<usize>> {
let mut idx = 2;
while idx + 4 <= bytes.len() {
if bytes[idx] != 0xff {
idx += 1;
continue;
}
while idx < bytes.len() && bytes[idx] == 0xff {
idx += 1;
}
let marker = *bytes.get(idx)?;
idx += 1;
if marker == 0xd9 {
return None;
}
if !marker_has_length(marker) {
continue;
}
let segment_len = usize::from(read_be_u16(bytes, idx)?);
if marker == 0xda && (segment_len < 2 || idx + segment_len > bytes.len()) {
let start = idx.min(bytes.len());
let end = bytes.len().saturating_sub(2);
return (end > start).then_some(start..end);
}
if segment_len < 2 || idx + segment_len > bytes.len() {
return None;
}
if marker == 0xda {
let start = idx + segment_len;
let end = bytes.len().saturating_sub(2);
return (end > start).then_some(start..end);
}
idx += segment_len;
}
None
}
/// Inspect one MJPEG payload without full pixel decode.
///
/// Inputs: compressed JPEG bytes. Output: dimensions and entropy-shape metrics.
/// Why: these metrics make the guard explainable and cheap enough to run on
/// every UVC-bound frame, while still catching black slabs, truncation, and
/// profile mismatches that byte-size checks alone cannot distinguish.
pub(super) fn inspect_mjpeg_frame(bytes: &[u8]) -> MjpegFrameInspection {
let complete = looks_like_complete_jpeg(bytes);
let mut width = None;
let mut height = None;
let mut idx = 2;
while idx + 4 <= bytes.len() {
if bytes[idx] != 0xff {
idx += 1;
continue;
}
while idx < bytes.len() && bytes[idx] == 0xff {
idx += 1;
}
let Some(marker) = bytes.get(idx).copied() else {
break;
};
idx += 1;
if marker == 0xda || marker == 0xd9 {
break;
}
if !marker_has_length(marker) {
continue;
}
let Some(segment_len) = read_be_u16(bytes, idx).map(usize::from) else {
break;
};
if segment_len < 2 || idx + segment_len > bytes.len() {
break;
}
if is_start_of_frame_marker(marker)
&& segment_len >= 7
&& let (Some(frame_height), Some(frame_width)) =
(read_be_u16(bytes, idx + 3), read_be_u16(bytes, idx + 5))
{
height = Some(frame_height);
width = Some(frame_width);
break;
}
idx += segment_len;
}
let mut inspection = MjpegFrameInspection {
bytes: bytes.len(),
complete,
width,
height,
..MjpegFrameInspection::default()
};
let Some(range) = jpeg_entropy_range(bytes) else {
return inspection;
};
let payload = &bytes[range];
if payload.is_empty() {
return inspection;
}
let mut counts = [0u32; 256];
let mut max_run = 1usize;
let mut current_run = 0usize;
let mut previous = None;
for byte in payload {
counts[*byte as usize] += 1;
if previous == Some(*byte) {
current_run += 1;
} else {
current_run = 1;
previous = Some(*byte);
}
max_run = max_run.max(current_run);
}
let distinct = counts.iter().filter(|count| **count > 0).count() as u16;
let dominant = counts.iter().copied().max().unwrap_or(0) as u64;
let total = payload.len() as u64;
inspection.entropy_bytes = payload.len();
inspection.entropy_distinct_bytes = distinct;
inspection.entropy_dominant_pct =
((dominant.saturating_mul(100) + total.saturating_sub(1)) / total) as u8;
inspection.entropy_max_run = max_run;
inspection
}
/// Return whether one complete JPEG has an implausibly flat payload. /// Return whether one complete JPEG has an implausibly flat payload.
/// ///
/// Inputs: decoded MJPEG bytes. Output: true for dominant-byte or very low /// Inputs: decoded MJPEG bytes. Output: true for dominant-byte or very low
@ -154,29 +349,13 @@ fn suspiciously_flat_payload(bytes: &[u8]) -> bool {
if bytes.len() < min_reference_bytes() as usize / 4 { if bytes.len() < min_reference_bytes() as usize / 4 {
return false; return false;
} }
let inspection = inspect_mjpeg_frame(bytes);
let start = bytes if inspection.entropy_bytes < 512 {
.windows(2)
.position(|pair| pair == [0xff, 0xda])
.map(|idx| (idx + 2).min(bytes.len()))
.unwrap_or_else(|| bytes.len().min(256));
let end = bytes.len().saturating_sub(2);
if end <= start || end - start < 512 {
return false; return false;
} }
let payload = &bytes[start..end]; u32::from(inspection.entropy_distinct_bytes) < min_payload_distinct_bytes()
let mut counts = [0u32; 256]; || u32::from(inspection.entropy_dominant_pct) >= dominant_byte_pct()
for byte in payload {
counts[*byte as usize] += 1;
}
let distinct = counts.iter().filter(|count| **count > 0).count() as u32;
let dominant = counts.iter().copied().max().unwrap_or(0) as u64;
let total = payload.len() as u64;
distinct < min_payload_distinct_bytes()
|| dominant.saturating_mul(100) >= total.saturating_mul(u64::from(dominant_byte_pct()))
} }
/// Decide whether a decoded HEVC-to-MJPEG frame should be frozen out. /// Decide whether a decoded HEVC-to-MJPEG frame should be frozen out.
@ -218,19 +397,59 @@ pub(super) fn should_freeze_decoded_mjpeg_frame(previous_bytes: u64, decoded_mjp
/// implausibly flat, or a dramatic size collapse. Why: direct MJPEG should be /// implausibly flat, or a dramatic size collapse. Why: direct MJPEG should be
/// less aggressive than decoded HEVC filtering, but complete black/collapsed /// less aggressive than decoded HEVC filtering, but complete black/collapsed
/// frames are still worse than a short last-good-frame freeze. /// 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 { 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) { if !looks_like_complete_jpeg(mjpeg) {
return true; 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() if !direct_mjpeg_visual_guard_enabled()
|| previous_bytes < u64::from(direct_mjpeg_min_reference_bytes()) || previous_bytes < u64::from(direct_mjpeg_min_reference_bytes())
{ {
return false; return None;
} }
let threshold_bytes = previous_bytes.saturating_mul(u64::from(direct_mjpeg_size_drop_pct())) let threshold_bytes = previous_bytes.saturating_mul(u64::from(direct_mjpeg_size_drop_pct()))
/ 100; / 100;
suspiciously_flat_payload(mjpeg) || (mjpeg.len() as u64) < threshold_bytes if suspiciously_flat_payload(mjpeg) {
return Some(DirectMjpegRejectReason::FlatPayload);
}
if (mjpeg.len() as u64) < threshold_bytes {
return Some(DirectMjpegRejectReason::SizeCollapse { threshold_bytes });
}
None
} }
#[cfg(test)] #[cfg(test)]
@ -367,6 +586,60 @@ mod tests {
); );
} }
#[test]
fn direct_mjpeg_guard_reports_oversize_and_profile_mismatch_when_configured() {
fn jpeg_with_sof(width: u16, height: u16, payload: &[u8]) -> Vec<u8> {
let mut bytes = vec![
0xff, 0xd8, // SOI
0xff, 0xc0, 0x00, 0x11, 0x08, // SOF0 len + precision
(height >> 8) as u8,
height as u8,
(width >> 8) as u8,
width as u8,
0x03, 0x01, 0x11, 0x00, 0x02, 0x11, 0x00, 0x03, 0x11, 0x00,
0xff, 0xda, 0x00, 0x08, 0x01, 0x01, 0x00, 0x00, 0x3f, 0x00,
];
bytes.extend_from_slice(payload);
bytes.extend_from_slice(&[0xff, 0xd9]);
bytes
}
let healthy_payload: Vec<u8> = (0..8_000).map(|idx| (idx % 251) as u8).collect();
let frame = jpeg_with_sof(1920, 1080, &healthy_payload);
let inspection = super::inspect_mjpeg_frame(&frame);
assert_eq!(inspection.width, Some(1920));
assert_eq!(inspection.height, Some(1080));
assert!(inspection.entropy_distinct_bytes > 64);
temp_env::with_var(
"LESAVKA_UVC_DIRECT_MJPEG_REJECT_PROFILE_MISMATCH",
Some("1"),
|| {
assert_eq!(
super::direct_mjpeg_reject_reason(
0,
Some(frame.len() + 1),
Some((1280, 720)),
&frame,
),
Some(super::DirectMjpegRejectReason::ProfileMismatch {
expected_width: 1280,
expected_height: 720,
actual_width: 1920,
actual_height: 1080,
})
);
},
);
assert_eq!(
super::direct_mjpeg_reject_reason(0, Some(frame.len() - 1), Some((1920, 1080)), &frame),
Some(super::DirectMjpegRejectReason::Oversized {
max_bytes: frame.len() - 1,
})
);
}
#[test] #[test]
fn direct_mjpeg_visual_guard_can_be_disabled_without_allowing_truncation() { fn direct_mjpeg_visual_guard_can_be_disabled_without_allowing_truncation() {
let mut complete = vec![0xff, 0xd8, 0xff, 0xda]; let mut complete = vec![0xff, 0xd8, 0xff, 0xda];

View File

@ -9,6 +9,8 @@ use gstreamer_app as gst_app;
static SPOOL_SEQUENCE: AtomicU64 = AtomicU64::new(1); static SPOOL_SEQUENCE: AtomicU64 = AtomicU64::new(1);
static SPOOL_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1); static SPOOL_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024;
const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000;
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
pub(super) struct MjpegSpoolTiming { pub(super) struct MjpegSpoolTiming {
@ -75,6 +77,61 @@ pub(super) fn mjpeg_spool_path() -> PathBuf {
.unwrap_or_else(|_| PathBuf::from("/run/lesavka-uvc-frame.mjpg")) .unwrap_or_else(|_| PathBuf::from("/run/lesavka-uvc-frame.mjpg"))
} }
fn env_u32_opt(name: &str) -> Option<u32> {
std::env::var(name)
.ok()
.and_then(|value| value.trim().parse::<u32>().ok())
}
fn env_flag_enabled(name: &str, default: bool) -> bool {
std::env::var(name)
.ok()
.map(|value| {
let trimmed = value.trim();
if trimmed.eq_ignore_ascii_case("0")
|| trimmed.eq_ignore_ascii_case("false")
|| trimmed.eq_ignore_ascii_case("no")
|| trimmed.eq_ignore_ascii_case("off")
{
false
} else if trimmed.eq_ignore_ascii_case("1")
|| trimmed.eq_ignore_ascii_case("true")
|| trimmed.eq_ignore_ascii_case("yes")
|| trimmed.eq_ignore_ascii_case("on")
{
true
} else {
default
}
})
.unwrap_or(default)
}
/// Resolve the MJPEG byte budget used before publishing to the helper.
///
/// Inputs: active FPS plus `LESAVKA_UVC_FRAME_MAX_BYTES`,
/// `LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC`, and
/// `LESAVKA_UVC_FRAME_SIZE_GUARD`. Output: maximum accepted frame bytes.
/// Why: oversized MJPEG frames are a common source of host-visible UVC tearing;
/// a short freeze is better than letting the USB gadget emit partial pictures.
pub(super) fn mjpeg_spool_frame_max_bytes(fps: u32) -> usize {
if !env_flag_enabled("LESAVKA_UVC_FRAME_SIZE_GUARD", true) {
return MAX_MJPEG_FRAME_BYTES;
}
if let Some(limit) = env_u32_opt("LESAVKA_UVC_FRAME_MAX_BYTES")
&& limit > 0
{
return (limit as usize).min(MAX_MJPEG_FRAME_BYTES);
}
let fps = fps.max(1);
let budget_per_sec = env_u32_opt("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC")
.unwrap_or(DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC)
.max(1);
let per_frame = (budget_per_sec / fps).max(64 * 1024);
per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize
}
/// Decide whether frame spool metadata should be published. /// Decide whether frame spool metadata should be published.
/// ///
/// Inputs: `LESAVKA_UVC_FRAME_META`. Output: false unless explicitly enabled. /// Inputs: `LESAVKA_UVC_FRAME_META`. Output: false unless explicitly enabled.
@ -288,6 +345,59 @@ mod tests {
}); });
} }
#[test]
fn mjpeg_spool_frame_budget_uses_live_budget_when_zero_or_unset() {
temp_env::with_vars(
[
("LESAVKA_UVC_FRAME_SIZE_GUARD", None::<&str>),
("LESAVKA_UVC_FRAME_MAX_BYTES", None::<&str>),
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", None::<&str>),
],
|| {
assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 333_333);
},
);
temp_env::with_vars(
[
("LESAVKA_UVC_FRAME_SIZE_GUARD", Some("1")),
("LESAVKA_UVC_FRAME_MAX_BYTES", Some("0")),
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("9")),
],
|| {
assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 65_536);
},
);
}
#[test]
fn mjpeg_spool_frame_budget_allows_explicit_limit_or_diagnostic_disable() {
temp_env::with_vars(
[
("LESAVKA_UVC_FRAME_SIZE_GUARD", Some("1")),
("LESAVKA_UVC_FRAME_MAX_BYTES", Some("123456")),
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")),
],
|| {
assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 123_456);
},
);
temp_env::with_vars(
[
("LESAVKA_UVC_FRAME_SIZE_GUARD", Some("0")),
("LESAVKA_UVC_FRAME_MAX_BYTES", Some("123456")),
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("1")),
],
|| {
assert_eq!(
super::mjpeg_spool_frame_max_bytes(30),
super::MAX_MJPEG_FRAME_BYTES
);
},
);
}
/// Verifies spool metadata remains opt-in and path-configurable. /// Verifies spool metadata remains opt-in and path-configurable.
/// ///
/// Input: default and explicit metadata env vars. Output: disabled by /// Input: default and explicit metadata env vars. Output: disabled by

View File

@ -23,7 +23,10 @@ mod mjpeg_spool;
#[cfg(not(coverage))] #[cfg(not(coverage))]
use gst::MessageView::{Error, StateChanged, Warning}; use gst::MessageView::{Error, StateChanged, Warning};
#[cfg(not(coverage))] #[cfg(not(coverage))]
use mjpeg_spool::{freshest_mjpeg_sample, spool_mjpeg_frame_with_timing, MjpegSpoolTiming}; use mjpeg_spool::{
MjpegSpoolTiming, freshest_mjpeg_sample, mjpeg_spool_frame_max_bytes,
spool_mjpeg_frame_with_timing,
};
use mjpeg_spool::{mjpeg_spool_enabled, mjpeg_spool_path}; use mjpeg_spool::{mjpeg_spool_enabled, mjpeg_spool_path};
/// Push H.264 or MJPEG frames into the USB UVC gadget. /// Push H.264 or MJPEG frames into the USB UVC gadget.
@ -42,6 +45,10 @@ pub struct WebcamSink {
hevc_mjpeg_appsrc: Option<gst_app::AppSrc>, hevc_mjpeg_appsrc: Option<gst_app::AppSrc>,
decoded_mjpeg_sink: Option<gst_app::AppSink>, decoded_mjpeg_sink: Option<gst_app::AppSink>,
last_mjpeg_passthrough_bytes: AtomicU64, last_mjpeg_passthrough_bytes: AtomicU64,
direct_mjpeg_max_bytes: usize,
uvc_width: u16,
uvc_height: u16,
direct_mjpeg_profile_mismatch_seen: AtomicBool,
last_decoded_mjpeg_bytes: AtomicU64, last_decoded_mjpeg_bytes: AtomicU64,
decoded_mjpeg_miss_count: AtomicU64, decoded_mjpeg_miss_count: AtomicU64,
decode_recovery_needs_irap: AtomicBool, decode_recovery_needs_irap: AtomicBool,
@ -377,6 +384,10 @@ impl WebcamSink {
hevc_mjpeg_appsrc: None, hevc_mjpeg_appsrc: None,
decoded_mjpeg_sink: None, decoded_mjpeg_sink: None,
last_mjpeg_passthrough_bytes: AtomicU64::new(0), last_mjpeg_passthrough_bytes: AtomicU64::new(0),
direct_mjpeg_max_bytes: mjpeg_spool::mjpeg_spool_frame_max_bytes(cfg.fps),
uvc_width: cfg.width.min(u32::from(u16::MAX)) as u16,
uvc_height: cfg.height.min(u32::from(u16::MAX)) as u16,
direct_mjpeg_profile_mismatch_seen: AtomicBool::new(false),
last_decoded_mjpeg_bytes: AtomicU64::new(0), last_decoded_mjpeg_bytes: AtomicU64::new(0),
decoded_mjpeg_miss_count: AtomicU64::new(0), decoded_mjpeg_miss_count: AtomicU64::new(0),
decode_recovery_needs_irap: AtomicBool::new(false), decode_recovery_needs_irap: AtomicBool::new(false),
@ -650,6 +661,10 @@ impl WebcamSink {
hevc_mjpeg_appsrc, hevc_mjpeg_appsrc,
decoded_mjpeg_sink, decoded_mjpeg_sink,
last_mjpeg_passthrough_bytes: AtomicU64::new(0), last_mjpeg_passthrough_bytes: AtomicU64::new(0),
direct_mjpeg_max_bytes: mjpeg_spool_frame_max_bytes(cfg.fps),
uvc_width: cfg.width.min(u32::from(u16::MAX)) as u16,
uvc_height: cfg.height.min(u32::from(u16::MAX)) as u16,
direct_mjpeg_profile_mismatch_seen: AtomicBool::new(false),
last_decoded_mjpeg_bytes: AtomicU64::new(0), last_decoded_mjpeg_bytes: AtomicU64::new(0),
decoded_mjpeg_miss_count: AtomicU64::new(0), decoded_mjpeg_miss_count: AtomicU64::new(0),
decode_recovery_needs_irap: AtomicBool::new(false), decode_recovery_needs_irap: AtomicBool::new(false),
@ -787,11 +802,40 @@ impl WebcamSink {
let previous_bytes = self let previous_bytes = self
.last_mjpeg_passthrough_bytes .last_mjpeg_passthrough_bytes
.load(std::sync::atomic::Ordering::Relaxed); .load(std::sync::atomic::Ordering::Relaxed);
if hevc_mjpeg_guard::should_reject_direct_mjpeg_frame(previous_bytes, &pkt.data) { let inspection = hevc_mjpeg_guard::inspect_mjpeg_frame(&pkt.data);
if let (Some(width), Some(height)) = (inspection.width, inspection.height)
&& (width, height) != (self.uvc_width, self.uvc_height)
&& !self
.direct_mjpeg_profile_mismatch_seen
.swap(true, std::sync::atomic::Ordering::Relaxed)
{
warn!( warn!(
target:"lesavka_server::video", target:"lesavka_server::video",
frame_width = width,
frame_height = height,
uvc_width = self.uvc_width,
uvc_height = self.uvc_height,
"📸⚠️ direct MJPEG frame dimensions differ from the live UVC profile; this can make browser output unstable"
);
}
if let Some(reason) = hevc_mjpeg_guard::direct_mjpeg_reject_reason(
previous_bytes,
Some(self.direct_mjpeg_max_bytes),
Some((self.uvc_width, self.uvc_height)),
&pkt.data,
) {
warn!(
target:"lesavka_server::video",
?reason,
previous_bytes, previous_bytes,
next_bytes = pkt.data.len(), next_bytes = pkt.data.len(),
max_bytes = self.direct_mjpeg_max_bytes,
frame_width = ?inspection.width,
frame_height = ?inspection.height,
entropy_bytes = inspection.entropy_bytes,
entropy_distinct_bytes = inspection.entropy_distinct_bytes,
entropy_dominant_pct = inspection.entropy_dominant_pct,
entropy_max_run = inspection.entropy_max_run,
"📸⚠️ freezing suspicious direct MJPEG frame before UVC spool" "📸⚠️ freezing suspicious direct MJPEG frame before UVC spool"
); );
return; return;

View File

@ -33,6 +33,14 @@ mod guard {
pub fn should_reject_direct_frame(previous_bytes: u64, mjpeg: &[u8]) -> bool { pub fn should_reject_direct_frame(previous_bytes: u64, mjpeg: &[u8]) -> bool {
should_reject_direct_mjpeg_frame(previous_bytes, mjpeg) should_reject_direct_mjpeg_frame(previous_bytes, mjpeg)
} }
pub fn should_reject_direct_frame_with_budget(
previous_bytes: u64,
max_bytes: usize,
mjpeg: &[u8],
) -> bool {
direct_mjpeg_reject_reason(previous_bytes, Some(max_bytes), None, mjpeg).is_some()
}
} }
const WEBCAM_SINK: &str = include_str!(concat!( const WEBCAM_SINK: &str = include_str!(concat!(
@ -115,7 +123,7 @@ fn server_hevc_recovery_and_freshest_spool_paths_remain_wired() {
"last_decoded_mjpeg_bytes", "last_decoded_mjpeg_bytes",
"last_mjpeg_passthrough_bytes", "last_mjpeg_passthrough_bytes",
"should_freeze_decoded_mjpeg_frame(previous_bytes, map.as_slice())", "should_freeze_decoded_mjpeg_frame(previous_bytes, map.as_slice())",
"should_reject_direct_mjpeg_frame(previous_bytes, &pkt.data)", "direct_mjpeg_reject_reason(",
"spool_direct_mjpeg_frame", "spool_direct_mjpeg_frame",
"freezing suspicious decoded HEVC->MJPEG frame", "freezing suspicious decoded HEVC->MJPEG frame",
"freezing suspicious direct MJPEG frame before UVC spool", "freezing suspicious direct MJPEG frame before UVC spool",
@ -159,6 +167,11 @@ fn direct_mjpeg_guard_is_conservative_but_filters_obvious_black_or_truncated_fra
|| { || {
assert!(!guard::should_reject_direct_frame(0, &flat)); assert!(!guard::should_reject_direct_frame(0, &flat));
assert!(!guard::should_reject_direct_frame(220_000, &healthy)); assert!(!guard::should_reject_direct_frame(220_000, &healthy));
assert!(guard::should_reject_direct_frame_with_budget(
220_000,
healthy.len() - 1,
&healthy
));
assert!(guard::should_reject_direct_frame(220_000, &flat)); assert!(guard::should_reject_direct_frame(220_000, &flat));
assert!(guard::should_reject_direct_frame(220_000, &tiny)); assert!(guard::should_reject_direct_frame(220_000, &tiny));
assert!(guard::should_reject_direct_frame(220_000, &truncated)); assert!(guard::should_reject_direct_frame(220_000, &truncated));

View File

@ -186,6 +186,8 @@ fn server_install_pins_hdmi_camera_and_display_defaults() {
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_WIDTH 1280")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_WIDTH 1280"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_HEIGHT 720")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_HEIGHT 720"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_CONTROL_READ_ONLY 0")); assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_CONTROL_READ_ONLY 0"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_FRAME_SIZE_GUARD 1"));
assert!(SERVER_INSTALL.contains("uvc_env_value LESAVKA_UVC_FRAME_MAX_BYTES 0"));
assert!( assert!(
!SERVER_INSTALL.contains("LESAVKA_UVC_CODEC=${LESAVKA_UVC_CODEC:-mjpeg}"), !SERVER_INSTALL.contains("LESAVKA_UVC_CODEC=${LESAVKA_UVC_CODEC:-mjpeg}"),
"install script should not let ambient LESAVKA_UVC_CODEC leak into persisted defaults" "install script should not let ambient LESAVKA_UVC_CODEC leak into persisted defaults"