"""
scripts/generate_cutter_report.py — CUTTER_REPORT.{md,html} from cache
Renders two editor-facing reports:
* ``CUTTER_REPORT.md`` — text + base64 stills, self-contained.
* ``CUTTER_REPORT.html`` — full detail with Frame-Locked Compare video per
beat (trailer left / source right, frame-synchronised), SMPTE timecodes,
scene and segment info, score warnings, and rematch hints.
This report is the single source of truth for the video editor and is
designed to eventually replace the legacy match_report.html.
Usage (from project root):
python scripts/generate_cutter_report.py # stills + compare clips
python scripts/generate_cutter_report.py --no-stills # text only
python scripts/generate_cutter_report.py --no-clips # stills only, no video
"""
from __future__ import annotations
import argparse
import base64
import json
import re
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
# ---------------------------------------------------------------------------
# Frame-rate / timecode helpers
# ---------------------------------------------------------------------------
def probe_fps(video_path: Path) -> float | None:
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 HH:MM:SS:FF."""
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}"
def fmt_s(t: float | None) -> str:
"""Format seconds as a short decimal string."""
return f"{t:.2f}s" if t is not None else "—"
# ---------------------------------------------------------------------------
# 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 ""
# ---------------------------------------------------------------------------
# Video / image extraction helpers
# ---------------------------------------------------------------------------
STILL_WIDTH = 480
STILL_QUALITY = 4
CLIP_WIDTH = 480
CLIP_MAX_DURATION_S = 30.0
# Each half of the side-by-side compare strip
COMPARE_HALF_W = 480
COMPARE_H = 270 # 16:9
def _run(cmd: list[str], timeout: int = 120) -> bool:
try:
subprocess.run(cmd, check=True, capture_output=True, timeout=timeout)
return True
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
return False
def extract_still(video_path: Path, t_s: float, out: Path) -> bool:
"""Always render fresh."""
if not video_path.exists():
return False
out.parent.mkdir(parents=True, exist_ok=True)
return _run([
"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),
], timeout=30)
def extract_clip(video_path: Path, start_s: float, duration_s: float, out: Path,
width: int = CLIP_WIDTH, fps: int = 25) -> bool:
"""Extract a re-encoded clip with accurate double-seek."""
if not video_path.exists():
return False
out.parent.mkdir(parents=True, exist_ok=True)
preroll = 2.0 if start_s >= 2.0 else 0.0
input_seek = max(0.0, start_s - preroll)
accurate_seek = start_s - input_seek
return _run([
"ffmpeg", "-y", "-loglevel", "error",
"-ss", f"{input_seek:.3f}", "-i", str(video_path),
"-ss", f"{accurate_seek:.3f}", "-t", f"{max(0.04, duration_s):.3f}",
"-map", "0:v:0",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
"-vf", f"fps={fps},scale={width}:-2,setsar=1,setpts=PTS-STARTPTS",
"-pix_fmt", "yuv420p", "-an", "-movflags", "+faststart", str(out),
], timeout=60)
def _black_clip(duration_s: float, out: Path, width: int, height: int, fps: int = 25) -> bool:
return _run([
"ffmpeg", "-y", "-loglevel", "error",
"-f", "lavfi",
"-i", f"color=c=black:s={width}x{height}:r={fps}:d={duration_s:.3f}",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
"-pix_fmt", "yuv420p", "-an", "-movflags", "+faststart", str(out),
], timeout=30)
def _concat_clips(parts: list[Path], out: Path) -> bool:
if not parts:
return False
if len(parts) == 1:
try:
if parts[0].resolve() != out.resolve():
if out.exists():
out.unlink()
parts[0].rename(out)
return out.exists()
except OSError:
return False
list_file = out.with_name(f"{out.stem}_concat.txt")
list_file.write_text(
"\n".join(f"file '{p.absolute().as_posix()}'" for p in parts) + "\n",
encoding="utf-8",
)
ok = _run([
"ffmpeg", "-y", "-loglevel", "error",
"-f", "concat", "-safe", "0", "-i", str(list_file),
"-c", "copy", "-movflags", "+faststart", str(out),
], timeout=120)
try:
list_file.unlink()
except OSError:
pass
return ok
def extract_source_beat_clip(
source_path: Path,
segments: list[dict],
total_duration_s: float,
out: Path,
width: int = CLIP_WIDTH,
) -> bool:
"""Build a beat-length source clip with black filler between segments.
Each segment is placed at its trailer_offset_s position; gaps before,
between, and after segments are filled with black so the clip is
frame-synchronised with the trailer.
"""
if not source_path.exists():
return False
out.parent.mkdir(parents=True, exist_ok=True)
height = width // 16 * 9
fps = 25
if not segments:
return _black_clip(max(0.04, total_duration_s), out, width, height, fps)
sorted_segs = sorted(segments, key=lambda s: float(s.get("trailer_offset_s", 0)))
parts: list[Path] = []
cursor = 0.0
def add_black(dur: float, idx: int) -> None:
if dur < 0.02:
return
tmp = out.with_name(f"{out.stem}_p{idx:02d}blk.mp4")
if _black_clip(dur, tmp, width, height, fps):
parts.append(tmp)
def add_src(start_s: float, dur: float, idx: int) -> None:
if dur < 0.02:
return
tmp = out.with_name(f"{out.stem}_p{idx:02d}src.mp4")
if extract_clip(source_path, start_s, dur, tmp, width, fps):
parts.append(tmp)
for i, seg in enumerate(sorted_segs):
offset = max(0.0, float(seg.get("trailer_offset_s", 0)))
dur = max(0.0, float(seg.get("duration_s", 0)))
add_black(offset - cursor, len(parts))
add_src(float(seg["in_point_s"]), dur, len(parts))
cursor = max(cursor, offset + dur)
add_black(total_duration_s - cursor, len(parts))
if not parts:
return False
ok = _concat_clips(parts, out)
for p in parts:
if p != out:
try:
p.unlink(missing_ok=True)
except OSError:
pass
return ok
def extract_concat_clip(
video_path: Path,
segments: list[tuple[float, float]],
out: Path,
) -> bool:
"""Simple back-to-back concat (no black fills) — used for cutter-side clips."""
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)
ok = _concat_clips(parts, out)
for p in parts:
if p != out:
try:
p.unlink(missing_ok=True)
except OSError:
pass
return ok
def build_compare_clip(
trailer_path: Path,
trailer_start: float,
trailer_dur: float,
source_path: Path,
segments: list[dict],
out: Path,
) -> bool:
"""Frame-locked side-by-side: trailer left, source right."""
if not trailer_path.exists():
return False
out.parent.mkdir(parents=True, exist_ok=True)
ref_tmp = out.with_name(f"{out.stem}_ref.mp4")
src_tmp = out.with_name(f"{out.stem}_src.mp4")
try:
if not extract_clip(trailer_path, trailer_start, trailer_dur, ref_tmp,
COMPARE_HALF_W, fps=25):
return False
if not extract_source_beat_clip(source_path, segments, trailer_dur, src_tmp,
COMPARE_HALF_W):
return False
norm = (f"fps=25,scale={COMPARE_HALF_W}:{COMPARE_H}:"
"force_original_aspect_ratio=decrease,"
f"pad={COMPARE_HALF_W}:{COMPARE_H}:(ow-iw)/2:(oh-ih)/2,"
"setsar=1,setpts=PTS-STARTPTS")
fc = f"[0:v]{norm}[ref];[1:v]{norm}[src];[ref][src]hstack=inputs=2[v]"
return _run([
"ffmpeg", "-y", "-loglevel", "error",
"-i", str(ref_tmp), "-i", str(src_tmp),
"-filter_complex", fc, "-map", "[v]",
"-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
"-pix_fmt", "yuv420p", "-an", "-movflags", "+faststart", str(out),
], timeout=180)
finally:
for p in (ref_tmp, src_tmp):
try:
p.unlink(missing_ok=True)
except OSError:
pass
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 | None, mime: str) -> str | None:
if not path or 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 model
# ---------------------------------------------------------------------------
@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
num_segments: int
segments: list[dict] = field(default_factory=list)
phase: str = ""
composition: str = ""
setting: str = ""
trailer_still: Path | None = None
source_still: Path | None = None
trailer_clip: Path | None = None
source_clip: Path | None = None
compare_clip: Path | None = None
@property
def status(self) -> str:
if not self.matched:
return "MAN."
return "OK" if self.confirmed else "?"
@property
def status_de(self) -> str:
if not self.matched:
return "Kein Treffer"
return "Bestätigt" if self.confirmed else "Vorläufig"
# ---------------------------------------------------------------------------
# Row collection
# ---------------------------------------------------------------------------
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 ""
segs: list[dict] = []
num_segs = 0
if rec is not None:
segs = rec.get("segments") or []
num_segs = len(segs)
trailer_still = source_still = None
trailer_clip = source_clip = compare_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:
beat_dur = max(0.5, min(CLIP_MAX_DURATION_S, beat["end_s"] - beat["start_s"]))
# Trailer clip (cutter-side, simple)
tmp4 = clips_dir / f"beat_{bid:02d}_trailer.mp4"
if extract_clip(trailer_path, beat["start_s"], beat_dur, tmp4):
trailer_clip = tmp4
if rec is not None:
smp4 = clips_dir / f"beat_{bid:02d}_source.mp4"
if num_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
# Frame-locked compare video
cmp4 = clips_dir / f"beat_{bid:02d}_compare.mp4"
if build_compare_clip(
trailer_path, beat["start_s"], beat_dur,
source_path, segs if num_segs >= 1 else [],
cmp4,
):
compare_clip = cmp4
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,
num_segments=num_segs,
segments=segs,
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,
compare_clip=compare_clip,
))
return rows
# ---------------------------------------------------------------------------
# Markdown renderer
# ---------------------------------------------------------------------------
def render_markdown(
rows: list[BeatRow], trailer_fps: float, source_fps: float,
trailer_path: Path, source_path: Path,
generated_at: datetime,
) -> 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"Generiert: **{generated_at.strftime('%Y-%m-%d %H:%M:%S')}**")
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.")
out.append("")
out.append("## Status-Legende")
out.append("")
out.append("| Status | Bedeutung |")
out.append("|--------|-----------|")
out.append("| `OK` | Bestätigt durch CV-Analyse — übernehmen |")
out.append("| `?` | Vorläufig — korrekte Szene, Phase im NLE prüfen |")
out.append("| `MAN.` | Kein automatischer Treffer — manuell setzen |")
out.append("")
out.append(
f"**{len(rows)}** Beats gesamt · **{matched}** automatisch (**{confirmed}** bestätigt)"
f" · **{len(rows) - matched}** manuell."
)
out.append("")
out.append("## Beat-Tabelle")
out.append("")
out.append("| Beat | Trailer In / Out | Source In / Out | Scene | Score | Status |")
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 "—"
scene = str(r.scene_id) if r.matched else "—"
if r.num_segments > 1:
scene += f" (+{r.num_segments - 1})"
out.append(
f"| {r.bid:>4} | {ti} – {to} | {si} – {so}"
f" | {scene} | {sc} | {r.status} |"
)
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)
dur = r.trailer_out_s - r.trailer_in_s
out.append(f"### Beat {r.bid:02d} — {r.status} / {r.status_de}")
out.append("")
out.append(f"- **Trailer**: {ti} – {to} ({dur:.2f} s)")
if r.matched:
si = smpte(r.source_in_s, source_fps)
so = smpte(r.source_out_s, source_fps)
scene_info = f"scene {r.scene_id}"
if r.num_segments > 1:
scene_ids = list(dict.fromkeys(
str(s.get("scene_id", "?")) for s in r.segments
))
scene_info = f"scenes {', '.join(scene_ids)} ({r.num_segments} Segmente)"
out.append(f"- **Source** : {si} – {so} ({scene_info}, score {r.score:.3f})")
if r.num_segments > 1:
for i, seg in enumerate(r.segments):
seg_tc = smpte(seg.get("in_point_s"), source_fps)
seg_dur = seg.get("duration_s", 0)
seg_offset = seg.get("trailer_offset_s", 0)
out.append(
f" - Seg {i + 1}: TC {seg_tc} dur {seg_dur:.2f}s"
f" @ Trailer-Offset {seg_offset:.2f}s"
f" (scene {seg.get('scene_id', '?')})"
)
else:
out.append("- **Source** : — (manuell setzen)")
if r.score > 0 and r.score < 0.65:
out.append(f"- ⚠ Score {r.score:.3f} unter 0.65 — visuell prüfen")
out.append(f"- **Rematch**: `python cli.py rematch --beat {r.bid}`")
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("")
if r.compare_clip:
rel_clip = f"output/cutter_clips/beat_{r.bid:02d}_compare.mp4"
out.append(f"**[▶️ Frame-Locked Compare Video ansehen]({rel_clip})**")
out.append("")
t_uri = data_uri(r.trailer_still, "image/jpeg")
s_uri = data_uri(r.source_still, "image/jpeg")
if t_uri or s_uri:
out.append("| Trailer | Source |")
out.append("|:---:|:---:|")
t_cell = f"" if t_uri else "_(kein Still)_"
s_cell = f"" if s_uri else "_(MAN.)_"
out.append(f"| {t_cell} | {s_cell} |")
out.append("")
return "\n".join(out)
# ---------------------------------------------------------------------------
# HTML renderer
# ---------------------------------------------------------------------------
HTML_HEAD = """\
Cutter-Report & Match-Report
"""
HTML_FOOT = "
\n"
def _he(s: str) -> str:
return (s.replace("&", "&").replace("<", "<")
.replace(">", ">").replace('"', """))
def _get_recent_changes(project_root: Path) -> str:
try:
import subprocess
proc = subprocess.run(
["git", "log", "--invert-grep", "--grep=Auto-update", "-1", "--pretty=%B"],
capture_output=True, text=True, cwd=str(project_root), timeout=5
)
if proc.stdout:
lines = proc.stdout.strip().split('\n')
cleaned = [line for line in lines if line and not line.startswith('Co-Authored-By')]
return " ".join(cleaned[:3]) or "No recent changes found."
except Exception:
pass
return "No recent changes available."
def render_html(
rows: list[BeatRow],
trailer_fps: float,
source_fps: float,
trailer_path: Path,
source_path: Path,
with_clips: bool,
generated_at: datetime,
project_root: Path = Path("."),
) -> 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]
# Header
recent = _get_recent_changes(project_root)
parts.append('')
# Legend
parts.append('Legende ')
parts.append('')
parts.append(
'OK '
'Bestätigt — direkt in Schnitt-Timeline übernehmen '
)
parts.append(
'? '
'Vorläufig — Phase und Aktion im NLE visuell prüfen '
)
parts.append(
'MAN. '
'Kein Treffer — manuell suchen oder Schwarzbild einfügen '
)
parts.append('
')
# Overview table
parts.append('Übersicht ')
parts.append('')
parts.append(
'
'
'Beat Trailer TC In–Out Dauer '
'Source TC In Scene Score Status '
' '
)
for r in rows:
ti = smpte(r.trailer_in_s, trailer_fps)
to = smpte(r.trailer_out_s, trailer_fps)
dur = r.trailer_out_s - r.trailer_in_s
si = smpte(r.source_in_s, source_fps) if r.matched else "—"
sc = f"{r.score:.3f}" if r.matched else "—"
scene = str(r.scene_id) if r.matched else "—"
if r.num_segments > 1:
all_scenes = list(dict.fromkeys(
str(s.get("scene_id", "?")) for s in r.segments
))
scene = "+".join(all_scenes)
bcls = {"OK": "ok", "?": "q", "MAN.": "man"}[r.status]
parts.append(
f''
f'{r.bid:02d} '
f'{ti}–{to} '
f'{dur:.2f}s '
f'{si} '
f'{_he(scene)} '
f'{sc} '
f'{r.status} '
f' '
)
parts.append('
')
# Per-beat cards
parts.append('Beat-Details ')
parts.append('')
for r in rows:
ti = smpte(r.trailer_in_s, trailer_fps)
to = smpte(r.trailer_out_s, trailer_fps)
dur = r.trailer_out_s - r.trailer_in_s
bcls = {"OK": "ok", "?": "q", "MAN.": "man"}[r.status]
parts.append(f'
')
# Header row
parts.append('
')
parts.append(f'
Beat {r.bid:02d} ')
parts.append(f'{r.status} ')
parts.append(f'{_he(r.status_de)} ')
parts.append('')
# Media: compare clip or still pair
parts.append('
')
cmp_uri = data_uri(r.compare_clip, "video/mp4") if (with_clips and r.compare_clip) else None
if cmp_uri:
parts.append(
'
'
'▶ Trailer / ▶ Source (Frame-Locked Compare)'
'
'
)
parts.append(f'
')
else:
t_uri = data_uri(r.trailer_still, "image/jpeg")
s_uri = data_uri(r.source_still, "image/jpeg")
if t_uri or s_uri:
parts.append('
')
parts.append(
f'
'
if t_uri else '
— kein Still —
'
)
parts.append(
f'
'
if s_uri else '
— manuell setzen —
'
)
parts.append('
')
else:
parts.append('
— kein Vorschau verfügbar —
')
parts.append('
') # .compare-wrap
# Metadata
parts.append('
') # .beat-meta
parts.append('
') # .beat
parts.append('
') # .beats-grid
parts.append(HTML_FOOT)
return "".join(parts)
# ---------------------------------------------------------------------------
# Top-level entry point
# ---------------------------------------------------------------------------
def render_report(
project_root: Path,
with_stills: bool = True,
with_clips: bool = True,
) -> tuple[str, str]:
"""Return (markdown_text, html_text). Caller writes both files."""
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 = float(getattr(cfg.export, "edl_frame_rate", 0.0)) or probe_fps(source_path) or 23.976
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,
)
now = datetime.now()
md = render_markdown(rows, trailer_fps, source_fps, trailer_path, source_path, now)
html = render_html(rows, trailer_fps, source_fps, trailer_path, source_path, with_clips, now)
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 (text-only markdown)")
parser.add_argument("--no-clips", action="store_true",
help="skip video clip rendering (stills only)")
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=not args.no_clips,
)
(project_root / "CUTTER_REPORT.md").write_text(md, encoding="utf-8")
(project_root / "CUTTER_REPORT.html").write_text(html, encoding="utf-8")
legacy_path = project_root / "output" / "report" / "match_report.html"
legacy_path.parent.mkdir(parents=True, exist_ok=True)
legacy_path.write_text(html, encoding="utf-8")
print(f"Wrote {project_root / 'CUTTER_REPORT.md'}")
print(f"Wrote {project_root / 'CUTTER_REPORT.html'}")
print(f"Wrote {legacy_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())