From 8f1371de97f438594840eaac6d2aa7b35cc9209b Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Thu, 13 Aug 2026 05:22:12 -0300 Subject: [PATCH] release: add receiver artifact replay mode --- Cargo.lock | 6 +- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- docs/architecture-and-media-readiness.md | 12 +++- scripts/manual/run_rct_uvc_artifact_probe.py | 43 +++++++++++- server/Cargo.toml | 2 +- .../rct_uvc_artifact_probe_manual_contract.rs | 70 +++++++++++++++++++ 7 files changed, 125 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a5375fc..4f0eb4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.27.14" +version = "0.27.15" dependencies = [ "anyhow", "async-stream", @@ -1692,7 +1692,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.27.14" +version = "0.27.15" dependencies = [ "anyhow", "base64", @@ -1704,7 +1704,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.27.14" +version = "0.27.15" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index a097314..57c8578 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.27.14" +version = "0.27.15" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 8f0f74a..989ee62 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.27.14" +version = "0.27.15" edition = "2024" build = "build.rs" diff --git a/docs/architecture-and-media-readiness.md b/docs/architecture-and-media-readiness.md index e1cd330..de30594 100644 --- a/docs/architecture-and-media-readiness.md +++ b/docs/architecture-and-media-readiness.md @@ -350,6 +350,12 @@ requires at least 90% duration coverage, at least 99% marker coverage, zero marked cadence or visual corruption, a bounded successful deep decode, intact native MJPEG, and clean mode-matched server-boundary evidence. +Release 0.27.15 adds first-class replay of extracted receiver recordings to the +real-video artifact probe. The same tear, grey-collapse, and exact-freeze +detector used for live Tethys captures can now analyze preserved files with an +explicit mode and duration, making historical bad/control A/B evidence +repeatable instead of dependent on one-off analysis commands. + ## 7. The Resolved Downstream Video Failure The current blank downstream feeds fail before transport or decoding. @@ -426,7 +432,7 @@ The safe completion sequence for this incident is: 6. Run `scripts/install/server.sh` as the trusted deployment path. Preserve the already-attached USB gadget unless a controlled rebuild is explicitly required. -7. Confirm Theia reports server version `0.27.14`, the pushed release revision, +7. Confirm Theia reports server version `0.27.15`, the pushed release revision, direct MJPEG normalizer timeout `0`, and a coherent UVC contract. 8. Open both downstream RPCs and prove that each emits changing, decodable H.264 frames. @@ -444,7 +450,7 @@ hardware contract is repeatable. The remaining work falls into five groups. ### A. Install And Version Parity -- Push and deploy `0.27.14` through the client/server install scripts. +- Push and deploy `0.27.15` through the client/server install scripts. - Confirm client/server version and revision in every hardware probe artifact. - Eliminate the current state where a fixed client talks to an unfixed server. @@ -510,6 +516,6 @@ host repair: 9. disconnect/reconnect and device changes recover without stale media; and 10. diagnostics identify the failed physical stage when any item breaks. -Until that sequence passes on the installed `0.27.14` client/server pair, the +Until that sequence passes on the installed `0.27.15` client/server pair, the current release should be described as a validated code correction awaiting hardware deployment and end-to-end acceptance, not as a completed product fix. diff --git a/scripts/manual/run_rct_uvc_artifact_probe.py b/scripts/manual/run_rct_uvc_artifact_probe.py index 2158a22..c6e2ea6 100755 --- a/scripts/manual/run_rct_uvc_artifact_probe.py +++ b/scripts/manual/run_rct_uvc_artifact_probe.py @@ -30,8 +30,13 @@ def parse_args() -> argparse.Namespace: ) ) parser.add_argument("--host", default="", help="optional SSH host, e.g. tethys") - parser.add_argument("--source", choices=["device", "x11"], default="device") + parser.add_argument("--source", choices=["device", "x11", "file"], default="device") parser.add_argument("--device", default="auto", help="video device or auto") + parser.add_argument( + "--input-video", + default="", + help="receiver recording to analyze with --source file", + ) parser.add_argument("--display", default=":0", help="X11 display for --source x11") parser.add_argument("--crop", default="", help="X11 crop as x,y,width,height for --source x11") parser.add_argument("--device-label", default=DEFAULT_DEVICE_LABEL) @@ -118,6 +123,8 @@ def run_remote(args: argparse.Namespace) -> int: args.source, "--device", args.device, + "--input-video", + args.input_video, "--device-label", args.device_label, "--display", @@ -496,6 +503,23 @@ def parse_crop(value: str, args: argparse.Namespace) -> tuple[int, int, int, int def ffmpeg_cmd(device: str, args: argparse.Namespace) -> list[str]: + if args.source == "file": + return [ + "ffmpeg", + "-hide_banner", + "-nostdin", + "-loglevel", + "warning", + "-i", + args.input_video, + "-an", + "-pix_fmt", + "gray", + "-f", + "rawvideo", + "-", + ] + if args.source == "x11": x, y, width, height = parse_crop(args.crop, args) display = f"{args.display}+{x},{y}" @@ -549,7 +573,16 @@ def ffmpeg_cmd(device: str, args: argparse.Namespace) -> list[str]: def run_capture(args: argparse.Namespace) -> int: artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else default_artifact_dir() artifact_dir.mkdir(parents=True, exist_ok=True) - device = detect_video_device(args.device_label) if args.device == "auto" else args.device + if args.source == "file": + if not args.input_video: + raise SystemExit("--input-video is required with --source file") + if args.deep_capture: + raise SystemExit("--deep-capture is only available with --source device") + if not args.host and not pathlib.Path(args.input_video).is_file(): + raise SystemExit(f"input video does not exist: {args.input_video}") + device = args.input_video + else: + device = detect_video_device(args.device_label) if args.device == "auto" else args.device command = ffmpeg_cmd(device, args) frame_size = args.width * args.height stderr_path = artifact_dir / "ffmpeg.stderr" @@ -787,7 +820,10 @@ def run_capture(args: argparse.Namespace) -> int: finally: raw_path.unlink(missing_ok=True) analysis_elapsed = time.monotonic() - analysis_started - elapsed = max(0.001, capture_elapsed) + elapsed = max( + 0.001, + frame_index / max(1, args.fps) if args.source == "file" else capture_elapsed, + ) expected_frames = max(1, int(args.duration * args.fps)) coverage_ok = frame_index >= int(expected_frames * 0.9) structural_ok = integrity_report is None or integrity_report.get("verdict") == "pass" @@ -802,6 +838,7 @@ def run_capture(args: argparse.Namespace) -> int: "schema": "lesavka.rct-uvc-artifact-probe.v1", "source": args.source, "device": device, + "input_video": args.input_video or None, "capture_mode": "deep-mjpeg" if args.deep_capture and args.source == "device" else ("stream" if args.stream_analyze else "rawfile"), "width": args.width, "height": args.height, diff --git a/server/Cargo.toml b/server/Cargo.toml index 5188bec..c3eb5c8 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.27.14" +version = "0.27.15" edition = "2024" autobins = false diff --git a/tests/manual/server/rct/rct_uvc_artifact_probe_manual_contract.rs b/tests/manual/server/rct/rct_uvc_artifact_probe_manual_contract.rs index 2ef8a1f..9352ac0 100644 --- a/tests/manual/server/rct/rct_uvc_artifact_probe_manual_contract.rs +++ b/tests/manual/server/rct/rct_uvc_artifact_probe_manual_contract.rs @@ -44,6 +44,8 @@ fn rct_uvc_artifact_probe_documents_late_path_lower_half_detection() { "static_pct", "reference_", "--source", + "--input-video", + "choices=[\"device\", \"x11\", \"file\"]", "--crop", "PGM", "--host", @@ -62,6 +64,74 @@ fn rct_uvc_artifact_probe_documents_late_path_lower_half_detection() { } } +#[test] +fn rct_uvc_artifact_probe_replays_extracted_receiver_video() { + let dir = tempfile::tempdir().expect("tempdir"); + let video = dir.path().join("receiver-control.mkv"); + let generate = Command::new("ffmpeg") + .args([ + "-hide_banner", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "testsrc2=size=64x48:rate=5:duration=1", + "-an", + "-c:v", + "ffv1", + "-y", + ]) + .arg(&video) + .output() + .expect("generate receiver control recording"); + assert!( + generate.status.success(), + "ffmpeg should generate the receiver control: {}", + String::from_utf8_lossy(&generate.stderr) + ); + + let artifacts = dir.path().join("replay"); + let output = Command::new("python3") + .arg(repo_script_path()) + .args(["--source", "file", "--input-video"]) + .arg(&video) + .args([ + "--width", + "64", + "--height", + "48", + "--fps", + "5", + "--duration", + "1", + "--max-exact-repeat-seconds", + "10", + "--artifact-dir", + ]) + .arg(&artifacts) + .output() + .expect("replay receiver recording"); + assert_ne!( + output.status.code(), + Some(2), + "valid extracted video must not fail capture: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let summary: Value = serde_json::from_str( + &fs::read_to_string(artifacts.join("summary.json")).expect("summary json"), + ) + .expect("parse summary json"); + assert_eq!(summary["source"], "file"); + assert_eq!(summary["input_video"], video.to_string_lossy().as_ref()); + assert_eq!(summary["frames"], 5); + assert_eq!(summary["expected_frames"], 5); + assert_eq!(summary["coverage_ok"], true); + assert_eq!(summary["ffmpeg_rc"], 0); +} + #[test] fn rct_uvc_artifact_probe_terminates_when_malformed_video_never_decodes() { for expected in [