diff --git a/HANDOVER.md b/HANDOVER.md
deleted file mode 100644
index 1aae663..0000000
--- a/HANDOVER.md
+++ /dev/null
@@ -1,125 +0,0 @@
-# Handover Notes
-
-Stand: 2026-05-03 (Beat-20-Reparatur abgeschlossen).
-
-## Zustand
-
-- `pytest tests/ -q` → 52/52 grün.
-- `python cli.py match --beat 20 --vision` läuft erfolgreich durch und schreibt
- einen confirmed Match (Score 0.6632, scene 613, in=5284.706s, dur=0.88s).
-- Vorheriger Cache wurde nach `.cache/match_results.json.bak` gesichert.
-- Kein offener PR; lokale Änderungen sind committed (siehe letzter Commit).
-
-## Was zuletzt geändert wurde und warum
-
-### 1. `cli.py` — `realign_window` wählt das Action-Window pro Segment
-
-In `_filter_semantically_invalid_vision_matches.realign_window`:
-
-- **Vorher:** `find_action_window_in_scene(action_beat or check_beat, …)` — bei
- segmentierten Beats wurde immer der ganze Beat als semantischer Kontext
- benutzt. Das hat für Beat 20 die Source-Position auf die Kuss-Phase
- (5270 s) gelegt, obwohl das *sichtbare* Segment nur "approaching and pulling
- apart" zeigt — diese Phase liegt im Source erst um 5284 s.
-- **Jetzt:** Es werden zwei Fenster gesucht (Segment-Beschreibung *und* Beat-
- Beschreibung). Der Beat-Kontext gewinnt nur bei deutlichem (>0.06) Score-
- Vorsprung. Der Trailer-Offset-Shift (`visible_content_offset`) wird nur
- angewendet, wenn tatsächlich der Beat-Kontext benutzt wurde — sonst zeigt
- das Segment-Fenster bereits auf die richtige Phase.
-
-Effekt für Beat 20: 5270.118 → 5284.706, Score 0.6449 (provisional) → 0.6632
-(confirmed).
-
-### 2. `cli.py` — Filter-/Repair-Stufe ist crash-tolerant
-
-`_filter_semantically_invalid_vision_matches` hat den Per-Result-Body in eine
-lokale Funktion `_filter_repair_one` herausgezogen und in einen try/except
-verpackt. Wenn die Reparatur abbricht (z. B. weil Vision-API mitten in der
-Antwort wegfällt), wird der bisher gecachte Treffer behalten statt komplett
-verworfen.
-
-### 3. `src/llm/vision_cache.py` — Vision-Retry für Lesefehler
-
-`_call_vision_model` fängt jetzt zusätzlich `TimeoutError`,
-`socket.timeout`, `ConnectionError` und `OSError` während des Antwort-Lesens
-und retryt mit demselben Backoff wie HTTP-/URL-Fehler. Die Auslöse-Bedingung
-war ein 24-h-DSL-Disconnect mitten im Lauf; davor wurde der Match-Lauf hart
-abgebrochen und der Cache stand auf "kein Match".
-
-### 4. `README.md`
-
-Zwei kurze Absätze ergänzt, die (1) die Segment-vs-Beat-Window-Auswahl und
-(2) das neue Crash-/Netzfehler-Verhalten beschreiben.
-
-## Nicht angefasst, aber relevant für die Übergabe
-
-- Der **vollständige FFmpeg-Vollscan** liefert für Beat 20 weiterhin keinen
- bestätigten Treffer (final score 0.419 < provisional 0.430). Den
- Confirmed-Match liefert die Action-Window-Reparatur. Das ist erwartet:
- das sichtbare Segment ist visuell sehr generisch (Two-Shot Profil mit
- unscharfem Hintergrund), die korrekte Phase fällt erst durch die
- semantische Aktionsbeschreibung auf.
-- Die `candidate_points`-Schleife in `realign_window` (lines ~700–765) sucht
- nur ±~2 s um `start_s` herum. Solange `start_s` jetzt aus dem Segment-
- Fenster kommt, liegt der korrekte Source-Punkt in diesem Bereich. Wenn
- künftig Beats mit längeren visiblen Inseln auftauchen, kann diese Range
- zu eng werden — dann den Suchradius erweitern statt das Window-Picking
- rückgängig machen.
-- Es gibt **keine Tests** für `_filter_semantically_invalid_vision_matches`
- oder `realign_window`. Wer das anfasst, sollte Beat 20 als Live-Smoke-Test
- benutzen (siehe unten).
-
-## Reproduktion / Smoke-Test
-
-```powershell
-.\.venv\Scripts\Activate.ps1
-python cli.py match --beat 20 --vision
-```
-
-Erwartet: `Beat 20: realigned semantically valid long scene by motion/action
-windows`, danach `is_confirmed: true` für Beat 20 in
-`.cache/match_results.json` mit `in_point_s ≈ 5284.7` und `match_score ≥ 0.65`.
-
-Wenn das fehlschlägt:
-
-1. `python -m pytest tests/ -q` — falls rot, ist die Codebasis selbst kaputt.
-2. `.cache/vision_descriptions.json` prüfen — die Schlüssel
- `beat:20:73.560:74.680:…` und `action_window:613:5282.390:5285.430:…` müssen
- existieren, sonst ruft Vision live ab (kostet Credits; braucht Netz).
-3. `match_results.json.bak` zurückspielen, falls der Cache zerschossen ist.
-
-## Aktuelle Coverage (vor neuestem Lauf)
-
-```
-total beats: 25
-matched: 20 (5 confirmed, 15 provisional)
-unmatched: beats 0, 2, 21, 23, 24
-```
-
-Beat 0 ist das SHO-Logo (kein Source-Match möglich, korrekt).
-Beats 22/23/24 haben keine sichtbaren Inseln (Endcredits/Title) — auch
-korrekt unmatched.
-Beat 2 und Beat 21 sind die echten Recovery-Kandidaten; die neue
-Recovery-Stufe versucht sie beim nächsten `match`-Lauf nachzuziehen.
-
-## Offene Risiken / Bekannte Schwächen
-
-- Die Schwelle `0.06` für "Beat-Kontext gewinnt" in `realign_window` ist
- kalibriert an Beat 20. Andere Beats sollten auch durchlaufen werden, bevor
- weitere Beats angefasst werden — am besten ein voller `python cli.py match`
- ohne `--beat` und Diff der `match_results.json` gegen `.bak`.
-- Die Filter-/Repair-Stufe kann durch Vision-Calls minutenlang laufen. Das
- ist nicht neu, aber bei Netzproblemen sehr sichtbar.
-- Die `_filter_repair_one`-Funktion bekommt viele Argumente durchgereicht
- (closure-Variablen aus dem Parent). Bei einer nächsten Iteration könnte das
- in eine kleine Klasse umgebaut werden.
-
-## Useful greps
-
-- `find_action_window_in_scene` — semantische Action-Window-Suche (Vision).
-- `_reference_scoreable_segments` — bestimmt die sichtbaren Inseln eines
- Beats.
-- `estimate_usable_source_duration` — kürzt Match-Clips, wenn die Source
- vor Beat-Ende in eine andere Phase wechselt.
-- `_filter_semantically_invalid_vision_matches` — Eintrittspunkt der
- Repair-Stufe in `cli.py`.
diff --git a/cli.py b/cli.py
index 8b112c5..048c19d 100644
--- a/cli.py
+++ b/cli.py
@@ -149,7 +149,12 @@ def _regenerate_cutter_report(cfg: "AppConfig") -> None: # type: ignore[name-de
md, html = render_report(project_root, with_stills=True, with_clips=True)
(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 + compare clips)")
+
+ legacy_report_path = project_root / "output" / "report" / "match_report.html"
+ legacy_report_path.parent.mkdir(parents=True, exist_ok=True)
+ legacy_report_path.write_text(html, encoding="utf-8")
+
+ logging.getLogger(__name__).info("Cutter report regenerated (md + html + compare clips + legacy match_report.html)")
except Exception as exc:
logging.getLogger(__name__).warning("Cutter report regen failed: %s", exc)
@@ -1890,17 +1895,12 @@ def cmd_rematch(args: argparse.Namespace, cfg) -> None:
def cmd_report(args: argparse.Namespace, cfg) -> None:
- from src.pipeline.reporter import generate_report
- beats = _select_beats(_load_beats(cfg), getattr(args, "beat", None))
- beat_ids = {b.beat_id for b in beats} if getattr(args, "beat", None) is not None else None
- results = _select_results(_normalize_cached_results(_load_beats(cfg), _load_results(cfg), cfg), beat_ids)
- out = generate_report(beats, results, cfg)
- if getattr(args, "beat", None) is not None and not results:
- print(
- f"\n⚠️ Beat {args.beat} has no cached match yet. "
- f"Run: python cli.py match --beat {args.beat}"
- )
- print(f"\n\u2705 Report \u2192 {out}")
+ if getattr(args, "beat", None) is not None:
+ print(f"\n⚠️ Generating cutter report for all beats (ignoring --beat {args.beat}).")
+
+ _regenerate_cutter_report(cfg)
+ project_root = cfg.paths.cache_dir.parent
+ print(f"\n✅ Report → {project_root / 'CUTTER_REPORT.html'} and CUTTER_REPORT.md")
def cmd_export(args: argparse.Namespace, cfg) -> None:
diff --git a/scripts/generate_cutter_report.py b/scripts/generate_cutter_report.py
index c7c418b..9789565 100644
--- a/scripts/generate_cutter_report.py
+++ b/scripts/generate_cutter_report.py
@@ -591,6 +591,11 @@ def render_markdown(
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:
@@ -613,89 +618,133 @@ HTML_HEAD = """\
-Cutter-Report
+
+Cutter-Report & Match-Report
+
@@ -709,6 +758,22 @@ def _he(s: str) -> str:
.replace(">", ">").replace('"', """))
+def _get_recent_changes(project_root: Path) -> str:
+ try:
+ import subprocess
+ proc = subprocess.run(
+ ["git", "log", "--invert-grep", "--grep=Auto-update", "-1", "--pretty=%B"],
+ capture_output=True, text=True, cwd=str(project_root), timeout=5
+ )
+ if proc.stdout:
+ lines = proc.stdout.strip().split('\n')
+ cleaned = [line for line in lines if line and not line.startswith('Co-Authored-By')]
+ return " ".join(cleaned[:3]) or "No recent changes found."
+ except Exception:
+ pass
+ return "No recent changes available."
+
+
def render_html(
rows: list[BeatRow],
trailer_fps: float,
@@ -717,6 +782,7 @@ def render_html(
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)
@@ -724,43 +790,49 @@ def render_html(
parts: list[str] = [HTML_HEAD]
# Header
- parts.append(f'
Vorläufig — Phase im NLE prüfen, Source-In ggf. nachjustieren
'
+ '
Vorläufig — Phase und Aktion im NLE visuell prüfen
'
)
parts.append(
'
MAN.
'
- '
Kein automatischer Treffer — manuell setzen oder Schwarzfade
'
+ '
Kein Treffer — manuell suchen oder Schwarzbild einfügen
'
)
parts.append('
')
# Overview table
parts.append('
Übersicht
')
+ parts.append('
')
parts.append(
'
'
- '
Beat
Trailer In–Out (TC)
Dauer
'
- '
Source In (TC)
Scene
Score
Status
'
+ '
Beat
Trailer TC In–Out
Dauer
'
+ '
Source TC In
Scene
Score
Status
'
'
'
)
for r in rows:
@@ -787,10 +859,11 @@ def render_html(
f'
{r.status}
'
f''
)
- parts.append('
')
+ parts.append('
')
# Per-beat cards
parts.append('
Beat-Details
')
+ parts.append('
')
for r in rows:
ti = smpte(r.trailer_in_s, trailer_fps)
to = smpte(r.trailer_out_s, trailer_fps)
@@ -839,13 +912,13 @@ def render_html(
# Left col: trailer info
parts.append('
')
- parts.append(f'
Trailer{ti}–{to}'
- f' ({dur:.2f}s)
')
+ parts.append(f'
Trailer
{ti}–{to}'
+ f' ({dur:.2f}s)
')
if r.phase:
- parts.append(f'
Phase {_he(r.phase)}
')
+ parts.append(f'
Phase{_he(r.phase)}
')
if r.composition:
extra = f", {r.setting}" if r.setting else ""
- parts.append(f'
Bild {_he(r.composition + extra)}
')
+ parts.append(f'
Bild{_he(r.composition + extra)}
')
parts.append('
')
# Right col: source info
@@ -859,10 +932,10 @@ def render_html(
))
scene_str = f"Scenes {', '.join(all_scenes)} · {r.num_segments} Segmente"
parts.append(
- f'
Source{si}'
- f' (multi-shot)
'
+ f'
Source
{si}'
+ f' (multi-shot)
'
)
- parts.append(f'
Scene {scene_str}
')
+ parts.append(f'
Scene{scene_str}
')
# Segment list
parts.append('
')
for i, seg in enumerate(r.segments):
@@ -882,11 +955,11 @@ def render_html(
else:
parts.append(
f'
Source'
- f' {si}–{so}
'
+ f'
{si}–{so}
'
)
parts.append(
- f'
Scene {r.scene_id}'
- f' · Score {r.score:.3f}
'
+ f'
Scene{r.scene_id}'
+ f' · Score {r.score:.3f}
'
)
if r.score > 0 and r.score < 0.65:
parts.append(
@@ -904,6 +977,7 @@ def render_html(
parts.append('') # .beat-meta
parts.append('') # .beat
+ parts.append('') # .beats-grid
parts.append(HTML_FOOT)
return "".join(parts)
@@ -966,8 +1040,13 @@ def main() -> int:
)
(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
diff --git a/src/cv/global_scan.py b/src/cv/global_scan.py
index ccd945b..da24211 100644
--- a/src/cv/global_scan.py
+++ b/src/cv/global_scan.py
@@ -1422,12 +1422,46 @@ def run_global_scan(
motion_score = 0.0
if len(motion_templates) >= 2:
with open_video(cfg.paths.source_movie) as motion_cap:
- motion_score = _motion_phase_score(
+ original_motion_score = _motion_phase_score(
motion_cap,
adjusted_in_s,
motion_templates,
cfg,
)
+
+ motion_in_s, align_motion_score = align_in_point_by_motion(
+ b,
+ adjusted_in_s,
+ cfg,
+ search_window_s=(
+ local_align_window_s
+ if local_align_window_s is not None
+ else min(1.0, cfg.cv.deep_scan.content_align_window_seconds)
+ ),
+ )
+
+ if align_motion_score >= original_motion_score + 0.015:
+ adjusted_in_s = motion_in_s
+ motion_score = align_motion_score
+ scene = _find_scene_for_time(scenes, adjusted_in_s, cfg)
+ usable_duration_s = max(0.0, duration_s)
+ out_s = adjusted_in_s + usable_duration_s
+ if scene is not None:
+ out_s = min(out_s, scene.end_s)
+ duration_s = max(0.0, out_s - adjusted_in_s)
+ duration_coverage = (
+ min(1.0, duration_s / matchable_duration_s)
+ if matchable_duration_s > 0 else 0.0
+ )
+ with open_video(cfg.paths.source_movie) as validation_cap:
+ content_score = _fixed_content_sequence_score(
+ validation_cap,
+ adjusted_in_s,
+ validation_templates,
+ cfg,
+ )
+ else:
+ motion_score = original_motion_score
if is_weighted_seed_candidate and scene is not None and content_score >= content_gate:
contiguous_usable_s = _contiguous_scene_coverage_duration(
diff --git a/src/pipeline/reporter.py b/src/pipeline/reporter.py
deleted file mode 100644
index a84610d..0000000
--- a/src/pipeline/reporter.py
+++ /dev/null
@@ -1,427 +0,0 @@
-"""
-src/pipeline/reporter.py — Visual Match Report Generator
-
-Generates an HTML file containing side-by-side video clips of:
- Left: The original beat from the reference trailer
- Right: The matched scene from the source movie
-
-This allows instant visual verification of the CV pipeline's results.
-"""
-
-from __future__ import annotations
-
-import logging
-import subprocess
-from pathlib import Path
-
-from src.core.config import AppConfig
-
-logger = logging.getLogger(__name__)
-
-
-def _extract_clip(video_path: Path, start_s: float, duration_s: float, out_path: Path) -> None:
- """Use ffmpeg to extract a silent, low-res preview clip."""
- out_path.parent.mkdir(parents=True, exist_ok=True)
-
- # Fast input seek close to the target, then accurate output seek for
- # frame-faithful preview clips. A plain "-ss before -i" can land on a
- # nearby keyframe and make the report look several frames out of sync.
- preroll_s = 2.0 if start_s >= 2.0 else 0.0
- input_seek_s = max(0.0, start_s - preroll_s)
- accurate_seek_s = start_s - input_seek_s
-
- cmd = [
- "ffmpeg", "-y", "-loglevel", "error",
- "-ss", str(input_seek_s),
- "-i", str(video_path),
- "-ss", str(accurate_seek_s),
- "-t", str(duration_s),
- "-map", "0:v:0",
- "-c:v", "libx264",
- "-preset", "ultrafast",
- "-crf", "28",
- "-vf", "scale=640:-2", # scale down for lightweight report
- "-an", # no audio
- "-movflags", "+faststart",
- str(out_path)
- ]
-
- result = subprocess.run(cmd, capture_output=True)
- if result.returncode != 0:
- logger.error(
- "ffmpeg clip extraction failed for %s:\n%s",
- out_path.name, result.stderr.decode(errors="replace")
- )
-
-
-def _extract_clip_with_black_tail(
- video_path: Path,
- start_s: float,
- source_duration_s: float,
- total_duration_s: float,
- out_path: Path,
-) -> None:
- """Extract a source preview and append black frames for trailer-only tails."""
- tail_s = max(0.0, total_duration_s - source_duration_s)
- if tail_s <= 0.02:
- _extract_clip(video_path, start_s, source_duration_s, out_path)
- return
-
- out_path.parent.mkdir(parents=True, exist_ok=True)
- source_tmp = out_path.with_name(f"{out_path.stem}_source_tmp.mp4")
- tail_tmp = out_path.with_name(f"{out_path.stem}_tail_tmp.mp4")
- preroll_s = 2.0 if start_s >= 2.0 else 0.0
- input_seek_s = max(0.0, start_s - preroll_s)
- accurate_seek_s = start_s - input_seek_s
-
- # First render the matched source portion with the same accurate seek path
- # as _extract_clip(). Using trim=start=... after an input seek is brittle
- # because FFmpeg may preserve non-zero packet timestamps around keyframes.
- source_cmd = [
- "ffmpeg", "-y", "-loglevel", "error",
- "-ss", str(input_seek_s),
- "-i", str(video_path),
- "-ss", str(accurate_seek_s),
- "-t", str(source_duration_s),
- "-map", "0:v:0",
- "-c:v", "libx264",
- "-preset", "ultrafast",
- "-crf", "28",
- "-vf", "scale=640:360,setsar=1,fps=25,setpts=PTS-STARTPTS",
- "-an",
- "-movflags", "+faststart",
- str(source_tmp),
- ]
-
- result = subprocess.run(source_cmd, capture_output=True)
- if result.returncode != 0:
- logger.error(
- "ffmpeg source preview extraction failed for %s:\n%s",
- out_path.name,
- result.stderr.decode(errors="replace"),
- )
- return
-
- tail_cmd = [
- "ffmpeg", "-y", "-loglevel", "error",
- "-f", "lavfi",
- "-i", f"color=c=black:s=640x360:r=25:d={tail_s}",
- "-c:v", "libx264",
- "-preset", "ultrafast",
- "-crf", "28",
- "-an",
- "-movflags", "+faststart",
- str(tail_tmp),
- ]
- result = subprocess.run(tail_cmd, capture_output=True)
- if result.returncode != 0:
- logger.error(
- "ffmpeg black tail render failed for %s:\n%s",
- out_path.name,
- result.stderr.decode(errors="replace"),
- )
- return
-
- concat_cmd = [
- "ffmpeg", "-y", "-loglevel", "error",
- "-i", str(source_tmp),
- "-i", str(tail_tmp),
- "-filter_complex", "[0:v][1:v]concat=n=2:v=1:a=0[v]",
- "-map", "[v]",
- "-c:v", "libx264",
- "-preset", "ultrafast",
- "-crf", "28",
- "-an",
- "-movflags", "+faststart",
- str(out_path),
- ]
- result = subprocess.run(concat_cmd, capture_output=True)
- if result.returncode != 0:
- logger.error(
- "ffmpeg tailed preview concat failed for %s:\n%s",
- out_path.name,
- result.stderr.decode(errors="replace"),
- )
-
- for tmp in (source_tmp, tail_tmp):
- try:
- tmp.unlink(missing_ok=True)
- except OSError:
- pass
-
-
-def _extract_segmented_clip(
- video_path: Path,
- segments: list,
- total_duration_s: float,
- out_path: Path,
-) -> None:
- """Render a beat-length source preview from multiple matched source islands."""
- if not segments:
- _extract_clip_with_black_tail(video_path, 0.0, 0.0, total_duration_s, out_path)
- return
-
- out_path.parent.mkdir(parents=True, exist_ok=True)
- tmp_paths: list[Path] = []
- cursor = 0.0
-
- def add_black(duration_s: float) -> None:
- if duration_s <= 0.02:
- return
- tmp = out_path.with_name(f"{out_path.stem}_part_{len(tmp_paths):03d}_black.mp4")
- cmd = [
- "ffmpeg", "-y", "-loglevel", "error",
- "-f", "lavfi",
- "-i", f"color=c=black:s=640x360:r=25:d={duration_s}",
- "-c:v", "libx264", "-preset", "ultrafast", "-crf", "28",
- "-an", "-movflags", "+faststart",
- str(tmp),
- ]
- result = subprocess.run(cmd, capture_output=True)
- if result.returncode == 0:
- tmp_paths.append(tmp)
- else:
- logger.error("ffmpeg black segment render failed:\n%s", result.stderr.decode(errors="replace"))
-
- def add_source(start_s: float, duration_s: float) -> None:
- if duration_s <= 0.02:
- return
- tmp = out_path.with_name(f"{out_path.stem}_part_{len(tmp_paths):03d}_src.mp4")
- preroll_s = 2.0 if start_s >= 2.0 else 0.0
- input_seek_s = max(0.0, start_s - preroll_s)
- accurate_seek_s = start_s - input_seek_s
- cmd = [
- "ffmpeg", "-y", "-loglevel", "error",
- "-ss", str(input_seek_s),
- "-i", str(video_path),
- "-ss", str(accurate_seek_s),
- "-t", str(duration_s),
- "-map", "0:v:0",
- "-c:v", "libx264", "-preset", "ultrafast", "-crf", "28",
- "-vf", "scale=640:360,setsar=1,fps=25,setpts=PTS-STARTPTS",
- "-an", "-movflags", "+faststart",
- str(tmp),
- ]
- result = subprocess.run(cmd, capture_output=True)
- if result.returncode == 0 and tmp.exists():
- tmp_paths.append(tmp)
- else:
- logger.error("ffmpeg source segment render failed:\n%s", result.stderr.decode(errors="replace"))
-
- for segment in sorted(segments, key=lambda s: s.trailer_offset_s):
- offset_s = max(0.0, float(segment.trailer_offset_s))
- duration_s = max(0.0, float(segment.duration_s))
- add_black(offset_s - cursor)
- add_source(float(segment.in_point_s), duration_s)
- cursor = max(cursor, offset_s + duration_s)
-
- add_black(total_duration_s - cursor)
-
- if len(tmp_paths) == 1:
- tmp_paths[0].replace(out_path)
- return
-
- inputs: list[str] = []
- labels: list[str] = []
- for idx, tmp in enumerate(tmp_paths):
- inputs.extend(["-i", str(tmp)])
- labels.append(f"[{idx}:v]")
- filter_complex = "".join(labels) + f"concat=n={len(tmp_paths)}:v=1:a=0[v]"
- cmd = [
- "ffmpeg", "-y", "-loglevel", "error",
- *inputs,
- "-filter_complex", filter_complex,
- "-map", "[v]",
- "-c:v", "libx264", "-preset", "ultrafast", "-crf", "28",
- "-an", "-movflags", "+faststart",
- str(out_path),
- ]
- result = subprocess.run(cmd, capture_output=True)
- if result.returncode != 0:
- logger.error("ffmpeg segmented preview concat failed:\n%s", result.stderr.decode(errors="replace"))
-
- for tmp in tmp_paths:
- try:
- tmp.unlink(missing_ok=True)
- except OSError:
- pass
-
-
-def _build_frame_locked_compare(ref_path: Path, src_path: Path, out_path: Path) -> None:
- """Render reference and source into one side-by-side video stream."""
- out_path.parent.mkdir(parents=True, exist_ok=True)
- normalize = (
- "fps=25,scale=640:360:force_original_aspect_ratio=decrease,"
- "pad=640:360:(ow-iw)/2:(oh-ih)/2,setsar=1,setpts=PTS-STARTPTS"
- )
- filter_complex = (
- f"[0:v]{normalize}[ref];"
- f"[1:v]{normalize}[src];"
- "[ref][src]hstack=inputs=2[v]"
- )
- cmd = [
- "ffmpeg", "-y", "-loglevel", "error",
- "-i", str(ref_path),
- "-i", str(src_path),
- "-filter_complex", filter_complex,
- "-map", "[v]",
- "-c:v", "libx264",
- "-preset", "ultrafast",
- "-crf", "28",
- "-an",
- "-movflags", "+faststart",
- str(out_path),
- ]
- result = subprocess.run(cmd, capture_output=True)
- if result.returncode != 0:
- logger.error(
- "ffmpeg compare render failed for %s:\n%s",
- out_path.name,
- result.stderr.decode(errors="replace"),
- )
-
-
-def generate_report(beats: list, results: list, cfg: AppConfig) -> Path:
- """
- Generate an HTML side-by-side report.
- Returns the path to the .html file.
- """
- report_dir = cfg.paths.output_dir / "report"
- report_dir.mkdir(parents=True, exist_ok=True)
-
- html_path = report_dir / "match_report.html"
- results_by_beat = {r.beat_id: r for r in results}
-
- logger.info("Generating report clips in %s (this might take a moment) ...", report_dir)
-
- html = [
- "",
- "AI Trailer Match Report",
- "",
- f"
AI Trailer Generator — Match Report
",
- f"
Total Beats: {len(beats)} | Matched: {len(results)}