97a8f9e305
- 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>
208 lines
8.6 KiB
Python
208 lines
8.6 KiB
Python
"""
|
|
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())
|