release: add receiver artifact replay mode
This commit is contained in:
parent
ed9faecb20
commit
8f1371de97
6
Cargo.lock
generated
6
Cargo.lock
generated
@ -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",
|
||||
|
||||
@ -4,7 +4,7 @@ path = "src/main.rs"
|
||||
|
||||
[package]
|
||||
name = "lesavka_client"
|
||||
version = "0.27.14"
|
||||
version = "0.27.15"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lesavka_common"
|
||||
version = "0.27.14"
|
||||
version = "0.27.15"
|
||||
edition = "2024"
|
||||
build = "build.rs"
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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,6 +573,15 @@ 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)
|
||||
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
|
||||
@ -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,
|
||||
|
||||
@ -16,7 +16,7 @@ bench = false
|
||||
|
||||
[package]
|
||||
name = "lesavka_server"
|
||||
version = "0.27.14"
|
||||
version = "0.27.15"
|
||||
edition = "2024"
|
||||
autobins = false
|
||||
|
||||
|
||||
@ -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 [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user