2a3840e528
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>
621 lines
25 KiB
Python
621 lines
25 KiB
Python
"""
|
||
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"" 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 = """<!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("&", "&").replace("<", "<")
|
||
.replace(">", ">").replace('"', """))
|
||
|
||
|
||
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())
|