2026-05-09 11:34:13 -03:00
|
|
|
use std::fs::{self, OpenOptions};
|
|
|
|
|
use std::io::Write;
|
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
|
|
|
|
|
|
use gstreamer as gst;
|
|
|
|
|
use gstreamer_app as gst_app;
|
|
|
|
|
|
|
|
|
|
static SPOOL_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
2026-05-13 17:54:14 -03:00
|
|
|
static SPOOL_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
2026-05-14 05:18:36 -03:00
|
|
|
const MAX_MJPEG_FRAME_BYTES: usize = 8 * 1024 * 1024;
|
|
|
|
|
const DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC: u32 = 10_000_000;
|
2026-05-15 08:37:26 -03:00
|
|
|
const DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT: u32 = 85;
|
|
|
|
|
const HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC: u32 = 8_000;
|
|
|
|
|
const CONFIGFS_UVC_BASE: &str = "/sys/kernel/config/usb_gadget/lesavka/functions/uvc.usb0";
|
2026-05-09 11:34:13 -03:00
|
|
|
|
|
|
|
|
#[derive(Clone, Copy)]
|
|
|
|
|
pub(super) struct MjpegSpoolTiming {
|
|
|
|
|
pub profile: &'static str,
|
|
|
|
|
pub source_pts_us: Option<u64>,
|
|
|
|
|
pub decoded_pts_us: Option<u64>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl MjpegSpoolTiming {
|
|
|
|
|
/// Build metadata for direct MJPEG ingress.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: the upstream packet PTS in microseconds. Output: timing metadata
|
|
|
|
|
/// labeled as passthrough MJPEG. Why: direct MJPEG and decoded HEVC share
|
|
|
|
|
/// the same spool file, so future diagnostics need to distinguish them.
|
|
|
|
|
pub(super) fn mjpeg_passthrough(source_pts_us: u64) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
profile: "mjpeg-passthrough",
|
|
|
|
|
source_pts_us: Some(source_pts_us),
|
|
|
|
|
decoded_pts_us: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 02:53:49 -03:00
|
|
|
/// Build metadata for direct MJPEG after local decode/re-encode.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: the upstream packet PTS in microseconds. Output: timing metadata
|
|
|
|
|
/// labeled as normalized MJPEG. Why: browser-visible UVC corruption can
|
|
|
|
|
/// happen after a syntactically valid camera JPEG, so probes need to know
|
|
|
|
|
/// when the server has intentionally emitted a clean re-encoded frame.
|
|
|
|
|
pub(super) fn mjpeg_normalized(source_pts_us: u64) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
profile: "mjpeg-normalized",
|
|
|
|
|
source_pts_us: Some(source_pts_us),
|
|
|
|
|
decoded_pts_us: None,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 11:34:13 -03:00
|
|
|
/// Build metadata for decoded HEVC entering the MJPEG UVC helper.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: upstream packet PTS plus the decoded appsink buffer PTS.
|
|
|
|
|
/// Output: timing metadata labeled as HEVC-decoded MJPEG. Why: the
|
|
|
|
|
/// remaining HEVC sync jitter appears after transport, so we need a
|
|
|
|
|
/// low-overhead marker at the decode-to-UVC handoff boundary.
|
|
|
|
|
pub(super) fn hevc_decoded_mjpeg(source_pts_us: u64, decoded_pts_us: Option<u64>) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
profile: "hevc-decoded-mjpeg",
|
|
|
|
|
source_pts_us: Some(source_pts_us),
|
|
|
|
|
decoded_pts_us,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Decide whether the UVC helper file-spool path should own MJPEG emission.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: `LESAVKA_UVC_MJPEG_SPOOL`. Output: true unless explicitly disabled.
|
|
|
|
|
/// Why: the helper path prevents two processes from fighting over the UVC
|
|
|
|
|
/// gadget node, while preserving a direct `v4l2sink` fallback for diagnostics.
|
|
|
|
|
pub(super) fn mjpeg_spool_enabled() -> bool {
|
|
|
|
|
std::env::var("LESAVKA_UVC_MJPEG_SPOOL")
|
|
|
|
|
.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)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resolve the frame path consumed by the UVC helper.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: `LESAVKA_UVC_FRAME_PATH`. Output: filesystem path for the newest
|
|
|
|
|
/// MJPEG frame. Why: the helper polls a single atomic frame file, so both direct
|
|
|
|
|
/// MJPEG and decoded HEVC output need to agree on the handoff location.
|
|
|
|
|
pub(super) fn mjpeg_spool_path() -> PathBuf {
|
|
|
|
|
std::env::var("LESAVKA_UVC_FRAME_PATH")
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.unwrap_or_else(|_| PathBuf::from("/run/lesavka-uvc-frame.mjpg"))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 05:18:36 -03:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 08:37:26 -03:00
|
|
|
fn read_u32_file(path: impl AsRef<Path>) -> Option<u32> {
|
|
|
|
|
fs::read_to_string(path)
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|value| value.trim().parse::<u32>().ok())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn uvc_bulk_transfer_enabled() -> bool {
|
|
|
|
|
if !env_flag_enabled("LESAVKA_UVC_BULK", true) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
let base = Path::new(CONFIGFS_UVC_BASE);
|
2026-05-16 17:47:58 -03:00
|
|
|
!base.exists() || base.join("streaming_bulk").exists()
|
2026-05-15 08:37:26 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn uvc_streaming_maxpacket(bulk: bool) -> u32 {
|
|
|
|
|
let mut maxpacket = env_u32_opt("LESAVKA_UVC_MAXPACKET").unwrap_or(1024);
|
|
|
|
|
if let Some(live) = read_u32_file(Path::new(CONFIGFS_UVC_BASE).join("streaming_maxpacket")) {
|
|
|
|
|
maxpacket = maxpacket.min(live);
|
|
|
|
|
}
|
|
|
|
|
if bulk {
|
|
|
|
|
maxpacket.min(512)
|
|
|
|
|
} else {
|
|
|
|
|
maxpacket.min(1024)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn uvc_isochronous_budget_bytes_per_sec(maxpacket: u32) -> u32 {
|
|
|
|
|
let pct = env_u32_opt("LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT")
|
|
|
|
|
.unwrap_or(DEFAULT_UVC_ISOCHRONOUS_LIMIT_PCT)
|
|
|
|
|
.clamp(1, 100);
|
|
|
|
|
let bytes = u64::from(maxpacket)
|
|
|
|
|
.saturating_mul(u64::from(HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC))
|
|
|
|
|
.saturating_mul(u64::from(pct))
|
|
|
|
|
/ 100;
|
|
|
|
|
bytes.min(u64::from(u32::MAX)) as u32
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn effective_mjpeg_budget_bytes_per_sec() -> u32 {
|
|
|
|
|
let configured = env_u32_opt("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC")
|
|
|
|
|
.unwrap_or(DEFAULT_UVC_MJPEG_BUDGET_BYTES_PER_SEC)
|
|
|
|
|
.max(1);
|
|
|
|
|
if uvc_bulk_transfer_enabled() {
|
|
|
|
|
configured
|
|
|
|
|
} else {
|
|
|
|
|
configured
|
|
|
|
|
.min(uvc_isochronous_budget_bytes_per_sec(uvc_streaming_maxpacket(false)))
|
|
|
|
|
.max(1)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 05:18:36 -03:00
|
|
|
/// 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);
|
2026-05-15 08:37:26 -03:00
|
|
|
let budget_per_sec = effective_mjpeg_budget_bytes_per_sec();
|
2026-05-14 05:18:36 -03:00
|
|
|
let per_frame = (budget_per_sec / fps).max(64 * 1024);
|
|
|
|
|
per_frame.min(MAX_MJPEG_FRAME_BYTES as u32) as usize
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 11:34:13 -03:00
|
|
|
/// Decide whether frame spool metadata should be published.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: `LESAVKA_UVC_FRAME_META`. Output: false unless explicitly enabled.
|
|
|
|
|
/// Why: the metadata is useful for HEVC boundary diagnostics, but it adds one
|
|
|
|
|
/// extra atomic sidecar write per frame and should stay opt-in during calls.
|
|
|
|
|
pub(super) fn mjpeg_spool_metadata_enabled() -> bool {
|
|
|
|
|
std::env::var("LESAVKA_UVC_FRAME_META")
|
|
|
|
|
.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(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resolve the metadata sidecar path for the UVC helper spool.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: frame path plus `LESAVKA_UVC_FRAME_META_PATH`. Output: sidecar path.
|
|
|
|
|
/// Why: keeping this path explicit lets capture scripts fetch timing evidence
|
|
|
|
|
/// without guessing where the virtual webcam helper found the frame.
|
|
|
|
|
pub(super) fn mjpeg_spool_metadata_path(frame_path: &Path) -> PathBuf {
|
|
|
|
|
std::env::var("LESAVKA_UVC_FRAME_META_PATH")
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
.unwrap_or_else(|_| frame_path.with_extension("mjpg.meta.json"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Resolve the optional JSONL metadata log for full-probe diagnostics.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: `LESAVKA_UVC_FRAME_META_LOG_PATH`. Output: an append-only log path
|
|
|
|
|
/// when configured. Why: a latest-frame sidecar is enough for spot checks, but
|
|
|
|
|
/// client-to-RCT HEVC probes need the whole decode/spool timing sequence.
|
|
|
|
|
pub(super) fn mjpeg_spool_metadata_log_path() -> Option<PathBuf> {
|
|
|
|
|
std::env::var("LESAVKA_UVC_FRAME_META_LOG_PATH")
|
|
|
|
|
.ok()
|
|
|
|
|
.map(|value| value.trim().to_string())
|
|
|
|
|
.filter(|value| !value.is_empty())
|
|
|
|
|
.map(PathBuf::from)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Bound how long one HEVC handoff may wait for decoded MJPEG output.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: `LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS`, clamped to 0..=50ms.
|
|
|
|
|
/// Output: the timeout used by appsink polling.
|
|
|
|
|
/// Why: decoded frames should be published when they are due, but the video
|
|
|
|
|
/// handoff worker must not build a WAN-sized backlog while waiting on decode.
|
|
|
|
|
pub(super) fn decoded_mjpeg_pull_timeout() -> gst::ClockTime {
|
|
|
|
|
let timeout_ms = std::env::var("LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS")
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|value| value.trim().parse::<u64>().ok())
|
2026-05-12 16:11:53 -03:00
|
|
|
.unwrap_or(20)
|
2026-05-09 11:34:13 -03:00
|
|
|
.min(50);
|
|
|
|
|
gst::ClockTime::from_mseconds(timeout_ms)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Drain the decoded-MJPEG appsink down to its freshest sample.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: the appsink owned by the HEVC-to-MJPEG branch. Output: the newest
|
|
|
|
|
/// available sample, if any. Why: the UVC helper should see the latest decoded
|
|
|
|
|
/// frame rather than letting stale decode output accumulate during CPU spikes.
|
|
|
|
|
#[cfg(not(coverage))]
|
|
|
|
|
pub(super) fn freshest_mjpeg_sample(sink: &gst_app::AppSink) -> Option<gst::Sample> {
|
|
|
|
|
let mut newest = sink.try_pull_sample(decoded_mjpeg_pull_timeout());
|
|
|
|
|
while let Some(sample) = sink.try_pull_sample(gst::ClockTime::ZERO) {
|
|
|
|
|
newest = Some(sample);
|
|
|
|
|
}
|
|
|
|
|
newest
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn unix_now_ns() -> u128 {
|
|
|
|
|
SystemTime::now()
|
|
|
|
|
.duration_since(UNIX_EPOCH)
|
|
|
|
|
.map(|duration| duration.as_nanos())
|
|
|
|
|
.unwrap_or(0)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn json_number_or_null(value: Option<u64>) -> String {
|
|
|
|
|
value
|
|
|
|
|
.map(|value| value.to_string())
|
|
|
|
|
.unwrap_or_else(|| "null".to_string())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Atomically write a text sidecar beside the current frame.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: a destination path and complete text payload. Output: success or
|
|
|
|
|
/// filesystem error. Why: the latest-frame metadata sidecar should never be
|
|
|
|
|
/// observed half-written while RCT probe scripts are collecting artifacts.
|
|
|
|
|
fn write_atomic_text(path: &Path, data: &str) -> anyhow::Result<()> {
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
fs::create_dir_all(parent)?;
|
|
|
|
|
}
|
|
|
|
|
let tmp = path.with_extension(format!("json.{}.tmp", std::process::id()));
|
|
|
|
|
fs::write(&tmp, data)?;
|
|
|
|
|
fs::rename(&tmp, path)?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Append one timing record to the optional full-probe metadata log.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: a JSONL path and already formatted metadata record. Output: success
|
|
|
|
|
/// or filesystem error. Why: HEVC/RCT debugging needs every decoded-MJPEG
|
|
|
|
|
/// handoff timestamp, while the latest sidecar only preserves the newest frame.
|
|
|
|
|
fn append_metadata_log(path: &Path, record: &str) -> anyhow::Result<()> {
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
fs::create_dir_all(parent)?;
|
|
|
|
|
}
|
|
|
|
|
OpenOptions::new()
|
|
|
|
|
.create(true)
|
|
|
|
|
.append(true)
|
|
|
|
|
.open(path)?
|
|
|
|
|
.write_all(record.as_bytes())?;
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Render one metadata record for a spooled MJPEG frame.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: a sequence number, frame size, and timing labels. Output: compact
|
|
|
|
|
/// JSON suitable for sidecar artifacts. Why: keeping the format deterministic
|
|
|
|
|
/// makes later client-to-RCT scripts able to compare server decode/spool timing
|
|
|
|
|
/// against final RCT observations without parsing log prose.
|
|
|
|
|
pub(super) fn format_mjpeg_spool_metadata(
|
|
|
|
|
sequence: u64,
|
|
|
|
|
bytes: usize,
|
|
|
|
|
timing: MjpegSpoolTiming,
|
|
|
|
|
) -> String {
|
|
|
|
|
format!(
|
|
|
|
|
"{{\"schema\":\"lesavka.uvc-mjpeg-spool-meta.v1\",\"sequence\":{},\"profile\":\"{}\",\"bytes\":{},\"source_pts_us\":{},\"decoded_pts_us\":{},\"spool_unix_ns\":{}}}\n",
|
|
|
|
|
sequence,
|
|
|
|
|
timing.profile,
|
|
|
|
|
bytes,
|
|
|
|
|
json_number_or_null(timing.source_pts_us),
|
|
|
|
|
json_number_or_null(timing.decoded_pts_us),
|
|
|
|
|
unix_now_ns()
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Atomically publish one MJPEG frame plus optional timing metadata.
|
|
|
|
|
///
|
|
|
|
|
/// Inputs: destination path, JPEG bytes, and optional timing metadata. Output:
|
|
|
|
|
/// success or filesystem error. Why: HEVC transport debugging needs to know
|
|
|
|
|
/// whether residual jitter happens before or after the decoded-MJPEG handoff,
|
|
|
|
|
/// while the default runtime path should remain identical when metadata is off.
|
|
|
|
|
pub(super) fn spool_mjpeg_frame_with_timing(
|
|
|
|
|
path: &Path,
|
|
|
|
|
data: &[u8],
|
|
|
|
|
timing: Option<MjpegSpoolTiming>,
|
|
|
|
|
) -> anyhow::Result<()> {
|
|
|
|
|
if let Some(parent) = path.parent() {
|
|
|
|
|
fs::create_dir_all(parent)?;
|
|
|
|
|
}
|
2026-05-13 17:54:14 -03:00
|
|
|
let tmp = path.with_extension(format!(
|
|
|
|
|
"mjpg.{}.{}.tmp",
|
|
|
|
|
std::process::id(),
|
|
|
|
|
SPOOL_TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
|
|
|
|
));
|
2026-05-09 11:34:13 -03:00
|
|
|
fs::write(&tmp, data)?;
|
|
|
|
|
fs::rename(&tmp, path)?;
|
|
|
|
|
|
|
|
|
|
if mjpeg_spool_metadata_enabled()
|
|
|
|
|
&& let Some(timing) = timing
|
|
|
|
|
{
|
|
|
|
|
let sequence = SPOOL_SEQUENCE.fetch_add(1, Ordering::Relaxed);
|
|
|
|
|
let record = format_mjpeg_spool_metadata(sequence, data.len(), timing);
|
|
|
|
|
write_atomic_text(&mjpeg_spool_metadata_path(path), &record)?;
|
|
|
|
|
if let Some(log_path) = mjpeg_spool_metadata_log_path() {
|
|
|
|
|
append_metadata_log(&log_path, &record)?;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
/// Verifies HEVC decoded-frame polling defaults to a freshness-first wait.
|
|
|
|
|
///
|
2026-05-12 16:11:53 -03:00
|
|
|
/// Input: unset timeout env var. Output: 20ms appsink poll timeout. Why:
|
|
|
|
|
/// server-side hardware decode/JPEG encode often lands inside one 30fps
|
|
|
|
|
/// frame interval, and a 5ms poll was starving Meet-visible UVC output.
|
2026-05-09 11:34:13 -03:00
|
|
|
#[test]
|
|
|
|
|
fn decoded_mjpeg_pull_timeout_defaults_to_short_bounded_wait() {
|
|
|
|
|
temp_env::with_var_unset("LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS", || {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
super::decoded_mjpeg_pull_timeout(),
|
2026-05-12 16:11:53 -03:00
|
|
|
gstreamer::ClockTime::from_mseconds(20)
|
2026-05-09 11:34:13 -03:00
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Verifies explicit HEVC spool polling overrides stay bounded.
|
|
|
|
|
///
|
|
|
|
|
/// Input: zero and oversized timeout values. Output: direct zero polling
|
|
|
|
|
/// and a 50ms safety cap. Why: lab tuning may need aggressive polling, but
|
|
|
|
|
/// no override should recreate the multi-second decoded-frame backlog.
|
|
|
|
|
#[test]
|
|
|
|
|
fn decoded_mjpeg_pull_timeout_allows_fast_poll_and_clamps_slow_waits() {
|
|
|
|
|
temp_env::with_var("LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS", Some("0"), || {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
super::decoded_mjpeg_pull_timeout(),
|
|
|
|
|
gstreamer::ClockTime::from_mseconds(0)
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
temp_env::with_var("LESAVKA_UVC_HEVC_SPOOL_PULL_TIMEOUT_MS", Some("250"), || {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
super::decoded_mjpeg_pull_timeout(),
|
|
|
|
|
gstreamer::ClockTime::from_mseconds(50)
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 05:18:36 -03:00
|
|
|
#[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);
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-05-15 08:37:26 -03:00
|
|
|
|
|
|
|
|
temp_env::with_vars(
|
|
|
|
|
[
|
|
|
|
|
("LESAVKA_UVC_FRAME_SIZE_GUARD", Some("1")),
|
|
|
|
|
("LESAVKA_UVC_FRAME_MAX_BYTES", None::<&str>),
|
|
|
|
|
("LESAVKA_UVC_MJPEG_BUDGET_BYTES_PER_SEC", Some("10000000")),
|
|
|
|
|
("LESAVKA_UVC_BULK", Some("0")),
|
|
|
|
|
("LESAVKA_UVC_MAXPACKET", Some("1024")),
|
|
|
|
|
("LESAVKA_UVC_ISOCHRONOUS_LIMIT_PCT", None::<&str>),
|
|
|
|
|
],
|
|
|
|
|
|| {
|
|
|
|
|
assert_eq!(super::mjpeg_spool_frame_max_bytes(30), 232_106);
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-05-14 05:18:36 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 11:34:13 -03:00
|
|
|
/// Verifies spool metadata remains opt-in and path-configurable.
|
|
|
|
|
///
|
|
|
|
|
/// Input: default and explicit metadata env vars. Output: disabled by
|
|
|
|
|
/// default plus deterministic sidecar path selection. Why: diagnostics must
|
|
|
|
|
/// not add per-frame writes unless the operator asks for timing evidence.
|
|
|
|
|
#[test]
|
|
|
|
|
fn mjpeg_spool_metadata_is_opt_in_and_path_configurable() {
|
|
|
|
|
temp_env::with_var_unset("LESAVKA_UVC_FRAME_META", || {
|
|
|
|
|
assert!(!super::mjpeg_spool_metadata_enabled());
|
|
|
|
|
});
|
|
|
|
|
temp_env::with_var("LESAVKA_UVC_FRAME_META", Some("yes"), || {
|
|
|
|
|
assert!(super::mjpeg_spool_metadata_enabled());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let frame = std::path::Path::new("/tmp/lesavka-frame.mjpg");
|
|
|
|
|
temp_env::with_var_unset("LESAVKA_UVC_FRAME_META_PATH", || {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
super::mjpeg_spool_metadata_path(frame),
|
|
|
|
|
std::path::PathBuf::from("/tmp/lesavka-frame.mjpg.meta.json")
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
temp_env::with_var(
|
|
|
|
|
"LESAVKA_UVC_FRAME_META_PATH",
|
|
|
|
|
Some("/tmp/custom-meta.json"),
|
|
|
|
|
|| {
|
|
|
|
|
assert_eq!(
|
|
|
|
|
super::mjpeg_spool_metadata_path(frame),
|
|
|
|
|
std::path::PathBuf::from("/tmp/custom-meta.json")
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
temp_env::with_var_unset("LESAVKA_UVC_FRAME_META_LOG_PATH", || {
|
|
|
|
|
assert_eq!(super::mjpeg_spool_metadata_log_path(), None);
|
|
|
|
|
});
|
|
|
|
|
temp_env::with_var("LESAVKA_UVC_FRAME_META_LOG_PATH", Some(" "), || {
|
|
|
|
|
assert_eq!(super::mjpeg_spool_metadata_log_path(), None);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Verifies metadata records carry enough timing evidence for RCT analysis.
|
|
|
|
|
///
|
|
|
|
|
/// Input: HEVC-decoded spool timing. Output: JSON fields for source and
|
|
|
|
|
/// decoded PTS. Why: future blind end-to-end probes need to tell whether a
|
|
|
|
|
/// bad RCT result came from transport/decode or from the UVC helper/browser.
|
|
|
|
|
#[test]
|
|
|
|
|
fn mjpeg_spool_metadata_formats_timing_fields() {
|
|
|
|
|
let record = super::format_mjpeg_spool_metadata(
|
|
|
|
|
7,
|
|
|
|
|
1234,
|
|
|
|
|
super::MjpegSpoolTiming::hevc_decoded_mjpeg(42_000, Some(43_000)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert!(record.contains("\"schema\":\"lesavka.uvc-mjpeg-spool-meta.v1\""));
|
|
|
|
|
assert!(record.contains("\"sequence\":7"));
|
|
|
|
|
assert!(record.contains("\"profile\":\"hevc-decoded-mjpeg\""));
|
|
|
|
|
assert!(record.contains("\"bytes\":1234"));
|
|
|
|
|
assert!(record.contains("\"source_pts_us\":42000"));
|
|
|
|
|
assert!(record.contains("\"decoded_pts_us\":43000"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Verifies direct MJPEG metadata explicitly marks passthrough timing.
|
|
|
|
|
///
|
|
|
|
|
/// Input: an upstream MJPEG packet PTS. Output: metadata with no decoded
|
|
|
|
|
/// PTS. Why: direct MJPEG ingress must remain distinguishable from HEVC
|
|
|
|
|
/// decode when later RCT timing evidence is compared across profiles.
|
|
|
|
|
#[test]
|
|
|
|
|
fn mjpeg_passthrough_metadata_uses_source_pts_and_null_decode_pts() {
|
|
|
|
|
let record = super::format_mjpeg_spool_metadata(
|
|
|
|
|
8,
|
|
|
|
|
99,
|
|
|
|
|
super::MjpegSpoolTiming::mjpeg_passthrough(55_000),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert!(record.contains("\"profile\":\"mjpeg-passthrough\""));
|
|
|
|
|
assert!(record.contains("\"source_pts_us\":55000"));
|
|
|
|
|
assert!(record.contains("\"decoded_pts_us\":null"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-16 02:53:49 -03:00
|
|
|
/// Verifies normalized direct-MJPEG handoffs are distinguishable.
|
|
|
|
|
///
|
|
|
|
|
/// Input: an upstream MJPEG packet PTS after decode/re-encode. Output:
|
|
|
|
|
/// metadata with the normalized profile marker. Why: RCT artifact probes
|
|
|
|
|
/// need to separate raw passthrough from the safer browser-facing path.
|
|
|
|
|
#[test]
|
|
|
|
|
fn mjpeg_normalized_metadata_uses_source_pts_and_profile_marker() {
|
|
|
|
|
let record = super::format_mjpeg_spool_metadata(
|
|
|
|
|
9,
|
|
|
|
|
101,
|
|
|
|
|
super::MjpegSpoolTiming::mjpeg_normalized(66_000),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert!(record.contains("\"profile\":\"mjpeg-normalized\""));
|
|
|
|
|
assert!(record.contains("\"source_pts_us\":66000"));
|
|
|
|
|
assert!(record.contains("\"decoded_pts_us\":null"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 11:34:13 -03:00
|
|
|
/// Verifies frame spooling preserves default behavior unless metadata is enabled.
|
|
|
|
|
///
|
|
|
|
|
/// Input: a temporary frame path plus disabled metadata env vars. Output:
|
|
|
|
|
/// the frame file is atomically written and no sidecar appears. Why:
|
|
|
|
|
/// diagnostics must not alter the normal UVC helper handoff during calls.
|
|
|
|
|
#[test]
|
|
|
|
|
fn spool_mjpeg_frame_writes_frame_without_default_sidecar() {
|
|
|
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
|
|
|
let frame = dir.path().join("nested").join("frame.mjpg");
|
|
|
|
|
let meta = frame.with_extension("mjpg.meta.json");
|
|
|
|
|
|
|
|
|
|
temp_env::with_var_unset("LESAVKA_UVC_FRAME_META", || {
|
|
|
|
|
super::spool_mjpeg_frame_with_timing(
|
|
|
|
|
&frame,
|
|
|
|
|
b"jpeg-bytes",
|
|
|
|
|
Some(super::MjpegSpoolTiming::mjpeg_passthrough(10)),
|
|
|
|
|
)
|
|
|
|
|
.expect("spool frame");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
assert_eq!(std::fs::read(&frame).expect("read frame"), b"jpeg-bytes");
|
|
|
|
|
assert!(!meta.exists());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Verifies enabled frame metadata is atomically written beside the frame.
|
|
|
|
|
///
|
|
|
|
|
/// Input: explicit metadata enablement, custom sidecar path, and HEVC
|
|
|
|
|
/// timing. Output: both frame and sidecar are published. Why: this gives
|
|
|
|
|
/// client-to-RCT probes a precise server decode/spool boundary without
|
|
|
|
|
/// requiring invasive server logging.
|
|
|
|
|
#[test]
|
|
|
|
|
fn spool_mjpeg_frame_writes_enabled_sidecar_with_timing() {
|
|
|
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
|
|
|
let frame = dir.path().join("frame.mjpg");
|
|
|
|
|
let meta = dir.path().join("frame-meta.json");
|
|
|
|
|
let log = dir.path().join("frames.jsonl");
|
|
|
|
|
|
|
|
|
|
temp_env::with_vars(
|
|
|
|
|
[
|
|
|
|
|
("LESAVKA_UVC_FRAME_META", Some("on")),
|
|
|
|
|
(
|
|
|
|
|
"LESAVKA_UVC_FRAME_META_PATH",
|
|
|
|
|
Some(meta.to_str().expect("utf8 path")),
|
|
|
|
|
),
|
|
|
|
|
(
|
|
|
|
|
"LESAVKA_UVC_FRAME_META_LOG_PATH",
|
|
|
|
|
Some(log.to_str().expect("utf8 path")),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
|| {
|
|
|
|
|
super::spool_mjpeg_frame_with_timing(
|
|
|
|
|
&frame,
|
|
|
|
|
b"decoded-jpeg",
|
|
|
|
|
Some(super::MjpegSpoolTiming::hevc_decoded_mjpeg(
|
|
|
|
|
100_000,
|
|
|
|
|
Some(101_000),
|
|
|
|
|
)),
|
|
|
|
|
)
|
|
|
|
|
.expect("spool frame with metadata");
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
assert_eq!(std::fs::read(&frame).expect("read frame"), b"decoded-jpeg");
|
|
|
|
|
let record = std::fs::read_to_string(&meta).expect("read metadata");
|
|
|
|
|
assert!(record.contains("\"profile\":\"hevc-decoded-mjpeg\""));
|
|
|
|
|
assert!(record.contains("\"bytes\":12"));
|
|
|
|
|
assert!(record.contains("\"source_pts_us\":100000"));
|
|
|
|
|
assert!(record.contains("\"decoded_pts_us\":101000"));
|
|
|
|
|
|
|
|
|
|
let log_record = std::fs::read_to_string(&log).expect("read metadata log");
|
|
|
|
|
assert_eq!(log_record.lines().count(), 1);
|
|
|
|
|
assert!(log_record.contains("\"profile\":\"hevc-decoded-mjpeg\""));
|
|
|
|
|
}
|
|
|
|
|
}
|