""" 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 SCENE_START_GUARD_S = 0.32 # 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, 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) 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 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" 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 build_compare_clip( trailer_path, beat["start_s"], beat_dur, source_path, compare_segs, cmp4, ): compare_clip = cmp4 rows.append(BeatRow( bid=bid, trailer_in_s=beat["start_s"], trailer_out_s=beat["end_s"], source_in_s=rec["in_point_s"] if rec else None, source_out_s=rec["out_point_s"] if rec else None, scene_id=rec["scene_id"] if rec else None, score=rec["match_score"] if rec else 0.0, confirmed=bool(rec and rec.get("is_confirmed")), matched=rec is not None, num_segments=num_segs, segments=segs, phase=parse_field(desc, "action_phase") or parse_field(desc, "subject"), composition=parse_field(desc, "composition"), setting=parse_field(desc, "setting"), trailer_still=trailer_still, source_still=source_still, trailer_clip=trailer_clip, source_clip=source_clip, compare_clip=compare_clip, )) return rows # --------------------------------------------------------------------------- # Markdown renderer # --------------------------------------------------------------------------- def render_markdown( rows: list[BeatRow], trailer_fps: float, source_fps: float, trailer_path: Path, source_path: Path, generated_at: datetime, ) -> str: matched = sum(1 for r in rows if r.matched) confirmed = sum(1 for r in rows if r.confirmed) out: list[str] = [] out.append("# Cutter-Report — manuelles Nachschneiden") out.append("") out.append(f"Generiert: **{generated_at.strftime('%Y-%m-%d %H:%M:%S')}**") out.append("") out.append(f"- **Trailer**: `{trailer_path.name}` @ {trailer_fps:.3f} fps") out.append(f"- **Source** : `{source_path.name}` @ {source_fps:.3f} fps") out.append("") out.append("Trailer-TC in Trailer-Framerate, Source-TC in Source-Framerate.") out.append("") out.append("## Status-Legende") out.append("") out.append("| Status | Bedeutung |") out.append("|--------|-----------|") out.append("| `OK` | Bestätigt durch CV-Analyse — übernehmen |") out.append("| `?` | Vorläufig — korrekte Szene, Phase im NLE prüfen |") out.append("| `MAN.` | Kein automatischer Treffer — manuell setzen |") out.append("") out.append( f"**{len(rows)}** Beats gesamt · **{matched}** automatisch (**{confirmed}** bestätigt)" f" · **{len(rows) - matched}** manuell." ) out.append("") out.append("## Beat-Tabelle") out.append("") out.append("| Beat | Trailer In / Out | Source In / Out | Scene | Score | Status |") out.append("|-----:|------------------|-----------------|------:|------:|:------:|") for r in rows: ti = smpte(r.trailer_in_s, trailer_fps) to = smpte(r.trailer_out_s, trailer_fps) si = smpte(r.source_in_s, source_fps) if r.matched else "—" so = smpte(r.source_out_s, source_fps) if r.matched else "—" sc = f"{r.score:.3f}" if r.matched else "—" scene = str(r.scene_id) if r.matched else "—" if r.num_segments > 1: scene += f" (+{r.num_segments - 1})" out.append( f"| {r.bid:>4} | {ti} – {to} | {si} – {so}" f" | {scene} | {sc} | {r.status} |" ) out.append("") out.append("## Beat-Details") out.append("") for r in rows: ti = smpte(r.trailer_in_s, trailer_fps) to = smpte(r.trailer_out_s, trailer_fps) dur = r.trailer_out_s - r.trailer_in_s out.append(f"### Beat {r.bid:02d} — {r.status} / {r.status_de}") out.append("") out.append(f"- **Trailer**: {ti} – {to} ({dur:.2f} s)") if r.matched: si = smpte(r.source_in_s, source_fps) so = smpte(r.source_out_s, source_fps) scene_info = f"scene {r.scene_id}" if r.num_segments > 1: scene_ids = list(dict.fromkeys( str(s.get("scene_id", "?")) for s in r.segments )) scene_info = f"scenes {', '.join(scene_ids)} ({r.num_segments} Segmente)" out.append(f"- **Source** : {si} – {so} ({scene_info}, score {r.score:.3f})") if r.num_segments > 1: for i, seg in enumerate(r.segments): seg_tc = smpte(seg.get("in_point_s"), source_fps) seg_dur = seg.get("duration_s", 0) seg_offset = seg.get("trailer_offset_s", 0) out.append( f" - Seg {i + 1}: TC {seg_tc} dur {seg_dur:.2f}s" f" @ Trailer-Offset {seg_offset:.2f}s" f" (scene {seg.get('scene_id', '?')})" ) else: out.append("- **Source** : — (manuell setzen)") if r.score > 0 and r.score < 0.65: out.append(f"- ⚠ Score {r.score:.3f} unter 0.65 — visuell prüfen") out.append(f"- **Rematch**: `python cli.py rematch --beat {r.bid}`") if r.phase: out.append(f"- **Phase**: {r.phase}") if r.composition: extra = f", {r.setting}" if r.setting else "" out.append(f"- **Bild**: {r.composition}{extra}") out.append("") if r.compare_clip: rel_clip = f"output/cutter_clips/beat_{r.bid:02d}_compare.mp4" out.append(f"**[▶️ Frame-Locked Compare Video ansehen]({rel_clip})**") out.append("") t_uri = data_uri(r.trailer_still, "image/jpeg") s_uri = data_uri(r.source_still, "image/jpeg") if t_uri or s_uri: out.append("| Trailer | Source |") out.append("|:---:|:---:|") t_cell = f"![Trailer {r.bid}]({t_uri})" if t_uri else "_(kein Still)_" s_cell = f"![Source {r.bid}]({s_uri})" if s_uri else "_(MAN.)_" out.append(f"| {t_cell} | {s_cell} |") out.append("") return "\n".join(out) # --------------------------------------------------------------------------- # HTML renderer # --------------------------------------------------------------------------- HTML_HEAD = """\ Cutter-Report & Match-Report
""" HTML_FOOT = "
\n" def _he(s: str) -> str: return (s.replace("&", "&").replace("<", "<") .replace(">", ">").replace('"', """)) def _get_recent_changes(project_root: Path) -> str: try: import subprocess proc = subprocess.run( ["git", "log", "--invert-grep", "--grep=Auto-update", "-1", "--pretty=%B"], capture_output=True, text=True, cwd=str(project_root), timeout=5 ) if proc.stdout: lines = proc.stdout.strip().split('\n') cleaned = [line for line in lines if line and not line.startswith('Co-Authored-By')] return "
".join(cleaned[:3]) or "No recent changes found." except Exception: pass return "No recent changes available." def render_html( rows: list[BeatRow], trailer_fps: float, source_fps: float, trailer_path: Path, source_path: Path, with_clips: bool, generated_at: datetime, project_root: Path = Path("."), ) -> str: matched = sum(1 for r in rows if r.matched) confirmed = sum(1 for r in rows if r.confirmed) parts: list[str] = [HTML_HEAD] # Header recent = _get_recent_changes(project_root) parts.append('
') parts.append(f'

Cutter & Match Report

') parts.append('
') parts.append( f'Generiert: {generated_at.strftime("%Y-%m-%d %H:%M:%S")}' f'Trailer: {_he(trailer_path.name)} @ {trailer_fps:.3f} fps' f'Source: {_he(source_path.name)} @ {source_fps:.3f} fps' ) parts.append('
') parts.append( f'
{len(rows)} Beats — ' f'{matched} automatisch ({confirmed} bestätigt) — ' f'{len(rows) - matched} manuell.
' ) parts.append(f'
Recent Changes:
{recent}
') parts.append('
') # Legend parts.append('

Legende

') parts.append('') parts.append( '' '' ) parts.append( '' '' ) parts.append( '' '' ) parts.append('
OKBestätigt — direkt in Schnitt-Timeline übernehmen
?Vorläufig — Phase und Aktion im NLE visuell prüfen
MAN.Kein Treffer — manuell suchen oder Schwarzbild einfügen
') # Overview table parts.append('

Übersicht

') parts.append('
') parts.append( '' '' '' '' ) for r in rows: ti = smpte(r.trailer_in_s, trailer_fps) to = smpte(r.trailer_out_s, trailer_fps) dur = r.trailer_out_s - r.trailer_in_s si = smpte(r.source_in_s, source_fps) if r.matched else "—" sc = f"{r.score:.3f}" if r.matched else "—" scene = str(r.scene_id) if r.matched else "—" if r.num_segments > 1: all_scenes = list(dict.fromkeys( str(s.get("scene_id", "?")) for s in r.segments )) scene = "+".join(all_scenes) bcls = {"OK": "ok", "?": "q", "MAN.": "man"}[r.status] parts.append( f'' f'' f'' f'' f'' f'' f'' f'' f'' ) parts.append('
BeatTrailer TC In–OutDauerSource TC InSceneScoreStatus
{r.bid:02d}{ti}–{to}{dur:.2f}s{si}{_he(scene)}{sc}{r.status}
') # Per-beat cards parts.append('

Beat-Details

') parts.append('
') for r in rows: ti = smpte(r.trailer_in_s, trailer_fps) to = smpte(r.trailer_out_s, trailer_fps) dur = r.trailer_out_s - r.trailer_in_s bcls = {"OK": "ok", "?": "q", "MAN.": "man"}[r.status] parts.append(f'
') # Header row parts.append('
') parts.append(f'

Beat {r.bid:02d}

') parts.append(f'{r.status}') parts.append(f'{_he(r.status_de)}') parts.append('
') # Media: compare clip or still pair parts.append('
') cmp_uri = data_uri(r.compare_clip, "video/mp4") if (with_clips and r.compare_clip) else None if cmp_uri: parts.append( '
' '▶ Trailer  /  ▶ Source (Frame-Locked Compare)' '
' ) parts.append(f'') else: t_uri = data_uri(r.trailer_still, "image/jpeg") s_uri = data_uri(r.source_still, "image/jpeg") if t_uri or s_uri: parts.append('
') parts.append( f'Trailer {r.bid}' if t_uri else '
— kein Still —
' ) parts.append( f'Source {r.bid}' if s_uri else '
— manuell setzen —
' ) parts.append('
') else: parts.append('
— kein Vorschau verfügbar —
') parts.append('
') # .compare-wrap # Metadata parts.append('
') # Left col: trailer info parts.append('
') parts.append(f'
Trailer
{ti}–{to}' f'  ({dur:.2f}s)
') if r.phase: parts.append(f'
Phase {_he(r.phase)}
') if r.composition: extra = f", {r.setting}" if r.setting else "" parts.append(f'
Bild {_he(r.composition + extra)}
') parts.append('
') # Right col: source info parts.append('
') 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'
Source
{si}' f'  (multi-shot)
' ) parts.append(f'
Scene {scene_str}
') # Segment list parts.append('
    ') 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'
  • Seg {i + 1}: {seg_tc}' f'  dur {seg_dur:.2f}s' f'  @ off {seg_off:.2f}s' f'  sc {seg_sc}' f'  score {seg_score:.3f}
  • ' ) parts.append('
') else: parts.append( f'
Source' f'
{si}–{so}
' ) parts.append( f'
Scene {r.scene_id}' f' · Score {r.score:.3f}
' ) if r.score > 0 and r.score < 0.65: parts.append( f'
⚠ Score {r.score:.3f} unter 0.65' f' — visuell prüfen
' ) else: parts.append('
— kein automatischer Treffer —
') parts.append( f'
python cli.py rematch --beat {r.bid}
' ) parts.append('
') # right col parts.append('
') # .beat-meta parts.append('
') # .beat parts.append('
') # .beats-grid parts.append(HTML_FOOT) return "".join(parts) # --------------------------------------------------------------------------- # Top-level entry point # --------------------------------------------------------------------------- def render_report( project_root: Path, with_stills: bool = True, with_clips: bool = True, ) -> tuple[str, str]: """Return (markdown_text, html_text). Caller writes both files.""" sys.path.insert(0, str(project_root)) from src.core.config import load_config cfg = load_config(project_root / "config.toml") trailer_path = Path(cfg.paths.reference_trailer) source_path = Path(cfg.paths.source_movie) source_fps = float(getattr(cfg.export, "edl_frame_rate", 0.0)) or probe_fps(source_path) or 23.976 trailer_fps_cfg = getattr(cfg.paths, "trailer_frame_rate", None) trailer_fps = float(trailer_fps_cfg) if trailer_fps_cfg else (probe_fps(trailer_path) or source_fps) cache = project_root / ".cache" results = {r["beat_id"]: r for r in json.loads((cache / "match_results.json").read_text())} beats = json.loads((cache / "trailer_beats.json").read_text()) 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") legacy_path = project_root / "output" / "report" / "match_report.html" legacy_path.parent.mkdir(parents=True, exist_ok=True) legacy_path.write_text(html, encoding="utf-8") print(f"Wrote {project_root / 'CUTTER_REPORT.md'}") print(f"Wrote {project_root / 'CUTTER_REPORT.html'}") print(f"Wrote {legacy_path}") return 0 if __name__ == "__main__": raise SystemExit(main())