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 ef8e857..8f698d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,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 c8ad564..d001ef0 100644 --- a/README.md +++ b/README.md @@ -252,9 +252,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 @@ -440,7 +470,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 799ec7b..51dac2f 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 ( CLASSES, ClassCoverage, @@ -127,6 +133,8 @@ "blur_radius", "blur_filter_complex", "apply_blur", + "require_ffmpeg", + "FfmpegUnavailableError", # coverage "CoverageReport", "ClassCoverage", 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 ed8fcb3..ad67430 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 .coverage import CLASSES, RequiredClassError from .models import DEFAULT_MODELS, ModelSpec from .pipeline import CoverageError, redact_video @@ -76,7 +78,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", @@ -112,58 +133,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: @@ -181,6 +260,18 @@ def _check_expectations(args: argparse.Namespace) -> int: 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 _print_coverage(report) -> None: """One line per privacy class. UNCHECKED is printed as loudly as a failure: it means unknown, and "coverage OK" must never stand in for it.""" @@ -201,11 +292,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 506dfba..2762b1a 100644 --- a/redactcam/pipeline.py +++ b/redactcam/pipeline.py @@ -23,6 +23,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 ( @@ -107,7 +108,9 @@ 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. + Raises ``CoverageError`` when verification finds an uncovered vehicle cabin, + and ``FfmpegUnavailableError`` (before any detection) when ffmpeg/ffprobe are + not usable. ``require_verified`` names privacy classes (``cabin``, ``face``, ``plate``, ``person``) that must be VERIFIED, not merely un-failed. A required class that @@ -118,6 +121,7 @@ def redact_video( 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 d74702e..7d6c2b4 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 @@ -198,6 +209,164 @@ def _boom(*a, **k): assert "not verified" in capsys.readouterr().err.lower() +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 005c73d..9fe3094 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( @@ -240,3 +241,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