Files
aitrailer/scripts/generate_cutter_report.py
2026-05-09 18:48:24 +02:00

1158 lines
46 KiB
Python
Raw Permalink 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 — 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
the only report that should be opened for review.
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 os
import re
import subprocess
import sys
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from PIL import Image, ImageStat
# ---------------------------------------------------------------------------
# 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
SCENE_START_GUARD_S = 0.04
# 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 _forced_beats() -> set[int]:
raw = os.environ.get("CUTTER_REPORT_FORCE_BEATS", "")
forced: set[int] = set()
for part in re.split(r"[,;\s]+", raw):
if not part:
continue
try:
forced.add(int(part))
except ValueError:
continue
return forced
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
is_graphic: bool = False
@property
def status(self) -> str:
if self.is_graphic:
return "GFX"
if not self.matched:
return "MAN."
return "OK" if self.confirmed else "?"
@property
def status_de(self) -> str:
if self.is_graphic:
return "Titel/Grafik"
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,
scenes_by_id: dict[int, dict] | None = None,
) -> 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)
force_beats = _forced_beats()
def is_dark_title_card(path: Path | None) -> bool:
if path is None or not path.exists():
return False
try:
image = Image.open(path).convert("L").resize((160, 90))
except Exception:
return False
stat = ImageStat.Stat(image)
mean = float(stat.mean[0])
extrema = image.getextrema()
if mean > 55.0 or extrema[1] < 90:
return False
pixels = list(image.getdata())
bright = sum(1 for p in pixels if p >= 92)
mid = sum(1 for p in pixels if 30 <= p < 92)
total = max(1, len(pixels))
bright_ratio = bright / total
mid_ratio = mid / total
return 0.004 <= bright_ratio <= 0.18 and mid_ratio <= 0.35
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)
if scenes_by_id:
rec_scene = scenes_by_id.get(int(rec.get("scene_id", -1)))
if rec_scene and float(rec["in_point_s"]) < float(rec_scene["start_s"]) + SCENE_START_GUARD_S:
guarded_start = min(
float(rec_scene["end_s"]) - 0.04,
float(rec_scene["start_s"]) + SCENE_START_GUARD_S,
)
shift = guarded_start - float(rec["in_point_s"])
rec = dict(rec)
rec["in_point_s"] = guarded_start
rec["out_point_s"] = max(float(rec["in_point_s"]) + 0.04, float(rec["out_point_s"]) + shift)
fixed_segs = []
for seg in segs:
fixed = dict(seg)
seg_scene = scenes_by_id.get(int(fixed.get("scene_id", -1)))
if seg_scene and float(fixed["in_point_s"]) < float(seg_scene["start_s"]) + SCENE_START_GUARD_S:
guarded_start = min(
float(seg_scene["end_s"]) - 0.04,
float(seg_scene["start_s"]) + SCENE_START_GUARD_S,
)
shift = guarded_start - float(fixed["in_point_s"])
fixed["in_point_s"] = guarded_start
fixed["out_point_s"] = max(float(fixed["in_point_s"]) + 0.04, float(fixed["out_point_s"]) + shift)
fixed_segs.append(fixed)
segs = fixed_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 tjpg.exists() and bid not in force_beats:
trailer_still = tjpg
elif 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 sjpg.exists() and bid not in force_beats:
source_still = sjpg
elif 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 tmp4.exists() and bid not in force_beats:
trailer_clip = tmp4
elif 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 smp4.exists() and bid not in force_beats:
source_clip = smp4
elif 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"
compare_segs = segs
if not compare_segs:
seg_dur = max(0.04, min(beat_dur, rec["out_point_s"] - rec["in_point_s"]))
compare_segs = [{
"trailer_offset_s": 0.0,
"duration_s": seg_dur,
"scene_id": rec.get("scene_id"),
"in_point_s": rec["in_point_s"],
"out_point_s": rec["in_point_s"] + seg_dur,
"match_score": rec.get("match_score", 0.0),
"is_confirmed": rec.get("is_confirmed", False),
}]
if cmp4.exists() and bid not in force_beats:
compare_clip = cmp4
elif build_compare_clip(
trailer_path, beat["start_s"], beat_dur,
source_path, compare_segs,
cmp4,
):
compare_clip = cmp4
is_graphic = (rec is None and is_dark_title_card(trailer_still))
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,
is_graphic=is_graphic,
))
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)
graphic = sum(1 for r in rows if r.is_graphic)
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("| `GFX` | Titel-/Grafikkarte — nicht aus dem Spielfilm matchen |")
out.append("| `MAN.` | Kein automatischer Treffer — manuell setzen |")
out.append("")
out.append(
f"**{len(rows)}** Beats gesamt · **{matched}** automatisch (**{confirmed}** bestätigt)"
f" · **{graphic}** Grafik/Titel · **{len(rows) - matched - graphic}** 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:
if r.is_graphic:
out.append("- **Source** : — (Titel-/Grafikkarte, nicht aus Source matchen)")
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")
if not r.is_graphic:
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"![Trailer {r.bid}]({t_uri})" if t_uri else "_(kein Still)_"
s_cell = f"![Source {r.bid}]({s_uri})" if s_uri else f"_({r.status})_"
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">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cutter-Report & Match-Report</title>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--fg: #e4e4e7;
--bg-gradient: linear-gradient(135deg, #09090b 0%, #18181b 100%);
--mut: #a1a1aa;
--card-bg: rgba(24, 24, 27, 0.6);
--card-border: rgba(255, 255, 255, 0.08);
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
--accent: #6366f1;
--accent-hover: #818cf8;
--ok: #4ade80;
--q: #fbbf24;
--man: #f87171;
--ok-bg: rgba(74, 222, 128, 0.15);
--q-bg: rgba(251, 191, 36, 0.15);
--man-bg: rgba(248, 113, 113, 0.15);
--code-bg: rgba(0, 0, 0, 0.3);
--code-fg: #86efac;
--warn: #fef08a;
--warn-bg: rgba(161, 98, 7, 0.3);
}
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: 'Inter', sans-serif;
color: var(--fg);
background: var(--bg-gradient);
background-attachment: fixed;
font-size: 15px; line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.wrap { max-width: 1400px; margin: 0 auto; padding: 40px 24px; }
h1, h2, h3 { font-family: 'Outfit', sans-serif; font-weight: 600; letter-spacing: -0.02em; }
h1 {
margin: 0 0 8px; font-size: 36px;
background: linear-gradient(to right, #a855f7, #6366f1, #3b82f6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
display: inline-block;
}
h2 { margin: 48px 0 20px; font-size: 24px; border-bottom: 1px solid var(--card-border); padding-bottom: 12px; }
.header-glass {
background: rgba(15, 15, 16, 0.4);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--card-border);
border-radius: 16px;
padding: 24px;
margin-bottom: 40px;
box-shadow: var(--glass-shadow);
}
.meta { color: var(--mut); font-size: 14px; margin-bottom: 16px; display: flex; flex-wrap: wrap; gap: 16px; }
.meta b { color: var(--fg); font-weight: 500; }
.meta-chip { background: rgba(255, 255, 255, 0.05); padding: 6px 12px; border-radius: 20px; border: 1px solid var(--card-border); }
.summary { font-size: 16px; font-weight: 500; }
.recent-changes { margin-top: 16px; padding-top: 16px; border-top: 1px dashed var(--card-border); color: #c084fc; font-size: 14px; }
/* Legend table */
table.leg { border-collapse: separate; border-spacing: 0; margin: 12px 0 32px; font-size: 14px; width: 100%; max-width: 600px; }
table.leg td { padding: 12px 16px; border-bottom: 1px solid var(--card-border); background: var(--card-bg); }
table.leg tr:first-child td:first-child { border-top-left-radius: 12px; }
table.leg tr:first-child td:last-child { border-top-right-radius: 12px; }
table.leg tr:last-child td:first-child { border-bottom-left-radius: 12px; border-bottom: none; }
table.leg tr:last-child td:last-child { border-bottom-right-radius: 12px; border-bottom: none; }
/* Overview table */
.table-container { overflow-x: auto; border-radius: 12px; border: 1px solid var(--card-border); margin-bottom: 48px; box-shadow: var(--glass-shadow); }
table.ov { width: 100%; border-collapse: collapse; font-size: 14px; background: var(--card-bg); }
table.ov th, table.ov td { padding: 14px 16px; border-bottom: 1px solid var(--card-border); text-align: left; white-space: nowrap; }
table.ov th { background: rgba(255, 255, 255, 0.03); font-family: 'Outfit', sans-serif; font-weight: 500; font-size: 15px; color: var(--mut); text-transform: uppercase; letter-spacing: 0.05em; }
table.ov td.num { text-align: right; font-variant-numeric: tabular-nums; font-family: 'Inter', monospace; }
table.ov tr { transition: background-color 0.2s ease; }
table.ov tr:hover { background: rgba(255, 255, 255, 0.05); }
/* Badges */
.badge { display: inline-flex; align-items: center; justify-content: center; padding: 4px 10px; border-radius: 6px; font-weight: 600; font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase; }
.badge.ok { background: var(--ok-bg); color: var(--ok); border: 1px solid rgba(74, 222, 128, 0.2); }
.badge.q { background: var(--q-bg); color: var(--q); border: 1px solid rgba(251, 191, 36, 0.2); }
.badge.man { background: var(--man-bg); color: var(--man); border: 1px solid rgba(248, 113, 113, 0.2); }
.badge.gfx { background: rgba(96, 165, 250, 0.12); color: #93c5fd; border: 1px solid rgba(147, 197, 253, 0.24); }
/* Beat cards */
.beats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(600px, 1fr)); gap: 32px; }
.beat {
background: var(--card-bg);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid var(--card-border);
border-radius: 16px;
padding: 24px;
box-shadow: var(--glass-shadow);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.beat:hover { transform: translateY(-4px); box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.45); border-color: rgba(255,255,255,0.15); }
.beat-hdr { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.beat-hdr h3 { margin: 0; font-size: 20px; color: #fff; }
.beat-hdr .status-text { font-size: 14px; color: var(--mut); margin-left: auto; }
/* Compare video */
.compare-wrap { margin-bottom: 20px; border-radius: 12px; overflow: hidden; position: relative; background: #000; box-shadow: inset 0 0 0 1px var(--card-border); }
.compare-label { position: absolute; top: 12px; left: 12px; z-index: 10; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); padding: 4px 10px; border-radius: 4px; color: #fff; border: 1px solid rgba(255,255,255,0.1); }
.compare-wrap video, .compare-wrap img { width: 100%; height: auto; display: block; transition: opacity 0.3s; }
.compare-wrap:hover video { opacity: 0.95; }
.stills-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 2px; background: var(--card-border); }
.stills-pair img { width: 100%; height: auto; background: #000; }
.empty-media { display: flex; align-items: center; justify-content: center; aspect-ratio: 32/9; background: rgba(0,0,0,0.5); color: var(--mut); font-size: 14px; }
/* Metadata footer inside card */
.beat-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px; font-size: 14px; border-top: 1px solid var(--card-border); padding-top: 20px; }
.kv { margin: 8px 0; display: flex; flex-direction: column; gap: 2px; }
.kv b { color: var(--mut); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
.kv-val { color: var(--fg); font-weight: 400; }
.tc { font-family: 'Inter', monospace; font-size: 13px; background: var(--code-bg); padding: 2px 6px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.05); }
.seg-list { margin: 8px 0 0 0; padding-left: 0; font-size: 13px; list-style: none; display: flex; flex-direction: column; gap: 6px; }
.seg-list li { background: rgba(255,255,255,0.03); padding: 8px 12px; border-radius: 6px; border: 1px solid var(--card-border); font-family: 'Inter', monospace; }
.warn-box { background: var(--warn-bg); color: var(--warn); border-radius: 8px; padding: 10px 14px; font-size: 13px; margin: 12px 0; border: 1px solid rgba(254, 240, 138, 0.2); display: flex; align-items: center; gap: 8px; }
.hint { display: inline-block; background: var(--code-bg); color: var(--code-fg); font-family: 'Inter', monospace; font-size: 12px; padding: 6px 12px; border-radius: 6px; margin-top: 12px; border: 1px solid rgba(134, 239, 172, 0.2); }
</style>
</head>
<body><div class="wrap">
"""
HTML_FOOT = "</div></body></html>\n"
def _he(s: str) -> str:
return (s.replace("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace('"', "&quot;"))
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 "<br>".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('<div class="header-glass">')
parts.append(f'<h1>Cutter & Match Report</h1>')
parts.append('<div class="meta">')
parts.append(
f'<span class="meta-chip">Generiert: <b>{generated_at.strftime("%Y-%m-%d %H:%M:%S")}</b></span>'
f'<span class="meta-chip">Trailer: <b>{_he(trailer_path.name)}</b> @ {trailer_fps:.3f} fps</span>'
f'<span class="meta-chip">Source: <b>{_he(source_path.name)}</b> @ {source_fps:.3f} fps</span>'
)
parts.append('</div>')
parts.append(
f'<div class="summary"><b>{len(rows)}</b> Beats &mdash; '
f'<span style="color:var(--ok)"><b>{matched}</b> automatisch (<b>{confirmed}</b> bestätigt)</span> &mdash; '
f'<span style="color:var(--man)"><b>{len(rows) - matched}</b> manuell</span>.</div>'
)
parts.append(f'<div class="recent-changes"><b>Recent Changes:</b><br>{recent}</div>')
parts.append('</div>')
# Legend
parts.append('<h2>Legende</h2>')
parts.append('<table class="leg"><tbody>')
parts.append(
'<tr><td><span class="badge ok">OK</span></td>'
'<td>Bestätigt — direkt in Schnitt-Timeline übernehmen</td></tr>'
)
parts.append(
'<tr><td><span class="badge q">?</span></td>'
'<td>Vorläufig — Phase und Aktion im NLE visuell prüfen</td></tr>'
)
parts.append(
'<tr><td><span class="badge gfx">GFX</span></td>'
'<td>Titel-/Grafikkarte — als Trailer-Grafik übernehmen, nicht im Spielfilm suchen</td></tr>'
)
parts.append(
'<tr><td><span class="badge man">MAN.</span></td>'
'<td>Kein Treffer — manuell suchen oder Schwarzbild einfügen</td></tr>'
)
parts.append('</tbody></table>')
# Overview table
parts.append('<h2>Übersicht</h2>')
parts.append('<div class="table-container">')
parts.append(
'<table class="ov"><thead><tr>'
'<th>Beat</th><th>Trailer TC In&ndash;Out</th><th>Dauer</th>'
'<th>Source TC In</th><th>Scene</th><th>Score</th><th>Status</th>'
'</tr></thead><tbody>'
)
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", "GFX": "gfx", "MAN.": "man"}[r.status]
parts.append(
f'<tr>'
f'<td class="num"><a href="#beat-{r.bid:02d}">{r.bid:02d}</a></td>'
f'<td class="tc">{ti}&ndash;{to}</td>'
f'<td class="num">{dur:.2f}s</td>'
f'<td class="tc">{si}</td>'
f'<td class="num">{_he(scene)}</td>'
f'<td class="num">{sc}</td>'
f'<td><span class="badge {bcls}">{r.status}</span></td>'
f'</tr>'
)
parts.append('</tbody></table></div>')
# Per-beat cards
parts.append('<h2>Beat-Details</h2>')
parts.append('<div class="beats-grid">')
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", "GFX": "gfx", "MAN.": "man"}[r.status]
parts.append(f'<div class="beat" id="beat-{r.bid:02d}">')
# Header row
parts.append('<div class="beat-hdr">')
parts.append(f'<h3>Beat {r.bid:02d}</h3>')
parts.append(f'<span class="badge {bcls}">{r.status}</span>')
parts.append(f'<span class="status-text">{_he(r.status_de)}</span>')
parts.append('</div>')
# Media: compare clip or still pair
parts.append('<div class="compare-wrap">')
cmp_uri = data_uri(r.compare_clip, "video/mp4") if (with_clips and r.compare_clip) else None
if cmp_uri:
parts.append(
'<div class="compare-label">'
'&#9654; Trailer &nbsp;/&nbsp; &#9654; Source (Frame-Locked Compare)'
'</div>'
)
parts.append(f'<video controls preload="metadata" muted loop src="{cmp_uri}"></video>')
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('<div class="stills-pair">')
parts.append(
f'<img src="{t_uri}" alt="Trailer {r.bid}">'
if t_uri else '<div class="empty-media">— kein Still —</div>'
)
parts.append(
f'<img src="{s_uri}" alt="Source {r.bid}">'
if s_uri else '<div class="empty-media">— manuell setzen —</div>'
)
parts.append('</div>')
else:
parts.append('<div class="empty-media">— kein Vorschau verfügbar —</div>')
parts.append('</div>') # .compare-wrap
# Metadata
parts.append('<div class="beat-meta">')
# Left col: trailer info
parts.append('<div>')
parts.append(f'<div class="kv"><b>Trailer</b> <div class="kv-val"><span class="tc">{ti}&ndash;{to}</span>'
f' &nbsp;<span style="color:var(--mut)">({dur:.2f}s)</span></div></div>')
if r.phase:
parts.append(f'<div class="kv"><b>Phase</b> <span class="kv-val">{_he(r.phase)}</span></div>')
if r.composition:
extra = f", {r.setting}" if r.setting else ""
parts.append(f'<div class="kv"><b>Bild</b> <span class="kv-val">{_he(r.composition + extra)}</span></div>')
parts.append('</div>')
# Right col: source info
parts.append('<div>')
if r.matched:
si = smpte(r.source_in_s, source_fps)
so = smpte(r.source_out_s, source_fps)
if r.num_segments > 1:
all_scenes = list(dict.fromkeys(
str(s.get("scene_id", "?")) for s in r.segments
))
scene_str = f"Scenes {', '.join(all_scenes)} &middot; {r.num_segments}&nbsp;Segmente"
parts.append(
f'<div class="kv"><b>Source</b> <div class="kv-val"><span class="tc">{si}</span>'
f' &nbsp;<span style="color:var(--mut)">(multi-shot)</span></div></div>'
)
parts.append(f'<div class="kv"><b>Scene</b> <span class="kv-val">{scene_str}</span></div>')
# Segment list
parts.append('<ul class="seg-list">')
for i, seg in enumerate(r.segments):
seg_tc = smpte(seg.get("in_point_s"), source_fps)
seg_dur = float(seg.get("duration_s", 0))
seg_off = float(seg.get("trailer_offset_s", 0))
seg_sc = int(seg.get("scene_id", 0))
seg_score = float(seg.get("match_score", 0))
parts.append(
f'<li>Seg&nbsp;{i + 1}: <span class="tc">{seg_tc}</span>'
f' &nbsp;dur&nbsp;{seg_dur:.2f}s'
f' &nbsp;@&nbsp;off&nbsp;{seg_off:.2f}s'
f' &nbsp;sc&nbsp;{seg_sc}'
f' &nbsp;score&nbsp;{seg_score:.3f}</li>'
)
parts.append('</ul>')
else:
parts.append(
f'<div class="kv"><b>Source</b>'
f' <div class="kv-val"><span class="tc">{si}&ndash;{so}</span></div></div>'
)
parts.append(
f'<div class="kv"><b>Scene</b> <span class="kv-val">{r.scene_id}'
f' &middot; Score&nbsp;{r.score:.3f}</span></div>'
)
if r.score > 0 and r.score < 0.65:
parts.append(
f'<div class="warn-box">&#9888; Score {r.score:.3f} unter 0.65'
f' &mdash; visuell prüfen</div>'
)
else:
parts.append('<div class="kv">&mdash; kein automatischer Treffer &mdash;</div>')
parts.append(
f'<div><code class="hint">python cli.py rematch --beat {r.bid}</code></div>'
)
parts.append('</div>') # right col
parts.append('</div>') # .beat-meta
parts.append('</div>') # .beat
parts.append('</div>') # .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())
scene_path = cache / "scene_index.json"
scenes = json.loads(scene_path.read_text()) if scene_path.exists() else []
scenes_by_id = {int(s["scene_id"]): s for s in scenes}
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, scenes_by_id,
)
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")
print(f"Wrote {project_root / 'CUTTER_REPORT.md'}")
print(f"Wrote {project_root / 'CUTTER_REPORT.html'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())