From 45769aa366290bc66f85bb3bd1299d62141d419e Mon Sep 17 00:00:00 2001 From: Melbar Date: Wed, 6 May 2026 12:44:10 +0200 Subject: [PATCH] Refactor report pipeline: redesign HTML, add motion alignment, remove legacy reporter - scripts/generate_cutter_report.py: complete HTML redesign with glassmorphism dark-mode style, compare video links in markdown output - cli.py: cmd_report now calls _regenerate_cutter_report directly; also writes legacy match_report.html; removes dependency on src/pipeline/reporter.py - src/cv/global_scan.py: add motion-phase alignment refinement step after initial in-point search (align_in_point_by_motion, threshold +0.015) - Remove HANDOVER.md and src/pipeline/reporter.py (superseded) Co-Authored-By: Claude Sonnet 4.6 --- HANDOVER.md | 125 --------- cli.py | 24 +- scripts/generate_cutter_report.py | 259 +++++++++++------- src/cv/global_scan.py | 36 ++- src/pipeline/reporter.py | 427 ------------------------------ 5 files changed, 216 insertions(+), 655 deletions(-) delete mode 100644 HANDOVER.md delete mode 100644 src/pipeline/reporter.py 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'

Cutter-Report

') + recent = _get_recent_changes(project_root) + + parts.append('
') + parts.append(f'

Cutter & Match Report

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

Legende

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

Übersicht

') + parts.append('
') parts.append( '' - '' - '' + '' + '' '' ) for r in rows: @@ -787,10 +859,11 @@ def render_html( f'' f'' ) - parts.append('
BeatTrailer In–Out (TC)DauerSource In (TC)SceneScoreStatusBeatTrailer TC In–OutDauerSource TC InSceneScoreStatus
{r.status}
') + 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)}
", - "" - ] - - for beat in beats: - res = results_by_beat.get(beat.beat_id) - - # Extract Reference Clip - ref_mp4 = report_dir / f"beat_{beat.beat_id:03d}_ref.mp4" - _extract_clip(beat.trailer_path, beat.start_s, beat.duration_s, ref_mp4) - - html.append("
") - - # Info Panel - html.append("
") - html.append(f"

Beat {beat.beat_id:03d}

") - html.append(f"

Type: {beat.beat_type.name}

") - html.append(f"

Trailer: {beat.start_s:.2f}s → {beat.end_s:.2f}s

") - - if res: - segments = list(getattr(res, "segments", ()) or []) - source_duration = sum(max(0.0, float(s.duration_s)) for s in segments) - if not segments: - source_duration = max(0.0, res.out_point_s - res.in_point_s) - preview_duration = min(beat.duration_s, source_duration) if source_duration > 0 else beat.duration_s - last_segment_end = max( - (float(s.trailer_offset_s) + float(s.duration_s) for s in segments), - default=preview_duration, - ) - trailer_tail_s = max(0.0, beat.duration_s - last_segment_end) - if getattr(res, "is_confirmed", True): - html.append("

MATCHED

") - else: - html.append("

PROVISIONAL MATCH

") - html.append(f"

Scene ID: {res.scene_id}

") - html.append(f"

Movie In: {res.in_point_s:.2f}s

") - html.append(f"

Source Dur: {source_duration:.2f}s

") - if len(segments) > 1: - html.append(f"

Segments: {len(segments)} matched visual islands

") - if trailer_tail_s > 0: - html.append(f"

Unmatched Tail: {trailer_tail_s:.2f}s placeholder

") - html.append(f"

Score: {res.match_score:.3f}

") - if trailer_tail_s > 0: - html.append("

Some trailer frames are still unmatched; report fills only those gaps with placeholder black.

") - - # Warn if score is low - if res.match_score < 0.80: - html.append("

⚠️ Score below 0.80. Verify visually.

") - - # Extract Source Clip - src_mp4 = report_dir / f"beat_{beat.beat_id:03d}_src.mp4" - compare_mp4 = report_dir / f"beat_{beat.beat_id:03d}_compare.mp4" - if segments: - _extract_segmented_clip(res.source_path, segments, beat.duration_s, src_mp4) - else: - _extract_clip_with_black_tail( - res.source_path, - res.in_point_s, - preview_duration, - beat.duration_s, - src_mp4, - ) - _build_frame_locked_compare(ref_mp4, src_mp4, compare_mp4) - else: - html.append("

NO MATCH

") - src_mp4 = None - compare_mp4 = None - - html.append(f"
python cli.py rematch --beat {beat.beat_id}
") - html.append("
") # /info - - # Video Panel - html.append("
") - if compare_mp4: - html.append(f"

Frame-Locked Compare

") - else: - html.append("
") - html.append(f"

Reference Trailer

") - html.append("

Matched Source

No Match
") - html.append("
") # /video-container - html.append("
") # /videos - html.append("
") # /beat-row - - html.append("") - - html_path.write_text("\n".join(html), encoding="utf-8") - return html_path