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]]
|
[[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",
|
||||||
|
|||||||
@ -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]
|
||||||
|
|||||||
@ -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"
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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 [
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user