45769aa366
- scripts/generate_cutter_report.py: complete HTML redesign with glassmorphism dark-mode style, compare video links in markdown output - cli.py: cmd_report now calls _regenerate_cutter_report directly; also writes legacy match_report.html; removes dependency on src/pipeline/reporter.py - src/cv/global_scan.py: add motion-phase alignment refinement step after initial in-point search (align_in_point_by_motion, threshold +0.015) - Remove HANDOVER.md and src/pipeline/reporter.py (superseded) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1055 lines
41 KiB
Python
1055 lines
41 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("")
|
||
|
||
if r.compare_clip:
|
||
rel_clip = f"output/cutter_clips/beat_{r.bid:02d}_compare.mp4"
|
||
out.append(f"**[▶️ Frame-Locked Compare Video ansehen]({rel_clip})**")
|
||
out.append("")
|
||
|
||
t_uri = data_uri(r.trailer_still, "image/jpeg")
|
||
s_uri = data_uri(r.source_still, "image/jpeg")
|
||
if t_uri or s_uri:
|
||
out.append("| Trailer | Source |")
|
||
out.append("|:---:|:---:|")
|
||
t_cell = f"" if t_uri else "_(kein Still)_"
|
||
s_cell = f"" if s_uri else "_(MAN.)_"
|
||
out.append(f"| {t_cell} | {s_cell} |")
|
||
out.append("")
|
||
|
||
return "\n".join(out)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# HTML renderer
|
||
# ---------------------------------------------------------------------------
|
||
|
||
HTML_HEAD = """\
|
||
<!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); }
|
||
|
||
/* 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("&", "&").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 "<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 — '
|
||
f'<span style="color:var(--ok)"><b>{matched}</b> automatisch (<b>{confirmed}</b> bestätigt)</span> — '
|
||
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 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–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", "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></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", "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> <div class="kv-val"><span class="tc">{ti}–{to}</span>'
|
||
f' <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)} · {r.num_segments} Segmente"
|
||
parts.append(
|
||
f'<div class="kv"><b>Source</b> <div class="kv-val"><span class="tc">{si}</span>'
|
||
f' <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 {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' <div class="kv-val"><span class="tc">{si}–{so}</span></div></div>'
|
||
)
|
||
parts.append(
|
||
f'<div class="kv"><b>Scene</b> <span class="kv-val">{r.scene_id}'
|
||
f' · Score {r.score:.3f}</span></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('</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())
|
||
vis_path = cache / "vision_descriptions.json"
|
||
vis_items = json.loads(vis_path.read_text())["items"] if vis_path.exists() else {}
|
||
|
||
rows = collect_rows(
|
||
project_root, beats, results, vis_items,
|
||
trailer_path, source_path, with_stills, with_clips,
|
||
)
|
||
|
||
now = datetime.now()
|
||
md = render_markdown(rows, trailer_fps, source_fps, trailer_path, source_path, now)
|
||
html = render_html(rows, trailer_fps, source_fps, trailer_path, source_path, with_clips, now)
|
||
return md, html
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser(
|
||
description="Render CUTTER_REPORT.{md,html} from current cache"
|
||
)
|
||
parser.add_argument("--no-stills", action="store_true",
|
||
help="skip frame extraction (text-only markdown)")
|
||
parser.add_argument("--no-clips", action="store_true",
|
||
help="skip video clip rendering (stills only)")
|
||
args = parser.parse_args()
|
||
|
||
here = Path(__file__).resolve().parent
|
||
project_root = here.parent
|
||
md, html = render_report(
|
||
project_root,
|
||
with_stills=not args.no_stills,
|
||
with_clips=not args.no_clips,
|
||
)
|
||
(project_root / "CUTTER_REPORT.md").write_text(md, encoding="utf-8")
|
||
(project_root / "CUTTER_REPORT.html").write_text(html, encoding="utf-8")
|
||
legacy_path = project_root / "output" / "report" / "match_report.html"
|
||
legacy_path.parent.mkdir(parents=True, exist_ok=True)
|
||
legacy_path.write_text(html, encoding="utf-8")
|
||
|
||
print(f"Wrote {project_root / 'CUTTER_REPORT.md'}")
|
||
print(f"Wrote {project_root / 'CUTTER_REPORT.html'}")
|
||
print(f"Wrote {legacy_path}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|