""" scripts/generate_cutter_report.py — generate CUTTER_REPORT.md from current cache Regenerates CUTTER_REPORT.md from .cache/match_results.json, .cache/trailer_beats.json and .cache/vision_descriptions.json. The report is a hand-off document for a video editor (Cutter) doing the manual recut: it lists, per beat, the trailer position, the proposed source position in SMPTE timecodes, the match score, and what the vision model saw in the trailer beat. Usage (from project root): python scripts/generate_cutter_report.py Run this any time after `python cli.py match` to keep CUTTER_REPORT.md in sync with the latest cache. """ from __future__ import annotations import json import re import sys from datetime import date from pathlib import Path def smpte(t: float | None, fps: int) -> str: if t is None: return "--:--:--:--" total = int(round(t * fps)) h = total // (3600 * fps) m = (total // (60 * fps)) % 60 s = (total // fps) % 60 f = total % fps return f"{h:02d}:{m:02d}:{s:02d}:{f:02d}" def best_beat_description(items: dict, beat_id: int, start_s: float, end_s: float) -> str | None: best, best_diff = None, 1e9 for key, value in items.items(): if not key.startswith(f"beat:{beat_id}:") or not isinstance(value, dict): continue try: parts = key.split(":") ks, ke = float(parts[2]), float(parts[3]) except (IndexError, ValueError): continue diff = abs(ks - start_s) + abs(ke - end_s) if diff < best_diff: best_diff = diff best = value return best.get("description", "") if best else None def parse_field(desc: str | None, key: str) -> str: if not desc: return "" match = re.search(rf'"{key}"\s*:\s*"([^"]+)"', desc) return match.group(1) if match else "" def render_report(project_root: Path) -> str: sys.path.insert(0, str(project_root)) from src.core.config import load_config cfg = load_config(project_root / "config.toml") fps = int(round(cfg.export.edl_frame_rate)) cache = project_root / ".cache" results = {r["beat_id"]: r for r in json.loads((cache / "match_results.json").read_text())} beats = json.loads((cache / "trailer_beats.json").read_text()) vis_path = cache / "vision_descriptions.json" vis_items = json.loads(vis_path.read_text())["items"] if vis_path.exists() else {} lines: list[str] = [] lines.append("# Cutter-Report — manuelles Nachschneiden") lines.append("") lines.append( f"Stand: {date.today().isoformat()}. Frame-Rate: {cfg.export.edl_frame_rate} fps. " f"Source: {Path(cfg.paths.source_movie).name} — Trailer: {Path(cfg.paths.reference_trailer).name}." ) lines.append("") lines.append( "Diese Datei wird automatisch aus dem Match-Cache erzeugt. " "Nach jedem `python cli.py match` mit `python scripts/generate_cutter_report.py` neu generieren." ) lines.append("") lines.append("## Wie diese Tabelle zu lesen ist") lines.append("") lines.append("- **Beat**: Nummer im Referenz-Trailer.") lines.append("- **Trailer In/Out**: SMPTE-Position des Beats im Trailer (h:mm:ss:ff).") lines.append("- **Source In/Out**: vorgeschlagene Position im Quellfilm. Bei `MAN.` selbst aussuchen.") lines.append("- **Scene**: ID der Source-Szene aus PySceneDetect (nur fuer Debug-Zwecke).") lines.append("- **Score**: 0..1, je hoeher desto besser. >=0.65 ist als bestaetigt eingestuft.") lines.append("- **Status**:") lines.append(" - `OK` — bestaetigt durch CV + Vision-Phasenpruefung, kann ohne weitere Pruefung uebernommen werden.") lines.append(" - `?` — vorlaeufig, korrekte Szene aber Score unter 0.65; Bewegungsphase im Vorschauclip pruefen und ggf. um wenige Frames verschieben.") lines.append(" - `MAN.` — kein automatischer Treffer; entweder manuell suchen oder als Schwarzfade/Titel uebernehmen.") lines.append("- **Phase**: was im Trailerbeat zu sehen ist (aus Vision-Beschreibung). Hilft dir, die richtige Stelle im Source zu finden.") lines.append("") matched = sum(1 for b in beats if b["beat_id"] in results) confirmed = sum(1 for b in beats if b["beat_id"] in results and results[b["beat_id"]]["is_confirmed"]) lines.append("## Status-Uebersicht") lines.append("") lines.append(f"- **Beats gesamt**: {len(beats)}") lines.append(f"- **Automatisch gefunden**: {matched} ({confirmed} davon bestaetigt)") lines.append(f"- **Manuell zu setzen**: {len(beats) - matched}") lines.append("") lines.append("## Beat-Tabelle") lines.append("") lines.append("| Beat | Trailer In / Out | Source In / Out | Scene | Score | Status | Was im Bild zu sehen ist |") lines.append("|-----:|------------------|------------------|------:|------:|:------:|---------------------------|") def status_for(rec: dict | None) -> str: if rec is None: return "MAN." return "OK" if rec.get("is_confirmed") else "?" for beat in beats: bid = beat["beat_id"] rec = results.get(bid) ti, to = smpte(beat["start_s"], fps), smpte(beat["end_s"], fps) if rec is not None: si, so = smpte(rec["in_point_s"], fps), smpte(rec["out_point_s"], fps) scn = rec["scene_id"] sc = rec["match_score"] else: si = so = "—" scn = "—" sc = 0.0 desc = best_beat_description(vis_items, bid, beat["start_s"], beat["end_s"]) or "" phase = (parse_field(desc, "action_phase") or parse_field(desc, "subject"))[:90] lines.append(f"| {bid:>4} | {ti}-{to} | {si}-{so} | {scn} | {sc:.3f} | {status_for(rec)} | {phase} |") lines.append("") lines.append("## Beats die manuelle Aufmerksamkeit brauchen") lines.append("") lines.append("### Manuell setzen (Status `MAN.`)") lines.append("") for beat in beats: bid = beat["beat_id"] if bid in results: continue ti, to = smpte(beat["start_s"], fps), smpte(beat["end_s"], fps) desc = best_beat_description(vis_items, bid, beat["start_s"], beat["end_s"]) or "" phase = parse_field(desc, "action_phase") note = phase or "keine Vision-Beschreibung — vermutlich Title-Card / Fade / Logo" lines.append(f"- **Beat {bid}** {ti}-{to}: {note}") lines.append("") lines.append("### Vorlaeufig (Status `?`) — bitte sichten") lines.append("") lines.append("| Beat | Score | Source In | Phase laut Vision |") lines.append("|-----:|------:|-----------|--------------------|") for beat in beats: bid = beat["beat_id"] rec = results.get(bid) if rec is None or rec.get("is_confirmed"): continue desc = best_beat_description(vis_items, bid, beat["start_s"], beat["end_s"]) or "" phase = parse_field(desc, "action_phase") lines.append(f"| {bid:>4} | {rec['match_score']:.3f} | {smpte(rec['in_point_s'], fps)} | {phase[:90]} |") lines.append("") lines.append("### Bestaetigt (Status `OK`) — kann uebernommen werden") lines.append("") lines.append("| Beat | Score | Source In | Phase laut Vision |") lines.append("|-----:|------:|-----------|--------------------|") for beat in beats: bid = beat["beat_id"] rec = results.get(bid) if rec is None or not rec.get("is_confirmed"): continue desc = best_beat_description(vis_items, bid, beat["start_s"], beat["end_s"]) or "" phase = parse_field(desc, "action_phase") lines.append(f"| {bid:>4} | {rec['match_score']:.3f} | {smpte(rec['in_point_s'], fps)} | {phase[:90]} |") lines.append("") lines.append("## Hinweise zur Pruefung") lines.append("") lines.append( "1. Source-Times sollten zur jeweiligen Trailer-Bewegungsphase passen. " "Wenn nicht: Source-In innerhalb derselben Source-Szene wenige Frames vor/zurueck verschieben." ) lines.append( "2. Wenn der Source-Clip kuerzer ist als der Trailerbeat (Source-Out < Trailer-Out gerechnet ab Source-In), " "enthaelt der Trailerbeat eine Blende/Titelkarte; im Schnitt mit Schwarzfade oder Source-Tail auffuellen." ) lines.append( "3. `OK`-Beats sind durch CV + Vision-Phasenpruefung doppelt verifiziert; trotzdem stichprobenartig sichten." ) lines.append("") return "\n".join(lines) def main() -> int: here = Path(__file__).resolve().parent project_root = here.parent out = project_root / "CUTTER_REPORT.md" out.write_text(render_report(project_root), encoding="utf-8") print(f"Wrote {out}") return 0 if __name__ == "__main__": raise SystemExit(main())