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
+99
-121
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]
|
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:
|
try:
|
||||||
from scripts.generate_cutter_report import render_report
|
from scripts.generate_cutter_report import render_report
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -101,9 +101,10 @@ def _regenerate_cutter_report(cfg: "AppConfig") -> None: # type: ignore[name-de
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
project_root = cfg.paths.cache_dir.parent
|
project_root = cfg.paths.cache_dir.parent
|
||||||
out = project_root / "CUTTER_REPORT.md"
|
md, html = render_report(project_root, with_stills=True, with_clips=False)
|
||||||
out.write_text(render_report(project_root), encoding="utf-8")
|
(project_root / "CUTTER_REPORT.md").write_text(md, encoding="utf-8")
|
||||||
logging.getLogger(__name__).info("Cutter report regenerated → %s", out)
|
(project_root / "CUTTER_REPORT.html").write_text(html, encoding="utf-8")
|
||||||
|
logging.getLogger(__name__).info("Cutter report regenerated (md + html)")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logging.getLogger(__name__).warning("Cutter report regen failed: %s", exc)
|
logging.getLogger(__name__).warning("Cutter report regen failed: %s", exc)
|
||||||
|
|
||||||
|
|||||||
+457
-206
@@ -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``,
|
Renders two reports for the video editor:
|
||||||
``.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).
|
|
||||||
|
|
||||||
Important: trailer and source can have different frame rates (e.g. trailer
|
* ``CUTTER_REPORT.md`` — text + base64-embedded preview stills. Self-
|
||||||
25 fps, source 23.976 fps). This script probes each file with ffprobe and
|
contained (no broken image links on git server), opens in any markdown
|
||||||
renders trailer timecodes in trailer fps and source timecodes in source fps,
|
viewer.
|
||||||
so the timecode matches what the cutter sees in the NLE.
|
* ``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):
|
Usage (from project root):
|
||||||
|
|
||||||
python scripts/generate_cutter_report.py # text + stills
|
python scripts/generate_cutter_report.py # full report
|
||||||
python scripts/generate_cutter_report.py --no-stills # text only
|
python scripts/generate_cutter_report.py --no-stills # text-only md
|
||||||
|
python scripts/generate_cutter_report.py --with-clips # also render
|
||||||
Stills go to ``output/cutter_stills/beat_NN_{trailer,source}.jpg`` and are
|
# video previews
|
||||||
referenced from the markdown. They are only re-rendered when the underlying
|
|
||||||
match position has changed — fast on repeat runs.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import date
|
from datetime import date
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -40,15 +44,16 @@ from pathlib import Path
|
|||||||
|
|
||||||
|
|
||||||
def probe_fps(video_path: Path) -> float | None:
|
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():
|
if not video_path.exists():
|
||||||
return None
|
return None
|
||||||
|
for key in ("r_frame_rate", "avg_frame_rate"):
|
||||||
try:
|
try:
|
||||||
proc = subprocess.run(
|
proc = subprocess.run(
|
||||||
[
|
[
|
||||||
"ffprobe", "-v", "error",
|
"ffprobe", "-v", "error",
|
||||||
"-select_streams", "v:0",
|
"-select_streams", "v:0",
|
||||||
"-show_entries", "stream=avg_frame_rate",
|
"-show_entries", f"stream={key}",
|
||||||
"-of", "default=noprint_wrappers=1:nokey=1",
|
"-of", "default=noprint_wrappers=1:nokey=1",
|
||||||
str(video_path),
|
str(video_path),
|
||||||
],
|
],
|
||||||
@@ -57,21 +62,25 @@ def probe_fps(video_path: Path) -> float | None:
|
|||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||||
return None
|
return None
|
||||||
raw = proc.stdout.strip()
|
raw = proc.stdout.strip()
|
||||||
|
if not raw or raw == "0/0":
|
||||||
|
continue
|
||||||
if "/" in raw:
|
if "/" in raw:
|
||||||
num, _, den = raw.partition("/")
|
num, _, den = raw.partition("/")
|
||||||
try:
|
try:
|
||||||
n, d = float(num), float(den)
|
n, d = float(num), float(den)
|
||||||
return n / d if d else None
|
if d:
|
||||||
|
return n / d
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None
|
continue
|
||||||
try:
|
try:
|
||||||
return float(raw)
|
return float(raw)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
continue
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def smpte(t: float | None, fps: float) -> str:
|
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:
|
if t is None:
|
||||||
return "--:--:--:--"
|
return "--:--:--:--"
|
||||||
fps_int = max(1, int(round(fps)))
|
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_WIDTH = 480
|
||||||
STILL_QUALITY = 5 # ffmpeg -q:v scale 1 (best) .. 31 (worst)
|
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:
|
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():
|
if not video_path.exists():
|
||||||
return False
|
return False
|
||||||
try:
|
if not _stale(out, video_path):
|
||||||
if out.exists() and out.stat().st_mtime >= video_path.stat().st_mtime and out.stat().st_size > 0:
|
|
||||||
return True
|
return True
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
out.parent.mkdir(parents=True, exist_ok=True)
|
out.parent.mkdir(parents=True, exist_ok=True)
|
||||||
cmd = [
|
cmd = [
|
||||||
"ffmpeg", "-y", "-loglevel", "error",
|
"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
|
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:
|
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)
|
duration = max(0.04, end_s - start_s)
|
||||||
return start_s + min(0.4, duration * 0.3)
|
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))
|
sys.path.insert(0, str(project_root))
|
||||||
from src.core.config import load_config
|
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)
|
trailer_path = Path(cfg.paths.reference_trailer)
|
||||||
source_path = Path(cfg.paths.source_movie)
|
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"
|
cache = project_root / ".cache"
|
||||||
results = {r["beat_id"]: r for r in json.loads((cache / "match_results.json").read_text())}
|
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_path = cache / "vision_descriptions.json"
|
||||||
vis_items = json.loads(vis_path.read_text())["items"] if vis_path.exists() else {}
|
vis_items = json.loads(vis_path.read_text())["items"] if vis_path.exists() else {}
|
||||||
|
|
||||||
stills_dir = project_root / "output" / "cutter_stills"
|
rows = collect_rows(
|
||||||
if with_stills:
|
project_root, beats, results, vis_items,
|
||||||
stills_dir.mkdir(parents=True, exist_ok=True)
|
trailer_path, source_path, with_stills, with_clips,
|
||||||
|
|
||||||
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."
|
|
||||||
)
|
)
|
||||||
lines.append("")
|
md = render_markdown(rows, trailer_fps, source_fps, trailer_path, source_path)
|
||||||
lines.append(
|
html = render_html(rows, trailer_fps, source_fps, trailer_path, source_path, with_clips)
|
||||||
"Diese Datei wird automatisch erzeugt — nach jedem `python cli.py match` "
|
return md, html
|
||||||
"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
|
|
||||||
# ----------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
parser = argparse.ArgumentParser(description="Render CUTTER_REPORT.md from current cache")
|
parser = argparse.ArgumentParser(description="Render CUTTER_REPORT.{md,html} from current cache")
|
||||||
parser.add_argument("--no-stills", action="store_true", help="skip frame extraction")
|
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()
|
args = parser.parse_args()
|
||||||
|
|
||||||
here = Path(__file__).resolve().parent
|
here = Path(__file__).resolve().parent
|
||||||
project_root = here.parent
|
project_root = here.parent
|
||||||
out = project_root / "CUTTER_REPORT.md"
|
md, html = render_report(
|
||||||
out.write_text(render_report(project_root, with_stills=not args.no_stills), encoding="utf-8")
|
project_root,
|
||||||
print(f"Wrote {out}")
|
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
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user