probe: keep synthetic rct media within uvc budget

This commit is contained in:
Brad Stein 2026-05-17 00:42:08 -03:00
parent a79e98ec58
commit 6cbe78e576
7 changed files with 259 additions and 35 deletions

6
Cargo.lock generated
View File

@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lesavka_client"
version = "0.22.50"
version = "0.22.51"
dependencies = [
"anyhow",
"async-stream",
@ -1686,7 +1686,7 @@ dependencies = [
[[package]]
name = "lesavka_common"
version = "0.22.50"
version = "0.22.51"
dependencies = [
"anyhow",
"base64",
@ -1698,7 +1698,7 @@ dependencies = [
[[package]]
name = "lesavka_server"
version = "0.22.50"
version = "0.22.51"
dependencies = [
"anyhow",
"base64",

View File

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

View File

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

View File

@ -17,6 +17,10 @@ from typing import Any
DEFAULT_DEVICE_LABEL = "Lesavka Composite"
DEFAULT_MODES = "1280x720@20,1280x720@30,1920x1080@20,1920x1080@30"
DEFAULT_JPEG_QUALITY = 82
HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC = 8000
DEFAULT_ISOCHRONOUS_LIMIT_PCT = 85
DEFAULT_UVC_MAX_PACKET = 1024
MARKER_BITS = 32
MARKER_COLUMNS = 16
@ -52,6 +56,14 @@ def parse_args() -> argparse.Namespace:
help="start RCT capture before synthetic uplink; default starts uplink first so superseded injectors fail fast",
)
parser.add_argument("--inject-warmup-s", type=float, default=1.25)
parser.add_argument("--capture-finish-grace-s", type=float, default=5.0)
parser.add_argument("--jpeg-quality", type=int, default=DEFAULT_JPEG_QUALITY)
parser.add_argument(
"--inject-max-frame-bytes",
type=int,
default=0,
help="max encoded synthetic MJPEG bytes; default uses the safe high-speed isochronous budget for the selected fps",
)
parser.add_argument("--x-step", type=int, default=8)
parser.add_argument("--y-step", type=int, default=4)
parser.add_argument("--bands", type=int, default=24)
@ -96,6 +108,16 @@ def mode_dimensions(args: argparse.Namespace) -> tuple[int, int, int]:
return width, height, fps
def default_inject_max_frame_bytes(fps: int) -> int:
bytes_per_second = (
DEFAULT_UVC_MAX_PACKET
* HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC
* DEFAULT_ISOCHRONOUS_LIMIT_PCT
// 100
)
return max(64 * 1024, bytes_per_second // max(1, fps))
def default_artifact_dir(mode: str) -> pathlib.Path:
safe_mode = mode.replace("@", "-").replace("x", "x")
return pathlib.Path("artifacts/synthetic-rct") / f"{safe_mode}-{timestamp()}"
@ -107,6 +129,7 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
if not shutil.which("ssh") or not shutil.which("scp"):
raise SystemExit("ssh and scp are required for the remote synthetic probe")
width, height, fps = mode_dimensions(args)
inject_max_frame_bytes = args.inject_max_frame_bytes or default_inject_max_frame_bytes(fps)
artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else default_artifact_dir(args.mode)
artifact_dir.mkdir(parents=True, exist_ok=True)
remote_rct_dir = args.remote_rct_dir or f"/tmp/lesavka-synthetic-rct-capture-{timestamp()}"
@ -183,6 +206,10 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
str(args.duration + 2.0),
"--artifact-dir",
remote_inject_dir,
"--jpeg-quality",
str(args.jpeg_quality),
"--max-frame-bytes",
str(inject_max_frame_bytes),
"--print-every",
str(args.progress_every),
]
@ -197,6 +224,8 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
"fps": fps,
"source": args.source,
"duration_s": args.duration,
"jpeg_quality": args.jpeg_quality,
"inject_max_frame_bytes": inject_max_frame_bytes,
"inject_host": args.inject_host,
"rct_host": args.rct_host,
},
@ -230,9 +259,20 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
return capture_status, inject_process.wait()
inject_status = inject_process.poll()
if inject_status is not None:
diagnosis.append(
"synthetic uplink exited while RCT capture was still active; stopping capture because the run is not isolated"
)
if inject_status == 0:
deadline = time.monotonic() + max(0.0, args.capture_finish_grace_s)
while time.monotonic() < deadline:
capture_status = capture_process.poll()
if capture_status is not None:
return capture_status, inject_status
time.sleep(0.25)
diagnosis.append(
"synthetic uplink completed but RCT capture did not finish; capture likely lagged, froze, or was blocked by another consumer"
)
else:
diagnosis.append(
"synthetic uplink exited while RCT capture was still active; stopping capture because the run is not isolated or the injector failed"
)
print(
f"synthetic uplink exited during capture rc={inject_status}; stopping RCT capture",
file=sys.stderr,
@ -274,6 +314,32 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int:
diagnosis.append(
"captured frames did not consistently contain synthetic markers and the injector failed; the RCT capture likely measured a mixed, previous, or live webcam stream"
)
fps_observed = float(capture_data.get("fps_observed") or 0.0)
fps_requested = float(capture_data.get("fps_requested") or fps)
if fps_observed and fps_observed < fps_requested * 0.5:
diagnosis.append(
f"RCT capture decoded only {fps_observed:.3f} fps from a {fps_requested:.0f} fps mode; check for a frozen UVC device or another browser/process holding the camera"
)
frames = int(capture_data.get("frames") or 0)
reason_counts = capture_data.get("reason_counts") or {}
repeats = int(reason_counts.get("frame_repeat") or 0)
if frames > 0 and repeats >= max(3, int(frames * 0.9)):
diagnosis.append(
"RCT capture repeated nearly every decoded synthetic marker; the received UVC stream was stale/frozen instead of advancing"
)
except Exception:
pass
inject_summary = local_inject / "summary.json"
if inject_summary.exists():
try:
inject_data = json.loads(inject_summary.read_text())
oversize_frames = int(inject_data.get("encoded_oversize_frames") or 0)
max_bytes = inject_data.get("encoded_max_bytes")
max_frame_bytes = inject_data.get("max_frame_bytes")
if oversize_frames:
diagnosis.append(
f"synthetic injector produced {oversize_frames} over-budget MJPEG frame(s), max={max_bytes} cap={max_frame_bytes}; the server will freeze instead of spooling those frames"
)
except Exception:
pass
summary = {
@ -397,21 +463,27 @@ def fill_rect(frame: bytearray, width: int, height: int, x0: int, y0: int, w: in
def synthetic_gray(width: int, height: int, sequence: int) -> bytes:
data = bytearray(width * height)
moving_period = max(width // 3, 64)
moving_width = max(width // 18, 12)
moving_offset = (sequence * 17) % moving_period
safe_width = max(width, 1)
safe_height = max(height, 1)
moving_width = min(max(width // 10, 32), safe_width)
moving_offset = (sequence * 13) % safe_width
center_x = width // 2
center_y = height // 2
block_w = max(width // 24, 24)
block_h = max(height // 18, 18)
for y in range(height):
row = y * width
for x in range(width):
value = (x * 3 + y * 5 + sequence * 11) & 0xFF
if (x + moving_offset) % moving_period < moving_width:
value = min(255, value + 70)
base = 44 + (x * 72 // safe_width) + (y * 52 // safe_height) + ((sequence * 3) % 28)
checker = 30 if (((x // block_w) + (y // block_h) + (sequence // 5)) & 1) == 0 else 0
value = min(238, base + checker)
moving = (x + safe_width - moving_offset) % safe_width
if moving < moving_width:
value = min(255, 220 - (y * 54 // safe_height))
elif moving < moving_width + 4:
value = 28
if abs(x - center_x) < width // 9 and abs(y - center_y) < height // 12:
value = 255 - value // 2
if y >= height // 2 and (((x // 32) + (y // 24) + sequence) & 1) == 0:
value //= 3
data[row + x] = value
draw_marker(data, width, height, sequence)
return bytes(data)
@ -623,6 +695,7 @@ def run_capture(args: argparse.Namespace) -> int:
previous_seq: int | None = None
decoded_frames = 0
reason_counts: collections.Counter[str] = collections.Counter()
sequence_counts: collections.Counter[int] = collections.Counter()
max_total_mae = max_upper_mae = max_lower_mae = 0.0
worst: list[dict[str, Any]] = []
with stderr_path.open("wb") as err, metrics_path.open("w") as metrics:
@ -638,6 +711,7 @@ def run_capture(args: argparse.Namespace) -> int:
decoded_seq = result["decoded_sequence"]
if decoded_seq is not None:
decoded_frames += 1
sequence_counts[int(decoded_seq)] += 1
previous_seq = int(decoded_seq)
result.update({"frame": frame_index, "elapsed_s": round(time.monotonic() - started, 3)})
max_total_mae = max(max_total_mae, float(result["total_mae"]))
@ -690,6 +764,7 @@ def run_capture(args: argparse.Namespace) -> int:
"suspicious_frames": suspicious_count,
"suspicious_pct": round(suspicious_count / frame_index * 100.0, 3) if frame_index else 0.0,
"reason_counts": dict(reason_counts),
"decoded_sequence_counts": dict(sequence_counts.most_common(12)),
"max_total_mae": round(max_total_mae, 3),
"max_upper_mae": round(max_upper_mae, 3),
"max_lower_mae": round(max_lower_mae, 3),
@ -729,7 +804,7 @@ def run_self_test(args: argparse.Namespace) -> int:
height = 180
frames = [synthetic_gray(width, height, idx) for idx in range(6)]
corrupt = bytearray(synthetic_gray(width, height, 6))
fill_rect(corrupt, width, height, 0, height // 2, width, height // 4, 128)
fill_rect(corrupt, width, height, 0, height // 2, width, height // 4, 0)
frames.append(bytes(corrupt))
shifted = bytearray(width * height)
expected = synthetic_gray(width, height, 7)

View File

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

View File

@ -17,7 +17,7 @@ use tonic::transport::{Certificate, Channel, ClientTlsConfig, Identity};
const DEFAULT_SERVER: &str = "http://127.0.0.1:50051";
const DEFAULT_SAMPLE_RATE: u32 = 48_000;
const DEFAULT_CHANNELS: u32 = 2;
const DEFAULT_JPEG_QUALITY: i32 = 90;
const DEFAULT_JPEG_QUALITY: i32 = 82;
const MARKER_BITS: usize = 32;
const MARKER_COLUMNS: usize = 16;
@ -32,6 +32,7 @@ struct Args {
session_id: u64,
artifact_dir: Option<PathBuf>,
print_every: u64,
max_frame_bytes: usize,
tls_ca: Option<PathBuf>,
tls_client_cert: Option<PathBuf>,
tls_client_key: Option<PathBuf>,
@ -50,6 +51,7 @@ impl Args {
session_id: unix_millis(),
artifact_dir: None,
print_every: 150,
max_frame_bytes: env_usize("LESAVKA_SYNTHETIC_MAX_FRAME_BYTES").unwrap_or(0),
tls_ca: env_path("LESAVKA_TLS_CA").or_else(|| default_pki_path("ca.crt")),
tls_client_cert: env_path("LESAVKA_TLS_CLIENT_CERT")
.or_else(|| default_pki_path("client.crt")),
@ -76,6 +78,7 @@ impl Args {
args.artifact_dir = Some(PathBuf::from(next_value(&mut it, &flag)?))
}
"--print-every" => args.print_every = parse_next(&mut it, &flag)?,
"--max-frame-bytes" => args.max_frame_bytes = parse_next(&mut it, &flag)?,
"--tls-ca" => args.tls_ca = Some(PathBuf::from(next_value(&mut it, &flag)?)),
"--tls-client-cert" => {
args.tls_client_cert = Some(PathBuf::from(next_value(&mut it, &flag)?))
@ -124,6 +127,39 @@ struct MjpegEncoder {
frame_step_us: u64,
}
#[derive(Clone, Copy, Debug, Default)]
struct EncodeStats {
frames: u64,
total_bytes: u128,
min_bytes: usize,
max_bytes: usize,
oversize_frames: u64,
}
impl EncodeStats {
fn record(&mut self, bytes: usize, max_frame_bytes: usize) {
self.frames = self.frames.saturating_add(1);
self.total_bytes = self.total_bytes.saturating_add(bytes as u128);
self.min_bytes = if self.frames == 1 {
bytes
} else {
self.min_bytes.min(bytes)
};
self.max_bytes = self.max_bytes.max(bytes);
if max_frame_bytes > 0 && bytes > max_frame_bytes {
self.oversize_frames = self.oversize_frames.saturating_add(1);
}
}
fn mean_bytes(&self) -> usize {
if self.frames == 0 {
0
} else {
(self.total_bytes / u128::from(self.frames)).min(usize::MAX as u128) as usize
}
}
}
impl MjpegEncoder {
fn new(args: &Args) -> Result<Self> {
gst::init().context("gst init")?;
@ -316,7 +352,7 @@ async fn main() -> Result<()> {
dir.join("command.txt"),
std::env::args().collect::<Vec<_>>().join(" ") + "\n",
)?;
std::fs::write(dir.join("summary.json"), args_summary_json(&args) + "\n")?;
write_summary(&args, None)?;
}
let channel = connect_channel(&args).await?;
@ -338,6 +374,7 @@ async fn main() -> Result<()> {
});
let mut encoder = MjpegEncoder::new(&args)?;
let mut encode_stats = EncodeStats::default();
let frame_step = Duration::from_micros(args.frame_step_us());
let started = tokio::time::Instant::now() + Duration::from_millis(250);
let total_frames = args.total_frames();
@ -349,6 +386,15 @@ async fn main() -> Result<()> {
tokio::time::sleep_until(started + duration_mul(frame_step, sequence)).await;
let pts_us = sequence.saturating_mul(args.frame_step_us());
let encoded = encoder.encode(sequence)?;
encode_stats.record(encoded.len(), args.max_frame_bytes);
if args.max_frame_bytes > 0 && encoded.len() > args.max_frame_bytes {
write_summary(&args, Some(&encode_stats))?;
bail!(
"encoded synthetic frame {sequence} is {} bytes, above --max-frame-bytes {}; lower --jpeg-quality or use a more compressible synthetic pattern before trusting the RCT probe",
encoded.len(),
args.max_frame_bytes
);
}
let bundle = synthetic_bundle(&args, sequence, pts_us, encoded);
if tx.send(bundle).await.is_err() {
let response_result = response_task
@ -372,6 +418,7 @@ async fn main() -> Result<()> {
response_task
.await
.context("joining StreamWebcamMedia task")??;
write_summary(&args, Some(&encode_stats))?;
eprintln!("lesavka synthetic uplink complete: frames={total_frames}");
Ok(())
}
@ -427,21 +474,17 @@ fn silence_pcm(duration_us: u64) -> Vec<u8> {
fn synthetic_rgb_frame(width: usize, height: usize, sequence: u64) -> Vec<u8> {
let mut frame = vec![0u8; width.saturating_mul(height).saturating_mul(3)];
let moving_period = (width / 3).max(64);
let moving_width = (width / 18).max(12);
let moving_offset = (sequence as usize).wrapping_mul(17) % moving_period;
let moving_width = (width / 10).max(32).min(width.max(1));
let moving_offset = (sequence as usize).wrapping_mul(13) % width.max(1);
for y in 0..height {
for x in 0..width {
let mut value = synthetic_luma(
let value = synthetic_luma(
(width, height),
x,
y,
sequence,
(moving_period, moving_width, moving_offset),
(moving_width, moving_offset),
);
if y >= height / 2 && (((x / 32) + (y / 24) + sequence as usize) & 1) == 0 {
value /= 3;
}
let offset = (y * width + x) * 3;
frame[offset] = value;
frame[offset + 1] = value;
@ -457,14 +500,29 @@ fn synthetic_luma(
x: usize,
y: usize,
sequence: u64,
moving_bar: (usize, usize, usize),
moving_bar: (usize, usize),
) -> u8 {
let (width, height) = frame_size;
let (moving_period, moving_width, moving_offset) = moving_bar;
let mut value = ((x as u64 * 3 + y as u64 * 5 + sequence.saturating_mul(11)) & 0xff) as u8;
let moving = (x + moving_offset) % moving_period;
let (moving_width, moving_offset) = moving_bar;
let width = width.max(1);
let height = height.max(1);
let block_w = (width / 24).max(24);
let block_h = (height / 18).max(18);
let base = 44
+ (x.saturating_mul(72) / width)
+ (y.saturating_mul(52) / height)
+ ((sequence as usize).saturating_mul(3) % 28);
let checker = if (((x / block_w) + (y / block_h) + (sequence as usize / 5)) & 1) == 0 {
30
} else {
0
};
let mut value = (base + checker).min(238) as u8;
let moving = (x + width - moving_offset) % width;
if moving < moving_width {
value = value.saturating_add(70);
value = (220usize.saturating_sub(y.saturating_mul(54) / height)).min(255) as u8;
} else if moving < moving_width + 4 {
value = 28;
}
let center_x = width / 2;
let center_y = height / 2;
@ -561,6 +619,12 @@ where
Ok(next_value(it, flag)?.parse()?)
}
fn env_usize(name: &str) -> Option<usize> {
std::env::var(name)
.ok()
.and_then(|value| value.trim().parse::<usize>().ok())
}
fn duration_mul(duration: Duration, count: u64) -> Duration {
Duration::from_nanos(
duration
@ -578,9 +642,34 @@ fn unix_millis() -> u64 {
.min(u128::from(u64::MAX)) as u64
}
fn args_summary_json(args: &Args) -> String {
fn write_summary(args: &Args, stats: Option<&EncodeStats>) -> Result<()> {
if let Some(dir) = &args.artifact_dir {
std::fs::write(
dir.join("summary.json"),
args_summary_json(args, stats) + "\n",
)?;
}
Ok(())
}
fn json_usize_or_null(value: Option<usize>) -> String {
value
.map(|value| value.to_string())
.unwrap_or_else(|| "null".to_string())
}
fn args_summary_json(args: &Args, stats: Option<&EncodeStats>) -> String {
let frames = stats.map(|stats| stats.frames).unwrap_or(0);
let min_bytes =
json_usize_or_null(stats.and_then(|stats| (stats.frames > 0).then_some(stats.min_bytes)));
let max_bytes =
json_usize_or_null(stats.and_then(|stats| (stats.frames > 0).then_some(stats.max_bytes)));
let mean_bytes = json_usize_or_null(
stats.and_then(|stats| (stats.frames > 0).then_some(stats.mean_bytes())),
);
let oversize_frames = stats.map(|stats| stats.oversize_frames).unwrap_or(0);
format!(
"{{\"schema\":\"lesavka.synthetic-uplink.v1\",\"server\":{server:?},\"width\":{width},\"height\":{height},\"fps\":{fps},\"duration_s\":{duration:.3},\"session_id\":{session},\"tls\":{tls}}}",
"{{\"schema\":\"lesavka.synthetic-uplink.v1\",\"server\":{server:?},\"width\":{width},\"height\":{height},\"fps\":{fps},\"duration_s\":{duration:.3},\"session_id\":{session},\"tls\":{tls},\"jpeg_quality\":{quality},\"max_frame_bytes\":{max_frame_bytes},\"encoded_frames\":{frames},\"encoded_min_bytes\":{min_bytes},\"encoded_max_bytes\":{max_bytes},\"encoded_mean_bytes\":{mean_bytes},\"encoded_oversize_frames\":{oversize_frames}}}",
server = args.server,
width = args.width,
height = args.height,
@ -588,6 +677,13 @@ fn args_summary_json(args: &Args) -> String {
duration = args.duration.as_secs_f64(),
session = args.session_id,
tls = is_https(&args.server),
quality = args.jpeg_quality,
max_frame_bytes = args.max_frame_bytes,
frames = frames,
min_bytes = min_bytes,
max_bytes = max_bytes,
mean_bytes = mean_bytes,
oversize_frames = oversize_frames,
)
}
@ -601,7 +697,52 @@ fn print_help() {
--width N --height N --fps N\n\
--duration SECONDS default 300\n\
--jpeg-quality N default {DEFAULT_JPEG_QUALITY}\n\
--max-frame-bytes N fail fast if an encoded frame exceeds N bytes\n\
--artifact-dir PATH write command/summary metadata\n\
--print-every N progress interval in frames"
);
}
#[cfg(test)]
mod tests {
use super::*;
fn test_args(width: usize, height: usize, fps: u32) -> Args {
Args {
server: DEFAULT_SERVER.to_string(),
width,
height,
fps,
duration: Duration::from_secs(1),
jpeg_quality: DEFAULT_JPEG_QUALITY,
session_id: 1,
artifact_dir: None,
print_every: 0,
max_frame_bytes: 232_106,
tls_ca: None,
tls_client_cert: None,
tls_client_key: None,
tls_domain: None,
}
}
#[test]
fn synthetic_frames_fit_safe_720p_and_1080p_isochronous_budget() {
for (width, height, fps) in [(1280, 720, 30), (1920, 1080, 30)] {
let args = test_args(width, height, fps);
let mut encoder = MjpegEncoder::new(&args).expect("synthetic encoder");
for sequence in [0, 1, 30, 120, 300] {
let encoded = encoder.encode(sequence).expect("encode frame");
assert!(
encoded.len() <= args.max_frame_bytes,
"{}x{}@{} synthetic frame {sequence} encoded to {} bytes, above {}",
width,
height,
fps,
encoded.len(),
args.max_frame_bytes
);
}
}
}
}

View File

@ -39,6 +39,9 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
"--capture-only",
"--capture-before-inject",
"--inject-warmup-s",
"--capture-finish-grace-s",
"--jpeg-quality",
"--inject-max-frame-bytes",
"--source",
"--mode",
"1280x720@20,1280x720@30,1920x1080@20,1920x1080@30",
@ -53,6 +56,9 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() {
"suspicious_",
"decoded_pct",
"diagnosis",
"encoded_oversize_frames",
"decoded_sequence_counts",
"synthetic uplink completed but RCT capture did not finish",
"synthetic uplink exited before capture warmup completed",
"max_lower_mae",
"ffmpeg",
@ -85,6 +91,8 @@ fn synthetic_injector_enters_the_public_bundled_media_rpc() {
"--tls-ca",
"--tls-client-cert",
"--tls-client-key",
"--max-frame-bytes",
"encoded synthetic frame",
"StreamWebcamMedia closed before accepting synthetic frame",
] {
assert!(