Files
aitrailer/scripts/generate_cutter_report.py
T
Melbar 54d3f04616 Fix matching regressions, cache guard, and multi-shot algorithm for beat 15
- config.toml: revert scoreable_luma/contrast thresholds to 24/58/24 (lowering
  them let cross-fade blend frames contaminate content-validation templates,
  dropping scores below provisional_content_threshold)
- src/cv/global_scan.py: _is_dark_reference_frame now requires contrast<30 so
  genuine dark silhouette frames are not rejected as scoreable; two-path
  _is_scoreable_reference_frame separates standard vs fade-content scoring
- cli.py: _keeps_cached_match() guard prevents a weaker single-span rematch
  from overwriting a better multi-segment provisional cache entry
- cli.py: _fade_content_shots() restricted to between-island gaps only—
  pre-island black leaders were incorrectly emitted as matchable shots
- cli.py: island[0] of _match_unmatched_visual_segments() now uses no
  continuity seed so an insert cut at the start of a multi-shot beat is not
  forced toward the previous beat's scene
- scripts/generate_cutter_report.py: fix ffmpeg concat demuxer on Windows—
  use part.absolute().as_posix() so paths in the concat txt are absolute and
  not double-resolved relative to the concat file's directory

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-06 00:05:37 +02:00

687 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
scripts/generate_cutter_report.py — generate CUTTER_REPORT.{md,html} from cache
Renders two reports for the video editor:
* ``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 # 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 re
import subprocess
import sys
from dataclasses import dataclass
from datetime import date
from pathlib import Path
# ----------------------------------------------------------------------------
# Frame-rate handling
# ----------------------------------------------------------------------------
def probe_fps(video_path: Path) -> float | 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", f"stream={key}",
"-of", "default=noprint_wrappers=1:nokey=1",
str(video_path),
],
capture_output=True, text=True, timeout=10,
)
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)
if d:
return n / d
except ValueError:
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 using nearest-int frame counter."""
if t is None:
return "--:--:--:--"
fps_int = max(1, int(round(fps)))
total = int(round(t * fps_int))
h = total // (3600 * fps_int)
m = (total // (60 * fps_int)) % 60
s = (total // fps_int) % 60
f = total % fps_int
return f"{h:02d}:{m:02d}:{s:02d}:{f:02d}"
# ----------------------------------------------------------------------------
# Vision-description helpers
# ----------------------------------------------------------------------------
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 ""
# ----------------------------------------------------------------------------
# Stills / clips
# ----------------------------------------------------------------------------
STILL_WIDTH = 480
STILL_QUALITY = 4
CLIP_WIDTH = 480
# Clips run for the full beat / match duration, with this cap as a safety net
# so a runaway match doesn't pull a 60 s preview. Most beats are below 10 s
# anyway. The cap should never silently truncate a normal beat — set it well
# above any realistic beat length.
CLIP_MAX_DURATION_S = 30.0
def extract_still(video_path: Path, t_s: float, out: Path) -> bool:
"""Always render fresh. The match position can change without the source
video changing, so a mtime-based cache would silently serve stale frames
from the previous match. The cutter expects bit-current previews."""
if not video_path.exists():
return False
out.parent.mkdir(parents=True, exist_ok=True)
cmd = [
"ffmpeg", "-y", "-loglevel", "error",
"-ss", f"{max(0.0, t_s):.3f}",
"-i", str(video_path),
"-frames:v", "1",
"-vf", f"scale={STILL_WIDTH}:-2",
"-q:v", str(STILL_QUALITY),
str(out),
]
try:
subprocess.run(cmd, check=True, capture_output=True, timeout=30)
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
return False
return out.exists() and out.stat().st_size > 0
def extract_clip(video_path: Path, start_s: float, duration_s: float, out: Path) -> bool:
"""Always render fresh — see extract_still for rationale."""
if not video_path.exists():
return False
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 extract_concat_clip(
video_path: Path,
segments: list[tuple[float, float]],
out: Path,
) -> bool:
"""Render each (start_s, duration_s) segment then concat into one MP4.
Used for multi-shot source matches so the cutter sees the assembled
source side-by-side with the trailer beat instead of just one segment.
"""
if not video_path.exists() or not segments:
return False
out.parent.mkdir(parents=True, exist_ok=True)
parts: list[Path] = []
for idx, (seg_start, seg_dur) in enumerate(segments):
part = out.with_name(f"{out.stem}_seg{idx:02d}.mp4")
if not extract_clip(video_path, seg_start, seg_dur, part):
return False
parts.append(part)
if not parts:
return False
# Single segment: just rename / copy.
if len(parts) == 1:
if parts[0].resolve() != out.resolve():
try:
if out.exists():
out.unlink()
parts[0].rename(out)
except OSError:
return False
return out.exists() and out.stat().st_size > 0
# Multi-segment: concat via ffmpeg concat demuxer (codec params match
# because every segment is rendered through extract_clip with identical
# encoder settings).
list_file = out.with_name(f"{out.stem}_concat.txt")
list_file.write_text(
"\n".join(f"file '{part.absolute().as_posix()}'" for part in parts) + "\n",
encoding="utf-8",
)
cmd = [
"ffmpeg", "-y", "-loglevel", "error",
"-f", "concat", "-safe", "0", "-i", str(list_file),
"-c", "copy",
"-movflags", "+faststart",
str(out),
]
try:
subprocess.run(cmd, check=True, capture_output=True, timeout=60)
finally:
try:
list_file.unlink()
except OSError:
pass
return out.exists() and out.stat().st_size > 0
def beat_still_time(start_s: float, end_s: float) -> float:
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}"
# ----------------------------------------------------------------------------
# Per-beat data
# ----------------------------------------------------------------------------
@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:
# Trailer clip: full beat duration so the cutter sees the entire
# reference beat, not an arbitrary-length excerpt.
tdur = max(0.5, min(CLIP_MAX_DURATION_S, 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
# Source clip:
# - segmented match (multi-shot beat): concatenate each
# segment back-to-back so the cutter sees the assembled
# source (e.g. man-shot + reaction shot) at the same total
# length as the trailer beat.
# - single match: extract the full matched span.
# May be shorter than the beat when a match drops before the
# beat ends (fade / shot change in source) — intentional.
if rec is not None:
segs = rec.get("segments") or []
smp4 = clips_dir / f"beat_{bid:02d}_source.mp4"
if len(segs) >= 2:
seg_specs = [
(
float(s["in_point_s"]),
max(0.04, float(s["out_point_s"]) - float(s["in_point_s"])),
)
for s in segs
if float(s["out_point_s"]) > float(s["in_point_s"])
]
if seg_specs and extract_concat_clip(source_path, seg_specs, smp4):
source_clip = smp4
else:
sdur = max(0.5, min(CLIP_MAX_DURATION_S, rec["out_point_s"] - rec["in_point_s"]))
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
cfg = load_config(project_root / "config.toml")
trailer_path = Path(cfg.paths.reference_trailer)
source_path = Path(cfg.paths.source_movie)
# 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())}
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 {}
rows = collect_rows(
project_root, beats, results, vis_items,
trailer_path, source_path, with_stills, with_clips,
)
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,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
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
if __name__ == "__main__":
raise SystemExit(main())