Embed cutter-report stills inline + add HTML report with video previews

Two issues fixed:

1. Source frame rate was wrong. The script trusted ffprobe, which on this
   re-wrapped proxy reports 25 fps. The real number for the EDL/FCPXML and
   for what the cutter sees in the NLE comes from config.toml's
   edl_frame_rate (here 23.976). Source fps now reads that value first;
   ffprobe is only a fallback. Trailer fps still probes ffprobe (correct
   for the trailer file) with optional config override.

2. Stills in CUTTER_REPORT.md showed as broken links because output/ is
   gitignored, so the git server can't serve them. Stills are now embedded
   as base64 data URIs directly in the markdown. The file is therefore
   self-contained and renders in any markdown viewer including the git
   server's web preview.

3. New CUTTER_REPORT.html alongside the markdown: same data, proper card
   layout, side-by-side trailer/source columns per beat, base64-embedded
   stills, and (with --with-clips) base64-embedded 3 s MP4 video previews
   so the cutter can sight-check phase agreement directly in a browser.
   The auto-regen on each match writes both files; --with-clips is opt-in
   from the CLI for slower full renders.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Melbar
2026-05-04 13:33:07 +02:00
parent 5a6ae2175c
commit a405df0ddb
4 changed files with 633 additions and 359 deletions
File diff suppressed because one or more lines are too long
+99 -121
View File
File diff suppressed because one or more lines are too long
+5 -4
View File
@@ -93,7 +93,7 @@ def _save_results(results: list, cfg: "AppConfig") -> None: # type: ignore[name
def _regenerate_cutter_report(cfg: "AppConfig") -> None: # type: ignore[name-defined]
"""Re-render CUTTER_REPORT.md after each cache write so it stays in sync."""
"""Re-render CUTTER_REPORT.{md,html} after each cache write so they stay in sync."""
try:
from scripts.generate_cutter_report import render_report
except Exception as exc:
@@ -101,9 +101,10 @@ def _regenerate_cutter_report(cfg: "AppConfig") -> None: # type: ignore[name-de
return
try:
project_root = cfg.paths.cache_dir.parent
out = project_root / "CUTTER_REPORT.md"
out.write_text(render_report(project_root), encoding="utf-8")
logging.getLogger(__name__).info("Cutter report regenerated → %s", out)
md, html = render_report(project_root, with_stills=True, with_clips=False)
(project_root / "CUTTER_REPORT.md").write_text(md, encoding="utf-8")
(project_root / "CUTTER_REPORT.html").write_text(html, encoding="utf-8")
logging.getLogger(__name__).info("Cutter report regenerated (md + html)")
except Exception as exc:
logging.getLogger(__name__).warning("Cutter report regen failed: %s", exc)
+457 -206
View File
@@ -1,36 +1,40 @@
"""
scripts/generate_cutter_report.py generate CUTTER_REPORT.md from current cache
scripts/generate_cutter_report.py generate CUTTER_REPORT.{md,html} from 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: per beat it lists trailer timecode, the proposed source timecode, the
match score, what the vision model saw in the trailer beat, and side-by-side
preview stills (extracted via ffmpeg).
Renders two reports for the video editor:
Important: trailer and source can have different frame rates (e.g. trailer
25 fps, source 23.976 fps). This script probes each file with ffprobe and
renders trailer timecodes in trailer fps and source timecodes in source fps,
so the timecode matches what the cutter sees in the NLE.
* ``CUTTER_REPORT.md`` text + base64-embedded preview stills. Self-
contained (no broken image links on git server), opens in any markdown
viewer.
* ``CUTTER_REPORT.html`` same data with HTML layout, side-by-side
preview stills, and optionally side-by-side 3-second MP4 video clips
per beat for sight-checking phase agreement.
Frame rates:
* Trailer fps is taken from ``config.toml`` if ``[paths] trailer_frame_rate``
is set, otherwise from ffprobe on the trailer file.
* Source fps is taken from ``[export] edl_frame_rate`` in config.toml. This
is the value the EDL/FCPXML uses, so it matches what the cutter sees in
the NLE timeline. ffprobe is used only as a last-resort fallback.
Usage (from project root):
python scripts/generate_cutter_report.py # text + stills
python scripts/generate_cutter_report.py --no-stills # text only
Stills go to ``output/cutter_stills/beat_NN_{trailer,source}.jpg`` and are
referenced from the markdown. They are only re-rendered when the underlying
match position has changed fast on repeat runs.
python scripts/generate_cutter_report.py # full report
python scripts/generate_cutter_report.py --no-stills # text-only md
python scripts/generate_cutter_report.py --with-clips # also render
# video previews
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import re
import subprocess
import sys
from dataclasses import dataclass
from datetime import date
from pathlib import Path
@@ -40,15 +44,16 @@ from pathlib import Path
def probe_fps(video_path: Path) -> float | None:
"""Return container fps (avg_frame_rate) for a video file, or None."""
"""Return the file's frame rate via ffprobe. Tries r_frame_rate first."""
if not video_path.exists():
return None
for key in ("r_frame_rate", "avg_frame_rate"):
try:
proc = subprocess.run(
[
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=avg_frame_rate",
"-show_entries", f"stream={key}",
"-of", "default=noprint_wrappers=1:nokey=1",
str(video_path),
],
@@ -57,21 +62,25 @@ def probe_fps(video_path: Path) -> float | None:
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
raw = proc.stdout.strip()
if not raw or raw == "0/0":
continue
if "/" in raw:
num, _, den = raw.partition("/")
try:
n, d = float(num), float(den)
return n / d if d else None
if d:
return n / d
except ValueError:
return None
continue
try:
return float(raw)
except ValueError:
continue
return None
def smpte(t: float | None, fps: float) -> str:
"""Format seconds as h:mm:ss:ff, frame counter rounded to nearest int fps."""
"""Format seconds as h:mm:ss:ff using nearest-int frame counter."""
if t is None:
return "--:--:--:--"
fps_int = max(1, int(round(fps)))
@@ -113,23 +122,28 @@ def parse_field(desc: str | None, key: str) -> str:
# ----------------------------------------------------------------------------
# Stills
# Stills / clips
# ----------------------------------------------------------------------------
STILL_WIDTH = 360 # px, downscaled for fast preview in the markdown
STILL_QUALITY = 5 # ffmpeg -q:v scale 1 (best) .. 31 (worst)
STILL_WIDTH = 480
STILL_QUALITY = 4
CLIP_WIDTH = 480
CLIP_DURATION_S = 3.0
def _stale(out: Path, src: Path) -> bool:
try:
return not (out.exists() and out.stat().st_mtime >= src.stat().st_mtime and out.stat().st_size > 0)
except OSError:
return True
def extract_still(video_path: Path, t_s: float, out: Path) -> bool:
"""Extract one JPEG frame at t_s. Skip if out is newer than video."""
if not video_path.exists():
return False
try:
if out.exists() and out.stat().st_mtime >= video_path.stat().st_mtime and out.stat().st_size > 0:
if not _stale(out, video_path):
return True
except OSError:
pass
out.parent.mkdir(parents=True, exist_ok=True)
cmd = [
"ffmpeg", "-y", "-loglevel", "error",
@@ -147,18 +161,400 @@ def extract_still(video_path: Path, t_s: float, out: Path) -> bool:
return out.exists() and out.stat().st_size > 0
def extract_clip(video_path: Path, start_s: float, duration_s: float, out: Path) -> bool:
if not video_path.exists():
return False
if not _stale(out, video_path):
return True
out.parent.mkdir(parents=True, exist_ok=True)
cmd = [
"ffmpeg", "-y", "-loglevel", "error",
"-ss", f"{max(0.0, start_s):.3f}",
"-i", str(video_path),
"-t", f"{max(0.04, duration_s):.3f}",
"-vf", f"scale={CLIP_WIDTH}:-2",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
"-pix_fmt", "yuv420p",
"-an",
"-movflags", "+faststart",
str(out),
]
try:
subprocess.run(cmd, check=True, capture_output=True, timeout=60)
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
return False
return out.exists() and out.stat().st_size > 0
def beat_still_time(start_s: float, end_s: float) -> float:
"""Pick a representative time inside the beat (~30% in, but at least 0.4 s)."""
duration = max(0.04, end_s - start_s)
return start_s + min(0.4, duration * 0.3)
def data_uri(path: Path, mime: str) -> str | None:
if not path.exists() or path.stat().st_size == 0:
return None
payload = base64.b64encode(path.read_bytes()).decode("ascii")
return f"data:{mime};base64,{payload}"
# ----------------------------------------------------------------------------
# Renderer
# Per-beat data
# ----------------------------------------------------------------------------
def render_report(project_root: Path, with_stills: bool = True) -> str:
@dataclass
class BeatRow:
bid: int
trailer_in_s: float
trailer_out_s: float
source_in_s: float | None
source_out_s: float | None
scene_id: int | None
score: float
confirmed: bool
matched: bool
phase: str
composition: str
setting: str
trailer_still: Path | None
source_still: Path | None
trailer_clip: Path | None
source_clip: Path | None
@property
def status(self) -> str:
if not self.matched:
return "MAN."
return "OK" if self.confirmed else "?"
def collect_rows(
project_root: Path,
beats: list[dict],
results: dict[int, dict],
vis_items: dict,
trailer_path: Path,
source_path: Path,
with_stills: bool,
with_clips: bool,
) -> list[BeatRow]:
stills_dir = project_root / "output" / "cutter_stills"
clips_dir = project_root / "output" / "cutter_clips"
if with_stills:
stills_dir.mkdir(parents=True, exist_ok=True)
if with_clips:
clips_dir.mkdir(parents=True, exist_ok=True)
rows: list[BeatRow] = []
for beat in beats:
bid = beat["beat_id"]
rec = results.get(bid)
desc = best_beat_description(vis_items, bid, beat["start_s"], beat["end_s"]) or ""
trailer_still = source_still = trailer_clip = source_clip = None
if with_stills:
t_still = beat_still_time(beat["start_s"], beat["end_s"])
tjpg = stills_dir / f"beat_{bid:02d}_trailer.jpg"
if extract_still(trailer_path, t_still, tjpg):
trailer_still = tjpg
if rec is not None:
src_dur = max(0.04, rec["out_point_s"] - rec["in_point_s"])
s_still = rec["in_point_s"] + min(0.4, src_dur * 0.3)
sjpg = stills_dir / f"beat_{bid:02d}_source.jpg"
if extract_still(source_path, s_still, sjpg):
source_still = sjpg
if with_clips:
tdur = min(CLIP_DURATION_S, max(0.5, beat["end_s"] - beat["start_s"]))
tmp4 = clips_dir / f"beat_{bid:02d}_trailer.mp4"
if extract_clip(trailer_path, beat["start_s"], tdur, tmp4):
trailer_clip = tmp4
if rec is not None:
src_dur = max(0.5, rec["out_point_s"] - rec["in_point_s"])
sdur = min(CLIP_DURATION_S, src_dur)
smp4 = clips_dir / f"beat_{bid:02d}_source.mp4"
if extract_clip(source_path, rec["in_point_s"], sdur, smp4):
source_clip = smp4
rows.append(BeatRow(
bid=bid,
trailer_in_s=beat["start_s"], trailer_out_s=beat["end_s"],
source_in_s=rec["in_point_s"] if rec else None,
source_out_s=rec["out_point_s"] if rec else None,
scene_id=rec["scene_id"] if rec else None,
score=rec["match_score"] if rec else 0.0,
confirmed=bool(rec and rec.get("is_confirmed")),
matched=rec is not None,
phase=parse_field(desc, "action_phase") or parse_field(desc, "subject"),
composition=parse_field(desc, "composition"),
setting=parse_field(desc, "setting"),
trailer_still=trailer_still,
source_still=source_still,
trailer_clip=trailer_clip,
source_clip=source_clip,
))
return rows
# ----------------------------------------------------------------------------
# Markdown renderer
# ----------------------------------------------------------------------------
def render_markdown(
rows: list[BeatRow], trailer_fps: float, source_fps: float,
trailer_path: Path, source_path: Path,
) -> str:
matched = sum(1 for r in rows if r.matched)
confirmed = sum(1 for r in rows if r.confirmed)
out: list[str] = []
out.append("# Cutter-Report — manuelles Nachschneiden")
out.append("")
out.append(f"Stand: {date.today().isoformat()}")
out.append("")
out.append(f"- **Trailer**: `{trailer_path.name}` @ {trailer_fps:.3f} fps")
out.append(f"- **Source** : `{source_path.name}` @ {source_fps:.3f} fps")
out.append("")
out.append(
"Trailer-TC in Trailer-Framerate, Source-TC in Source-Framerate — "
"passt 1:1 zum Schnittplatz."
)
out.append("")
out.append(
"Bilder sind base64-eingebettet (kein toter Link). Für Video-Vorschau "
"siehe `CUTTER_REPORT.html` (gleiche Daten, mit Clips)."
)
out.append("")
out.append("## Status-Legende")
out.append("")
out.append("| Status | Bedeutung | Was tun? |")
out.append("|--------|-----------|----------|")
out.append("| `OK` | bestätigt durch CV + Vision-Phasenprüfung | übernehmen |")
out.append("| `?` | korrekte Szene, Phase ggf. um wenige Frames verschoben | im NLE prüfen |")
out.append("| `MAN.` | kein automatischer Treffer | manuell setzen oder Schwarzfade |")
out.append("")
out.append(f"**Beats:** {len(rows)} gesamt · **{matched}** automatisch (**{confirmed}** bestätigt) · **{len(rows)-matched}** manuell.")
out.append("")
out.append("## Beat-Tabelle")
out.append("")
out.append("| Beat | Trailer In / Out | Source In / Out | Score | Status | Bild laut Vision |")
out.append("|-----:|------------------|------------------|------:|:------:|-------------------|")
for r in rows:
ti = smpte(r.trailer_in_s, trailer_fps)
to = smpte(r.trailer_out_s, trailer_fps)
si = smpte(r.source_in_s, source_fps) if r.matched else ""
so = smpte(r.source_out_s, source_fps) if r.matched else ""
sc = f"{r.score:.3f}" if r.matched else ""
out.append(f"| {r.bid:>4} | {ti}-{to} | {si}-{so} | {sc} | {r.status} | {r.phase[:80]} |")
out.append("")
out.append("## Beat-Details")
out.append("")
for r in rows:
ti = smpte(r.trailer_in_s, trailer_fps)
to = smpte(r.trailer_out_s, trailer_fps)
out.append(f"### Beat {r.bid:02d} — Status `{r.status}`")
out.append("")
out.append(f"- **Trailer**: {ti} {to}")
if r.matched:
si = smpte(r.source_in_s, source_fps)
so = smpte(r.source_out_s, source_fps)
out.append(f"- **Source** : {si} {so} (scene {r.scene_id}, score {r.score:.3f})")
else:
out.append("- **Source** : — (manuell setzen)")
if r.phase:
out.append(f"- **Phase** : {r.phase}")
if r.composition:
extra = f", {r.setting}" if r.setting else ""
out.append(f"- **Bild** : {r.composition}{extra}")
out.append("")
# Inline base64 stills
t_uri = data_uri(r.trailer_still, "image/jpeg") if r.trailer_still else None
s_uri = data_uri(r.source_still, "image/jpeg") if r.source_still else None
if t_uri or s_uri:
out.append("| Trailer | Source |")
out.append("|:---:|:---:|")
t_cell = f"![Trailer beat {r.bid}]({t_uri})" if t_uri else "_(kein Still)_"
s_cell = f"![Source beat {r.bid}]({s_uri})" if s_uri else "_(MAN.)_"
out.append(f"| {t_cell} | {s_cell} |")
out.append("")
return "\n".join(out)
# ----------------------------------------------------------------------------
# HTML renderer
# ----------------------------------------------------------------------------
HTML_HEAD = """<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Cutter-Report</title>
<style>
:root { color-scheme: light dark; --fg:#222; --bg:#fafafa; --mut:#666;
--ok:#1e8e3e; --q:#d18907; --man:#c33; --card:#fff; --bd:#ddd; }
@media (prefers-color-scheme: dark) {
:root { --fg:#eee; --bg:#181818; --mut:#aaa; --card:#222; --bd:#333; }
}
html,body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",
Roboto,sans-serif;color:var(--fg);background:var(--bg);font-size:14px;}
.wrap{max-width:1200px;margin:0 auto;padding:24px;}
h1{margin:0 0 4px;font-size:24px;}
h2{margin:32px 0 12px;font-size:18px;border-bottom:1px solid var(--bd);padding-bottom:4px;}
h3{margin:24px 0 6px;font-size:16px;}
.meta{color:var(--mut);margin-bottom:16px;}
.summary{margin:8px 0 24px;font-size:15px;}
table.tab{width:100%;border-collapse:collapse;margin:12px 0 24px;font-size:13px;}
table.tab th,table.tab td{padding:6px 8px;border-bottom:1px solid var(--bd);text-align:left;
vertical-align:top;}
table.tab th{background:var(--card);}
table.tab td.tc{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap;}
table.tab td.num{text-align:right;font-variant-numeric:tabular-nums;}
.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-weight:600;font-size:12px;}
.badge.ok{background:rgba(30,142,62,.15);color:var(--ok);}
.badge.q{background:rgba(209,137,7,.15);color:var(--q);}
.badge.man{background:rgba(204,51,51,.15);color:var(--man);}
.beat{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:14px 0 28px;
background:var(--card);border:1px solid var(--bd);border-radius:8px;padding:14px;}
.beat .full{grid-column:1/-1;}
.col h4{margin:0 0 6px;font-size:13px;color:var(--mut);font-weight:600;
text-transform:uppercase;letter-spacing:.05em;}
.col img,.col video{width:100%;height:auto;border-radius:4px;background:#000;}
.kv{margin:4px 0;font-size:13px;}
.kv b{display:inline-block;min-width:64px;color:var(--mut);font-weight:500;}
.tc{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;}
.empty{display:flex;align-items:center;justify-content:center;width:100%;
aspect-ratio:16/9;background:#000;color:#888;border-radius:4px;font-size:13px;}
</style>
</head>
<body><div class="wrap">
"""
HTML_FOOT = """</div></body></html>
"""
def html_escape(s: str) -> str:
return (s.replace("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace('"', "&quot;"))
def render_html(
rows: list[BeatRow], trailer_fps: float, source_fps: float,
trailer_path: Path, source_path: Path, with_clips: bool,
) -> str:
matched = sum(1 for r in rows if r.matched)
confirmed = sum(1 for r in rows if r.confirmed)
parts: list[str] = [HTML_HEAD]
parts.append(f'<h1>Cutter-Report — {date.today().isoformat()}</h1>')
parts.append('<div class="meta">')
parts.append(f"<div><b>Trailer</b> <code>{html_escape(trailer_path.name)}</code> @ {trailer_fps:.3f} fps</div>")
parts.append(f"<div><b>Source</b> <code>{html_escape(source_path.name)}</code> @ {source_fps:.3f} fps</div>")
parts.append("<div>Trailer-TC in Trailer-Framerate, Source-TC in Source-Framerate.</div>")
parts.append("</div>")
parts.append(f'<div class="summary">{len(rows)} Beats — <b>{matched}</b> automatisch (<b>{confirmed}</b> bestätigt) — <b>{len(rows)-matched}</b> manuell.</div>')
parts.append("<h2>Status-Legende</h2>")
parts.append('<table class="tab"><thead><tr><th>Status</th><th>Bedeutung</th><th>Was tun?</th></tr></thead><tbody>')
parts.append('<tr><td><span class="badge ok">OK</span></td><td>bestätigt durch CV + Vision-Phasenprüfung</td><td>übernehmen, optional sichten</td></tr>')
parts.append('<tr><td><span class="badge q">?</span></td><td>korrekte Szene, Phase ggf. um wenige Frames verschoben</td><td>im NLE prüfen, Source-In nachjustieren</td></tr>')
parts.append('<tr><td><span class="badge man">MAN.</span></td><td>kein automatischer Treffer</td><td>manuell suchen oder Schwarzfade</td></tr>')
parts.append('</tbody></table>')
# Compact table
parts.append("<h2>Beat-Tabelle</h2>")
parts.append('<table class="tab"><thead><tr><th>Beat</th><th>Trailer In / Out</th><th>Source In / Out</th><th>Score</th><th>Status</th><th>Phase</th></tr></thead><tbody>')
for r in rows:
ti = smpte(r.trailer_in_s, trailer_fps)
to = smpte(r.trailer_out_s, trailer_fps)
si = smpte(r.source_in_s, source_fps) if r.matched else ""
so = smpte(r.source_out_s, source_fps) if r.matched else ""
sc = f"{r.score:.3f}" if r.matched else ""
bcls = {"OK": "ok", "?": "q", "MAN.": "man"}[r.status]
parts.append(
f'<tr><td class="num">{r.bid}</td>'
f'<td class="tc">{ti}{to}</td>'
f'<td class="tc">{si}{so}</td>'
f'<td class="num">{sc}</td>'
f'<td><span class="badge {bcls}">{r.status}</span></td>'
f'<td>{html_escape(r.phase[:120])}</td></tr>'
)
parts.append("</tbody></table>")
# Per-beat detail cards
parts.append("<h2>Beat-Details</h2>")
for r in rows:
ti = smpte(r.trailer_in_s, trailer_fps)
to = smpte(r.trailer_out_s, trailer_fps)
bcls = {"OK": "ok", "?": "q", "MAN.": "man"}[r.status]
parts.append('<div class="beat">')
parts.append(f'<div class="full"><h3>Beat {r.bid:02d} <span class="badge {bcls}">{r.status}</span></h3></div>')
# Trailer column
parts.append('<div class="col">')
parts.append('<h4>Trailer</h4>')
clip_uri = data_uri(r.trailer_clip, "video/mp4") if (with_clips and r.trailer_clip) else None
if clip_uri:
parts.append(f'<video controls preload="metadata" muted loop src="{clip_uri}"></video>')
elif r.trailer_still:
uri = data_uri(r.trailer_still, "image/jpeg") or ""
parts.append(f'<img src="{uri}" alt="Trailer beat {r.bid}">')
else:
parts.append('<div class="empty">— kein Vorschaubild —</div>')
parts.append(f'<div class="kv tc"><b>TC</b> {ti} {to}</div>')
if r.phase:
parts.append(f'<div class="kv"><b>Phase</b> {html_escape(r.phase)}</div>')
if r.composition:
extra = f", {r.setting}" if r.setting else ""
parts.append(f'<div class="kv"><b>Bild</b> {html_escape(r.composition + extra)}</div>')
parts.append('</div>')
# Source column
parts.append('<div class="col">')
parts.append('<h4>Source</h4>')
clip_uri = data_uri(r.source_clip, "video/mp4") if (with_clips and r.source_clip) else None
if clip_uri:
parts.append(f'<video controls preload="metadata" muted loop src="{clip_uri}"></video>')
elif r.source_still:
uri = data_uri(r.source_still, "image/jpeg") or ""
parts.append(f'<img src="{uri}" alt="Source beat {r.bid}">')
else:
parts.append('<div class="empty">— manuell setzen —</div>')
if r.matched:
si = smpte(r.source_in_s, source_fps)
so = smpte(r.source_out_s, source_fps)
parts.append(f'<div class="kv tc"><b>TC</b> {si} {so}</div>')
parts.append(f'<div class="kv"><b>Scene</b> {r.scene_id} · <b>Score</b> {r.score:.3f}</div>')
else:
parts.append('<div class="kv">— kein automatischer Treffer —</div>')
parts.append('</div>')
parts.append('</div>') # .beat
parts.append(HTML_FOOT)
return "".join(parts)
# ----------------------------------------------------------------------------
# Top-level
# ----------------------------------------------------------------------------
def render_report(
project_root: Path,
with_stills: bool = True,
with_clips: bool = False,
) -> tuple[str, str]:
"""Return (markdown, html). Both written by main()."""
sys.path.insert(0, str(project_root))
from src.core.config import load_config
@@ -166,8 +562,15 @@ def render_report(project_root: Path, with_stills: bool = True) -> str:
trailer_path = Path(cfg.paths.reference_trailer)
source_path = Path(cfg.paths.source_movie)
trailer_fps = probe_fps(trailer_path) or 25.0
source_fps = probe_fps(source_path) or float(cfg.export.edl_frame_rate)
# Source fps: trust config.toml's edl_frame_rate (it's what the EDL/FCPXML
# uses, hence what the cutter sees in the NLE). Fall back to ffprobe only
# if no value is configured.
source_fps = float(getattr(cfg.export, "edl_frame_rate", 0.0)) or probe_fps(source_path) or 23.976
# Trailer fps: optional config override, else ffprobe, else fallback to
# source fps so the two sides at least share a number.
trailer_fps_cfg = getattr(cfg.paths, "trailer_frame_rate", None)
trailer_fps = float(trailer_fps_cfg) if trailer_fps_cfg else (probe_fps(trailer_path) or source_fps)
cache = project_root / ".cache"
results = {r["beat_id"]: r for r in json.loads((cache / "match_results.json").read_text())}
@@ -175,184 +578,32 @@ def render_report(project_root: Path, with_stills: bool = True) -> str:
vis_path = cache / "vision_descriptions.json"
vis_items = json.loads(vis_path.read_text())["items"] if vis_path.exists() else {}
stills_dir = project_root / "output" / "cutter_stills"
if with_stills:
stills_dir.mkdir(parents=True, exist_ok=True)
lines: list[str] = []
lines.append("# Cutter-Report — manuelles Nachschneiden")
lines.append("")
lines.append(f"Stand: {date.today().isoformat()}")
lines.append("")
lines.append(f"- **Trailer**: `{trailer_path.name}` @ {trailer_fps:.3f} fps")
lines.append(f"- **Source** : `{source_path.name}` @ {source_fps:.3f} fps")
lines.append("")
lines.append(
"Trailer-Timecodes sind in **Trailer-Framerate** angegeben, "
"Source-Timecodes in **Source-Framerate**. So passen sie 1:1 zu dem, "
"was du in deinem NLE auf den jeweiligen Spuren siehst."
rows = collect_rows(
project_root, beats, results, vis_items,
trailer_path, source_path, with_stills, with_clips,
)
lines.append("")
lines.append(
"Diese Datei wird automatisch erzeugt — nach jedem `python cli.py match` "
"neu generieren mit:"
)
lines.append("")
lines.append("```powershell")
lines.append("python scripts/generate_cutter_report.py")
lines.append("```")
lines.append("")
lines.append("## Status-Legende")
lines.append("")
lines.append("| Status | Bedeutung | Was tun? |")
lines.append("|--------|-----------|----------|")
lines.append("| `OK` | bestätigt durch CV + Vision-Phasenprüfung | übernehmen, optional stichprobenartig sichten |")
lines.append("| `?` | korrekte Szene, Phase eventuell um wenige Frames verschoben | im NLE prüfen, Source-In ggf. nachjustieren |")
lines.append("| `MAN.` | kein automatischer Treffer | manuell suchen oder als Schwarzfade/Titel übernehmen |")
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("## Übersicht")
lines.append("")
lines.append(f"- Beats gesamt: **{len(beats)}**")
lines.append(f"- Automatisch gefunden: **{matched}** ({confirmed} davon bestätigt)")
lines.append(f"- Manuell zu setzen: **{len(beats) - matched}**")
lines.append("")
# ---- Compact table (timecode-only, no images) ------------------------
lines.append("## Beat-Tabelle (kompakt)")
lines.append("")
lines.append("| Beat | Trailer In / Out | Source In / Out | 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 = smpte(beat["start_s"], trailer_fps)
to = smpte(beat["end_s"], trailer_fps)
if rec is not None:
si = smpte(rec["in_point_s"], source_fps)
so = smpte(rec["out_point_s"], source_fps)
sc = rec["match_score"]
else:
si = so = ""
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"))[:80]
lines.append(f"| {bid:>4} | {ti}-{to} | {si}-{so} | {sc:.3f} | {status_for(rec)} | {phase} |")
lines.append("")
# ---- Detailed per-beat sections with stills --------------------------
lines.append("## Beat-Details mit Vorschau-Stills")
lines.append("")
if not with_stills:
lines.append("_Stills sind in diesem Lauf deaktiviert (`--no-stills`)._")
lines.append("")
for beat in beats:
bid = beat["beat_id"]
rec = results.get(bid)
ti = smpte(beat["start_s"], trailer_fps)
to = smpte(beat["end_s"], trailer_fps)
if rec is not None:
si = smpte(rec["in_point_s"], source_fps)
so = smpte(rec["out_point_s"], source_fps)
sc_str = f"{rec['match_score']:.3f}"
scn = rec["scene_id"]
else:
si = so = ""
sc_str = ""
scn = ""
status = status_for(rec)
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") or "(keine Vision-Beschreibung)"
composition = parse_field(desc, "composition")
setting = parse_field(desc, "setting")
lines.append(f"### Beat {bid:02d} — Status `{status}`")
lines.append("")
lines.append(f"- **Trailer**: {ti} {to}")
if rec is not None:
lines.append(f"- **Source** : {si} {so} (scene {scn}, score {sc_str})")
else:
lines.append("- **Source** : — (kein Treffer; manuell setzen)")
lines.append(f"- **Phase** : {phase}")
if composition:
lines.append(f"- **Bild** : {composition}{', ' + setting if setting else ''}")
lines.append("")
if with_stills:
t_still = beat_still_time(beat["start_s"], beat["end_s"])
trailer_jpg = stills_dir / f"beat_{bid:02d}_trailer.jpg"
ok_t = extract_still(trailer_path, t_still, trailer_jpg)
source_jpg = stills_dir / f"beat_{bid:02d}_source.jpg"
if rec is not None:
s_t = rec["in_point_s"] + min(0.4, max(0.04, rec["out_point_s"] - rec["in_point_s"]) * 0.3)
ok_s = extract_still(source_path, s_t, source_jpg)
else:
ok_s = False
cells_h = []
cells_t = []
cells_h.append("Trailer")
if ok_t:
rel_t = trailer_jpg.relative_to(project_root).as_posix()
cells_t.append(f"![Trailer beat {bid}]({rel_t})")
else:
cells_t.append("_(kein Still)_")
cells_h.append("Source")
if ok_s:
rel_s = source_jpg.relative_to(project_root).as_posix()
cells_t.append(f"![Source beat {bid}]({rel_s})")
else:
cells_t.append("_(kein Still)_")
lines.append("| " + " | ".join(cells_h) + " |")
lines.append("|" + "|".join(["---"] * len(cells_h)) + "|")
lines.append("| " + " | ".join(cells_t) + " |")
lines.append("")
lines.append("## Hinweise zur Prüfung")
lines.append("")
lines.append(
"1. Wenn die Bewegungsphase im Source-Still nicht zum Trailer-Still passt, im NLE den Source-In um wenige Frames verschieben — innerhalb derselben Source-Szene reicht das meistens."
)
lines.append(
"2. Wenn der Source-Clip kürzer ist als der Trailerbeat (Source-Out < Trailer-Out), enthält der Trailerbeat eine Blende oder Titelkarte; im Schnitt mit Schwarzfade oder dem Source-Tail auffüllen."
)
lines.append(
"3. `OK`-Beats sind doppelt verifiziert (CV + Vision-Phase). Trotzdem stichprobenartig sichten."
)
lines.append(
"4. Stills liegen unter `output/cutter_stills/`. Bei Bedarf einzelne neu generieren: einfach die Datei löschen und das Skript erneut laufen lassen."
)
lines.append("")
return "\n".join(lines)
# ----------------------------------------------------------------------------
# CLI entry
# ----------------------------------------------------------------------------
md = render_markdown(rows, trailer_fps, source_fps, trailer_path, source_path)
html = render_html(rows, trailer_fps, source_fps, trailer_path, source_path, with_clips)
return md, html
def main() -> int:
parser = argparse.ArgumentParser(description="Render CUTTER_REPORT.md from current cache")
parser.add_argument("--no-stills", action="store_true", help="skip frame extraction")
parser = argparse.ArgumentParser(description="Render CUTTER_REPORT.{md,html} from current cache")
parser.add_argument("--no-stills", action="store_true", help="skip frame extraction (markdown stays text-only)")
parser.add_argument("--with-clips", action="store_true", help="also render 3 s MP4 previews per beat (slow)")
args = parser.parse_args()
here = Path(__file__).resolve().parent
project_root = here.parent
out = project_root / "CUTTER_REPORT.md"
out.write_text(render_report(project_root, with_stills=not args.no_stills), encoding="utf-8")
print(f"Wrote {out}")
md, html = render_report(
project_root,
with_stills=not args.no_stills,
with_clips=args.with_clips,
)
(project_root / "CUTTER_REPORT.md").write_text(md, encoding="utf-8")
(project_root / "CUTTER_REPORT.html").write_text(html, encoding="utf-8")
print(f"Wrote {project_root / 'CUTTER_REPORT.md'}")
print(f"Wrote {project_root / 'CUTTER_REPORT.html'}")
return 0