From 6cbe78e576c45b802669eb54003f18f2c45972c3 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 17 May 2026 00:42:08 -0300 Subject: [PATCH] probe: keep synthetic rct media within uvc budget --- Cargo.lock | 6 +- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- scripts/manual/run_synthetic_rct_uvc_probe.py | 99 ++++++++-- server/Cargo.toml | 2 +- server/src/bin/lesavka-synthetic-uplink.rs | 175 ++++++++++++++++-- ...synthetic_rct_uvc_probe_manual_contract.rs | 8 + 7 files changed, 259 insertions(+), 35 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 37e0caf..5083fe5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/client/Cargo.toml b/client/Cargo.toml index b748fa3..4bcb078 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.50" +version = "0.22.51" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 4809218..70fd992 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.50" +version = "0.22.51" edition = "2024" build = "build.rs" diff --git a/scripts/manual/run_synthetic_rct_uvc_probe.py b/scripts/manual/run_synthetic_rct_uvc_probe.py index 0161f26..1e0ca8b 100755 --- a/scripts/manual/run_synthetic_rct_uvc_probe.py +++ b/scripts/manual/run_synthetic_rct_uvc_probe.py @@ -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) diff --git a/server/Cargo.toml b/server/Cargo.toml index f45361f..c64eb15 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.50" +version = "0.22.51" edition = "2024" autobins = false diff --git a/server/src/bin/lesavka-synthetic-uplink.rs b/server/src/bin/lesavka-synthetic-uplink.rs index de86ac7..7663edd 100755 --- a/server/src/bin/lesavka-synthetic-uplink.rs +++ b/server/src/bin/lesavka-synthetic-uplink.rs @@ -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, print_every: u64, + max_frame_bytes: usize, tls_ca: Option, tls_client_cert: Option, tls_client_key: Option, @@ -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 { gst::init().context("gst init")?; @@ -316,7 +352,7 @@ async fn main() -> Result<()> { dir.join("command.txt"), std::env::args().collect::>().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 { fn synthetic_rgb_frame(width: usize, height: usize, sequence: u64) -> Vec { 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 { + std::env::var(name) + .ok() + .and_then(|value| value.trim().parse::().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) -> 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 + ); + } + } + } +} diff --git a/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs b/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs index b65e973..ff62034 100644 --- a/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs +++ b/tests/manual/server/rct/synthetic_rct_uvc_probe_manual_contract.rs @@ -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!(