Embed cutter-report stills inline + add HTML report with video previews
Two issues fixed: 1. Source frame rate was wrong. The script trusted ffprobe, which on this re-wrapped proxy reports 25 fps. The real number for the EDL/FCPXML and for what the cutter sees in the NLE comes from config.toml's edl_frame_rate (here 23.976). Source fps now reads that value first; ffprobe is only a fallback. Trailer fps still probes ffprobe (correct for the trailer file) with optional config override. 2. Stills in CUTTER_REPORT.md showed as broken links because output/ is gitignored, so the git server can't serve them. Stills are now embedded as base64 data URIs directly in the markdown. The file is therefore self-contained and renders in any markdown viewer including the git server's web preview. 3. New CUTTER_REPORT.html alongside the markdown: same data, proper card layout, side-by-side trailer/source columns per beat, base64-embedded stills, and (with --with-clips) base64-embedded 3 s MP4 video previews so the cutter can sight-check phase agreement directly in a browser. The auto-regen on each match writes both files; --with-clips is opt-in from the CLI for slower full renders. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
+106
-128
File diff suppressed because one or more lines are too long
@@ -93,7 +93,7 @@ def _save_results(results: list, cfg: "AppConfig") -> None: # type: ignore[name
|
||||
|
||||
|
||||
def _regenerate_cutter_report(cfg: "AppConfig") -> None: # type: ignore[name-defined]
|
||||
"""Re-render CUTTER_REPORT.md after each cache write so it stays in sync."""
|
||||
"""Re-render CUTTER_REPORT.{md,html} after each cache write so they stay in sync."""
|
||||
try:
|
||||
from scripts.generate_cutter_report import render_report
|
||||
except Exception as exc:
|
||||
@@ -101,9 +101,10 @@ def _regenerate_cutter_report(cfg: "AppConfig") -> None: # type: ignore[name-de
|
||||
return
|
||||
try:
|
||||
project_root = cfg.paths.cache_dir.parent
|
||||
out = project_root / "CUTTER_REPORT.md"
|
||||
out.write_text(render_report(project_root), encoding="utf-8")
|
||||
logging.getLogger(__name__).info("Cutter report regenerated → %s", out)
|
||||
md, html = render_report(project_root, with_stills=True, with_clips=False)
|
||||
(project_root / "CUTTER_REPORT.md").write_text(md, encoding="utf-8")
|
||||
(project_root / "CUTTER_REPORT.html").write_text(html, encoding="utf-8")
|
||||
logging.getLogger(__name__).info("Cutter report regenerated (md + html)")
|
||||
except Exception as exc:
|
||||
logging.getLogger(__name__).warning("Cutter report regen failed: %s", exc)
|
||||
|
||||
|
||||
+478
-227
@@ -1,36 +1,40 @@
|
||||
"""
|
||||
scripts/generate_cutter_report.py — generate CUTTER_REPORT.md from current cache
|
||||
scripts/generate_cutter_report.py — generate CUTTER_REPORT.{md,html} from cache
|
||||
|
||||
Regenerates ``CUTTER_REPORT.md`` from ``.cache/match_results.json``,
|
||||
``.cache/trailer_beats.json`` and ``.cache/vision_descriptions.json``. The
|
||||
report is a hand-off document for a video editor (Cutter) doing the manual
|
||||
recut: per beat it lists trailer timecode, the proposed source timecode, the
|
||||
match score, what the vision model saw in the trailer beat, and side-by-side
|
||||
preview stills (extracted via ffmpeg).
|
||||
Renders two reports for the video editor:
|
||||
|
||||
Important: trailer and source can have different frame rates (e.g. trailer
|
||||
25 fps, source 23.976 fps). This script probes each file with ffprobe and
|
||||
renders trailer timecodes in trailer fps and source timecodes in source fps,
|
||||
so the timecode matches what the cutter sees in the NLE.
|
||||
* ``CUTTER_REPORT.md`` — text + base64-embedded preview stills. Self-
|
||||
contained (no broken image links on git server), opens in any markdown
|
||||
viewer.
|
||||
* ``CUTTER_REPORT.html`` — same data with HTML layout, side-by-side
|
||||
preview stills, and optionally side-by-side 3-second MP4 video clips
|
||||
per beat for sight-checking phase agreement.
|
||||
|
||||
Frame rates:
|
||||
|
||||
* Trailer fps is taken from ``config.toml`` if ``[paths] trailer_frame_rate``
|
||||
is set, otherwise from ffprobe on the trailer file.
|
||||
* Source fps is taken from ``[export] edl_frame_rate`` in config.toml. This
|
||||
is the value the EDL/FCPXML uses, so it matches what the cutter sees in
|
||||
the NLE timeline. ffprobe is used only as a last-resort fallback.
|
||||
|
||||
Usage (from project root):
|
||||
|
||||
python scripts/generate_cutter_report.py # text + stills
|
||||
python scripts/generate_cutter_report.py --no-stills # text only
|
||||
|
||||
Stills go to ``output/cutter_stills/beat_NN_{trailer,source}.jpg`` and are
|
||||
referenced from the markdown. They are only re-rendered when the underlying
|
||||
match position has changed — fast on repeat runs.
|
||||
python scripts/generate_cutter_report.py # full report
|
||||
python scripts/generate_cutter_report.py --no-stills # text-only md
|
||||
python scripts/generate_cutter_report.py --with-clips # also render
|
||||
# video previews
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
@@ -40,38 +44,43 @@ from pathlib import Path
|
||||
|
||||
|
||||
def probe_fps(video_path: Path) -> float | None:
|
||||
"""Return container fps (avg_frame_rate) for a video file, or None."""
|
||||
"""Return the file's frame rate via ffprobe. Tries r_frame_rate first."""
|
||||
if not video_path.exists():
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[
|
||||
"ffprobe", "-v", "error",
|
||||
"-select_streams", "v:0",
|
||||
"-show_entries", "stream=avg_frame_rate",
|
||||
"-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 "/" in raw:
|
||||
num, _, den = raw.partition("/")
|
||||
for key in ("r_frame_rate", "avg_frame_rate"):
|
||||
try:
|
||||
n, d = float(num), float(den)
|
||||
return n / d if d else None
|
||||
except ValueError:
|
||||
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
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
raw = proc.stdout.strip()
|
||||
if not raw or raw == "0/0":
|
||||
continue
|
||||
if "/" in raw:
|
||||
num, _, den = raw.partition("/")
|
||||
try:
|
||||
n, d = float(num), float(den)
|
||||
if d:
|
||||
return n / d
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def smpte(t: float | None, fps: float) -> str:
|
||||
"""Format seconds as h:mm:ss:ff, frame counter rounded to nearest int fps."""
|
||||
"""Format seconds as h:mm:ss:ff using nearest-int frame counter."""
|
||||
if t is None:
|
||||
return "--:--:--:--"
|
||||
fps_int = max(1, int(round(fps)))
|
||||
@@ -113,23 +122,28 @@ def parse_field(desc: str | None, key: str) -> str:
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Stills
|
||||
# Stills / clips
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
STILL_WIDTH = 360 # px, downscaled for fast preview in the markdown
|
||||
STILL_QUALITY = 5 # ffmpeg -q:v scale 1 (best) .. 31 (worst)
|
||||
STILL_WIDTH = 480
|
||||
STILL_QUALITY = 4
|
||||
CLIP_WIDTH = 480
|
||||
CLIP_DURATION_S = 3.0
|
||||
|
||||
|
||||
def _stale(out: Path, src: Path) -> bool:
|
||||
try:
|
||||
return not (out.exists() and out.stat().st_mtime >= src.stat().st_mtime and out.stat().st_size > 0)
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def extract_still(video_path: Path, t_s: float, out: Path) -> bool:
|
||||
"""Extract one JPEG frame at t_s. Skip if out is newer than video."""
|
||||
if not video_path.exists():
|
||||
return False
|
||||
try:
|
||||
if out.exists() and out.stat().st_mtime >= video_path.stat().st_mtime and out.stat().st_size > 0:
|
||||
return True
|
||||
except OSError:
|
||||
pass
|
||||
if not _stale(out, video_path):
|
||||
return True
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-loglevel", "error",
|
||||
@@ -147,18 +161,400 @@ def extract_still(video_path: Path, t_s: float, out: Path) -> bool:
|
||||
return out.exists() and out.stat().st_size > 0
|
||||
|
||||
|
||||
def extract_clip(video_path: Path, start_s: float, duration_s: float, out: Path) -> bool:
|
||||
if not video_path.exists():
|
||||
return False
|
||||
if not _stale(out, video_path):
|
||||
return True
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
"ffmpeg", "-y", "-loglevel", "error",
|
||||
"-ss", f"{max(0.0, start_s):.3f}",
|
||||
"-i", str(video_path),
|
||||
"-t", f"{max(0.04, duration_s):.3f}",
|
||||
"-vf", f"scale={CLIP_WIDTH}:-2",
|
||||
"-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-an",
|
||||
"-movflags", "+faststart",
|
||||
str(out),
|
||||
]
|
||||
try:
|
||||
subprocess.run(cmd, check=True, capture_output=True, timeout=60)
|
||||
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
return False
|
||||
return out.exists() and out.stat().st_size > 0
|
||||
|
||||
|
||||
def beat_still_time(start_s: float, end_s: float) -> float:
|
||||
"""Pick a representative time inside the beat (~30% in, but at least 0.4 s)."""
|
||||
duration = max(0.04, end_s - start_s)
|
||||
return start_s + min(0.4, duration * 0.3)
|
||||
|
||||
|
||||
def data_uri(path: Path, mime: str) -> str | None:
|
||||
if not path.exists() or path.stat().st_size == 0:
|
||||
return None
|
||||
payload = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
return f"data:{mime};base64,{payload}"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Renderer
|
||||
# Per-beat data
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_report(project_root: Path, with_stills: bool = True) -> str:
|
||||
@dataclass
|
||||
class BeatRow:
|
||||
bid: int
|
||||
trailer_in_s: float
|
||||
trailer_out_s: float
|
||||
source_in_s: float | None
|
||||
source_out_s: float | None
|
||||
scene_id: int | None
|
||||
score: float
|
||||
confirmed: bool
|
||||
matched: bool
|
||||
phase: str
|
||||
composition: str
|
||||
setting: str
|
||||
trailer_still: Path | None
|
||||
source_still: Path | None
|
||||
trailer_clip: Path | None
|
||||
source_clip: Path | None
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not self.matched:
|
||||
return "MAN."
|
||||
return "OK" if self.confirmed else "?"
|
||||
|
||||
|
||||
def collect_rows(
|
||||
project_root: Path,
|
||||
beats: list[dict],
|
||||
results: dict[int, dict],
|
||||
vis_items: dict,
|
||||
trailer_path: Path,
|
||||
source_path: Path,
|
||||
with_stills: bool,
|
||||
with_clips: bool,
|
||||
) -> list[BeatRow]:
|
||||
stills_dir = project_root / "output" / "cutter_stills"
|
||||
clips_dir = project_root / "output" / "cutter_clips"
|
||||
if with_stills:
|
||||
stills_dir.mkdir(parents=True, exist_ok=True)
|
||||
if with_clips:
|
||||
clips_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
rows: list[BeatRow] = []
|
||||
for beat in beats:
|
||||
bid = beat["beat_id"]
|
||||
rec = results.get(bid)
|
||||
desc = best_beat_description(vis_items, bid, beat["start_s"], beat["end_s"]) or ""
|
||||
|
||||
trailer_still = source_still = trailer_clip = source_clip = None
|
||||
if with_stills:
|
||||
t_still = beat_still_time(beat["start_s"], beat["end_s"])
|
||||
tjpg = stills_dir / f"beat_{bid:02d}_trailer.jpg"
|
||||
if extract_still(trailer_path, t_still, tjpg):
|
||||
trailer_still = tjpg
|
||||
if rec is not None:
|
||||
src_dur = max(0.04, rec["out_point_s"] - rec["in_point_s"])
|
||||
s_still = rec["in_point_s"] + min(0.4, src_dur * 0.3)
|
||||
sjpg = stills_dir / f"beat_{bid:02d}_source.jpg"
|
||||
if extract_still(source_path, s_still, sjpg):
|
||||
source_still = sjpg
|
||||
if with_clips:
|
||||
tdur = min(CLIP_DURATION_S, max(0.5, beat["end_s"] - beat["start_s"]))
|
||||
tmp4 = clips_dir / f"beat_{bid:02d}_trailer.mp4"
|
||||
if extract_clip(trailer_path, beat["start_s"], tdur, tmp4):
|
||||
trailer_clip = tmp4
|
||||
if rec is not None:
|
||||
src_dur = max(0.5, rec["out_point_s"] - rec["in_point_s"])
|
||||
sdur = min(CLIP_DURATION_S, src_dur)
|
||||
smp4 = clips_dir / f"beat_{bid:02d}_source.mp4"
|
||||
if extract_clip(source_path, rec["in_point_s"], sdur, smp4):
|
||||
source_clip = smp4
|
||||
|
||||
rows.append(BeatRow(
|
||||
bid=bid,
|
||||
trailer_in_s=beat["start_s"], trailer_out_s=beat["end_s"],
|
||||
source_in_s=rec["in_point_s"] if rec else None,
|
||||
source_out_s=rec["out_point_s"] if rec else None,
|
||||
scene_id=rec["scene_id"] if rec else None,
|
||||
score=rec["match_score"] if rec else 0.0,
|
||||
confirmed=bool(rec and rec.get("is_confirmed")),
|
||||
matched=rec is not None,
|
||||
phase=parse_field(desc, "action_phase") or parse_field(desc, "subject"),
|
||||
composition=parse_field(desc, "composition"),
|
||||
setting=parse_field(desc, "setting"),
|
||||
trailer_still=trailer_still,
|
||||
source_still=source_still,
|
||||
trailer_clip=trailer_clip,
|
||||
source_clip=source_clip,
|
||||
))
|
||||
return rows
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Markdown renderer
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_markdown(
|
||||
rows: list[BeatRow], trailer_fps: float, source_fps: float,
|
||||
trailer_path: Path, source_path: Path,
|
||||
) -> str:
|
||||
matched = sum(1 for r in rows if r.matched)
|
||||
confirmed = sum(1 for r in rows if r.confirmed)
|
||||
|
||||
out: list[str] = []
|
||||
out.append("# Cutter-Report — manuelles Nachschneiden")
|
||||
out.append("")
|
||||
out.append(f"Stand: {date.today().isoformat()}")
|
||||
out.append("")
|
||||
out.append(f"- **Trailer**: `{trailer_path.name}` @ {trailer_fps:.3f} fps")
|
||||
out.append(f"- **Source** : `{source_path.name}` @ {source_fps:.3f} fps")
|
||||
out.append("")
|
||||
out.append(
|
||||
"Trailer-TC in Trailer-Framerate, Source-TC in Source-Framerate — "
|
||||
"passt 1:1 zum Schnittplatz."
|
||||
)
|
||||
out.append("")
|
||||
out.append(
|
||||
"Bilder sind base64-eingebettet (kein toter Link). Für Video-Vorschau "
|
||||
"siehe `CUTTER_REPORT.html` (gleiche Daten, mit Clips)."
|
||||
)
|
||||
out.append("")
|
||||
|
||||
out.append("## Status-Legende")
|
||||
out.append("")
|
||||
out.append("| Status | Bedeutung | Was tun? |")
|
||||
out.append("|--------|-----------|----------|")
|
||||
out.append("| `OK` | bestätigt durch CV + Vision-Phasenprüfung | übernehmen |")
|
||||
out.append("| `?` | korrekte Szene, Phase ggf. um wenige Frames verschoben | im NLE prüfen |")
|
||||
out.append("| `MAN.` | kein automatischer Treffer | manuell setzen oder Schwarzfade |")
|
||||
out.append("")
|
||||
|
||||
out.append(f"**Beats:** {len(rows)} gesamt · **{matched}** automatisch (**{confirmed}** bestätigt) · **{len(rows)-matched}** manuell.")
|
||||
out.append("")
|
||||
|
||||
out.append("## Beat-Tabelle")
|
||||
out.append("")
|
||||
out.append("| Beat | Trailer In / Out | Source In / Out | Score | Status | Bild laut Vision |")
|
||||
out.append("|-----:|------------------|------------------|------:|:------:|-------------------|")
|
||||
for r in rows:
|
||||
ti = smpte(r.trailer_in_s, trailer_fps)
|
||||
to = smpte(r.trailer_out_s, trailer_fps)
|
||||
si = smpte(r.source_in_s, source_fps) if r.matched else "—"
|
||||
so = smpte(r.source_out_s, source_fps) if r.matched else "—"
|
||||
sc = f"{r.score:.3f}" if r.matched else "—"
|
||||
out.append(f"| {r.bid:>4} | {ti}-{to} | {si}-{so} | {sc} | {r.status} | {r.phase[:80]} |")
|
||||
out.append("")
|
||||
|
||||
out.append("## Beat-Details")
|
||||
out.append("")
|
||||
for r in rows:
|
||||
ti = smpte(r.trailer_in_s, trailer_fps)
|
||||
to = smpte(r.trailer_out_s, trailer_fps)
|
||||
out.append(f"### Beat {r.bid:02d} — Status `{r.status}`")
|
||||
out.append("")
|
||||
out.append(f"- **Trailer**: {ti} – {to}")
|
||||
if r.matched:
|
||||
si = smpte(r.source_in_s, source_fps)
|
||||
so = smpte(r.source_out_s, source_fps)
|
||||
out.append(f"- **Source** : {si} – {so} (scene {r.scene_id}, score {r.score:.3f})")
|
||||
else:
|
||||
out.append("- **Source** : — (manuell setzen)")
|
||||
if r.phase:
|
||||
out.append(f"- **Phase** : {r.phase}")
|
||||
if r.composition:
|
||||
extra = f", {r.setting}" if r.setting else ""
|
||||
out.append(f"- **Bild** : {r.composition}{extra}")
|
||||
out.append("")
|
||||
|
||||
# Inline base64 stills
|
||||
t_uri = data_uri(r.trailer_still, "image/jpeg") if r.trailer_still else None
|
||||
s_uri = data_uri(r.source_still, "image/jpeg") if r.source_still else None
|
||||
if t_uri or s_uri:
|
||||
out.append("| Trailer | Source |")
|
||||
out.append("|:---:|:---:|")
|
||||
t_cell = f"" if t_uri else "_(kein Still)_"
|
||||
s_cell = f"" if s_uri else "_(MAN.)_"
|
||||
out.append(f"| {t_cell} | {s_cell} |")
|
||||
out.append("")
|
||||
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# HTML renderer
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
HTML_HEAD = """<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Cutter-Report</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; --fg:#222; --bg:#fafafa; --mut:#666;
|
||||
--ok:#1e8e3e; --q:#d18907; --man:#c33; --card:#fff; --bd:#ddd; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root { --fg:#eee; --bg:#181818; --mut:#aaa; --card:#222; --bd:#333; }
|
||||
}
|
||||
html,body{margin:0;padding:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",
|
||||
Roboto,sans-serif;color:var(--fg);background:var(--bg);font-size:14px;}
|
||||
.wrap{max-width:1200px;margin:0 auto;padding:24px;}
|
||||
h1{margin:0 0 4px;font-size:24px;}
|
||||
h2{margin:32px 0 12px;font-size:18px;border-bottom:1px solid var(--bd);padding-bottom:4px;}
|
||||
h3{margin:24px 0 6px;font-size:16px;}
|
||||
.meta{color:var(--mut);margin-bottom:16px;}
|
||||
.summary{margin:8px 0 24px;font-size:15px;}
|
||||
table.tab{width:100%;border-collapse:collapse;margin:12px 0 24px;font-size:13px;}
|
||||
table.tab th,table.tab td{padding:6px 8px;border-bottom:1px solid var(--bd);text-align:left;
|
||||
vertical-align:top;}
|
||||
table.tab th{background:var(--card);}
|
||||
table.tab td.tc{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;white-space:nowrap;}
|
||||
table.tab td.num{text-align:right;font-variant-numeric:tabular-nums;}
|
||||
.badge{display:inline-block;padding:1px 6px;border-radius:3px;font-weight:600;font-size:12px;}
|
||||
.badge.ok{background:rgba(30,142,62,.15);color:var(--ok);}
|
||||
.badge.q{background:rgba(209,137,7,.15);color:var(--q);}
|
||||
.badge.man{background:rgba(204,51,51,.15);color:var(--man);}
|
||||
.beat{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin:14px 0 28px;
|
||||
background:var(--card);border:1px solid var(--bd);border-radius:8px;padding:14px;}
|
||||
.beat .full{grid-column:1/-1;}
|
||||
.col h4{margin:0 0 6px;font-size:13px;color:var(--mut);font-weight:600;
|
||||
text-transform:uppercase;letter-spacing:.05em;}
|
||||
.col img,.col video{width:100%;height:auto;border-radius:4px;background:#000;}
|
||||
.kv{margin:4px 0;font-size:13px;}
|
||||
.kv b{display:inline-block;min-width:64px;color:var(--mut);font-weight:500;}
|
||||
.tc{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;}
|
||||
.empty{display:flex;align-items:center;justify-content:center;width:100%;
|
||||
aspect-ratio:16/9;background:#000;color:#888;border-radius:4px;font-size:13px;}
|
||||
</style>
|
||||
</head>
|
||||
<body><div class="wrap">
|
||||
"""
|
||||
|
||||
|
||||
HTML_FOOT = """</div></body></html>
|
||||
"""
|
||||
|
||||
|
||||
def html_escape(s: str) -> str:
|
||||
return (s.replace("&", "&").replace("<", "<")
|
||||
.replace(">", ">").replace('"', """))
|
||||
|
||||
|
||||
def render_html(
|
||||
rows: list[BeatRow], trailer_fps: float, source_fps: float,
|
||||
trailer_path: Path, source_path: Path, with_clips: bool,
|
||||
) -> str:
|
||||
matched = sum(1 for r in rows if r.matched)
|
||||
confirmed = sum(1 for r in rows if r.confirmed)
|
||||
|
||||
parts: list[str] = [HTML_HEAD]
|
||||
parts.append(f'<h1>Cutter-Report — {date.today().isoformat()}</h1>')
|
||||
parts.append('<div class="meta">')
|
||||
parts.append(f"<div><b>Trailer</b> <code>{html_escape(trailer_path.name)}</code> @ {trailer_fps:.3f} fps</div>")
|
||||
parts.append(f"<div><b>Source</b> <code>{html_escape(source_path.name)}</code> @ {source_fps:.3f} fps</div>")
|
||||
parts.append("<div>Trailer-TC in Trailer-Framerate, Source-TC in Source-Framerate.</div>")
|
||||
parts.append("</div>")
|
||||
parts.append(f'<div class="summary">{len(rows)} Beats — <b>{matched}</b> automatisch (<b>{confirmed}</b> bestätigt) — <b>{len(rows)-matched}</b> manuell.</div>')
|
||||
|
||||
parts.append("<h2>Status-Legende</h2>")
|
||||
parts.append('<table class="tab"><thead><tr><th>Status</th><th>Bedeutung</th><th>Was tun?</th></tr></thead><tbody>')
|
||||
parts.append('<tr><td><span class="badge ok">OK</span></td><td>bestätigt durch CV + Vision-Phasenprüfung</td><td>übernehmen, optional sichten</td></tr>')
|
||||
parts.append('<tr><td><span class="badge q">?</span></td><td>korrekte Szene, Phase ggf. um wenige Frames verschoben</td><td>im NLE prüfen, Source-In nachjustieren</td></tr>')
|
||||
parts.append('<tr><td><span class="badge man">MAN.</span></td><td>kein automatischer Treffer</td><td>manuell suchen oder Schwarzfade</td></tr>')
|
||||
parts.append('</tbody></table>')
|
||||
|
||||
# Compact table
|
||||
parts.append("<h2>Beat-Tabelle</h2>")
|
||||
parts.append('<table class="tab"><thead><tr><th>Beat</th><th>Trailer In / Out</th><th>Source In / Out</th><th>Score</th><th>Status</th><th>Phase</th></tr></thead><tbody>')
|
||||
for r in rows:
|
||||
ti = smpte(r.trailer_in_s, trailer_fps)
|
||||
to = smpte(r.trailer_out_s, trailer_fps)
|
||||
si = smpte(r.source_in_s, source_fps) if r.matched else "—"
|
||||
so = smpte(r.source_out_s, source_fps) if r.matched else "—"
|
||||
sc = f"{r.score:.3f}" if r.matched else "—"
|
||||
bcls = {"OK": "ok", "?": "q", "MAN.": "man"}[r.status]
|
||||
parts.append(
|
||||
f'<tr><td class="num">{r.bid}</td>'
|
||||
f'<td class="tc">{ti}–{to}</td>'
|
||||
f'<td class="tc">{si}–{so}</td>'
|
||||
f'<td class="num">{sc}</td>'
|
||||
f'<td><span class="badge {bcls}">{r.status}</span></td>'
|
||||
f'<td>{html_escape(r.phase[:120])}</td></tr>'
|
||||
)
|
||||
parts.append("</tbody></table>")
|
||||
|
||||
# Per-beat detail cards
|
||||
parts.append("<h2>Beat-Details</h2>")
|
||||
for r in rows:
|
||||
ti = smpte(r.trailer_in_s, trailer_fps)
|
||||
to = smpte(r.trailer_out_s, trailer_fps)
|
||||
bcls = {"OK": "ok", "?": "q", "MAN.": "man"}[r.status]
|
||||
parts.append('<div class="beat">')
|
||||
parts.append(f'<div class="full"><h3>Beat {r.bid:02d} <span class="badge {bcls}">{r.status}</span></h3></div>')
|
||||
|
||||
# Trailer column
|
||||
parts.append('<div class="col">')
|
||||
parts.append('<h4>Trailer</h4>')
|
||||
clip_uri = data_uri(r.trailer_clip, "video/mp4") if (with_clips and r.trailer_clip) else None
|
||||
if clip_uri:
|
||||
parts.append(f'<video controls preload="metadata" muted loop src="{clip_uri}"></video>')
|
||||
elif r.trailer_still:
|
||||
uri = data_uri(r.trailer_still, "image/jpeg") or ""
|
||||
parts.append(f'<img src="{uri}" alt="Trailer beat {r.bid}">')
|
||||
else:
|
||||
parts.append('<div class="empty">— kein Vorschaubild —</div>')
|
||||
parts.append(f'<div class="kv tc"><b>TC</b> {ti} – {to}</div>')
|
||||
if r.phase:
|
||||
parts.append(f'<div class="kv"><b>Phase</b> {html_escape(r.phase)}</div>')
|
||||
if r.composition:
|
||||
extra = f", {r.setting}" if r.setting else ""
|
||||
parts.append(f'<div class="kv"><b>Bild</b> {html_escape(r.composition + extra)}</div>')
|
||||
parts.append('</div>')
|
||||
|
||||
# Source column
|
||||
parts.append('<div class="col">')
|
||||
parts.append('<h4>Source</h4>')
|
||||
clip_uri = data_uri(r.source_clip, "video/mp4") if (with_clips and r.source_clip) else None
|
||||
if clip_uri:
|
||||
parts.append(f'<video controls preload="metadata" muted loop src="{clip_uri}"></video>')
|
||||
elif r.source_still:
|
||||
uri = data_uri(r.source_still, "image/jpeg") or ""
|
||||
parts.append(f'<img src="{uri}" alt="Source beat {r.bid}">')
|
||||
else:
|
||||
parts.append('<div class="empty">— manuell setzen —</div>')
|
||||
if r.matched:
|
||||
si = smpte(r.source_in_s, source_fps)
|
||||
so = smpte(r.source_out_s, source_fps)
|
||||
parts.append(f'<div class="kv tc"><b>TC</b> {si} – {so}</div>')
|
||||
parts.append(f'<div class="kv"><b>Scene</b> {r.scene_id} · <b>Score</b> {r.score:.3f}</div>')
|
||||
else:
|
||||
parts.append('<div class="kv">— kein automatischer Treffer —</div>')
|
||||
parts.append('</div>')
|
||||
|
||||
parts.append('</div>') # .beat
|
||||
|
||||
parts.append(HTML_FOOT)
|
||||
return "".join(parts)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Top-level
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
def render_report(
|
||||
project_root: Path,
|
||||
with_stills: bool = True,
|
||||
with_clips: bool = False,
|
||||
) -> tuple[str, str]:
|
||||
"""Return (markdown, html). Both written by main()."""
|
||||
sys.path.insert(0, str(project_root))
|
||||
from src.core.config import load_config
|
||||
|
||||
@@ -166,8 +562,15 @@ def render_report(project_root: Path, with_stills: bool = True) -> str:
|
||||
|
||||
trailer_path = Path(cfg.paths.reference_trailer)
|
||||
source_path = Path(cfg.paths.source_movie)
|
||||
trailer_fps = probe_fps(trailer_path) or 25.0
|
||||
source_fps = probe_fps(source_path) or float(cfg.export.edl_frame_rate)
|
||||
|
||||
# Source fps: trust config.toml's edl_frame_rate (it's what the EDL/FCPXML
|
||||
# uses, hence what the cutter sees in the NLE). Fall back to ffprobe only
|
||||
# if no value is configured.
|
||||
source_fps = float(getattr(cfg.export, "edl_frame_rate", 0.0)) or probe_fps(source_path) or 23.976
|
||||
# Trailer fps: optional config override, else ffprobe, else fallback to
|
||||
# source fps so the two sides at least share a number.
|
||||
trailer_fps_cfg = getattr(cfg.paths, "trailer_frame_rate", None)
|
||||
trailer_fps = float(trailer_fps_cfg) if trailer_fps_cfg else (probe_fps(trailer_path) or source_fps)
|
||||
|
||||
cache = project_root / ".cache"
|
||||
results = {r["beat_id"]: r for r in json.loads((cache / "match_results.json").read_text())}
|
||||
@@ -175,184 +578,32 @@ def render_report(project_root: Path, with_stills: bool = True) -> str:
|
||||
vis_path = cache / "vision_descriptions.json"
|
||||
vis_items = json.loads(vis_path.read_text())["items"] if vis_path.exists() else {}
|
||||
|
||||
stills_dir = project_root / "output" / "cutter_stills"
|
||||
if with_stills:
|
||||
stills_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append("# Cutter-Report — manuelles Nachschneiden")
|
||||
lines.append("")
|
||||
lines.append(f"Stand: {date.today().isoformat()}")
|
||||
lines.append("")
|
||||
lines.append(f"- **Trailer**: `{trailer_path.name}` @ {trailer_fps:.3f} fps")
|
||||
lines.append(f"- **Source** : `{source_path.name}` @ {source_fps:.3f} fps")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Trailer-Timecodes sind in **Trailer-Framerate** angegeben, "
|
||||
"Source-Timecodes in **Source-Framerate**. So passen sie 1:1 zu dem, "
|
||||
"was du in deinem NLE auf den jeweiligen Spuren siehst."
|
||||
rows = collect_rows(
|
||||
project_root, beats, results, vis_items,
|
||||
trailer_path, source_path, with_stills, with_clips,
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Diese Datei wird automatisch erzeugt — nach jedem `python cli.py match` "
|
||||
"neu generieren mit:"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("```powershell")
|
||||
lines.append("python scripts/generate_cutter_report.py")
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
lines.append("## Status-Legende")
|
||||
lines.append("")
|
||||
lines.append("| Status | Bedeutung | Was tun? |")
|
||||
lines.append("|--------|-----------|----------|")
|
||||
lines.append("| `OK` | bestätigt durch CV + Vision-Phasenprüfung | übernehmen, optional stichprobenartig sichten |")
|
||||
lines.append("| `?` | korrekte Szene, Phase eventuell um wenige Frames verschoben | im NLE prüfen, Source-In ggf. nachjustieren |")
|
||||
lines.append("| `MAN.` | kein automatischer Treffer | manuell suchen oder als Schwarzfade/Titel übernehmen |")
|
||||
lines.append("")
|
||||
|
||||
matched = sum(1 for b in beats if b["beat_id"] in results)
|
||||
confirmed = sum(1 for b in beats if b["beat_id"] in results and results[b["beat_id"]]["is_confirmed"])
|
||||
lines.append("## Übersicht")
|
||||
lines.append("")
|
||||
lines.append(f"- Beats gesamt: **{len(beats)}**")
|
||||
lines.append(f"- Automatisch gefunden: **{matched}** ({confirmed} davon bestätigt)")
|
||||
lines.append(f"- Manuell zu setzen: **{len(beats) - matched}**")
|
||||
lines.append("")
|
||||
|
||||
# ---- Compact table (timecode-only, no images) ------------------------
|
||||
lines.append("## Beat-Tabelle (kompakt)")
|
||||
lines.append("")
|
||||
lines.append("| Beat | Trailer In / Out | Source In / Out | Score | Status | Was im Bild zu sehen ist |")
|
||||
lines.append("|-----:|------------------|------------------|------:|:------:|---------------------------|")
|
||||
|
||||
def status_for(rec: dict | None) -> str:
|
||||
if rec is None:
|
||||
return "MAN."
|
||||
return "OK" if rec.get("is_confirmed") else "?"
|
||||
|
||||
for beat in beats:
|
||||
bid = beat["beat_id"]
|
||||
rec = results.get(bid)
|
||||
ti = smpte(beat["start_s"], trailer_fps)
|
||||
to = smpte(beat["end_s"], trailer_fps)
|
||||
if rec is not None:
|
||||
si = smpte(rec["in_point_s"], source_fps)
|
||||
so = smpte(rec["out_point_s"], source_fps)
|
||||
sc = rec["match_score"]
|
||||
else:
|
||||
si = so = "—"
|
||||
sc = 0.0
|
||||
desc = best_beat_description(vis_items, bid, beat["start_s"], beat["end_s"]) or ""
|
||||
phase = (parse_field(desc, "action_phase") or parse_field(desc, "subject"))[:80]
|
||||
lines.append(f"| {bid:>4} | {ti}-{to} | {si}-{so} | {sc:.3f} | {status_for(rec)} | {phase} |")
|
||||
|
||||
lines.append("")
|
||||
|
||||
# ---- Detailed per-beat sections with stills --------------------------
|
||||
lines.append("## Beat-Details mit Vorschau-Stills")
|
||||
lines.append("")
|
||||
if not with_stills:
|
||||
lines.append("_Stills sind in diesem Lauf deaktiviert (`--no-stills`)._")
|
||||
lines.append("")
|
||||
|
||||
for beat in beats:
|
||||
bid = beat["beat_id"]
|
||||
rec = results.get(bid)
|
||||
ti = smpte(beat["start_s"], trailer_fps)
|
||||
to = smpte(beat["end_s"], trailer_fps)
|
||||
if rec is not None:
|
||||
si = smpte(rec["in_point_s"], source_fps)
|
||||
so = smpte(rec["out_point_s"], source_fps)
|
||||
sc_str = f"{rec['match_score']:.3f}"
|
||||
scn = rec["scene_id"]
|
||||
else:
|
||||
si = so = "—"
|
||||
sc_str = "—"
|
||||
scn = "—"
|
||||
status = status_for(rec)
|
||||
desc = best_beat_description(vis_items, bid, beat["start_s"], beat["end_s"]) or ""
|
||||
phase = parse_field(desc, "action_phase") or parse_field(desc, "subject") or "(keine Vision-Beschreibung)"
|
||||
composition = parse_field(desc, "composition")
|
||||
setting = parse_field(desc, "setting")
|
||||
|
||||
lines.append(f"### Beat {bid:02d} — Status `{status}`")
|
||||
lines.append("")
|
||||
lines.append(f"- **Trailer**: {ti} – {to}")
|
||||
if rec is not None:
|
||||
lines.append(f"- **Source** : {si} – {so} (scene {scn}, score {sc_str})")
|
||||
else:
|
||||
lines.append("- **Source** : — (kein Treffer; manuell setzen)")
|
||||
lines.append(f"- **Phase** : {phase}")
|
||||
if composition:
|
||||
lines.append(f"- **Bild** : {composition}{', ' + setting if setting else ''}")
|
||||
lines.append("")
|
||||
|
||||
if with_stills:
|
||||
t_still = beat_still_time(beat["start_s"], beat["end_s"])
|
||||
trailer_jpg = stills_dir / f"beat_{bid:02d}_trailer.jpg"
|
||||
ok_t = extract_still(trailer_path, t_still, trailer_jpg)
|
||||
source_jpg = stills_dir / f"beat_{bid:02d}_source.jpg"
|
||||
if rec is not None:
|
||||
s_t = rec["in_point_s"] + min(0.4, max(0.04, rec["out_point_s"] - rec["in_point_s"]) * 0.3)
|
||||
ok_s = extract_still(source_path, s_t, source_jpg)
|
||||
else:
|
||||
ok_s = False
|
||||
|
||||
cells_h = []
|
||||
cells_t = []
|
||||
cells_h.append("Trailer")
|
||||
if ok_t:
|
||||
rel_t = trailer_jpg.relative_to(project_root).as_posix()
|
||||
cells_t.append(f"")
|
||||
else:
|
||||
cells_t.append("_(kein Still)_")
|
||||
cells_h.append("Source")
|
||||
if ok_s:
|
||||
rel_s = source_jpg.relative_to(project_root).as_posix()
|
||||
cells_t.append(f"")
|
||||
else:
|
||||
cells_t.append("_(kein Still)_")
|
||||
|
||||
lines.append("| " + " | ".join(cells_h) + " |")
|
||||
lines.append("|" + "|".join(["---"] * len(cells_h)) + "|")
|
||||
lines.append("| " + " | ".join(cells_t) + " |")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Hinweise zur Prüfung")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"1. Wenn die Bewegungsphase im Source-Still nicht zum Trailer-Still passt, im NLE den Source-In um wenige Frames verschieben — innerhalb derselben Source-Szene reicht das meistens."
|
||||
)
|
||||
lines.append(
|
||||
"2. Wenn der Source-Clip kürzer ist als der Trailerbeat (Source-Out < Trailer-Out), enthält der Trailerbeat eine Blende oder Titelkarte; im Schnitt mit Schwarzfade oder dem Source-Tail auffüllen."
|
||||
)
|
||||
lines.append(
|
||||
"3. `OK`-Beats sind doppelt verifiziert (CV + Vision-Phase). Trotzdem stichprobenartig sichten."
|
||||
)
|
||||
lines.append(
|
||||
"4. Stills liegen unter `output/cutter_stills/`. Bei Bedarf einzelne neu generieren: einfach die Datei löschen und das Skript erneut laufen lassen."
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# CLI entry
|
||||
# ----------------------------------------------------------------------------
|
||||
md = render_markdown(rows, trailer_fps, source_fps, trailer_path, source_path)
|
||||
html = render_html(rows, trailer_fps, source_fps, trailer_path, source_path, with_clips)
|
||||
return md, html
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Render CUTTER_REPORT.md from current cache")
|
||||
parser.add_argument("--no-stills", action="store_true", help="skip frame extraction")
|
||||
parser = argparse.ArgumentParser(description="Render CUTTER_REPORT.{md,html} from current cache")
|
||||
parser.add_argument("--no-stills", action="store_true", help="skip frame extraction (markdown stays text-only)")
|
||||
parser.add_argument("--with-clips", action="store_true", help="also render 3 s MP4 previews per beat (slow)")
|
||||
args = parser.parse_args()
|
||||
|
||||
here = Path(__file__).resolve().parent
|
||||
project_root = here.parent
|
||||
out = project_root / "CUTTER_REPORT.md"
|
||||
out.write_text(render_report(project_root, with_stills=not args.no_stills), encoding="utf-8")
|
||||
print(f"Wrote {out}")
|
||||
md, html = render_report(
|
||||
project_root,
|
||||
with_stills=not args.no_stills,
|
||||
with_clips=args.with_clips,
|
||||
)
|
||||
(project_root / "CUTTER_REPORT.md").write_text(md, encoding="utf-8")
|
||||
(project_root / "CUTTER_REPORT.html").write_text(html, encoding="utf-8")
|
||||
print(f"Wrote {project_root / 'CUTTER_REPORT.md'}")
|
||||
print(f"Wrote {project_root / 'CUTTER_REPORT.html'}")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user