Files
aitrailer/scripts/generate_cutter_report.py
T
Melbar 2a3840e528 Fix cutter-report clip duration: render full beat / match length
Bug: the report rendered every preview clip at most 3 s, so any beat longer
than 3 s (e.g. beat 02 at 8.56 s) only got a fragment in the cutter report
and looked wrong. The hard 3 s cap was an early prototype constant that
silently truncated normal-length beats.

Trailer clip is now the full beat duration so the cutter sees the entire
reference beat. Source clip is the full matched duration (may be shorter
than the beat when the match drops before the beat ends — that's correct,
the cutter needs to see exactly the matched span).

A 30 s safety cap stays as a guard against runaway durations but it should
never trip on a normal trailer beat.

All existing clips were dropped and re-rendered so the report on disk
matches the new logic.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-04 22:31:40 +02:00

621 lines
25 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 _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:
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, 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:
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:
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: full matched duration. May be shorter than the beat
# when the match drops out before the beat ends (fade / shot
# change in the source); that's intentional — the cutter needs to
# see exactly the matched span.
if rec is not None:
sdur = max(0.5, min(CLIP_MAX_DURATION_S, rec["out_point_s"] - rec["in_point_s"]))
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
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())