From 741232068efa8a5301d18ce00a3edcba2a667b44 Mon Sep 17 00:00:00 2001 From: Joe Petrucelli Date: Wed, 30 Sep 2026 11:34:24 -0400 Subject: [PATCH 1/2] feat: render receipts and stale-install detection --- CHANGELOG.md | 11 ++ README.md | 33 ++++++ redactcam/__init__.py | 11 ++ redactcam/apply.py | 25 ++++ redactcam/cli.py | 57 +++++++++- redactcam/pipeline.py | 78 +++++++++++++ redactcam/provenance.py | 240 +++++++++++++++++++++++++++++++++++++++ tests/test_cli.py | 35 ++++++ tests/test_pipeline.py | 82 +++++++++++++ tests/test_provenance.py | 171 ++++++++++++++++++++++++++++ 10 files changed, 741 insertions(+), 2 deletions(-) create mode 100644 redactcam/provenance.py create mode 100644 tests/test_provenance.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fb02cf9..f814e9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to this project are documented here. The format follows ## Unreleased +### Added + +- **Render receipts.** `redact_video()` writes `_redactcam_receipt.json` + (`result.receipt`) naming the exact code (`code_sha256`, installed git commit), + the SHA256 of every model file resolved, the source and output hashes, the + native runtime (OpenCV, numpy, which onnxruntime distribution, ffmpeg build) and + the coverage outcome. A run that reused a cached timeline says so. +- **Stale-install detection.** `redactcam --identity`, `--expect-revision`, + `--expect-code-sha256`, `--expect-version` and `redactcam.check_current()`; + an install whose identity cannot be determined never passes. + ### Changed - **`onnxruntime` is no longer a base dependency — install `redactcam[cpu]` or diff --git a/README.md b/README.md index 3e1c718..f3c4bca 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,36 @@ It exits non-zero and names the module if one fails to load, exits 1 with the repair when the GPU build is installed but CUDA is unavailable, and needs no model weights, so it will not trigger the first-run download. +### Which build is installed + +A version string did not change across the tracking fix, so it cannot say whether +an install is current. `redactcam --identity` prints the installed identity as +JSON: the version, `code_sha256` (a digest of the package's own sources), the git +commit it was installed from when that is recorded, and the native stack including +which onnxruntime *distribution* is present. To refuse a stale install, compare +against what you expect: + +```bash +redactcam --expect-revision c34854e # commit prefix, at least 7 characters +redactcam --expect-code-sha256 --expect-version 0.2.0 +``` + +Any mismatch, or a value that cannot be determined (a wheel or frozen build +records no commit), exits 1: an unknown is never a match. From Python, +`redactcam.check_current(expected_revision=…)` raises `StaleInstallError`. + +### Render receipts + +Every completed `redact_video()` run writes `_redactcam_receipt.json` beside +the mask (`result.receipt`). It records the installed identity above, the SHA256 +of the source, of the output, and of every model file the run resolved (a model +that did not resolve is `null`, a file that could not be read has a `null` hash), +the coverage outcome, and the settings. `"detection": "reused_sidecar"` marks a run +that loaded a cached timeline: the sidecar records the source hash but not what +produced it, so for those runs the receipt identifies the code that built the mask +and render, not necessarily the code that detected. Use `--fresh` when the +detection identity matters. + ### Python ```python @@ -379,6 +409,9 @@ Everything below is importable straight from `redactcam`. **Pipeline** — `redact_video()`, `RedactionResult`, `CoverageError`, `file_hash()`. +**Provenance** — `runtime_identity()`, `model_identity()`, `check_current()`, +`StaleInstallError`. + **Detection** — `detect_and_track()` (the dense per-frame workhorse), `detect_regions()` (sparse, no tracking), `detect_image()` (one still), `build_detectors()`, `build_image_detectors()`, and the four single-model diff --git a/redactcam/__init__.py b/redactcam/__init__.py index 040b73e..f71172e 100644 --- a/redactcam/__init__.py +++ b/redactcam/__init__.py @@ -51,6 +51,12 @@ ROAD_FOOTAGE_BOX_DILATION_PX, ROAD_FOOTAGE_FEATHER_PX, ) +from .provenance import ( + StaleInstallError, + check_current, + model_identity, + runtime_identity, +) from .timeline import ( BlurTimeline, build_timeline, @@ -117,6 +123,11 @@ "cabin_region", "plate_inside", "verify_cabin_coverage", + # provenance + "runtime_identity", + "model_identity", + "check_current", + "StaleInstallError", # models "ModelSpec", "resolve_model", diff --git a/redactcam/apply.py b/redactcam/apply.py index e04928a..770f4d4 100644 --- a/redactcam/apply.py +++ b/redactcam/apply.py @@ -20,11 +20,36 @@ from __future__ import annotations import logging +import shutil import subprocess from pathlib import Path logger = logging.getLogger(__name__) + +def tool_status(name: str) -> dict: + """Where ``name`` resolves on PATH and the first line of ``-version``. + + Never raises. ``error`` is ``None`` when the tool ran, else a sentence saying + what is wrong.""" + path = shutil.which(name) + if path is None: + return {"name": name, "path": None, "version": None, "error": f"{name} was not found on PATH"} + try: + proc = subprocess.run([path, "-version"], capture_output=True, text=True, timeout=15) + except (OSError, subprocess.SubprocessError) as exc: + return {"name": name, "path": path, "version": None, "error": f"{name} did not run: {exc}"} + lines = (proc.stdout or "").strip().splitlines() + if proc.returncode != 0 or not lines: + return { + "name": name, + "path": path, + "version": None, + "error": f"{name} -version exited {proc.returncode}", + } + return {"name": name, "path": path, "version": lines[0][:200], "error": None} + + # boxblur luma radius at 1920 px wide. Scaled by actual width in blur_radius(). # 12 at 1080p reduces a face to an unrecognisable smear while leaving the scene # legible; well below this and features start to survive at full-screen playback. diff --git a/redactcam/cli.py b/redactcam/cli.py index 7fa2583..470d96f 100644 --- a/redactcam/cli.py +++ b/redactcam/cli.py @@ -3,10 +3,12 @@ from __future__ import annotations import argparse +import json import logging import sys from pathlib import Path +from . import provenance from .models import DEFAULT_MODELS, ModelSpec from .pipeline import CoverageError, redact_video @@ -63,6 +65,22 @@ def _parser() -> argparse.ArgumentParser: action="store_true", help="load the native extensions, report their versions, and exit; takes no input", ) + p.add_argument( + "--identity", + action="store_true", + help="print the installed code/native-runtime identity as JSON and exit", + ) + p.add_argument("--expect-version", metavar="X.Y.Z", help="exit 1 unless the installed version matches") + p.add_argument( + "--expect-code-sha256", + metavar="HEX", + help="exit 1 unless the installed sources hash to this (see --identity)", + ) + p.add_argument( + "--expect-revision", + metavar="COMMIT", + help="exit 1 unless the installed git commit starts with this (>= 7 chars)", + ) return p @@ -135,6 +153,33 @@ def _models(pairs: list[str]) -> dict[str, ModelSpec]: return out +def _check_expectations(args: argparse.Namespace) -> int: + """Exit 1 when the installed package is not the build the caller expected.""" + try: + provenance.check_current( + expected_version=args.expect_version, + expected_code_sha256=args.expect_code_sha256, + expected_revision=args.expect_revision, + ) + except (provenance.StaleInstallError, ValueError) as exc: + print(f"redactcam: {exc}", file=sys.stderr) + return 1 + print("identity matches the expected build") + return 0 + + +def _models(pairs: list[str]) -> dict[str, ModelSpec]: + out: dict[str, ModelSpec] = {} + for pair in pairs: + kind, _, path = pair.partition("=") + if not path: + raise SystemExit(f"--model expects KIND=PATH, got {pair!r}") + if kind not in DEFAULT_MODELS: + raise SystemExit(f"unknown model kind {kind!r}; expected one of {sorted(DEFAULT_MODELS)}") + out[kind] = ModelSpec(name=kind, path=path) + return out + + def main(argv: list[str] | None = None) -> int: parser = _parser() args = parser.parse_args(argv) @@ -142,8 +187,16 @@ def main(argv: list[str] | None = None) -> int: level=logging.INFO if args.verbose else logging.WARNING, format="%(levelname)s %(name)s: %(message)s", ) - if args.check_deps: - return _check_deps() + expecting = args.expect_version or args.expect_code_sha256 or args.expect_revision + if args.check_deps or expecting or args.identity: + rc = 0 + if args.check_deps: + rc = _check_deps() + if expecting: + rc = max(rc, _check_expectations(args)) + if args.identity: + print(json.dumps(provenance.runtime_identity(), indent=2, sort_keys=True)) + return rc if args.input is None: parser.error("input is required") try: diff --git a/redactcam/pipeline.py b/redactcam/pipeline.py index dd1481e..14b7627 100644 --- a/redactcam/pipeline.py +++ b/redactcam/pipeline.py @@ -16,6 +16,7 @@ import hashlib import logging from dataclasses import dataclass +from datetime import UTC, datetime from pathlib import Path from . import apply as _apply @@ -33,6 +34,7 @@ ROAD_FOOTAGE_BOX_DILATION_PX, ROAD_FOOTAGE_FEATHER_PX, ) +from .provenance import model_identity, runtime_identity, write_receipt logger = logging.getLogger(__name__) @@ -60,6 +62,7 @@ class RedactionResult: face_detections: int plate_detections: int coverage: CoverageReport | None + receipt: Path | None = None # JSON identity of what produced this run; see provenance def file_hash(path: str | Path, block: int = 1 << 20) -> str: @@ -103,6 +106,8 @@ def redact_video( sidecar to feed into your own encode. Raises ``CoverageError`` when verification finds an uncovered vehicle cabin. + A ``_redactcam_receipt.json`` naming the exact code, model files and + native runtime is written beside the mask on every completed run. """ source_video = Path(source_video) work = Path(work_dir) if work_dir else source_video.parent @@ -126,7 +131,9 @@ def redact_video( if tl is not None and tl.source_hash == source_hash: logger.info("reusing timeline %s (hash match)", sidecar_path.name) n_face = n_plate = -1 # not re-counted on a reuse + detection = "reused_sidecar" else: + detection = "fresh" frame_boxes, decoded, n_face, n_plate = detect_and_track(source_video, **opts) # The decoded count is authoritative: container metadata routinely lies, # and a mask one frame short blurs the wrong pixels for the whole tail. @@ -183,6 +190,26 @@ def redact_video( strength=blur_strength, ) + receipt_path = write_receipt( + work / f"{stem}_redactcam_receipt.json", + _build_receipt( + source_hash=source_hash, + output=output, + models=model_identity(paths), + detection=detection, + report=report, + verify=verify, + settings={ + "render": render, + "blur_strength": blur_strength, + "min_coverage": min_coverage, + "box_dilation_px": box_dilation_px, + "feather_px": feather_px, + "detect": {k: v for k, v in opts.items() if not k.endswith("_model_path")}, + }, + ), + ) + return RedactionResult( sidecar=sidecar_path, mask=mask_path, @@ -191,4 +218,55 @@ def redact_video( face_detections=n_face, plate_detections=n_plate, coverage=report, + receipt=receipt_path, ) + + +RECEIPT_SCHEMA = "redactcam.render-receipt/v1" + + +def _hash_or_none(path: Path | None) -> str | None: + """SHA256 of ``path``; ``None`` when there is no output or it cannot be read + (recorded as unknown rather than failing a run that already rendered).""" + if path is None: + return None + try: + return file_hash(path) + except OSError: + return None + + +def _build_receipt( + *, + source_hash: str, + output: Path | None, + models: dict, + detection: str, + report: CoverageReport | None, + verify: bool, + settings: dict, +) -> dict: + """The render receipt: what ran, on what, with which files. + + ``detection`` is ``"reused_sidecar"`` when the timeline came from disk. The + sidecar records the source hash but not what produced it, so for a reused + timeline the code and models named here are the ones that built the mask and + render, not necessarily the ones that detected. Delete the sidecar (or pass + ``reuse_sidecar=False``) when the detection identity matters. + """ + return { + "schema": RECEIPT_SCHEMA, + "generated_utc": datetime.now(UTC).isoformat(timespec="seconds"), + "identity": runtime_identity(), + "models": models, + "source_sha256": source_hash, + "output_sha256": _hash_or_none(output), + "detection": detection, + "coverage": { + "checked": report is not None, + "ok": report.ok if report is not None else None, + "vehicle_frames": report.vehicle_frames if report is not None else None, + "leaks": len(report.leaks) if report is not None else None, + }, + "settings": settings, + } diff --git a/redactcam/provenance.py b/redactcam/provenance.py new file mode 100644 index 0000000..b5330ac --- /dev/null +++ b/redactcam/provenance.py @@ -0,0 +1,240 @@ +"""Processing identity — which code, which models, which native runtime. + +A version string is not an identity: ``0.2.0`` covered the tracker before and +after the one-detection-one-track fix, so two builds that blurred differently +carried the same number. A consumer that pins a git commit cannot tell from the +number whether what it imported is that commit either. This module answers with +things that cannot drift apart from the bytes that ran: + +* ``code_sha256`` — a digest of the package's own ``.py`` sources. +* ``installed_revision`` — the commit a VCS install recorded (PEP 610), else the + git HEAD of a source checkout, else unknown. +* ``model_identity`` — the SHA256 of every model file a run actually resolved. +* ``runtime_identity`` — Python, OpenCV, numpy, the onnxruntime *distribution* + (CPU and CUDA wheels share one import name), and the ffmpeg build. + +``check_current`` is the stale-install gate a consumer runs before calling the +package current. ``redact_video`` writes the same identity into a render receipt +beside every output. + +Everything here is bounded: fixed keys, no free-form logs, no URLs (a VCS install +URL can carry credentials, so only the commit is ever read from it). +""" + +from __future__ import annotations + +import hashlib +import importlib.metadata +import json +import os +import platform +import subprocess +import sys +import tempfile +from collections.abc import Mapping +from pathlib import Path + +from .apply import tool_status + +MIN_REVISION_CHARS = 7 + + +class StaleInstallError(RuntimeError): + """The installed package is not the one the caller expected.""" + + +def sha256_file(path: str | Path, block: int = 1 << 20) -> str: + h = hashlib.sha256() + with Path(path).open("rb") as f: + for chunk in iter(lambda: f.read(block), b""): + h.update(chunk) + return h.hexdigest() + + +def code_sha256(package_dir: Path | None = None) -> tuple[str | None, int]: + """``(digest, file_count)`` over every ``.py`` under the package directory. + + Path and content both feed the digest, files are taken in sorted order, and + CRLF is folded to LF so a Windows autocrlf checkout hashes like the wheel. + ``(None, 0)`` when there are no sources on disk (a frozen build): an empty + digest would look like an identity and match every other frozen build. + """ + root = Path(package_dir) if package_dir is not None else Path(__file__).resolve().parent + files = sorted(p for p in root.rglob("*.py") if "__pycache__" not in p.parts) + if not files: + return None, 0 + h = hashlib.sha256() + for f in files: + h.update(f.relative_to(root).as_posix().encode()) + h.update(b"\0") + h.update(f.read_bytes().replace(b"\r\n", b"\n")) + h.update(b"\0") + return h.hexdigest(), len(files) + + +def installed_revision(source_root: Path | None = None) -> dict: + """The commit this code came from: ``{"commit": str | None, "source": ...}``. + + ``source`` is ``"direct_url"`` (a ``pip install git+…`` recorded it), + ``"git"`` (a checkout whose HEAD was read) or ``None`` (a PyPI-style wheel or + a frozen build records nothing, and this does not guess). + """ + try: + text = importlib.metadata.distribution("redactcam").read_text("direct_url.json") + commit = (json.loads(text).get("vcs_info") or {}).get("commit_id") if text else None + if commit: + return {"commit": str(commit), "source": "direct_url"} + except (importlib.metadata.PackageNotFoundError, ValueError, AttributeError): + pass + root = Path(source_root) if source_root is not None else Path(__file__).resolve().parent.parent + if (root / ".git").exists(): + try: + out = subprocess.run( + ["git", "-C", str(root), "rev-parse", "HEAD"], + capture_output=True, + text=True, + timeout=5, + ) + head = out.stdout.strip() + if out.returncode == 0 and len(head) >= 40: + return {"commit": head, "source": "git"} + except (OSError, subprocess.SubprocessError): + pass + return {"commit": None, "source": None} + + +def model_identity(paths: Mapping[str, Path | str | None]) -> dict[str, dict | None]: + """``{kind: {"path", "sha256", "size_bytes"} | None}`` for the models a run + resolved. ``None`` records a kind that did not resolve, and a ``None`` hash a + file that could not be read — the receipt says so rather than dropping it. A file shared by two kinds (COCO serves both vehicle + and person) is hashed once.""" + by_path: dict[str, dict] = {} + out: dict[str, dict | None] = {} + for kind, raw in paths.items(): + if not raw: + out[kind] = None + continue + p = Path(raw) + key = str(p) + if key not in by_path: + try: + by_path[key] = { + "path": key, + "sha256": sha256_file(p), + "size_bytes": p.stat().st_size, + } + except OSError: + # An unreadable file is recorded as an unknown hash, never omitted + # and never faked: the receipt must not claim what it could not see. + by_path[key] = {"path": key, "sha256": None, "size_bytes": None} + out[kind] = dict(by_path[key]) + return out + + +def _dist_version(*names: str) -> tuple[str | None, str | None]: + for name in names: + try: + return name, importlib.metadata.version(name) + except importlib.metadata.PackageNotFoundError: + continue + return None, None + + +def runtime_identity() -> dict: + """Bounded description of what is running: this package, and its native stack.""" + import cv2 + import numpy + + import redactcam + + digest, count = code_sha256() + ort_dist, ort_version = _dist_version("onnxruntime-gpu", "onnxruntime") + providers: list[str] | None = None + try: + import onnxruntime + + providers = list(onnxruntime.get_available_providers()) + except ImportError: + pass + return { + "redactcam": { + "version": redactcam.__version__, + "code_sha256": digest, + "code_files": count, + "revision": installed_revision(), + }, + "python": sys.version.split()[0], + "platform": platform.platform(), + "frozen": bool(getattr(sys, "frozen", False)), + "native": { + "opencv": cv2.__version__, + "numpy": numpy.__version__, + "onnxruntime_distribution": ort_dist, + "onnxruntime": ort_version, + "onnxruntime_providers": providers, + "ffmpeg": tool_status("ffmpeg")["version"], + "ffprobe": tool_status("ffprobe")["version"], + }, + } + + +def check_current( + *, + expected_version: str | None = None, + expected_code_sha256: str | None = None, + expected_revision: str | None = None, +) -> dict: + """Return the installed identity if it matches every expectation given, else + raise ``StaleInstallError`` naming each mismatch. + + ``expected_revision`` is a commit id or a prefix of at least 7 characters. + An installed revision that cannot be determined never passes: an unknown is + not a match, and treating it as one is how a stale install gets called + current. + """ + if expected_revision is not None and len(expected_revision) < MIN_REVISION_CHARS: + raise ValueError(f"expected_revision needs at least {MIN_REVISION_CHARS} characters") + if not (expected_version or expected_code_sha256 or expected_revision): + raise ValueError("check_current needs at least one expectation to compare against") + + ident = runtime_identity() + me = ident["redactcam"] + problems: list[str] = [] + if expected_version and me["version"] != expected_version: + problems.append(f"version is {me['version']}, expected {expected_version}") + if expected_code_sha256: + if me["code_sha256"] is None: + problems.append("code_sha256 cannot be determined (no package sources on disk)") + elif me["code_sha256"] != expected_code_sha256: + problems.append( + f"code_sha256 is {me['code_sha256'][:12]}…, expected {expected_code_sha256[:12]}…" + ) + if expected_revision: + commit = me["revision"]["commit"] + if commit is None: + problems.append( + "installed revision cannot be determined (not a VCS install or a git checkout)" + ) + elif not commit.startswith(expected_revision.lower()): + problems.append(f"installed revision is {commit[:12]}, expected {expected_revision}") + if problems: + raise StaleInstallError( + "installed redactcam is not the expected build: " + "; ".join(problems) + ) + return ident + + +def write_receipt(path: str | Path, receipt: dict) -> Path: + """Write ``receipt`` as JSON via a temp file and rename, so a crash never + leaves a half-written receipt that looks like a real one.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(dir=path.parent, suffix=".tmp") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + json.dump(receipt, f, indent=2, sort_keys=True, default=str) + f.write("\n") + os.replace(tmp_name, path) + finally: + Path(tmp_name).unlink(missing_ok=True) + return path diff --git a/tests/test_cli.py b/tests/test_cli.py index df2df96..16779a1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -146,3 +146,38 @@ def _boom(_): monkeypatch.setattr(cli, "_session_options_for_check", _boom) assert cli.main(["--check-deps"]) == 1 assert "no onnxruntime here" in capsys.readouterr().err + + +class TestIdentityFlags: + @pytest.fixture(autouse=True) + def _fixed(self, monkeypatch): + from redactcam import provenance + + monkeypatch.setattr( + provenance, + "runtime_identity", + lambda: { + "redactcam": { + "version": "0.2.0", + "code_sha256": "ab" * 32, + "revision": {"commit": "c34854e" + "0" * 33, "source": "git"}, + } + }, + ) + + def test_identity_prints_json_and_needs_no_input(self, capsys): + import json + + assert cli.main(["--identity"]) == 0 + assert json.loads(capsys.readouterr().out)["redactcam"]["version"] == "0.2.0" + + def test_a_matching_expectation_exits_zero(self): + assert cli.main(["--expect-revision", "c34854e"]) == 0 + + def test_a_stale_install_is_a_nonzero_exit_that_says_so(self, capsys): + assert cli.main(["--expect-revision", "6d731a5fb"]) == 1 + assert "not the expected build" in capsys.readouterr().err + + def test_a_too_short_revision_is_refused(self, capsys): + assert cli.main(["--expect-revision", "c34"]) == 1 + assert "at least 7" in capsys.readouterr().err diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 26e6559..8911ba9 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -131,3 +131,85 @@ def test_file_hash_is_content_addressed(tmp_path): assert pl.file_hash(a) == pl.file_hash(b) b.write_bytes(b"different") assert pl.file_hash(a) != pl.file_hash(b) + + +class TestRenderReceipt: + """A render must say exactly what produced it: the code, the model files, the + native runtime -- not a version string that did not change across the + tracking fix.""" + + @pytest.fixture + def real_models(self, monkeypatch, tmp_path): + files = {} + + def _resolve(spec, cache=None): + p = tmp_path / f"{spec.name}.onnx" + p.write_bytes(f"weights-of-{spec.name}".encode()) + files[spec.name] = p + return p + + monkeypatch.setattr(pl, "resolve_model", _resolve) + return files + + def _receipt(self, result): + import json + + return json.loads(result.receipt.read_text()) + + def test_a_receipt_is_written_beside_the_outputs(self, wired, real_models, source, tmp_path): + result = pl.redact_video(source, work_dir=tmp_path / "work") + assert result.receipt == tmp_path / "work" / "clip_redactcam_receipt.json" + r = self._receipt(result) + assert r["schema"] == "redactcam.render-receipt/v1" + + def test_it_carries_code_and_model_hashes(self, wired, real_models, source, tmp_path): + from redactcam.provenance import code_sha256, sha256_file + + r = self._receipt(pl.redact_video(source, work_dir=tmp_path / "work")) + assert r["identity"]["redactcam"]["code_sha256"] == code_sha256()[0] + for kind, path in real_models.items(): + assert r["models"][kind]["sha256"] == sha256_file(path) + assert r["identity"]["native"]["opencv"] + + def test_it_carries_source_and_output_hashes(self, wired, real_models, source, tmp_path, monkeypatch): + monkeypatch.setattr( + pl._apply, + "apply_blur", + lambda src, mask, out, **kw: (out.write_bytes(b"rendered"), out)[1], + ) + result = pl.redact_video(source, work_dir=tmp_path / "work") + r = self._receipt(result) + assert r["source_sha256"] == pl.file_hash(source) + assert r["output_sha256"] == pl.file_hash(result.output) + + def test_no_render_has_no_output_hash(self, wired, real_models, source, tmp_path): + r = self._receipt(pl.redact_video(source, work_dir=tmp_path / "work", render=False)) + assert r["output_sha256"] is None + assert r["settings"]["render"] is False + + def test_a_reused_timeline_is_labelled_as_not_produced_by_this_run( + self, wired, real_models, source, tmp_path + ): + """The sidecar does not record what produced it, so a reused timeline's + code and models are unknown -- the receipt must not imply otherwise.""" + work = tmp_path / "work" + first = self._receipt(pl.redact_video(source, work_dir=work, render=False)) + again = self._receipt(pl.redact_video(source, work_dir=work, render=False)) + assert first["detection"] == "fresh" + assert again["detection"] == "reused_sidecar" + + def test_it_records_coverage_and_settings(self, wired, real_models, source, tmp_path): + r = self._receipt(pl.redact_video(source, work_dir=tmp_path / "work", blur_strength=20)) + assert r["coverage"] == {"checked": True, "ok": True, "vehicle_frames": 3, "leaks": 0} + assert r["settings"]["blur_strength"] == 20 + assert "face_model_path" not in r["settings"]["detect"] # paths live under models + + def test_skipped_verification_is_recorded_as_unchecked(self, wired, real_models, source, tmp_path): + r = self._receipt(pl.redact_video(source, work_dir=tmp_path / "work", verify=False)) + assert r["coverage"]["checked"] is False and r["coverage"]["ok"] is None + + def test_an_unresolved_model_is_recorded_as_missing(self, wired, source, tmp_path, monkeypatch): + monkeypatch.setattr(pl, "resolve_model", lambda spec, cache=None: None) + r = self._receipt(pl.redact_video(source, work_dir=tmp_path / "work", verify=False)) + assert r["models"]["plate"] is None + diff --git a/tests/test_provenance.py b/tests/test_provenance.py new file mode 100644 index 0000000..107af84 --- /dev/null +++ b/tests/test_provenance.py @@ -0,0 +1,171 @@ +"""Processing identity: what exactly ran, and is the installed copy the one the +caller thinks it is. + +A render receipt that says "redactcam 0.2.0" is not an identity -- the version +string did not change across the tracking fix (#10), so two very different +builds carried the same number. The identity is the hash of the code itself, +the commit it was installed from when that is knowable, and the hash of every +model file the run resolved. +""" + +import json + +import pytest + +from redactcam import provenance as pv + + +class TestCodeHash: + def test_is_a_sha256_over_the_package_sources(self): + digest, count = pv.code_sha256() + assert digest is not None and len(digest) == 64 + assert count >= 10 + + def test_changes_when_a_source_file_changes(self, tmp_path): + (tmp_path / "a.py").write_text("x = 1\n") + before, _ = pv.code_sha256(tmp_path) + (tmp_path / "a.py").write_text("x = 2\n") + after, _ = pv.code_sha256(tmp_path) + assert before != after + + def test_line_endings_do_not_change_it(self, tmp_path): + """A Windows checkout with autocrlf must hash the same as the wheel.""" + (tmp_path / "a.py").write_bytes(b"x = 1\ny = 2\n") + lf, _ = pv.code_sha256(tmp_path) + (tmp_path / "a.py").write_bytes(b"x = 1\r\ny = 2\r\n") + crlf, _ = pv.code_sha256(tmp_path) + assert lf == crlf + + def test_bytecode_caches_are_ignored(self, tmp_path): + (tmp_path / "a.py").write_text("x = 1\n") + before, _ = pv.code_sha256(tmp_path) + cache = tmp_path / "__pycache__" + cache.mkdir() + (cache / "a.py").write_text("junk") + assert pv.code_sha256(tmp_path)[0] == before + + def test_no_sources_on_disk_is_unknown_not_a_hash_of_nothing(self, tmp_path): + """A frozen build has no .py files. An empty digest would look like an + identity and match every other frozen build.""" + assert pv.code_sha256(tmp_path) == (None, 0) + + +class TestInstalledRevision: + def test_reads_the_commit_a_vcs_install_recorded(self, monkeypatch): + class Dist: + def read_text(self, name): + assert name == "direct_url.json" + return json.dumps( + { + "url": "https://user:secret@example.invalid/redactcam", + "vcs_info": {"vcs": "git", "commit_id": "6d731a5fb" + "0" * 31}, + } + ) + + monkeypatch.setattr(pv.importlib.metadata, "distribution", lambda name: Dist()) + rev = pv.installed_revision() + assert rev["commit"].startswith("6d731a5fb") + assert rev["source"] == "direct_url" + # the URL can carry credentials; it must never reach a receipt + assert "secret" not in json.dumps(rev) + + def test_unknown_when_nothing_records_it(self, monkeypatch, tmp_path): + class Dist: + def read_text(self, name): + return None + + monkeypatch.setattr(pv.importlib.metadata, "distribution", lambda name: Dist()) + rev = pv.installed_revision(tmp_path) # tmp_path has no .git either + assert rev == {"commit": None, "source": None} + + +class TestModelIdentity: + def test_hashes_each_resolved_file(self, tmp_path): + a = tmp_path / "face.onnx" + a.write_bytes(b"face-bytes") + out = pv.model_identity({"face": a, "plate": None}) + assert out["face"]["sha256"] == pv.sha256_file(a) + assert out["face"]["size_bytes"] == len(b"face-bytes") + assert out["plate"] is None # unresolved is recorded as such, not dropped + + def test_a_shared_file_is_reported_for_both_kinds(self, tmp_path): + coco = tmp_path / "coco.onnx" + coco.write_bytes(b"coco") + out = pv.model_identity({"vehicle": coco, "person": coco}) + assert out["vehicle"]["sha256"] == out["person"]["sha256"] + + + def test_an_unreadable_file_is_an_unknown_hash_not_a_crash(self, tmp_path): + out = pv.model_identity({"face": tmp_path / "gone.onnx"}) + assert out["face"]["sha256"] is None + + +class TestRuntimeIdentity: + def test_is_bounded_json_with_the_named_parts(self, monkeypatch): + monkeypatch.setattr(pv, "tool_status", lambda n: {"name": n, "version": f"{n} 1.0"}) + ident = pv.runtime_identity() + json.dumps(ident) # serialisable + assert ident["redactcam"]["version"] + assert len(ident["redactcam"]["code_sha256"]) == 64 + assert ident["native"]["opencv"] + assert ident["native"]["ffmpeg"] == "ffmpeg 1.0" + assert len(json.dumps(ident)) < 4000 + + +class TestCheckCurrent: + @pytest.fixture(autouse=True) + def _fixed_identity(self, monkeypatch): + monkeypatch.setattr( + pv, + "runtime_identity", + lambda: { + "redactcam": { + "version": "0.2.0", + "code_sha256": "ab" * 32, + "revision": {"commit": "c34854e" + "0" * 33, "source": "direct_url"}, + } + }, + ) + + def test_a_match_returns_the_identity(self): + ident = pv.check_current(expected_revision="c34854e", expected_version="0.2.0") + assert ident["redactcam"]["version"] == "0.2.0" + + def test_a_different_commit_is_stale(self): + with pytest.raises(pv.StaleInstallError, match="6d731a5"): + pv.check_current(expected_revision="6d731a5fb") + + def test_a_different_code_hash_is_stale_even_at_the_same_version(self): + with pytest.raises(pv.StaleInstallError, match="code_sha256"): + pv.check_current(expected_code_sha256="cd" * 32) + + def test_an_unknowable_revision_is_not_a_pass(self, monkeypatch): + monkeypatch.setattr( + pv, + "runtime_identity", + lambda: { + "redactcam": { + "version": "0.2.0", + "code_sha256": None, + "revision": {"commit": None, "source": None}, + } + }, + ) + with pytest.raises(pv.StaleInstallError, match="cannot be determined"): + pv.check_current(expected_revision="c34854e") + + def test_a_short_prefix_is_refused(self): + """Three hex characters match too many commits to mean anything.""" + with pytest.raises(ValueError, match="at least 7"): + pv.check_current(expected_revision="c34") + + def test_needs_something_to_compare_against(self): + with pytest.raises(ValueError): + pv.check_current() + + +def test_write_receipt_is_atomic_json(tmp_path): + path = tmp_path / "sub" / "r.json" + pv.write_receipt(path, {"schema": "x", "n": 1}) + assert json.loads(path.read_text()) == {"schema": "x", "n": 1} + assert not list(path.parent.glob("*.tmp")) From b811400c20179306a45b1f70f55af9a6ca4adf99 Mon Sep 17 00:00:00 2001 From: Joe Petrucelli Date: Wed, 30 Sep 2026 11:35:06 -0400 Subject: [PATCH 2/2] feat: check ffmpeg and models, add clean-machine receipt --- .github/workflows/release.yml | 7 +- CHANGELOG.md | 10 ++ README.md | 34 +++++- redactcam/__init__.py | 10 +- redactcam/apply.py | 23 ++++ redactcam/cli.py | 166 +++++++++++++++++++------- redactcam/pipeline.py | 9 +- redactcam/qualify.py | 213 ++++++++++++++++++++++++++++++++++ tests/test_cli.py | 169 +++++++++++++++++++++++++++ tests/test_packaging.py | 10 ++ tests/test_pipeline.py | 14 +++ tests/test_qualify.py | 190 ++++++++++++++++++++++++++++++ 12 files changed, 808 insertions(+), 47 deletions(-) create mode 100644 redactcam/qualify.py create mode 100644 tests/test_qualify.py diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index acb8727..8315595 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -75,10 +75,13 @@ jobs: # the comment above says the hooks were verified by hand once, which is # exactly the kind of check that goes stale without a gate. --check-deps # loads OpenCV and onnxruntime and builds a real SessionOptions, needing - # no video and no ~110 MB weights download. + # no video and no ~110 MB weights download. --skip-ffmpeg because the + # release runner is not a render machine (ffmpeg is deliberately not + # bundled); a bare --check-deps now requires it, which is the point on a + # user's machine and noise here. run: | dist\redactcam.exe --help - dist\redactcam.exe --check-deps + dist\redactcam.exe --check-deps --skip-ffmpeg - name: Install Inno Setup # Installed, not assumed: a runner image that quietly drops a diff --git a/CHANGELOG.md b/CHANGELOG.md index f814e9f..0b5e434 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,16 @@ All notable changes to this project are documented here. The format follows - **Stale-install detection.** `redactcam --identity`, `--expect-revision`, `--expect-code-sha256`, `--expect-version` and `redactcam.check_current()`; an install whose identity cannot be determined never passes. +- **`--check-deps` checks ffmpeg/ffprobe and supplied models.** A missing or broken + ffmpeg exits 1 with the install line (`--skip-ffmpeg` opts out; the Windows + release smoke test uses it). With `--model KIND=PATH` each file is hashed, + loaded and run on a blank frame. +- **Clean-machine qualification.** `redactcam --check-deps --receipt FILE` with all + four `--model` files renders a synthetic clip end to end and writes a versioned + receipt; exits 0 only if qualified. Never downloads weights. +- `redact_video()` now raises `FfmpegUnavailableError` (with install instructions) + before detection when ffmpeg or ffprobe is unusable, instead of failing at the + mask stage after the whole detection pass. ### Changed diff --git a/README.md b/README.md index f3c4bca..d335efb 100644 --- a/README.md +++ b/README.md @@ -240,9 +240,39 @@ session options intra=1 inter=1 inference CPUExecutionProvider ``` +It also runs `ffmpeg -version` and `ffprobe -version`, and exits 1 with the +per-platform install line when either is missing or will not run (`redactcam` also +checks this at the top of every render, before detection starts, rather than +hours later at the mask stage). `--skip-ffmpeg` drops that check for a build smoke +test on a runner with no ffmpeg; it is not valid with `--receipt`. + It exits non-zero and names the module if one fails to load, exits 1 with the repair when the GPU build is installed but CUDA is unavailable, and needs no model -weights, so it will not trigger the first-run download. +weights, so it will not trigger the first-run download. Add `--model KIND=PATH` +and it also hashes each supplied file, builds its detector and runs it on one +blank frame, so a file that is not a YOLO ONNX model fails here with the file and +the flag named, not on the first real clip. + +### Qualify a clean machine + +A successful download proves nothing about whether the machine can redact. On a +disposable install of the OS you intend to support, with the model files you have +qualified (nothing is downloaded, and the run refuses to start without all four): + +```bash +redactcam --check-deps --receipt clean-machine.json \ + --model face=/models/face.onnx --model plate=/models/plate.onnx \ + --model vehicle=/models/coco.onnx --model person=/models/coco.onnx +``` + +It runs every check above, then one synthetic 2 s clip (ffmpeg `testsrc`, no real +footage) through the whole pipeline to an encoded H.264 file, and writes a +versioned receipt (`redactcam.clean-machine-receipt/v1`): launch details, the +installed identity, the diagnostics, each detector's status and file hash, and the +render's output hash. `"qualified": true` only when the diagnostics, all four +detectors and the render passed; every gap writes a receipt with `false` and +exits 1. Which OS and which model files count as qualified is your decision: the +receipt records what ran, it does not judge it. ### Which build is installed @@ -410,7 +440,7 @@ Everything below is importable straight from `redactcam`. `file_hash()`. **Provenance** — `runtime_identity()`, `model_identity()`, `check_current()`, -`StaleInstallError`. +`StaleInstallError`, `require_ffmpeg()`, `FfmpegUnavailableError`. **Detection** — `detect_and_track()` (the dense per-frame workhorse), `detect_regions()` (sparse, no tracking), `detect_image()` (one still), diff --git a/redactcam/__init__.py b/redactcam/__init__.py index f71172e..6967409 100644 --- a/redactcam/__init__.py +++ b/redactcam/__init__.py @@ -12,7 +12,13 @@ ``pipeline.redact_video`` wires all five together for the common case. """ -from .apply import apply_blur, blur_filter_complex, blur_radius +from .apply import ( + FfmpegUnavailableError, + apply_blur, + blur_filter_complex, + blur_radius, + require_ffmpeg, +) from .coverage import CoverageReport, Leak, cabin_region, plate_inside, verify_cabin_coverage from .detect import ( ALLOW_CPU_ENV, @@ -117,6 +123,8 @@ "blur_radius", "blur_filter_complex", "apply_blur", + "require_ffmpeg", + "FfmpegUnavailableError", # coverage "CoverageReport", "Leak", diff --git a/redactcam/apply.py b/redactcam/apply.py index 770f4d4..266d5b0 100644 --- a/redactcam/apply.py +++ b/redactcam/apply.py @@ -26,6 +26,20 @@ logger = logging.getLogger(__name__) +FFMPEG_TOOLS = ("ffmpeg", "ffprobe") +FFMPEG_INSTALL_HINT = ( + "Install FFmpeg and make sure both ffmpeg and ffprobe are on PATH " + "(Windows: `winget install -e --id Gyan.FFmpeg`; macOS: `brew install ffmpeg`; " + "Debian/Ubuntu: `apt install ffmpeg` as root), then open a new terminal." +) + + +class FfmpegUnavailableError(RuntimeError): + """``ffmpeg`` or ``ffprobe`` is missing or does not run. Raised before any + detection starts: the mask render is the first ffmpeg call, so without this + a missing binary would surface only after the whole detection pass, hours + into a long video, as a bare ``FileNotFoundError``.""" + def tool_status(name: str) -> dict: """Where ``name`` resolves on PATH and the first line of ``-version``. @@ -50,6 +64,15 @@ def tool_status(name: str) -> dict: return {"name": name, "path": path, "version": lines[0][:200], "error": None} +def require_ffmpeg() -> dict[str, dict]: + """Confirm ``ffmpeg`` and ``ffprobe`` both run; raise ``FfmpegUnavailableError`` + with the install instructions if not. Returns each tool's ``tool_status``.""" + status = {name: tool_status(name) for name in FFMPEG_TOOLS} + problems = [s["error"] for s in status.values() if s["error"]] + if problems: + raise FfmpegUnavailableError("; ".join(problems) + ". " + FFMPEG_INSTALL_HINT) + return status + # boxblur luma radius at 1920 px wide. Scaled by actual width in blur_radius(). # 12 at 1080p reduces a face to an unrecognisable smear while leaving the scene # legible; well below this and features start to survive at full-screen playback. diff --git a/redactcam/cli.py b/redactcam/cli.py index 470d96f..35d2286 100644 --- a/redactcam/cli.py +++ b/redactcam/cli.py @@ -6,9 +6,11 @@ import json import logging import sys +import tempfile from pathlib import Path -from . import provenance +from . import provenance, qualify +from .apply import FFMPEG_INSTALL_HINT, FFMPEG_TOOLS, tool_status from .models import DEFAULT_MODELS, ModelSpec from .pipeline import CoverageError, redact_video @@ -63,7 +65,26 @@ def _parser() -> argparse.ArgumentParser: p.add_argument( "--check-deps", action="store_true", - help="load the native extensions, report their versions, and exit; takes no input", + help=( + "load the native extensions, check ffmpeg/ffprobe, verify any --model files " + "load, report versions, and exit; takes no input" + ), + ) + p.add_argument( + "--skip-ffmpeg", + action="store_true", + help="with --check-deps: do not require ffmpeg/ffprobe (for a build smoke test " + "on a runner that has none; not valid with --receipt)", + ) + p.add_argument( + "--receipt", + type=Path, + metavar="FILE", + help=( + "with --check-deps: qualify this machine and write a versioned clean-machine " + "receipt. Needs all four --model KIND=PATH files (nothing is downloaded); runs " + "one synthetic clip through the whole pipeline; exits 0 only if qualified" + ), ) p.add_argument( "--identity", @@ -99,58 +120,116 @@ def _check_inference_providers_for_check() -> list[str]: return check_inference_providers() -def _check_deps() -> int: +def _ffmpeg_tools_for_check() -> dict[str, dict]: + """Indirection so the ffmpeg check can be driven in a test on any machine.""" + return {name: tool_status(name) for name in FFMPEG_TOOLS} + + +def _check_deps( + models: dict[str, ModelSpec] | None = None, + *, + receipt: Path | None = None, + skip_ffmpeg: bool = False, + work_dir: Path | None = None, +) -> int: """Load the native extensions, use them, and say what loaded. - This is for the frozen Windows build. PyInstaller resolves OpenCV and - onnxruntime through hooks, and a miss surfaces only when someone runs a real - detection -- while the release smoke test was ``--help``, which never touches - onnxruntime, because detect.py imports it inside a function. The installer - could therefore ship an executable that could not detect anything with every - gate green. + This is for the frozen Windows build and for a first run on a clean machine. + PyInstaller resolves OpenCV and onnxruntime through hooks, and a miss surfaces + only when someone runs a real detection -- while the release smoke test was + ``--help``, which never touches onnxruntime, because detect.py imports it + inside a function. The installer could therefore ship an executable that + could not detect anything with every gate green. Importing alone would not prove much, so this builds a real SessionOptions - through the same helper the detectors call. It needs no video and no model - file on purpose: the weights are a ~110 MB download and a release runner has - neither. + through the same helper the detectors call. With no ``--model`` it needs no + video and no model file: the weights are a ~110 MB download and a release + runner has neither. ffmpeg and ffprobe are checked because the mask render + and the blur composite shell out to them; ``--model`` files are loaded and run + once, never downloaded. + + With ``receipt`` it is a qualification: every check runs even after one + fails (so the receipt says everything that is wrong, not the first thing), + all four models are required, a synthetic clip is rendered, and the receipt + is written whatever happened. Exit 0 only if it qualified. """ from .detect import GpuProviderUnavailableError, _import_onnxruntime + models = models or {} + diag: dict = {"errors": []} + errors: list[str] = diag["errors"] + + def fail(msg: str) -> None: + print(f"redactcam: {msg}", file=sys.stderr) + errors.append(msg) + try: import cv2 opts = _session_options_for_check(1) ort = _import_onnxruntime() except ImportError as exc: - print(f"redactcam: a native dependency did not load: {exc}", file=sys.stderr) - return 1 + fail(f"a native dependency did not load: {exc}") + else: + providers = list(ort.get_available_providers()) + print(f"opencv {cv2.__version__}") + print(f"onnxruntime {ort.__version__}") + print(f"providers {', '.join(providers)}") + print(f"session options intra={opts.intra_op_num_threads} inter={opts.inter_op_num_threads}") + diag.update(opencv=cv2.__version__, onnxruntime=ort.__version__, providers=providers) + # The provider LIST above is what the wheel advertises; this line is the + # decision a detector makes from it — and it is where a GPU build that lost + # CUDA fails, instead of exiting 0 and running on CPU for a day. + try: + inference = _check_inference_providers_for_check() + except GpuProviderUnavailableError as exc: + fail(str(exc)) + else: + print(f"inference {inference[0]}") + diag["inference"] = inference[0] - print(f"opencv {cv2.__version__}") - print(f"onnxruntime {ort.__version__}") - print(f"providers {', '.join(ort.get_available_providers())}") - print(f"session options intra={opts.intra_op_num_threads} inter={opts.inter_op_num_threads}") - # The provider LIST above is what the wheel advertises; this line is the - # decision a detector makes from it — and it is where a GPU build that lost - # CUDA fails, instead of exiting 0 and running on CPU for a day. - try: - inference = _check_inference_providers_for_check() - except GpuProviderUnavailableError as exc: - print(f"redactcam: {exc}", file=sys.stderr) - return 1 - print(f"inference {inference[0]}") - return 0 + if skip_ffmpeg: + print("ffmpeg skipped (--skip-ffmpeg)") + else: + tools = _ffmpeg_tools_for_check() + broken = False + for name, status in tools.items(): + diag[name] = status + if status["error"]: + fail(status["error"]) + broken = True + else: + print(f"{name:<15} {status['version']}") + if broken: + print(f"redactcam: {FFMPEG_INSTALL_HINT}", file=sys.stderr) + qualifying = receipt is not None + model_results: dict[str, dict] = {} + if models or qualifying: + model_results = qualify.check_models(models, require_all=qualifying) + for kind, m in model_results.items(): + if m["status"] == "ok": + print(f"model {kind:<9} ok {m['sha256'][:12]} {m['provider']}") + else: + fail(m["error"]) -def _models(pairs: list[str]) -> dict[str, ModelSpec]: - out: dict[str, ModelSpec] = {} - for pair in pairs: - kind, _, path = pair.partition("=") - if not path: - raise SystemExit(f"--model expects KIND=PATH, got {pair!r}") - if kind not in DEFAULT_MODELS: - raise SystemExit(f"unknown model kind {kind!r}; expected one of {sorted(DEFAULT_MODELS)}") - out[kind] = ModelSpec(name=kind, path=path) - return out + if not qualifying: + return 1 if errors else 0 + + render = {"attempted": False, "ok": False, "error": "skipped: an earlier check failed"} + if not errors: + with tempfile.TemporaryDirectory(prefix="redactcam-qualify-") as tmp: + render = qualify.synthetic_render(models, work_dir or Path(tmp)) + if render.get("ok"): + print(f"render ok {render['frames']} frames {render['output_sha256'][:12]}") + else: + fail(f"synthetic render failed: {render.get('error')}") + result = qualify.build_clean_machine_receipt( + diagnostics=diag, models=model_results, render=render + ) + provenance.write_receipt(receipt, result) + print(f"receipt {receipt} qualified={str(result['qualified']).lower()}") + return 0 if result["qualified"] else 1 def _check_expectations(args: argparse.Namespace) -> int: @@ -187,11 +266,20 @@ def main(argv: list[str] | None = None) -> int: level=logging.INFO if args.verbose else logging.WARNING, format="%(levelname)s %(name)s: %(message)s", ) + if (args.receipt or args.skip_ffmpeg) and not args.check_deps: + parser.error("--receipt and --skip-ffmpeg only apply together with --check-deps") + if args.receipt and args.skip_ffmpeg: + parser.error("--receipt qualifies a machine to render, so it cannot skip ffmpeg") expecting = args.expect_version or args.expect_code_sha256 or args.expect_revision if args.check_deps or expecting or args.identity: rc = 0 if args.check_deps: - rc = _check_deps() + rc = _check_deps( + _models(args.model), + receipt=args.receipt, + skip_ffmpeg=args.skip_ffmpeg, + work_dir=args.work_dir, + ) if expecting: rc = max(rc, _check_expectations(args)) if args.identity: diff --git a/redactcam/pipeline.py b/redactcam/pipeline.py index 14b7627..81c9c30 100644 --- a/redactcam/pipeline.py +++ b/redactcam/pipeline.py @@ -22,6 +22,7 @@ from . import apply as _apply from . import mask as _mask from . import timeline as _timeline +from .apply import require_ffmpeg from .coverage import CoverageReport, verify_cabin_coverage from .detect import detect_and_track from .models import ( @@ -105,10 +106,12 @@ def redact_video( time. ``render=False`` stops after verification, leaving you the mask and the sidecar to feed into your own encode. - Raises ``CoverageError`` when verification finds an uncovered vehicle cabin. - A ``_redactcam_receipt.json`` naming the exact code, model files and - native runtime is written beside the mask on every completed run. + Raises ``CoverageError`` when verification finds an uncovered vehicle cabin, + and ``FfmpegUnavailableError`` (before any detection) when ffmpeg/ffprobe are + not usable. A ``_redactcam_receipt.json`` naming the exact code, model + files and native runtime is written beside the mask on every completed run. """ + require_ffmpeg() source_video = Path(source_video) work = Path(work_dir) if work_dir else source_video.parent work.mkdir(parents=True, exist_ok=True) diff --git a/redactcam/qualify.py b/redactcam/qualify.py new file mode 100644 index 0000000..4c4c8d0 --- /dev/null +++ b/redactcam/qualify.py @@ -0,0 +1,213 @@ +"""Clean-machine qualification — can a fresh install actually redact a video? + +A successful download proves nothing: the wheel installs fine on a machine with +no ffmpeg, no usable onnxruntime, or a model file that is not ONNX at all, and +each of those surfaces only on the first real detection. ``redactcam +--check-deps --receipt FILE --synthetic-render --model KIND=PATH …`` runs the +checks a first real job would hit, in order, and records the outcome: + +1. the native stack loads (OpenCV, onnxruntime, the inference-provider decision); +2. ``ffmpeg`` and ``ffprobe`` run; +3. every **explicitly supplied** model file exists, hashes, and builds a detector + that survives one real inference — nothing is downloaded, ever, because the + point is to qualify *those* artifacts, not whatever a fetch returned; +4. a synthetic clip goes through the whole pipeline to an encoded output. + +The result is a versioned JSON receipt whose ``qualified`` flag is true only if +all four passed. Judging *which* OS and *which* model files count as supported is +the maintainer's call and is not encoded here: the receipt records what ran. +""" + +from __future__ import annotations + +import subprocess +import sys +from datetime import UTC, datetime +from pathlib import Path + +from .models import DEFAULT_MODELS, ModelSpec +from .provenance import runtime_identity, sha256_file + +RECEIPT_SCHEMA = "redactcam.clean-machine-receipt/v1" + +_INFER_SIZE = 640 +_IOU = 0.45 + + +def _load_detector(kind: str, path: Path) -> str: + """Build the detector for ``kind`` from ``path`` and run it on one blank + frame; return the execution provider its session landed on. + + Construction proves onnxruntime can read the file; the blank-frame inference + proves the graph runs and its output decodes, which a truncated or wrong-shape + model can fail even when it loads.""" + import numpy as np + + from .detect import PERSON_CLASSES, VEHICLE_CLASSES, YoloDetector + from .presets import ROAD_FOOTAGE + + min_aspect = max_aspect = 0.0 + class_filter = None + if kind == "plate": + min_aspect = float(ROAD_FOOTAGE["plate_min_aspect"]) + max_aspect = float(ROAD_FOOTAGE["plate_max_aspect"]) + elif kind == "vehicle": + class_filter = VEHICLE_CLASSES + elif kind == "person": + class_filter = PERSON_CLASSES + det = YoloDetector( + path, _INFER_SIZE, _IOU, min_aspect, 1, max_aspect=max_aspect, class_filter=class_filter + ) + det.detect(np.zeros((360, 640, 3), dtype=np.uint8), 0.9) + providers = det._sess.get_providers() + return providers[0] if providers else "unknown" + + +def check_models( + specs: dict[str, ModelSpec], + *, + require_all: bool = False, + loader=_load_detector, +) -> dict[str, dict]: + """Per-kind result for the models the caller supplied by ``path``. + + ``status`` is ``ok``, ``missing`` (file not there), ``failed`` (the runtime + rejected it), or ``not_supplied`` (no local path; only reported with + ``require_all``). A ``url``-only spec is not supplied: qualification judges + files a person handed over, and never fetches. + """ + out: dict[str, dict] = {} + for kind in DEFAULT_MODELS: + spec = specs.get(kind) + path_str = (spec.path or "").strip() if spec is not None else "" + if not path_str: + if require_all: + out[kind] = { + "status": "not_supplied", + "error": ( + f"no local {kind} model given. Supply the qualified file with " + f"--model {kind}=PATH; qualification never downloads weights." + ), + } + continue + path = Path(path_str).expanduser() + if not path.is_file(): + out[kind] = { + "status": "missing", + "path": str(path), + "error": f"{kind} model file not found: {path}. Fix --model {kind}=PATH.", + } + continue + entry = { + "path": str(path), + "sha256": sha256_file(path), + "size_bytes": path.stat().st_size, + } + try: + entry["provider"] = loader(kind, path) + entry["status"] = "ok" + except Exception as exc: # any load failure is a finding, not a crash + entry["status"] = "failed" + entry["error"] = ( + f"{kind} model {path.name} did not load or run: {type(exc).__name__}: {exc}. " + "It must be a YOLO-format ONNX file the installed onnxruntime can execute." + ) + out[kind] = entry + return out + + +def _make_synthetic_input(dest: Path) -> None: + """A 2 s, 640x360, 10 fps synthetic clip via ffmpeg's ``testsrc`` (no real + footage, ever). H.264 on purpose: the render encodes H.264, so a build + without libx264 fails here, early and legibly.""" + dest.parent.mkdir(parents=True, exist_ok=True) + proc = subprocess.run( + [ + "ffmpeg", "-y", "-nostats", "-loglevel", "error", + "-f", "lavfi", "-i", "testsrc=size=640x360:rate=10:duration=2", + "-c:v", "libx264", "-pix_fmt", "yuv420p", str(dest), + ], + capture_output=True, + text=True, + timeout=120, + ) + if proc.returncode != 0: + raise RuntimeError(f"ffmpeg could not create the synthetic clip: {proc.stderr[-300:]}") + + +def synthetic_render(specs: dict[str, ModelSpec], work_dir: Path) -> dict: + """Run one synthetic clip through ``redact_video`` and describe the outcome. + + Never raises. Attempted only when all four models are supplied locally, since + ``redact_video`` would otherwise resolve the missing ones by downloading. + """ + missing = [k for k in DEFAULT_MODELS if not (k in specs and (specs[k].path or "").strip())] + if missing: + return { + "attempted": False, + "ok": False, + "error": ( + "the synthetic render needs all four models supplied locally " + f"(missing: {', '.join(missing)}); it never downloads weights." + ), + } + work_dir = Path(work_dir) + src = work_dir / "synthetic_input.mp4" + try: + _make_synthetic_input(src) + except (OSError, RuntimeError, subprocess.SubprocessError) as exc: + return {"attempted": True, "ok": False, "stage": "synthetic_input", "error": str(exc)} + try: + from .pipeline import redact_video + + result = redact_video( + src, work_dir=work_dir, models=dict(specs), reuse_sidecar=False, render=True + ) + except Exception as exc: # the receipt must record a failed render, not die on it + return { + "attempted": True, + "ok": False, + "stage": "redact_video", + "error": f"{type(exc).__name__}: {exc}", + } + out = result.output + if out is None or not Path(out).is_file() or Path(out).stat().st_size == 0: + return { + "attempted": True, + "ok": False, + "stage": "redact_video", + "error": "the pipeline returned but produced no output video", + } + return { + "attempted": True, + "ok": True, + "frames": result.frame_count, + "output_sha256": sha256_file(out), + "output_size_bytes": Path(out).stat().st_size, + } + + +def build_clean_machine_receipt(*, diagnostics: dict, models: dict, render: dict) -> dict: + """Assemble the versioned receipt. ``qualified`` is true only when the + diagnostics carry no errors, all four detectors are ``ok``, and the render + completed — a gap anywhere is a not-qualified receipt, never a softer pass.""" + detectors_ok = set(models) >= set(DEFAULT_MODELS) and all( + models[k].get("status") == "ok" for k in DEFAULT_MODELS + ) + qualified = bool( + not diagnostics.get("errors") and detectors_ok and render.get("ok") is True + ) + return { + "schema": RECEIPT_SCHEMA, + "generated_utc": datetime.now(UTC).isoformat(timespec="seconds"), + "launch": { + "python": sys.version.split()[0], + "executable": sys.executable, + "frozen": bool(getattr(sys, "frozen", False)), + }, + "identity": runtime_identity(), + "diagnostics": diagnostics, + "detectors": models, + "render": render, + "qualified": qualified, + } diff --git a/tests/test_cli.py b/tests/test_cli.py index 16779a1..0d2b29d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -82,6 +82,16 @@ def _capture(*a, **k): assert seen["blur_strength"] == 20 +_HEALTHY_FFMPEG = { + "ffmpeg": {"name": "ffmpeg", "path": "/x/ffmpeg", "version": "ffmpeg version 7.1", "error": None}, + "ffprobe": {"name": "ffprobe", "path": "/x/ffprobe", "version": "ffprobe version 7.1", "error": None}, +} +_NO_FFMPEG = { + n: {"name": n, "path": None, "version": None, "error": f"{n} was not found on PATH"} + for n in ("ffmpeg", "ffprobe") +} + + class TestCheckDeps: """`--check-deps` exists so the frozen Windows build can prove its native extensions resolved. `--help` never touched onnxruntime -- detect.py imports @@ -95,6 +105,7 @@ def _host_independent(self, monkeypatch): --check-deps exit 1 by design (tested below with a stub). Accept the CPU path here; the assertion itself is unit-tested in test_detect.""" monkeypatch.setenv("REDACTCAM_ALLOW_CPU", "1") + monkeypatch.setattr(cli, "_ffmpeg_tools_for_check", lambda: _HEALTHY_FFMPEG) def test_reports_both_extensions_and_exits_zero(self, capsys): assert cli.main(["--check-deps"]) == 0 @@ -148,6 +159,164 @@ def _boom(_): assert "no onnxruntime here" in capsys.readouterr().err +class TestCheckDepsFfmpegAndModels: + """The clean-machine gaps: a first run on a fresh box fails on a missing + ffmpeg or an unusable model file, not on anything --check-deps used to test.""" + + @pytest.fixture(autouse=True) + def _host_independent(self, monkeypatch): + monkeypatch.setenv("REDACTCAM_ALLOW_CPU", "1") + monkeypatch.setattr(cli, "_ffmpeg_tools_for_check", lambda: _HEALTHY_FFMPEG) + + def test_reports_the_ffmpeg_and_ffprobe_builds(self, capsys): + assert cli.main(["--check-deps"]) == 0 + out = capsys.readouterr().out + assert "ffmpeg ffmpeg version 7.1" in out + assert "ffprobe ffprobe version 7.1" in out + + def test_a_missing_ffmpeg_is_a_nonzero_exit_with_the_install_line(self, monkeypatch, capsys): + monkeypatch.setattr(cli, "_ffmpeg_tools_for_check", lambda: _NO_FFMPEG) + assert cli.main(["--check-deps"]) == 1 + err = capsys.readouterr().err + assert "ffmpeg was not found on PATH" in err + assert "winget install" in err + + def test_skip_ffmpeg_is_for_a_build_smoke_test_that_has_none(self, monkeypatch, capsys): + monkeypatch.setattr(cli, "_ffmpeg_tools_for_check", lambda: _NO_FFMPEG) + assert cli.main(["--check-deps", "--skip-ffmpeg"]) == 0 + assert "skipped" in capsys.readouterr().out + + def test_supplied_models_are_checked_and_reported(self, monkeypatch, capsys): + monkeypatch.setattr( + cli.qualify, + "check_models", + lambda specs, **kw: { + "face": {"status": "ok", "sha256": "ab" * 32, "provider": "CPUExecutionProvider"} + }, + ) + assert cli.main(["--check-deps", "--model", "face=/m/f.onnx"]) == 0 + assert "model face ok" in capsys.readouterr().out + + def test_a_model_the_runtime_rejects_is_a_nonzero_exit(self, monkeypatch, capsys): + monkeypatch.setattr( + cli.qualify, + "check_models", + lambda specs, **kw: {"plate": {"status": "failed", "error": "plate model x did not load"}}, + ) + assert cli.main(["--check-deps", "--model", "plate=/m/p.onnx"]) == 1 + assert "plate model x did not load" in capsys.readouterr().err + + def test_no_model_flag_means_no_model_check_and_no_download(self, monkeypatch): + def _never(*a, **k): + raise AssertionError("must not check models when none were supplied") + + monkeypatch.setattr(cli.qualify, "check_models", _never) + assert cli.main(["--check-deps"]) == 0 + + +class TestCleanMachineReceipt: + @pytest.fixture(autouse=True) + def _host_independent(self, monkeypatch): + monkeypatch.setenv("REDACTCAM_ALLOW_CPU", "1") + monkeypatch.setattr(cli, "_ffmpeg_tools_for_check", lambda: _HEALTHY_FFMPEG) + + ALL = ["--model", "face=/m/f", "--model", "plate=/m/p", "--model", "vehicle=/m/v", + "--model", "person=/m/n"] + + def _fakes(self, monkeypatch, *, models_ok=True, render_ok=True): + seen = {"render_called": False, "require_all": None} + + def _models(specs, *, require_all=False, **kw): + seen["require_all"] = require_all + st = "ok" if models_ok else "failed" + return { + k: {"status": st, "error": "bad", "sha256": "cd" * 32, "provider": "CPUExecutionProvider"} + for k in ("face", "plate", "vehicle", "person") + } + + def _render(specs, work_dir): + seen["render_called"] = True + return { + "attempted": True, + "ok": render_ok, + "error": None if render_ok else "boom", + "frames": 20, + "output_sha256": "ef" * 32, + } + + monkeypatch.setattr(cli.qualify, "check_models", _models) + monkeypatch.setattr(cli.qualify, "synthetic_render", _render) + return seen + + def test_a_qualified_run_writes_the_receipt_and_exits_zero(self, tmp_path, monkeypatch): + import json + + seen = self._fakes(monkeypatch) + out = tmp_path / "r.json" + assert cli.main(["--check-deps", "--receipt", str(out), *self.ALL]) == 0 + r = json.loads(out.read_text()) + assert r["schema"] == "redactcam.clean-machine-receipt/v1" + assert r["qualified"] is True + assert r["diagnostics"]["onnxruntime"] + assert r["diagnostics"]["ffmpeg"]["version"] == "ffmpeg version 7.1" + assert seen["require_all"] is True and seen["render_called"] is True + + def test_a_failed_render_is_a_written_receipt_and_a_nonzero_exit(self, tmp_path, monkeypatch, capsys): + import json + + self._fakes(monkeypatch, render_ok=False) + out = tmp_path / "r.json" + assert cli.main(["--check-deps", "--receipt", str(out), *self.ALL]) == 1 + assert json.loads(out.read_text())["qualified"] is False + assert "boom" in capsys.readouterr().err + + def test_a_failed_detector_skips_the_render_but_still_writes_the_receipt( + self, tmp_path, monkeypatch + ): + import json + + seen = self._fakes(monkeypatch, models_ok=False) + out = tmp_path / "r.json" + assert cli.main(["--check-deps", "--receipt", str(out), *self.ALL]) == 1 + r = json.loads(out.read_text()) + assert seen["render_called"] is False + assert r["render"]["attempted"] is False and r["qualified"] is False + + def test_missing_ffmpeg_is_recorded_and_fails_the_receipt(self, tmp_path, monkeypatch): + import json + + self._fakes(monkeypatch) + monkeypatch.setattr(cli, "_ffmpeg_tools_for_check", lambda: _NO_FFMPEG) + out = tmp_path / "r.json" + assert cli.main(["--check-deps", "--receipt", str(out), *self.ALL]) == 1 + r = json.loads(out.read_text()) + assert r["qualified"] is False + assert any("ffmpeg" in e for e in r["diagnostics"]["errors"]) + + def test_a_broken_native_stack_still_produces_a_receipt(self, tmp_path, monkeypatch): + import json + + self._fakes(monkeypatch) + + def _boom(_): + raise ImportError("no onnxruntime here") + + monkeypatch.setattr(cli, "_session_options_for_check", _boom) + out = tmp_path / "r.json" + assert cli.main(["--check-deps", "--receipt", str(out), *self.ALL]) == 1 + r = json.loads(out.read_text()) + assert r["qualified"] is False + assert "no onnxruntime here" in " ".join(r["diagnostics"]["errors"]) + + def test_receipt_needs_check_deps(self): + with pytest.raises(SystemExit): + cli.main(["--receipt", "r.json"]) + + def test_receipt_cannot_skip_ffmpeg(self): + with pytest.raises(SystemExit): + cli.main(["--check-deps", "--skip-ffmpeg", "--receipt", "r.json"]) + + class TestIdentityFlags: @pytest.fixture(autouse=True) def _fixed(self, monkeypatch): diff --git a/tests/test_packaging.py b/tests/test_packaging.py index b0b873d..e5d89ca 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -45,3 +45,13 @@ def test_dev_extra_brings_a_runtime_so_the_suite_can_run(self): def test_version_is_declared_once(): """``__version__`` and pyproject drifted (0.1.0 vs 0.1.1) with nothing to catch it.""" assert redactcam.__version__ == _project()["version"] + + +def test_release_smoke_test_skips_the_ffmpeg_check(): + """``--check-deps`` requires ffmpeg. The Windows release runner deliberately has + none (it is not bundled), so the frozen-exe smoke test must opt out or every + release fails after the installer is already built.""" + workflow = (_PYPROJECT.parent / ".github" / "workflows" / "release.yml").read_text() + lines = [ln.strip() for ln in workflow.splitlines() if "redactcam.exe --check-deps" in ln] + assert lines, "the release smoke test no longer runs --check-deps" + assert all("--skip-ffmpeg" in ln for ln in lines) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 8911ba9..67d5ed7 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -16,6 +16,7 @@ def wired(monkeypatch, tmp_path): """Stub detection, mask rendering, model resolution and ffmpeg; record calls.""" calls = [] + monkeypatch.setattr(pl, "require_ffmpeg", lambda: {}) monkeypatch.setattr(pl, "resolve_model", lambda spec, cache=None: tmp_path / f"{spec.name}.onnx") monkeypatch.setattr(pl._timeline, "probe_video", lambda p: (30.0, 90, 1920, 1080)) monkeypatch.setattr( @@ -213,3 +214,16 @@ def test_an_unresolved_model_is_recorded_as_missing(self, wired, source, tmp_pat r = self._receipt(pl.redact_video(source, work_dir=tmp_path / "work", verify=False)) assert r["models"]["plate"] is None + +def test_a_missing_ffmpeg_stops_the_run_before_detection(wired, source, tmp_path, monkeypatch): + """The mask render is the first ffmpeg call. Without a gate a missing binary + surfaces only after the whole detection pass.""" + from redactcam.apply import FfmpegUnavailableError + + def _absent(): + raise FfmpegUnavailableError("ffmpeg was not found on PATH. Install FFmpeg") + + monkeypatch.setattr(pl, "require_ffmpeg", _absent) + with pytest.raises(FfmpegUnavailableError, match="Install FFmpeg"): + pl.redact_video(source, work_dir=tmp_path / "work") + assert wired == [] diff --git a/tests/test_qualify.py b/tests/test_qualify.py new file mode 100644 index 0000000..936f345 --- /dev/null +++ b/tests/test_qualify.py @@ -0,0 +1,190 @@ +"""Clean-machine qualification: the pieces that decide whether a fresh install +can actually redact a video, not just whether the download finished. + +Everything real-machine (a disposable supported OS, the supplied model files, a +GPU) is out of reach for a hermetic suite, so the loaders and the render are +seams here; what is proven is the decision logic, the error text a person acts +on, and that the synthetic clip generator's ffmpeg command works. +""" + +import shutil +import subprocess +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from redactcam import apply, qualify +from redactcam.models import ModelSpec + + +def _fake_clip(dest): + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_bytes(b"clip") + + +class TestFfmpegTools: + def test_a_missing_binary_is_named_with_the_install_line(self, monkeypatch): + monkeypatch.setattr(apply.shutil, "which", lambda name: None) + with pytest.raises(apply.FfmpegUnavailableError) as exc: + apply.require_ffmpeg() + msg = str(exc.value) + assert "ffmpeg was not found on PATH" in msg + assert "ffprobe was not found on PATH" in msg + assert "winget install" in msg + + def test_a_binary_that_will_not_run_is_not_a_pass(self, monkeypatch): + monkeypatch.setattr(apply.shutil, "which", lambda name: f"/x/{name}") + + def _boom(*a, **k): + raise OSError("exec format error") + + monkeypatch.setattr(apply.subprocess, "run", _boom) + status = apply.tool_status("ffmpeg") + assert status["version"] is None and "exec format error" in status["error"] + + def test_a_healthy_tool_reports_its_version_line(self, monkeypatch): + monkeypatch.setattr(apply.shutil, "which", lambda name: f"/x/{name}") + monkeypatch.setattr( + apply.subprocess, + "run", + lambda *a, **k: SimpleNamespace(returncode=0, stdout="ffmpeg version 7.1\nmore\n"), + ) + assert apply.tool_status("ffmpeg")["version"] == "ffmpeg version 7.1" + assert set(apply.require_ffmpeg()) == {"ffmpeg", "ffprobe"} + + +def _specs(tmp_path, kinds=("face", "plate", "vehicle", "person")): + out = {} + for k in kinds: + p = tmp_path / f"{k}.onnx" + p.write_bytes(f"w-{k}".encode()) + out[k] = ModelSpec(name=k, path=str(p)) + return out + + +class TestCheckModels: + def test_a_loadable_model_is_ok_with_its_hash_and_provider(self, tmp_path): + specs = _specs(tmp_path, ("face",)) + out = qualify.check_models(specs, loader=lambda kind, path: "CPUExecutionProvider") + assert out["face"]["status"] == "ok" + assert out["face"]["provider"] == "CPUExecutionProvider" + assert len(out["face"]["sha256"]) == 64 + assert "plate" not in out # not asked about, not required + + def test_a_missing_file_says_which_flag_to_fix(self, tmp_path): + specs = {"plate": ModelSpec(name="plate", path=str(tmp_path / "nope.onnx"))} + out = qualify.check_models(specs, loader=lambda k, p: "x") + assert out["plate"]["status"] == "missing" + assert "--model plate=" in out["plate"]["error"] + + def test_a_file_the_runtime_rejects_is_failed_with_the_reason(self, tmp_path): + def _bad(kind, path): + raise RuntimeError("INVALID_PROTOBUF: not an ONNX file") + + out = qualify.check_models(_specs(tmp_path, ("face",)), loader=_bad) + assert out["face"]["status"] == "failed" + assert "INVALID_PROTOBUF" in out["face"]["error"] + + def test_require_all_flags_every_unsupplied_kind_and_never_downloads(self, tmp_path): + out = qualify.check_models( + _specs(tmp_path, ("face",)), require_all=True, loader=lambda k, p: "CPUExecutionProvider" + ) + for kind in ("plate", "vehicle", "person"): + assert out[kind]["status"] == "not_supplied" + assert "download" in out[kind]["error"] + + def test_a_url_only_spec_counts_as_not_supplied(self): + """The qualification must judge supplied artifacts, not whatever a + network fetch happened to return.""" + specs = {"face": ModelSpec(name="face", url="https://x/f.onnx", sha256="ab" * 32)} + out = qualify.check_models(specs, require_all=True, loader=lambda k, p: "x") + assert out["face"]["status"] == "not_supplied" + + +class TestSyntheticRender: + def test_needs_all_four_supplied_and_downloads_nothing(self, tmp_path, monkeypatch): + called = [] + monkeypatch.setattr(qualify, "_make_synthetic_input", lambda p: called.append(p)) + out = qualify.synthetic_render(_specs(tmp_path, ("face",)), tmp_path / "w") + assert out["attempted"] is False and out["ok"] is False + assert "all four" in out["error"] + assert called == [] + + def test_a_completed_render_is_recorded_with_its_hash(self, tmp_path, monkeypatch): + monkeypatch.setattr(qualify, "_make_synthetic_input", _fake_clip) + + def _fake(src, out=None, **kw): + o = Path(kw["work_dir"]) / "o.mp4" + o.write_bytes(b"rendered") + return SimpleNamespace(output=o, frame_count=20, receipt=None, coverage=None) + + monkeypatch.setattr("redactcam.pipeline.redact_video", _fake) + out = qualify.synthetic_render(_specs(tmp_path), tmp_path / "w") + assert out["ok"] is True and out["frames"] == 20 + assert len(out["output_sha256"]) == 64 + + def test_a_pipeline_failure_is_recorded_not_raised(self, tmp_path, monkeypatch): + monkeypatch.setattr(qualify, "_make_synthetic_input", _fake_clip) + + def _boom(*a, **k): + raise RuntimeError("mask ffmpeg failed (rc=1): no libx264") + + monkeypatch.setattr("redactcam.pipeline.redact_video", _boom) + out = qualify.synthetic_render(_specs(tmp_path), tmp_path / "w") + assert out["ok"] is False and "no libx264" in out["error"] + + def test_an_empty_output_is_not_a_render(self, tmp_path, monkeypatch): + monkeypatch.setattr(qualify, "_make_synthetic_input", _fake_clip) + + def _empty(src, out=None, **kw): + o = Path(kw["work_dir"]) / "o.mp4" + o.write_bytes(b"") + return SimpleNamespace(output=o, frame_count=20, receipt=None, coverage=None) + + monkeypatch.setattr("redactcam.pipeline.redact_video", _empty) + assert qualify.synthetic_render(_specs(tmp_path), tmp_path / "w")["ok"] is False + + @pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="needs ffmpeg") + def test_the_generator_command_makes_a_real_clip(self, tmp_path): + src = tmp_path / "in.mp4" + qualify._make_synthetic_input(src) + assert src.stat().st_size > 0 + if shutil.which("ffprobe"): + probe = subprocess.run( + ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", + "stream=width,height", "-of", "csv=p=0", str(src)], + capture_output=True, text=True, + ) + assert probe.stdout.strip() == "640,360" + + +class TestReceipt: + def _ok_inputs(self): + return dict( + diagnostics={"errors": [], "onnxruntime": "1.0"}, + models={k: {"status": "ok"} for k in ("face", "plate", "vehicle", "person")}, + render={"attempted": True, "ok": True}, + ) + + def test_qualified_needs_diagnostics_detectors_and_render(self): + r = qualify.build_clean_machine_receipt(**self._ok_inputs()) + assert r["schema"] == "redactcam.clean-machine-receipt/v1" + assert r["qualified"] is True + assert r["identity"]["redactcam"]["code_sha256"] + assert r["launch"]["python"] + + @pytest.mark.parametrize( + "mutate", + [ + lambda i: i["diagnostics"].update(errors=["ffmpeg was not found on PATH"]), + lambda i: i["models"].update(plate={"status": "failed"}), + lambda i: i["models"].pop("person"), + lambda i: i.update(render={"attempted": False, "ok": False}), + lambda i: i.update(render={"attempted": True, "ok": False}), + ], + ) + def test_any_gap_is_not_qualified(self, mutate): + inputs = self._ok_inputs() + mutate(inputs) + assert qualify.build_clean_machine_receipt(**inputs)["qualified"] is False