Add cutter report and auto-regen on each match

- New CUTTER_REPORT.md: per-beat hand-off table for the video editor doing
  the manual recut. Per beat: trailer SMPTE in/out, source SMPTE in/out,
  scene id, score, status (OK / ? / MAN.), and a one-line phase
  description from the cached vision text.
- New scripts/generate_cutter_report.py: pure renderer that reads the
  current cache (match_results.json + trailer_beats.json + optional
  vision_descriptions.json) and writes CUTTER_REPORT.md. No side effects on
  the cache.
- cli.py: after every successful match the cutter report is regenerated
  automatically (best-effort; failures are logged and do not abort).
- README.md: new top-section "Fuer den Cutter" describing exactly what the
  editor needs (which two files to look at, how the status flag works,
  the recommended NLE workflow). The technical algorithm description
  follows below.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Melbar
2026-05-04 13:09:16 +02:00
parent 06a2326bf1
commit 97a8f9e305
4 changed files with 390 additions and 25 deletions
+207
View File
@@ -0,0 +1,207 @@
"""
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())