video_editing_poc/.claude/skills/video-editing-diagnostics-a.../scripts/inventory_projects.py

208 lines
9.4 KiB
Python

#!/usr/bin/env python3
"""Inventory edit/highlight project state without modifying it."""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
TERMINAL = {"RENDERED", "FAILED"}
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Inventory partial, inconsistent, and optionally stale edit/highlight projects."
)
parser.add_argument("--repo", type=Path, default=Path("."), help="Repository root (default: current directory).")
parser.add_argument("--edit-root", type=Path, help="Override edit-project root.")
parser.add_argument("--highlight-root", type=Path, help="Override highlight-project root.")
parser.add_argument(
"--allow-missing-roots", action="store_true",
help="Do not report missing edit/highlight roots as errors (use only when the absent workflow is intentional).",
)
parser.add_argument("--stale-hours", type=float, help="Flag nonterminal projects not modified within this many hours.")
parser.add_argument("--format", choices=("table", "json"), default="table")
parser.add_argument(
"--fail-on", choices=("error", "warning", "never"), default="error",
help="Return 2 at or above this finding severity (default: error).",
)
return parser.parse_args()
def load_json(path: Path) -> tuple[dict | None, str | None]:
try:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
return None, "top-level JSON value is not an object"
return value, None
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
return None, str(exc)
def qa_mode(path: Path) -> str:
if not path.is_file():
return "missing"
value, error = load_json(path)
if error or value is None:
return "invalid"
names = {str(check.get("name")) for check in value.get("checks", []) if isinstance(check, dict)}
measured = {"black_frames", "long_silence", "audio_clipping"}
return "measured" if measured <= names else "partial"
def finding(severity: str, code: str, detail: str) -> dict:
return {"severity": severity, "code": code, "detail": detail}
def inspect_project(kind: str, directory: Path, now: float, stale_hours: float | None) -> dict:
project_file = directory / "project.json"
project, error = load_json(project_file)
findings: list[dict] = []
if error:
findings.append(finding("error", "PROJECT_JSON_INVALID", error))
project = {}
status = str(project.get("status", "UNKNOWN"))
final = directory / "final.mp4"
render_manifest = directory / "render-manifest.json"
qa_report = directory / "qa-report.json"
if status == "RENDERED":
if not final.is_file():
findings.append(finding("error", "RENDERED_OUTPUT_MISSING", "status is RENDERED but final.mp4 is absent"))
if not render_manifest.is_file():
findings.append(finding("error", "RENDERED_MANIFEST_MISSING", "status is RENDERED but render-manifest.json is absent"))
if kind != "highlight" and not qa_report.is_file():
findings.append(finding("warning", "RENDERED_QA_MISSING", "status is RENDERED but qa-report.json is absent"))
elif final.is_file():
findings.append(finding("error", "OUTPUT_STATUS_MISMATCH", f"final.mp4 exists while status is {status}"))
if render_manifest.is_file() and not final.is_file():
findings.append(finding("error", "MANIFEST_OUTPUT_MISSING", "render-manifest.json exists but final.mp4 is absent"))
if status == "FAILED" and not (project.get("failureReason") or project.get("failureMessage")):
findings.append(finding("warning", "FAILURE_REASON_MISSING", "FAILED project has no recorded failure reason"))
if stale_hours is not None and status not in TERMINAL:
try:
newest = max((path.stat().st_mtime for path in directory.rglob("*") if path.is_file()), default=directory.stat().st_mtime)
age_hours = (now - newest) / 3600.0
if age_hours >= stale_hours:
findings.append(finding("warning", "NONTERMINAL_STALE", f"newest artifact is {age_hours:.1f} hours old"))
except OSError as exc:
findings.append(finding("error", "STAT_FAILED", str(exc)))
pending_assets = 0
highlight_count = 0
qa_modes: list[str] = []
if kind == "highlight":
director_plan = directory / "director" / "edit-plan.json"
if status in {"PLANNED", "RENDERING", "RENDERED"} and not director_plan.is_file():
findings.append(finding("error", "DIRECTOR_PLAN_MISSING", f"status is {status} but director/edit-plan.json is absent"))
highlights = directory / "highlights"
if highlights.is_dir():
for child in sorted((p for p in highlights.iterdir() if p.is_dir()), key=lambda p: p.name):
highlight_count += 1
mode = qa_mode(child / "qa-report.json")
if mode != "missing":
qa_modes.append(mode)
requests = child / "assets" / "requests"
if requests.is_dir():
pending_assets += sum(1 for p in requests.glob("*.json") if p.is_file())
if status == "RENDERED" and not qa_report.is_file():
if qa_modes:
findings.append(finding(
"warning", "HIGHLIGHT_AGGREGATE_QA_MISSING",
"project-root aggregate qa-report.json is absent; per-highlight QA exists but does not certify the concatenated final.mp4",
))
else:
findings.append(finding(
"warning", "RENDERED_QA_MISSING",
"no project-root aggregate or per-highlight qa-report.json was found",
))
if "partial" in qa_modes:
findings.append(finding("warning", "HIGHLIGHT_QA_PARTIAL", "per-highlight QA lacks one or more expected FFmpeg media probes"))
if "invalid" in qa_modes:
findings.append(finding("error", "HIGHLIGHT_QA_INVALID", "one or more per-highlight QA reports are invalid"))
else:
mode = qa_mode(qa_report)
if mode != "missing":
qa_modes.append(mode)
return {
"kind": kind,
"projectId": str(project.get("id") or directory.name),
"status": status,
"path": str(directory),
"finalExists": final.is_file(),
"manifestExists": render_manifest.is_file(),
"qaModes": sorted(set(qa_modes)),
"highlightCount": highlight_count,
"assetRequestCount": pending_assets,
"findings": findings,
}
def scan_root(kind: str, root: Path, now: float, stale_hours: float | None) -> list[dict]:
if not root.exists():
return []
if not root.is_dir():
raise ValueError(f"{kind} root is not a directory: {root}")
projects = []
for directory in sorted((p for p in root.iterdir() if p.is_dir()), key=lambda p: p.name):
if (directory / "project.json").is_file():
projects.append(inspect_project(kind, directory, now, stale_hours))
return projects
def print_table(projects: list[dict]) -> None:
print("KIND\tPROJECT\tSTATUS\tFINAL\tMANIFEST\tQA\tASSET_REQUESTS\tFINDINGS")
for item in projects:
codes = ",".join(f["severity"][0].upper() + ":" + f["code"] for f in item["findings"]) or "-"
print("\t".join((item["kind"], item["projectId"], item["status"], str(item["finalExists"]).lower(),
str(item["manifestExists"]).lower(), ",".join(item["qaModes"]) or "-",
str(item["assetRequestCount"]), codes)))
def main() -> int:
args = parse_args()
if args.stale_hours is not None and args.stale_hours < 0:
print("error: --stale-hours must be nonnegative", file=sys.stderr)
return 3
repo = args.repo.resolve()
if not (repo / "pom.xml").is_file():
print(f"error: not a recognized repository root (pom.xml missing): {repo}", file=sys.stderr)
return 3
roots = {
"edit": (args.edit_root or repo / "output" / "edit-projects").resolve(),
"highlight": (args.highlight_root or repo / "output" / "highlight-projects").resolve(),
}
try:
now = time.time()
missing_roots = [finding("error", "PROJECT_ROOT_MISSING", f"{kind} root is absent: {root}")
for kind, root in roots.items() if not root.exists()]
if args.allow_missing_roots:
missing_roots = []
projects = scan_root("edit", roots["edit"], now, args.stale_hours)
projects += scan_root("highlight", roots["highlight"], now, args.stale_hours)
except (OSError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 3
if args.format == "json":
print(json.dumps({"roots": {k: str(v) for k, v in roots.items()}, "rootFindings": missing_roots,
"projects": projects}, indent=2, sort_keys=True))
else:
for item in missing_roots:
print(f"ROOT\t-\t-\t-\t-\t-\t-\t{item['severity'][0].upper()}:{item['code']} {item['detail']}")
print_table(projects)
severities = {f["severity"] for f in missing_roots}
severities.update(f["severity"] for p in projects for f in p["findings"])
if args.fail_on == "error" and "error" in severities:
return 2
if args.fail_on == "warning" and severities & {"warning", "error"}:
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())