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

224 lines
12 KiB
Python

#!/usr/bin/env python3
"""Verify render manifests and media using local ffprobe/ffmpeg only."""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
def args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Verify one edit or highlight render without modifying it.")
parser.add_argument("--repo", type=Path, default=Path("."), help="Repository root used to resolve manifest paths.")
parser.add_argument("--project-dir", type=Path, required=True, help="Directory containing render-manifest.json.")
parser.add_argument("--manifest", type=Path, help="Manifest override (default: PROJECT_DIR/render-manifest.json).")
parser.add_argument("--qa-report", type=Path, help="QA report override (default: PROJECT_DIR/qa-report.json).")
parser.add_argument("--media", type=Path, help="Media override; otherwise use manifest outputPath.")
parser.add_argument("--duration-tolerance", type=float, default=0.10, help="Allowed manifest/probe difference in seconds.")
parser.add_argument("--deep", action="store_true", help="Also run read-only black, silence, and audio-peak FFmpeg probes.")
parser.add_argument("--format", choices=("text", "json"), default="text")
return parser.parse_args()
def read_object(path: Path) -> dict:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"top-level JSON is not an object: {path}")
return value
def run(command: list[str]) -> subprocess.CompletedProcess[str]:
return subprocess.run(command, text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False)
def resolve_media(value: str, repo: Path, project: Path) -> tuple[Path | None, list[str], list[str]]:
raw = Path(value)
# Current manifests record either an absolute path, a repository-relative path such as
# output/edit-projects/<id>/final.mp4, or a project-relative path such as final.mp4.
candidates = [raw] if raw.is_absolute() else [repo / raw, project / raw, project / raw.name]
normalized: list[Path] = []
for candidate in candidates:
resolved = candidate.resolve()
if resolved not in normalized:
normalized.append(resolved)
existing = [candidate for candidate in normalized if candidate.is_file()]
chosen = existing[0] if len(existing) == 1 else None
return chosen, [str(path) for path in normalized], [str(path) for path in existing]
def qa_summary(path: Path) -> dict:
if not path.is_file():
return {"present": False, "mode": "missing", "declaredPassed": None, "failedChecks": []}
report = read_object(path)
checks = [c for c in report.get("checks", []) if isinstance(c, dict)]
names = {str(c.get("name")) for c in checks}
expected = {"black_frames", "long_silence", "audio_clipping"}
mode = "measured" if expected <= names else "partial"
return {
"present": True,
"mode": mode,
"declaredPassed": report.get("passed"),
"failedChecks": [str(c.get("name")) for c in checks if c.get("passed") is False],
"invalidChecks": [str(c.get("name")) for c in checks if not isinstance(c.get("passed"), bool)],
"missingProbes": sorted(expected - names),
}
def deep_probes(ffmpeg: str, media: Path) -> tuple[dict, list[dict]]:
findings: list[dict] = []
probes: dict = {}
commands = {
"black": [ffmpeg, "-hide_banner", "-v", "info", "-i", str(media), "-vf", "blackdetect=d=0.5:pic_th=0.98", "-an", "-f", "null", "-"],
"silence": [ffmpeg, "-hide_banner", "-v", "info", "-i", str(media), "-af", "silencedetect=noise=-45dB:d=2", "-vn", "-f", "null", "-"],
"peaks": [ffmpeg, "-hide_banner", "-v", "info", "-i", str(media), "-af", "astats=metadata=1:reset=1", "-vn", "-f", "null", "-"],
}
for name, command in commands.items():
result = run(command)
output = result.stdout + result.stderr
probes[name] = {"exitCode": result.returncode}
if result.returncode != 0:
findings.append({"severity": "error", "code": f"FFMPEG_{name.upper()}_FAILED", "detail": f"exit={result.returncode}"})
continue
if name == "black":
count = len(re.findall(r"black_start:", output))
probes[name]["rangeCount"] = count
if count:
findings.append({"severity": "error", "code": "BLACK_RANGE_DETECTED", "detail": f"ranges={count}; threshold d=0.5,pic_th=0.98"})
elif name == "silence":
count = len(re.findall(r"silence_start:", output))
probes[name]["rangeCount"] = count
if count:
findings.append({"severity": "warning", "code": "LONG_SILENCE_DETECTED", "detail": f"ranges={count}; threshold -45dB for 2s"})
else:
peaks = []
for value in re.findall(r"Peak level dB:\s*([^\s]+)", output):
try:
peaks.append(float(value))
except ValueError:
pass
maximum = max(peaks) if peaks else None
probes[name]["maximumPeakDbfs"] = maximum
if maximum is None:
findings.append({"severity": "warning", "code": "AUDIO_PEAK_UNMEASURED", "detail": "astats emitted no numeric peak"})
elif maximum >= -0.1:
findings.append({"severity": "warning", "code": "AUDIO_PEAK_UNSAFE", "detail": f"maximum={maximum} dBFS"})
return probes, findings
def main() -> int:
opts = args()
if opts.duration_tolerance < 0:
print("error: --duration-tolerance must be nonnegative", file=sys.stderr)
return 3
repo = opts.repo.resolve()
project = opts.project_dir.resolve()
manifest_path = (opts.manifest or project / "render-manifest.json").resolve()
qa_path = (opts.qa_report or project / "qa-report.json").resolve()
findings: list[dict] = []
try:
manifest = read_object(manifest_path)
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 3
try:
qa = qa_summary(qa_path)
except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc:
qa = {"present": True, "mode": "invalid", "declaredPassed": None, "failedChecks": [],
"invalidChecks": [], "missingProbes": []}
findings.append({"severity": "error", "code": "QA_REPORT_INVALID", "detail": str(exc)})
if qa["mode"] == "missing":
findings.append({"severity": "error", "code": "QA_REPORT_MISSING", "detail": str(qa_path)})
elif qa["mode"] == "partial":
findings.append({"severity": "warning", "code": "QA_PARTIAL", "detail": f"missing probes={','.join(qa['missingProbes'])}"})
if qa["present"] and not isinstance(qa["declaredPassed"], bool):
findings.append({"severity": "error", "code": "QA_PASSED_INVALID", "detail": "passed must be a JSON boolean"})
if qa.get("invalidChecks"):
findings.append({"severity": "error", "code": "QA_CHECK_RESULT_INVALID", "detail": ",".join(qa["invalidChecks"])})
if qa["declaredPassed"] is True and qa["failedChecks"]:
findings.append({"severity": "error", "code": "QA_RESULT_INCONSISTENT", "detail": "passed=true with failed checks: " + ",".join(qa["failedChecks"])})
if qa["declaredPassed"] is False:
findings.append({"severity": "error", "code": "QA_DECLARED_FAILED", "detail": ",".join(qa["failedChecks"]) or "report passed=false"})
media_value = str(opts.media) if opts.media else str(manifest.get("outputPath") or "")
if not media_value:
print("error: no --media and manifest outputPath is empty", file=sys.stderr)
return 3
if opts.media:
explicit = opts.media.resolve()
media, candidates, existing = (explicit if explicit.is_file() else None), [str(explicit)], ([str(explicit)] if explicit.is_file() else [])
else:
media, candidates, existing = resolve_media(media_value, repo, project)
if len(existing) > 1:
findings.append({"severity": "error", "code": "MEDIA_RESOLUTION_AMBIGUOUS", "detail": "multiple candidates exist: " + ",".join(existing)})
if media is None:
if not existing:
findings.append({"severity": "error", "code": "MANIFEST_OUTPUT_MISSING", "detail": f"candidates={','.join(candidates)}"})
report = {"manifest": str(manifest_path), "media": None, "resolutionCandidates": candidates,
"qa": qa, "probe": None, "deepProbes": None, "findings": findings}
if opts.format == "json":
print(json.dumps(report, indent=2, sort_keys=True))
else:
for item in findings:
print(f"{item['severity'].upper()} {item['code']} {item['detail']}")
return 2
ffprobe = shutil.which("ffprobe")
if not ffprobe:
print("error: ffprobe is not available on PATH", file=sys.stderr)
return 3
result = run([ffprobe, "-v", "error", "-show_streams", "-show_format", "-of", "json", str(media)])
if result.returncode != 0:
print(f"error: ffprobe exited {result.returncode}: {result.stderr.strip()}", file=sys.stderr)
return 3
try:
probe = json.loads(result.stdout)
duration = float(probe.get("format", {}).get("duration"))
except (TypeError, ValueError, json.JSONDecodeError) as exc:
print(f"error: invalid ffprobe result: {exc}", file=sys.stderr)
return 3
expected = manifest.get("durationSeconds")
if isinstance(expected, bool) or not isinstance(expected, (int, float)) or float(expected) < 0:
findings.append({"severity": "error", "code": "MANIFEST_DURATION_INVALID", "detail": f"durationSeconds={expected!r}"})
elif abs(float(expected) - duration) > opts.duration_tolerance:
findings.append({"severity": "error", "code": "DURATION_MISMATCH", "detail": f"manifest={expected} probe={duration:.6f} tolerance={opts.duration_tolerance}"})
streams = probe.get("streams", [])
video_streams = [s for s in streams if s.get("codec_type") == "video"]
audio_streams = [s for s in streams if s.get("codec_type") == "audio"]
if not video_streams:
findings.append({"severity": "error", "code": "VIDEO_STREAM_MISSING", "detail": "ffprobe found no video stream"})
if not audio_streams:
findings.append({"severity": "warning", "code": "AUDIO_STREAM_MISSING", "detail": "ffprobe found no audio stream"})
deep = None
if opts.deep:
ffmpeg = shutil.which("ffmpeg")
if not ffmpeg:
print("error: ffmpeg is not available on PATH", file=sys.stderr)
return 3
deep, deep_findings = deep_probes(ffmpeg, media)
findings.extend(deep_findings)
summary = {
"durationSeconds": duration,
"sizeBytes": int(probe.get("format", {}).get("size", media.stat().st_size)),
"video": [{k: s.get(k) for k in ("codec_name", "width", "height", "pix_fmt", "avg_frame_rate")} for s in video_streams],
"audio": [{k: s.get(k) for k in ("codec_name", "sample_rate", "channels", "channel_layout")} for s in audio_streams],
}
report = {"manifest": str(manifest_path), "media": str(media), "resolutionCandidates": candidates,
"qa": qa, "probe": summary, "deepProbes": deep, "findings": findings}
if opts.format == "json":
print(json.dumps(report, indent=2, sort_keys=True))
else:
print(f"media={media}")
print(f"duration_seconds={duration:.6f} video_streams={len(video_streams)} audio_streams={len(audio_streams)} qa_mode={qa['mode']}")
for item in findings:
print(f"{item['severity'].upper()} {item['code']} {item['detail']}")
if not findings:
print("OK manifest and measured media properties agree")
return 2 if any(item["severity"] == "error" for item in findings) else 0
if __name__ == "__main__":
raise SystemExit(main())