64e0132cc7
generate_cutter_report.py: - Frame-Locked Compare video (trailer left / source right, single MP4) per beat replaces two separate side-by-side clips; rendered via accurate double-seek + black-fill segmented source reconstruction - Generation timestamp now includes HH:MM:SS (Uhrzeit-Angabe) - Per-beat segment list for multi-shot beats (TC, duration, offset, scene, score per segment) - Score warning badge (yellow) if score < 0.65 - python cli.py rematch --beat N command hint in every card - Overview table links to each beat card via #anchor - Cleaner dark/light CSS using design tokens (--fg/--bg/--card/--bd) - --no-clips flag (replaces --with-clips; default is now with clips) cli.py: - _auto_commit_push_reports(): after every report regeneration, stages the report output files (CUTTER_REPORT.*, output/cutter_clips/, output/report/) and auto-commits + pushes to origin/main so remote is always current - Removed the legacy match_report.html call from _regenerate_cutter_report (CUTTER_REPORT now supersedes it) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
976 lines
36 KiB
Python
976 lines
36 KiB
Python
"""
|
||
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("")
|
||
|
||
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 = """\
|
||
<!doctype html>
|
||
<html lang="de">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<title>Cutter-Report</title>
|
||
<style>
|
||
:root {
|
||
color-scheme: light dark;
|
||
--fg: #1a1a1a; --bg: #f4f4f5; --mut: #666; --card: #fff; --bd: #d4d4d8;
|
||
--ok: #16a34a; --q: #b45309; --man: #b91c1c;
|
||
--ok-bg: #dcfce7; --q-bg: #fef3c7; --man-bg: #fee2e2;
|
||
--code-bg: #18181b; --code-fg: #86efac;
|
||
--warn: #92400e; --warn-bg: #fef9c3;
|
||
}
|
||
@media (prefers-color-scheme: dark) {
|
||
:root {
|
||
--fg: #e4e4e7; --bg: #0f0f10; --mut: #a1a1aa; --card: #18181b; --bd: #27272a;
|
||
--ok: #4ade80; --q: #fbbf24; --man: #f87171;
|
||
--ok-bg: #14532d; --q-bg: #451a03; --man-bg: #450a0a;
|
||
--code-bg: #09090b; --code-fg: #86efac;
|
||
--warn: #fef08a; --warn-bg: #422006;
|
||
}
|
||
}
|
||
*, *::before, *::after { box-sizing: border-box; }
|
||
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; line-height: 1.5; }
|
||
.wrap { max-width: 1300px; margin: 0 auto; padding: 24px 20px; }
|
||
h1 { margin: 0 0 2px; font-size: 22px; }
|
||
h2 { margin: 36px 0 12px; font-size: 17px; border-bottom: 1px solid var(--bd);
|
||
padding-bottom: 6px; }
|
||
.meta { color: var(--mut); font-size: 13px; margin-bottom: 20px; }
|
||
.meta b { color: var(--fg); }
|
||
.summary { margin: 0 0 28px; font-size: 14px; }
|
||
|
||
/* Legend table */
|
||
table.leg { border-collapse: collapse; margin: 8px 0 20px; font-size: 13px; }
|
||
table.leg td { padding: 5px 10px; border: 1px solid var(--bd); }
|
||
|
||
/* Overview table */
|
||
table.ov { width: 100%; border-collapse: collapse; font-size: 13px; margin-bottom: 32px; }
|
||
table.ov th, table.ov td { padding: 6px 8px; border-bottom: 1px solid var(--bd);
|
||
text-align: left; white-space: nowrap; }
|
||
table.ov th { background: var(--card); font-weight: 600; }
|
||
table.ov td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||
table.ov tr:hover td { background: var(--bd); }
|
||
|
||
/* Badges */
|
||
.badge { display: inline-block; padding: 1px 7px; border-radius: 4px;
|
||
font-weight: 700; font-size: 11px; letter-spacing: .03em; }
|
||
.badge.ok { background: var(--ok-bg); color: var(--ok); }
|
||
.badge.q { background: var(--q-bg); color: var(--q); }
|
||
.badge.man { background: var(--man-bg); color: var(--man); }
|
||
|
||
/* Beat cards */
|
||
.beat { background: var(--card); border: 1px solid var(--bd); border-radius: 10px;
|
||
padding: 16px; margin-bottom: 28px; }
|
||
.beat-hdr { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
||
.beat-hdr h3 { margin: 0; font-size: 16px; }
|
||
.beat-hdr .status-text { font-size: 13px; color: var(--mut); }
|
||
|
||
/* Compare video — full width */
|
||
.compare-wrap { margin-bottom: 12px; }
|
||
.compare-label { font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||
letter-spacing: .06em; color: var(--mut); margin-bottom: 4px; }
|
||
.compare-wrap video, .compare-wrap img { width: 100%; height: auto;
|
||
border-radius: 6px; background: #000; display: block; }
|
||
.stills-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||
.stills-pair img { width: 100%; height: auto; border-radius: 6px; background: #000; }
|
||
.empty-media { display: flex; align-items: center; justify-content: center;
|
||
aspect-ratio: 32/9; background: #000; color: #555; border-radius: 6px;
|
||
font-size: 13px; }
|
||
|
||
/* Metadata footer inside card */
|
||
.beat-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 24px;
|
||
margin-top: 12px; font-size: 13px; border-top: 1px solid var(--bd); padding-top: 10px; }
|
||
.kv { margin: 3px 0; }
|
||
.kv b { display: inline-block; min-width: 72px; color: var(--mut); font-weight: 500; }
|
||
.tc { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
||
.seg-list { margin: 4px 0 0 0; padding-left: 16px; font-size: 12px; color: var(--mut);
|
||
list-style: disc; }
|
||
.seg-list li { margin: 2px 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||
.warn-box { background: var(--warn-bg); color: var(--warn); border-radius: 4px;
|
||
padding: 4px 8px; font-size: 12px; margin: 4px 0; }
|
||
.hint { display: inline-block; background: var(--code-bg); color: var(--code-fg);
|
||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px;
|
||
padding: 2px 8px; border-radius: 4px; margin-top: 6px; }
|
||
</style>
|
||
</head>
|
||
<body><div class="wrap">
|
||
"""
|
||
|
||
HTML_FOOT = "</div></body></html>\n"
|
||
|
||
|
||
def _he(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,
|
||
generated_at: datetime,
|
||
) -> 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
|
||
parts.append(f'<h1>Cutter-Report</h1>')
|
||
parts.append('<div class="meta">')
|
||
parts.append(
|
||
f'Generiert: <b>{generated_at.strftime("%Y-%m-%d %H:%M:%S")}</b> | '
|
||
f'Trailer: <b>{_he(trailer_path.name)}</b> @ {trailer_fps:.3f} fps | '
|
||
f'Source: <b>{_he(source_path.name)}</b> @ {source_fps:.3f} fps'
|
||
)
|
||
parts.append('</div>')
|
||
parts.append(
|
||
f'<div class="summary"><b>{len(rows)}</b> Beats — '
|
||
f'<b>{matched}</b> automatisch (<b>{confirmed}</b> bestätigt) — '
|
||
f'<b>{len(rows) - matched}</b> manuell.</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 — übernehmen</td></tr>'
|
||
)
|
||
parts.append(
|
||
'<tr><td><span class="badge q">?</span></td>'
|
||
'<td>Vorläufig — Phase im NLE prüfen, Source-In ggf. nachjustieren</td></tr>'
|
||
)
|
||
parts.append(
|
||
'<tr><td><span class="badge man">MAN.</span></td>'
|
||
'<td>Kein automatischer Treffer — manuell setzen oder Schwarzfade</td></tr>'
|
||
)
|
||
parts.append('</tbody></table>')
|
||
|
||
# Overview table
|
||
parts.append('<h2>Übersicht</h2>')
|
||
parts.append(
|
||
'<table class="ov"><thead><tr>'
|
||
'<th>Beat</th><th>Trailer In–Out (TC)</th><th>Dauer</th>'
|
||
'<th>Source In (TC)</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", "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}–{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>')
|
||
|
||
# Per-beat 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)
|
||
dur = r.trailer_out_s - r.trailer_in_s
|
||
bcls = {"OK": "ok", "?": "q", "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">'
|
||
'▶ Trailer / ▶ 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> <span class="tc">{ti}–{to}</span>'
|
||
f' <span style="color:var(--mut)">({dur:.2f}s)</span></div>')
|
||
if r.phase:
|
||
parts.append(f'<div class="kv"><b>Phase</b> {_he(r.phase)}</div>')
|
||
if r.composition:
|
||
extra = f", {r.setting}" if r.setting else ""
|
||
parts.append(f'<div class="kv"><b>Bild</b> {_he(r.composition + extra)}</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)} · {r.num_segments} Segmente"
|
||
parts.append(
|
||
f'<div class="kv"><b>Source</b> <span class="tc">{si}</span>'
|
||
f' <span style="color:var(--mut)">(multi-shot)</span></div>'
|
||
)
|
||
parts.append(f'<div class="kv"><b>Scene</b> {scene_str}</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 {i + 1}: <span class="tc">{seg_tc}</span>'
|
||
f' dur {seg_dur:.2f}s'
|
||
f' @ off {seg_off:.2f}s'
|
||
f' sc {seg_sc}'
|
||
f' score {seg_score:.3f}</li>'
|
||
)
|
||
parts.append('</ul>')
|
||
else:
|
||
parts.append(
|
||
f'<div class="kv"><b>Source</b>'
|
||
f' <span class="tc">{si}–{so}</span></div>'
|
||
)
|
||
parts.append(
|
||
f'<div class="kv"><b>Scene</b> {r.scene_id}'
|
||
f' · Score {r.score:.3f}</div>'
|
||
)
|
||
if r.score > 0 and r.score < 0.65:
|
||
parts.append(
|
||
f'<div class="warn-box">⚠ Score {r.score:.3f} unter 0.65'
|
||
f' — visuell prüfen</div>'
|
||
)
|
||
else:
|
||
parts.append('<div class="kv">— kein automatischer Treffer —</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(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")
|
||
print(f"Wrote {project_root / 'CUTTER_REPORT.md'}")
|
||
print(f"Wrote {project_root / 'CUTTER_REPORT.html'}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|