From 32b058973ed35b9341fe15325dd192f0313f1d80 Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Sun, 17 May 2026 17:54:56 -0300 Subject: [PATCH] probe: pause live upstream during synthetic rct runs --- Cargo.lock | 6 +- client/Cargo.toml | 2 +- common/Cargo.toml | 2 +- scripts/manual/run_synthetic_rct_uvc_probe.py | 94 +++++++++++++++---- server/Cargo.toml | 2 +- ...synthetic_rct_uvc_probe_manual_contract.rs | 5 + 6 files changed, 89 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2996cf8..5e62bfe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1652,7 +1652,7 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "lesavka_client" -version = "0.22.55" +version = "0.22.56" dependencies = [ "anyhow", "async-stream", @@ -1686,7 +1686,7 @@ dependencies = [ [[package]] name = "lesavka_common" -version = "0.22.55" +version = "0.22.56" dependencies = [ "anyhow", "base64", @@ -1698,7 +1698,7 @@ dependencies = [ [[package]] name = "lesavka_server" -version = "0.22.55" +version = "0.22.56" dependencies = [ "anyhow", "base64", diff --git a/client/Cargo.toml b/client/Cargo.toml index cf96872..f50de80 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -4,7 +4,7 @@ path = "src/main.rs" [package] name = "lesavka_client" -version = "0.22.55" +version = "0.22.56" edition = "2024" [dependencies] diff --git a/common/Cargo.toml b/common/Cargo.toml index 8f89fbb..dc37447 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lesavka_common" -version = "0.22.55" +version = "0.22.56" 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 b439e99..b8350ae 100755 --- a/scripts/manual/run_synthetic_rct_uvc_probe.py +++ b/scripts/manual/run_synthetic_rct_uvc_probe.py @@ -21,6 +21,7 @@ DEFAULT_JPEG_QUALITY = 82 HIGH_SPEED_ISOCHRONOUS_MICROFRAMES_PER_SEC = 8000 DEFAULT_ISOCHRONOUS_LIMIT_PCT = 85 DEFAULT_UVC_MAX_PACKET = 1024 +DEFAULT_MEDIA_CONTROL_PATH = "/tmp/lesavka-media.control" MARKER_BITS = 32 MARKER_COLUMNS = 16 CADENCE_REASONS = {"frame_repeat", "frame_gap", "frame_backwards"} @@ -53,6 +54,16 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--artifact-dir", default="") parser.add_argument("--remote-rct-dir", default="") parser.add_argument("--remote-inject-dir", default="") + parser.add_argument( + "--pause-local-live-upstream", + action="store_true", + help="temporarily write camera=0 to the local Lesavka media control file so a live client does not preempt the synthetic injector", + ) + parser.add_argument( + "--media-control-path", + default=os.environ.get("LESAVKA_MEDIA_CONTROL", DEFAULT_MEDIA_CONTROL_PATH), + help=f"local live-media control file used with --pause-local-live-upstream (default: {DEFAULT_MEDIA_CONTROL_PATH})", + ) parser.add_argument( "--capture-before-inject", action="store_true", @@ -155,6 +166,48 @@ def default_artifact_dir(mode: str) -> pathlib.Path: return pathlib.Path("artifacts/synthetic-rct") / f"{safe_mode}-{timestamp()}" +def media_control_with_camera(raw: str | None, enabled: bool) -> str: + tokens = raw.split() if raw else [] + rendered: list[str] = [] + saw_camera = False + saw_microphone = False + saw_audio = False + for token in tokens: + key, sep, _value = token.partition("=") + if sep and key == "camera": + rendered.append(f"camera={1 if enabled else 0}") + saw_camera = True + else: + rendered.append(token) + saw_microphone = saw_microphone or (sep and key in {"microphone", "mic"}) + saw_audio = saw_audio or (sep and key in {"audio", "speaker"}) + if not saw_camera: + rendered.insert(0, f"camera={1 if enabled else 0}") + if not saw_microphone: + rendered.append("microphone=1") + if not saw_audio: + rendered.append("audio=1") + return " ".join(rendered) + "\n" + + +def pause_local_live_upstream(args: argparse.Namespace) -> tuple[pathlib.Path, bytes | None]: + path = pathlib.Path(args.media_control_path) + original = path.read_bytes() if path.exists() else None + raw = original.decode(errors="replace") if original is not None else None + path.write_text(media_control_with_camera(raw, False)) + print(f"paused local live camera upstream via {path}", file=sys.stderr) + time.sleep(0.5) + return path, original + + +def restore_local_live_upstream(path: pathlib.Path, original: bytes | None) -> None: + if original is None: + path.unlink(missing_ok=True) + else: + path.write_bytes(original) + print(f"restored local live media control at {path}", file=sys.stderr) + + def run_remote_orchestrated(args: argparse.Namespace) -> int: if (not args.inject_host and not args.local_inject) or not args.rct_host: raise SystemExit( @@ -273,6 +326,8 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int: "inject_host": args.inject_host, "local_inject": args.local_inject, "rct_host": args.rct_host, + "pause_local_live_upstream": args.pause_local_live_upstream, + "media_control_path": args.media_control_path, }, indent=2, sort_keys=True, @@ -332,24 +387,31 @@ def run_remote_orchestrated(args: argparse.Namespace) -> int: capture: subprocess.Popen[Any] | None = None diagnosis: list[str] = [] - if args.capture_before_inject: - capture = start_capture() - time.sleep(1.0) - inject = start_inject() - capture_rc, inject_rc = wait_capture_or_inject_exit(capture, inject) - else: - inject = start_inject() - time.sleep(max(0.0, args.inject_warmup_s)) - inject_rc = inject.poll() - if inject_rc is not None: - capture_rc = None - diagnosis.append( - "synthetic uplink exited before capture warmup completed; disconnect the live client or pause upstream webcam before running the isolated probe" - ) - print(f"synthetic uplink exited before capture started rc={inject_rc}", file=sys.stderr) - else: + paused_control: tuple[pathlib.Path, bytes | None] | None = None + try: + if args.pause_local_live_upstream: + paused_control = pause_local_live_upstream(args) + if args.capture_before_inject: capture = start_capture() + time.sleep(1.0) + inject = start_inject() capture_rc, inject_rc = wait_capture_or_inject_exit(capture, inject) + else: + inject = start_inject() + time.sleep(max(0.0, args.inject_warmup_s)) + inject_rc = inject.poll() + if inject_rc is not None: + capture_rc = None + diagnosis.append( + "synthetic uplink exited before capture warmup completed; disconnect the live client or pause upstream webcam before running the isolated probe" + ) + print(f"synthetic uplink exited before capture started rc={inject_rc}", file=sys.stderr) + else: + capture = start_capture() + capture_rc, inject_rc = wait_capture_or_inject_exit(capture, inject) + finally: + if paused_control is not None: + restore_local_live_upstream(*paused_control) local_capture = artifact_dir / "capture" local_inject = artifact_dir / "inject" if capture is not None: diff --git a/server/Cargo.toml b/server/Cargo.toml index 692d8ea..9e5dbce 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -16,7 +16,7 @@ bench = false [package] name = "lesavka_server" -version = "0.22.55" +version = "0.22.56" edition = "2024" autobins = false 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 a0b5341..891df97 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 @@ -41,6 +41,8 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() { "--capture-before-inject", "--inject-warmup-s", "--capture-finish-grace-s", + "--pause-local-live-upstream", + "--media-control-path", "--jpeg-quality", "--inject-max-frame-bytes", "--stream-analyze", @@ -93,6 +95,9 @@ fn synthetic_probe_keeps_bundled_network_ingress_and_rct_comparison_markers() { "synthetic uplink completed but RCT capture did not finish", "synthetic injector was preempted after sending", "synthetic uplink exited before capture warmup completed", + "paused local live camera upstream", + "restored local live media control", + "media_control_with_camera", "max_lower_mae", "ffmpeg", "v4l2",