release: add receiver artifact replay mode

This commit is contained in:
Brad Stein 2026-08-13 05:22:12 -03:00
parent ed9faecb20
commit 8f1371de97
7 changed files with 125 additions and 12 deletions

6
Cargo.lock generated
View File

@ -1658,7 +1658,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]] [[package]]
name = "lesavka_client" name = "lesavka_client"
version = "0.27.14" version = "0.27.15"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-stream", "async-stream",
@ -1692,7 +1692,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_common" name = "lesavka_common"
version = "0.27.14" version = "0.27.15"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64", "base64",
@ -1704,7 +1704,7 @@ dependencies = [
[[package]] [[package]]
name = "lesavka_server" name = "lesavka_server"
version = "0.27.14" version = "0.27.15"
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.27.14" version = "0.27.15"
edition = "2024" edition = "2024"
[dependencies] [dependencies]

View File

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

View File

@ -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 marked cadence or visual corruption, a bounded successful deep decode, intact
native MJPEG, and clean mode-matched server-boundary evidence. 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 ## 7. The Resolved Downstream Video Failure
The current blank downstream feeds fail before transport or decoding. 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 6. Run `scripts/install/server.sh` as the trusted deployment path. Preserve the
already-attached USB gadget unless a controlled rebuild is explicitly already-attached USB gadget unless a controlled rebuild is explicitly
required. 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. direct MJPEG normalizer timeout `0`, and a coherent UVC contract.
8. Open both downstream RPCs and prove that each emits changing, decodable H.264 8. Open both downstream RPCs and prove that each emits changing, decodable H.264
frames. frames.
@ -444,7 +450,7 @@ hardware contract is repeatable. The remaining work falls into five groups.
### A. Install And Version Parity ### 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. - Confirm client/server version and revision in every hardware probe artifact.
- Eliminate the current state where a fixed client talks to an unfixed server. - 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 9. disconnect/reconnect and device changes recover without stale media; and
10. diagnostics identify the failed physical stage when any item breaks. 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 current release should be described as a validated code correction awaiting
hardware deployment and end-to-end acceptance, not as a completed product fix. hardware deployment and end-to-end acceptance, not as a completed product fix.

View File

@ -30,8 +30,13 @@ def parse_args() -> argparse.Namespace:
) )
) )
parser.add_argument("--host", default="", help="optional SSH host, e.g. tethys") 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("--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("--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("--crop", default="", help="X11 crop as x,y,width,height for --source x11")
parser.add_argument("--device-label", default=DEFAULT_DEVICE_LABEL) parser.add_argument("--device-label", default=DEFAULT_DEVICE_LABEL)
@ -118,6 +123,8 @@ def run_remote(args: argparse.Namespace) -> int:
args.source, args.source,
"--device", "--device",
args.device, args.device,
"--input-video",
args.input_video,
"--device-label", "--device-label",
args.device_label, args.device_label,
"--display", "--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]: 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": if args.source == "x11":
x, y, width, height = parse_crop(args.crop, args) x, y, width, height = parse_crop(args.crop, args)
display = f"{args.display}+{x},{y}" display = f"{args.display}+{x},{y}"
@ -549,6 +573,15 @@ def ffmpeg_cmd(device: str, args: argparse.Namespace) -> list[str]:
def run_capture(args: argparse.Namespace) -> int: def run_capture(args: argparse.Namespace) -> int:
artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else default_artifact_dir() artifact_dir = pathlib.Path(args.artifact_dir) if args.artifact_dir else default_artifact_dir()
artifact_dir.mkdir(parents=True, exist_ok=True) artifact_dir.mkdir(parents=True, exist_ok=True)
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 device = detect_video_device(args.device_label) if args.device == "auto" else args.device
command = ffmpeg_cmd(device, args) command = ffmpeg_cmd(device, args)
frame_size = args.width * args.height frame_size = args.width * args.height
@ -787,7 +820,10 @@ def run_capture(args: argparse.Namespace) -> int:
finally: finally:
raw_path.unlink(missing_ok=True) raw_path.unlink(missing_ok=True)
analysis_elapsed = time.monotonic() - analysis_started 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)) expected_frames = max(1, int(args.duration * args.fps))
coverage_ok = frame_index >= int(expected_frames * 0.9) coverage_ok = frame_index >= int(expected_frames * 0.9)
structural_ok = integrity_report is None or integrity_report.get("verdict") == "pass" 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", "schema": "lesavka.rct-uvc-artifact-probe.v1",
"source": args.source, "source": args.source,
"device": device, "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"), "capture_mode": "deep-mjpeg" if args.deep_capture and args.source == "device" else ("stream" if args.stream_analyze else "rawfile"),
"width": args.width, "width": args.width,
"height": args.height, "height": args.height,

View File

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

View File

@ -44,6 +44,8 @@ fn rct_uvc_artifact_probe_documents_late_path_lower_half_detection() {
"static_pct", "static_pct",
"reference_", "reference_",
"--source", "--source",
"--input-video",
"choices=[\"device\", \"x11\", \"file\"]",
"--crop", "--crop",
"PGM", "PGM",
"--host", "--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] #[test]
fn rct_uvc_artifact_probe_terminates_when_malformed_video_never_decodes() { fn rct_uvc_artifact_probe_terminates_when_malformed_video_never_decodes() {
for expected in [ for expected in [