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 <noreply@anthropic.com>
This commit is contained in:
-125
@@ -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`.
|
|
||||||
@@ -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)
|
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.md").write_text(md, encoding="utf-8")
|
||||||
(project_root / "CUTTER_REPORT.html").write_text(html, 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:
|
except Exception as exc:
|
||||||
logging.getLogger(__name__).warning("Cutter report regen failed: %s", 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:
|
def cmd_report(args: argparse.Namespace, cfg) -> None:
|
||||||
from src.pipeline.reporter import generate_report
|
if getattr(args, "beat", None) is not None:
|
||||||
beats = _select_beats(_load_beats(cfg), getattr(args, "beat", None))
|
print(f"\n⚠️ Generating cutter report for all beats (ignoring --beat {args.beat}).")
|
||||||
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)
|
_regenerate_cutter_report(cfg)
|
||||||
out = generate_report(beats, results, cfg)
|
project_root = cfg.paths.cache_dir.parent
|
||||||
if getattr(args, "beat", None) is not None and not results:
|
print(f"\n✅ Report → {project_root / 'CUTTER_REPORT.html'} and CUTTER_REPORT.md")
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_export(args: argparse.Namespace, cfg) -> None:
|
def cmd_export(args: argparse.Namespace, cfg) -> None:
|
||||||
|
|||||||
@@ -591,6 +591,11 @@ def render_markdown(
|
|||||||
out.append(f"- **Bild**: {r.composition}{extra}")
|
out.append(f"- **Bild**: {r.composition}{extra}")
|
||||||
out.append("")
|
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")
|
t_uri = data_uri(r.trailer_still, "image/jpeg")
|
||||||
s_uri = data_uri(r.source_still, "image/jpeg")
|
s_uri = data_uri(r.source_still, "image/jpeg")
|
||||||
if t_uri or s_uri:
|
if t_uri or s_uri:
|
||||||
@@ -613,89 +618,133 @@ HTML_HEAD = """\
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Cutter-Report</title>
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Cutter-Report & Match-Report</title>
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700&family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
--fg: #e4e4e7;
|
||||||
--fg: #1a1a1a; --bg: #f4f4f5; --mut: #666; --card: #fff; --bd: #d4d4d8;
|
--bg-gradient: linear-gradient(135deg, #09090b 0%, #18181b 100%);
|
||||||
--ok: #16a34a; --q: #b45309; --man: #b91c1c;
|
--mut: #a1a1aa;
|
||||||
--ok-bg: #dcfce7; --q-bg: #fef3c7; --man-bg: #fee2e2;
|
--card-bg: rgba(24, 24, 27, 0.6);
|
||||||
--code-bg: #18181b; --code-fg: #86efac;
|
--card-border: rgba(255, 255, 255, 0.08);
|
||||||
--warn: #92400e; --warn-bg: #fef9c3;
|
--glass-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
|
||||||
}
|
--accent: #6366f1;
|
||||||
@media (prefers-color-scheme: dark) {
|
--accent-hover: #818cf8;
|
||||||
:root {
|
|
||||||
--fg: #e4e4e7; --bg: #0f0f10; --mut: #a1a1aa; --card: #18181b; --bd: #27272a;
|
--ok: #4ade80;
|
||||||
--ok: #4ade80; --q: #fbbf24; --man: #f87171;
|
--q: #fbbf24;
|
||||||
--ok-bg: #14532d; --q-bg: #451a03; --man-bg: #450a0a;
|
--man: #f87171;
|
||||||
--code-bg: #09090b; --code-fg: #86efac;
|
--ok-bg: rgba(74, 222, 128, 0.15);
|
||||||
--warn: #fef08a; --warn-bg: #422006;
|
--q-bg: rgba(251, 191, 36, 0.15);
|
||||||
}
|
--man-bg: rgba(248, 113, 113, 0.15);
|
||||||
|
|
||||||
|
--code-bg: rgba(0, 0, 0, 0.3);
|
||||||
|
--code-fg: #86efac;
|
||||||
|
--warn: #fef08a;
|
||||||
|
--warn-bg: rgba(161, 98, 7, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
html, body { margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont,
|
html, body {
|
||||||
"Segoe UI", Roboto, sans-serif; color: var(--fg); background: var(--bg);
|
margin: 0; padding: 0;
|
||||||
font-size: 14px; line-height: 1.5; }
|
font-family: 'Inter', sans-serif;
|
||||||
.wrap { max-width: 1300px; margin: 0 auto; padding: 24px 20px; }
|
color: var(--fg);
|
||||||
h1 { margin: 0 0 2px; font-size: 22px; }
|
background: var(--bg-gradient);
|
||||||
h2 { margin: 36px 0 12px; font-size: 17px; border-bottom: 1px solid var(--bd);
|
background-attachment: fixed;
|
||||||
padding-bottom: 6px; }
|
font-size: 15px; line-height: 1.6;
|
||||||
.meta { color: var(--mut); font-size: 13px; margin-bottom: 20px; }
|
-webkit-font-smoothing: antialiased;
|
||||||
.meta b { color: var(--fg); }
|
}
|
||||||
.summary { margin: 0 0 28px; font-size: 14px; }
|
|
||||||
|
.wrap { max-width: 1400px; margin: 0 auto; padding: 40px 24px; }
|
||||||
|
|
||||||
|
h1, h2, h3 { font-family: 'Outfit', sans-serif; font-weight: 600; letter-spacing: -0.02em; }
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px; font-size: 36px;
|
||||||
|
background: linear-gradient(to right, #a855f7, #6366f1, #3b82f6);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
h2 { margin: 48px 0 20px; font-size: 24px; border-bottom: 1px solid var(--card-border); padding-bottom: 12px; }
|
||||||
|
|
||||||
|
.header-glass {
|
||||||
|
background: rgba(15, 15, 16, 0.4);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta { color: var(--mut); font-size: 14px; margin-bottom: 16px; display: flex; flex-wrap: wrap; gap: 16px; }
|
||||||
|
.meta b { color: var(--fg); font-weight: 500; }
|
||||||
|
.meta-chip { background: rgba(255, 255, 255, 0.05); padding: 6px 12px; border-radius: 20px; border: 1px solid var(--card-border); }
|
||||||
|
|
||||||
|
.summary { font-size: 16px; font-weight: 500; }
|
||||||
|
.recent-changes { margin-top: 16px; padding-top: 16px; border-top: 1px dashed var(--card-border); color: #c084fc; font-size: 14px; }
|
||||||
|
|
||||||
/* Legend table */
|
/* Legend table */
|
||||||
table.leg { border-collapse: collapse; margin: 8px 0 20px; font-size: 13px; }
|
table.leg { border-collapse: separate; border-spacing: 0; margin: 12px 0 32px; font-size: 14px; width: 100%; max-width: 600px; }
|
||||||
table.leg td { padding: 5px 10px; border: 1px solid var(--bd); }
|
table.leg td { padding: 12px 16px; border-bottom: 1px solid var(--card-border); background: var(--card-bg); }
|
||||||
|
table.leg tr:first-child td:first-child { border-top-left-radius: 12px; }
|
||||||
|
table.leg tr:first-child td:last-child { border-top-right-radius: 12px; }
|
||||||
|
table.leg tr:last-child td:first-child { border-bottom-left-radius: 12px; border-bottom: none; }
|
||||||
|
table.leg tr:last-child td:last-child { border-bottom-right-radius: 12px; border-bottom: none; }
|
||||||
|
|
||||||
/* Overview table */
|
/* Overview table */
|
||||||
table.ov { width: 100%; border-collapse: collapse; font-size: 13px; margin-bottom: 32px; }
|
.table-container { overflow-x: auto; border-radius: 12px; border: 1px solid var(--card-border); margin-bottom: 48px; box-shadow: var(--glass-shadow); }
|
||||||
table.ov th, table.ov td { padding: 6px 8px; border-bottom: 1px solid var(--bd);
|
table.ov { width: 100%; border-collapse: collapse; font-size: 14px; background: var(--card-bg); }
|
||||||
text-align: left; white-space: nowrap; }
|
table.ov th, table.ov td { padding: 14px 16px; border-bottom: 1px solid var(--card-border); text-align: left; white-space: nowrap; }
|
||||||
table.ov th { background: var(--card); font-weight: 600; }
|
table.ov th { background: rgba(255, 255, 255, 0.03); font-family: 'Outfit', sans-serif; font-weight: 500; font-size: 15px; color: var(--mut); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
table.ov td.num { text-align: right; font-variant-numeric: tabular-nums; }
|
table.ov td.num { text-align: right; font-variant-numeric: tabular-nums; font-family: 'Inter', monospace; }
|
||||||
table.ov tr:hover td { background: var(--bd); }
|
table.ov tr { transition: background-color 0.2s ease; }
|
||||||
|
table.ov tr:hover { background: rgba(255, 255, 255, 0.05); }
|
||||||
|
|
||||||
/* Badges */
|
/* Badges */
|
||||||
.badge { display: inline-block; padding: 1px 7px; border-radius: 4px;
|
.badge { display: inline-flex; align-items: center; justify-content: center; padding: 4px 10px; border-radius: 6px; font-weight: 600; font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase; }
|
||||||
font-weight: 700; font-size: 11px; letter-spacing: .03em; }
|
.badge.ok { background: var(--ok-bg); color: var(--ok); border: 1px solid rgba(74, 222, 128, 0.2); }
|
||||||
.badge.ok { background: var(--ok-bg); color: var(--ok); }
|
.badge.q { background: var(--q-bg); color: var(--q); border: 1px solid rgba(251, 191, 36, 0.2); }
|
||||||
.badge.q { background: var(--q-bg); color: var(--q); }
|
.badge.man { background: var(--man-bg); color: var(--man); border: 1px solid rgba(248, 113, 113, 0.2); }
|
||||||
.badge.man { background: var(--man-bg); color: var(--man); }
|
|
||||||
|
|
||||||
/* Beat cards */
|
/* Beat cards */
|
||||||
.beat { background: var(--card); border: 1px solid var(--bd); border-radius: 10px;
|
.beats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(600px, 1fr)); gap: 32px; }
|
||||||
padding: 16px; margin-bottom: 28px; }
|
.beat {
|
||||||
.beat-hdr { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
|
background: var(--card-bg);
|
||||||
.beat-hdr h3 { margin: 0; font-size: 16px; }
|
backdrop-filter: blur(12px);
|
||||||
.beat-hdr .status-text { font-size: 13px; color: var(--mut); }
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
border: 1px solid var(--card-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
box-shadow: var(--glass-shadow);
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
.beat:hover { transform: translateY(-4px); box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.45); border-color: rgba(255,255,255,0.15); }
|
||||||
|
.beat-hdr { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
|
||||||
|
.beat-hdr h3 { margin: 0; font-size: 20px; color: #fff; }
|
||||||
|
.beat-hdr .status-text { font-size: 14px; color: var(--mut); margin-left: auto; }
|
||||||
|
|
||||||
/* Compare video — full width */
|
/* Compare video */
|
||||||
.compare-wrap { margin-bottom: 12px; }
|
.compare-wrap { margin-bottom: 20px; border-radius: 12px; overflow: hidden; position: relative; background: #000; box-shadow: inset 0 0 0 1px var(--card-border); }
|
||||||
.compare-label { font-size: 11px; font-weight: 600; text-transform: uppercase;
|
.compare-label { position: absolute; top: 12px; left: 12px; z-index: 10; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); padding: 4px 10px; border-radius: 4px; color: #fff; border: 1px solid rgba(255,255,255,0.1); }
|
||||||
letter-spacing: .06em; color: var(--mut); margin-bottom: 4px; }
|
.compare-wrap video, .compare-wrap img { width: 100%; height: auto; display: block; transition: opacity 0.3s; }
|
||||||
.compare-wrap video, .compare-wrap img { width: 100%; height: auto;
|
.compare-wrap:hover video { opacity: 0.95; }
|
||||||
border-radius: 6px; background: #000; display: block; }
|
.stills-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 2px; background: var(--card-border); }
|
||||||
.stills-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
.stills-pair img { width: 100%; height: auto; background: #000; }
|
||||||
.stills-pair img { width: 100%; height: auto; border-radius: 6px; background: #000; }
|
.empty-media { display: flex; align-items: center; justify-content: center; aspect-ratio: 32/9; background: rgba(0,0,0,0.5); color: var(--mut); font-size: 14px; }
|
||||||
.empty-media { display: flex; align-items: center; justify-content: center;
|
|
||||||
aspect-ratio: 32/9; background: #000; color: #555; border-radius: 6px;
|
|
||||||
font-size: 13px; }
|
|
||||||
|
|
||||||
/* Metadata footer inside card */
|
/* Metadata footer inside card */
|
||||||
.beat-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 24px;
|
.beat-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; margin-top: 20px; font-size: 14px; border-top: 1px solid var(--card-border); padding-top: 20px; }
|
||||||
margin-top: 12px; font-size: 13px; border-top: 1px solid var(--bd); padding-top: 10px; }
|
.kv { margin: 8px 0; display: flex; flex-direction: column; gap: 2px; }
|
||||||
.kv { margin: 3px 0; }
|
.kv b { color: var(--mut); font-weight: 500; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||||
.kv b { display: inline-block; min-width: 72px; color: var(--mut); font-weight: 500; }
|
.kv-val { color: var(--fg); font-weight: 400; }
|
||||||
.tc { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }
|
.tc { font-family: 'Inter', monospace; font-size: 13px; background: var(--code-bg); padding: 2px 6px; border-radius: 4px; border: 1px solid rgba(255,255,255,0.05); }
|
||||||
.seg-list { margin: 4px 0 0 0; padding-left: 16px; font-size: 12px; color: var(--mut);
|
.seg-list { margin: 8px 0 0 0; padding-left: 0; font-size: 13px; list-style: none; display: flex; flex-direction: column; gap: 6px; }
|
||||||
list-style: disc; }
|
.seg-list li { background: rgba(255,255,255,0.03); padding: 8px 12px; border-radius: 6px; border: 1px solid var(--card-border); font-family: 'Inter', monospace; }
|
||||||
.seg-list li { margin: 2px 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
.warn-box { background: var(--warn-bg); color: var(--warn); border-radius: 8px; padding: 10px 14px; font-size: 13px; margin: 12px 0; border: 1px solid rgba(254, 240, 138, 0.2); display: flex; align-items: center; gap: 8px; }
|
||||||
.warn-box { background: var(--warn-bg); color: var(--warn); border-radius: 4px;
|
.hint { display: inline-block; background: var(--code-bg); color: var(--code-fg); font-family: 'Inter', monospace; font-size: 12px; padding: 6px 12px; border-radius: 6px; margin-top: 12px; border: 1px solid rgba(134, 239, 172, 0.2); }
|
||||||
padding: 4px 8px; font-size: 12px; margin: 4px 0; }
|
|
||||||
.hint { display: inline-block; background: var(--code-bg); color: var(--code-fg);
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px;
|
|
||||||
padding: 2px 8px; border-radius: 4px; margin-top: 6px; }
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body><div class="wrap">
|
<body><div class="wrap">
|
||||||
@@ -709,6 +758,22 @@ def _he(s: str) -> str:
|
|||||||
.replace(">", ">").replace('"', """))
|
.replace(">", ">").replace('"', """))
|
||||||
|
|
||||||
|
|
||||||
|
def _get_recent_changes(project_root: Path) -> str:
|
||||||
|
try:
|
||||||
|
import subprocess
|
||||||
|
proc = subprocess.run(
|
||||||
|
["git", "log", "--invert-grep", "--grep=Auto-update", "-1", "--pretty=%B"],
|
||||||
|
capture_output=True, text=True, cwd=str(project_root), timeout=5
|
||||||
|
)
|
||||||
|
if proc.stdout:
|
||||||
|
lines = proc.stdout.strip().split('\n')
|
||||||
|
cleaned = [line for line in lines if line and not line.startswith('Co-Authored-By')]
|
||||||
|
return "<br>".join(cleaned[:3]) or "No recent changes found."
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return "No recent changes available."
|
||||||
|
|
||||||
|
|
||||||
def render_html(
|
def render_html(
|
||||||
rows: list[BeatRow],
|
rows: list[BeatRow],
|
||||||
trailer_fps: float,
|
trailer_fps: float,
|
||||||
@@ -717,6 +782,7 @@ def render_html(
|
|||||||
source_path: Path,
|
source_path: Path,
|
||||||
with_clips: bool,
|
with_clips: bool,
|
||||||
generated_at: datetime,
|
generated_at: datetime,
|
||||||
|
project_root: Path = Path("."),
|
||||||
) -> str:
|
) -> str:
|
||||||
matched = sum(1 for r in rows if r.matched)
|
matched = sum(1 for r in rows if r.matched)
|
||||||
confirmed = sum(1 for r in rows if r.confirmed)
|
confirmed = sum(1 for r in rows if r.confirmed)
|
||||||
@@ -724,43 +790,49 @@ def render_html(
|
|||||||
parts: list[str] = [HTML_HEAD]
|
parts: list[str] = [HTML_HEAD]
|
||||||
|
|
||||||
# Header
|
# Header
|
||||||
parts.append(f'<h1>Cutter-Report</h1>')
|
recent = _get_recent_changes(project_root)
|
||||||
|
|
||||||
|
parts.append('<div class="header-glass">')
|
||||||
|
parts.append(f'<h1>Cutter & Match Report</h1>')
|
||||||
parts.append('<div class="meta">')
|
parts.append('<div class="meta">')
|
||||||
parts.append(
|
parts.append(
|
||||||
f'Generiert: <b>{generated_at.strftime("%Y-%m-%d %H:%M:%S")}</b> | '
|
f'<span class="meta-chip">Generiert: <b>{generated_at.strftime("%Y-%m-%d %H:%M:%S")}</b></span>'
|
||||||
f'Trailer: <b>{_he(trailer_path.name)}</b> @ {trailer_fps:.3f} fps | '
|
f'<span class="meta-chip">Trailer: <b>{_he(trailer_path.name)}</b> @ {trailer_fps:.3f} fps</span>'
|
||||||
f'Source: <b>{_he(source_path.name)}</b> @ {source_fps:.3f} fps'
|
f'<span class="meta-chip">Source: <b>{_he(source_path.name)}</b> @ {source_fps:.3f} fps</span>'
|
||||||
)
|
)
|
||||||
parts.append('</div>')
|
parts.append('</div>')
|
||||||
parts.append(
|
parts.append(
|
||||||
f'<div class="summary"><b>{len(rows)}</b> Beats — '
|
f'<div class="summary"><b>{len(rows)}</b> Beats — '
|
||||||
f'<b>{matched}</b> automatisch (<b>{confirmed}</b> bestätigt) — '
|
f'<span style="color:var(--ok)"><b>{matched}</b> automatisch (<b>{confirmed}</b> bestätigt)</span> — '
|
||||||
f'<b>{len(rows) - matched}</b> manuell.</div>'
|
f'<span style="color:var(--man)"><b>{len(rows) - matched}</b> manuell</span>.</div>'
|
||||||
)
|
)
|
||||||
|
parts.append(f'<div class="recent-changes"><b>Recent Changes:</b><br>{recent}</div>')
|
||||||
|
parts.append('</div>')
|
||||||
|
|
||||||
# Legend
|
# Legend
|
||||||
parts.append('<h2>Legende</h2>')
|
parts.append('<h2>Legende</h2>')
|
||||||
parts.append('<table class="leg"><tbody>')
|
parts.append('<table class="leg"><tbody>')
|
||||||
parts.append(
|
parts.append(
|
||||||
'<tr><td><span class="badge ok">OK</span></td>'
|
'<tr><td><span class="badge ok">OK</span></td>'
|
||||||
'<td>Bestätigt — übernehmen</td></tr>'
|
'<td>Bestätigt — direkt in Schnitt-Timeline übernehmen</td></tr>'
|
||||||
)
|
)
|
||||||
parts.append(
|
parts.append(
|
||||||
'<tr><td><span class="badge q">?</span></td>'
|
'<tr><td><span class="badge q">?</span></td>'
|
||||||
'<td>Vorläufig — Phase im NLE prüfen, Source-In ggf. nachjustieren</td></tr>'
|
'<td>Vorläufig — Phase und Aktion im NLE visuell prüfen</td></tr>'
|
||||||
)
|
)
|
||||||
parts.append(
|
parts.append(
|
||||||
'<tr><td><span class="badge man">MAN.</span></td>'
|
'<tr><td><span class="badge man">MAN.</span></td>'
|
||||||
'<td>Kein automatischer Treffer — manuell setzen oder Schwarzfade</td></tr>'
|
'<td>Kein Treffer — manuell suchen oder Schwarzbild einfügen</td></tr>'
|
||||||
)
|
)
|
||||||
parts.append('</tbody></table>')
|
parts.append('</tbody></table>')
|
||||||
|
|
||||||
# Overview table
|
# Overview table
|
||||||
parts.append('<h2>Übersicht</h2>')
|
parts.append('<h2>Übersicht</h2>')
|
||||||
|
parts.append('<div class="table-container">')
|
||||||
parts.append(
|
parts.append(
|
||||||
'<table class="ov"><thead><tr>'
|
'<table class="ov"><thead><tr>'
|
||||||
'<th>Beat</th><th>Trailer In–Out (TC)</th><th>Dauer</th>'
|
'<th>Beat</th><th>Trailer TC In–Out</th><th>Dauer</th>'
|
||||||
'<th>Source In (TC)</th><th>Scene</th><th>Score</th><th>Status</th>'
|
'<th>Source TC In</th><th>Scene</th><th>Score</th><th>Status</th>'
|
||||||
'</tr></thead><tbody>'
|
'</tr></thead><tbody>'
|
||||||
)
|
)
|
||||||
for r in rows:
|
for r in rows:
|
||||||
@@ -787,10 +859,11 @@ def render_html(
|
|||||||
f'<td><span class="badge {bcls}">{r.status}</span></td>'
|
f'<td><span class="badge {bcls}">{r.status}</span></td>'
|
||||||
f'</tr>'
|
f'</tr>'
|
||||||
)
|
)
|
||||||
parts.append('</tbody></table>')
|
parts.append('</tbody></table></div>')
|
||||||
|
|
||||||
# Per-beat cards
|
# Per-beat cards
|
||||||
parts.append('<h2>Beat-Details</h2>')
|
parts.append('<h2>Beat-Details</h2>')
|
||||||
|
parts.append('<div class="beats-grid">')
|
||||||
for r in rows:
|
for r in rows:
|
||||||
ti = smpte(r.trailer_in_s, trailer_fps)
|
ti = smpte(r.trailer_in_s, trailer_fps)
|
||||||
to = smpte(r.trailer_out_s, trailer_fps)
|
to = smpte(r.trailer_out_s, trailer_fps)
|
||||||
@@ -839,13 +912,13 @@ def render_html(
|
|||||||
|
|
||||||
# Left col: trailer info
|
# Left col: trailer info
|
||||||
parts.append('<div>')
|
parts.append('<div>')
|
||||||
parts.append(f'<div class="kv"><b>Trailer</b> <span class="tc">{ti}–{to}</span>'
|
parts.append(f'<div class="kv"><b>Trailer</b> <div class="kv-val"><span class="tc">{ti}–{to}</span>'
|
||||||
f' <span style="color:var(--mut)">({dur:.2f}s)</span></div>')
|
f' <span style="color:var(--mut)">({dur:.2f}s)</span></div></div>')
|
||||||
if r.phase:
|
if r.phase:
|
||||||
parts.append(f'<div class="kv"><b>Phase</b> {_he(r.phase)}</div>')
|
parts.append(f'<div class="kv"><b>Phase</b> <span class="kv-val">{_he(r.phase)}</span></div>')
|
||||||
if r.composition:
|
if r.composition:
|
||||||
extra = f", {r.setting}" if r.setting else ""
|
extra = f", {r.setting}" if r.setting else ""
|
||||||
parts.append(f'<div class="kv"><b>Bild</b> {_he(r.composition + extra)}</div>')
|
parts.append(f'<div class="kv"><b>Bild</b> <span class="kv-val">{_he(r.composition + extra)}</span></div>')
|
||||||
parts.append('</div>')
|
parts.append('</div>')
|
||||||
|
|
||||||
# Right col: source info
|
# Right col: source info
|
||||||
@@ -859,10 +932,10 @@ def render_html(
|
|||||||
))
|
))
|
||||||
scene_str = f"Scenes {', '.join(all_scenes)} · {r.num_segments} Segmente"
|
scene_str = f"Scenes {', '.join(all_scenes)} · {r.num_segments} Segmente"
|
||||||
parts.append(
|
parts.append(
|
||||||
f'<div class="kv"><b>Source</b> <span class="tc">{si}</span>'
|
f'<div class="kv"><b>Source</b> <div class="kv-val"><span class="tc">{si}</span>'
|
||||||
f' <span style="color:var(--mut)">(multi-shot)</span></div>'
|
f' <span style="color:var(--mut)">(multi-shot)</span></div></div>'
|
||||||
)
|
)
|
||||||
parts.append(f'<div class="kv"><b>Scene</b> {scene_str}</div>')
|
parts.append(f'<div class="kv"><b>Scene</b> <span class="kv-val">{scene_str}</span></div>')
|
||||||
# Segment list
|
# Segment list
|
||||||
parts.append('<ul class="seg-list">')
|
parts.append('<ul class="seg-list">')
|
||||||
for i, seg in enumerate(r.segments):
|
for i, seg in enumerate(r.segments):
|
||||||
@@ -882,11 +955,11 @@ def render_html(
|
|||||||
else:
|
else:
|
||||||
parts.append(
|
parts.append(
|
||||||
f'<div class="kv"><b>Source</b>'
|
f'<div class="kv"><b>Source</b>'
|
||||||
f' <span class="tc">{si}–{so}</span></div>'
|
f' <div class="kv-val"><span class="tc">{si}–{so}</span></div></div>'
|
||||||
)
|
)
|
||||||
parts.append(
|
parts.append(
|
||||||
f'<div class="kv"><b>Scene</b> {r.scene_id}'
|
f'<div class="kv"><b>Scene</b> <span class="kv-val">{r.scene_id}'
|
||||||
f' · Score {r.score:.3f}</div>'
|
f' · Score {r.score:.3f}</span></div>'
|
||||||
)
|
)
|
||||||
if r.score > 0 and r.score < 0.65:
|
if r.score > 0 and r.score < 0.65:
|
||||||
parts.append(
|
parts.append(
|
||||||
@@ -904,6 +977,7 @@ def render_html(
|
|||||||
parts.append('</div>') # .beat-meta
|
parts.append('</div>') # .beat-meta
|
||||||
parts.append('</div>') # .beat
|
parts.append('</div>') # .beat
|
||||||
|
|
||||||
|
parts.append('</div>') # .beats-grid
|
||||||
parts.append(HTML_FOOT)
|
parts.append(HTML_FOOT)
|
||||||
return "".join(parts)
|
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.md").write_text(md, encoding="utf-8")
|
||||||
(project_root / "CUTTER_REPORT.html").write_text(html, 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.md'}")
|
||||||
print(f"Wrote {project_root / 'CUTTER_REPORT.html'}")
|
print(f"Wrote {project_root / 'CUTTER_REPORT.html'}")
|
||||||
|
print(f"Wrote {legacy_path}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+35
-1
@@ -1422,13 +1422,47 @@ def run_global_scan(
|
|||||||
motion_score = 0.0
|
motion_score = 0.0
|
||||||
if len(motion_templates) >= 2:
|
if len(motion_templates) >= 2:
|
||||||
with open_video(cfg.paths.source_movie) as motion_cap:
|
with open_video(cfg.paths.source_movie) as motion_cap:
|
||||||
motion_score = _motion_phase_score(
|
original_motion_score = _motion_phase_score(
|
||||||
motion_cap,
|
motion_cap,
|
||||||
adjusted_in_s,
|
adjusted_in_s,
|
||||||
motion_templates,
|
motion_templates,
|
||||||
cfg,
|
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:
|
if is_weighted_seed_candidate and scene is not None and content_score >= content_gate:
|
||||||
contiguous_usable_s = _contiguous_scene_coverage_duration(
|
contiguous_usable_s = _contiguous_scene_coverage_duration(
|
||||||
b,
|
b,
|
||||||
|
|||||||
@@ -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 = [
|
|
||||||
"<!DOCTYPE html>",
|
|
||||||
"<html><head><meta charset='utf-8'><title>AI Trailer Match Report</title>",
|
|
||||||
"<style>",
|
|
||||||
"body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #0f0f0f; color: #e0e0e0; margin: 40px; }",
|
|
||||||
"h1 { color: #fff; border-bottom: 1px solid #333; padding-bottom: 10px; }",
|
|
||||||
".stats { font-size: 1.2em; margin-bottom: 30px; color: #aaa; }",
|
|
||||||
".beat-row { display: flex; margin-bottom: 30px; background: #1a1a1a; padding: 20px; border-radius: 12px; border: 1px solid #333; }",
|
|
||||||
".info { width: 250px; padding-right: 20px; flex-shrink: 0; }",
|
|
||||||
".info h3 { margin-top: 0; color: #fff; }",
|
|
||||||
".video-container { display: flex; gap: 20px; flex-grow: 1; }",
|
|
||||||
".videos { flex-grow: 1; }",
|
|
||||||
".compare { margin-bottom: 18px; }",
|
|
||||||
".video-col { flex: 1; }",
|
|
||||||
".video-col p { margin-top: 0; font-weight: bold; color: #888; }",
|
|
||||||
"video { width: 100%; border-radius: 6px; box-shadow: 0 4px 6px rgba(0,0,0,0.5); background: #000; }",
|
|
||||||
".status-match { color: #4ade80; font-weight: bold; font-size: 1.1em; }",
|
|
||||||
".status-miss { color: #f87171; font-weight: bold; font-size: 1.1em; }",
|
|
||||||
".score { font-family: monospace; font-size: 1.1em; color: #60a5fa; }",
|
|
||||||
".code-hint { background: #000; padding: 10px; border-radius: 4px; font-family: monospace; font-size: 0.9em; margin-top: 15px; color: #a3e635; }",
|
|
||||||
"</style></head><body>",
|
|
||||||
f"<h1>AI Trailer Generator — Match Report</h1>",
|
|
||||||
f"<div class='stats'>Total Beats: {len(beats)} | Matched: {len(results)}</div>",
|
|
||||||
"<script>",
|
|
||||||
"function syncBeat(row) {",
|
|
||||||
" const vids = row.querySelectorAll('video');",
|
|
||||||
" if (vids.length < 2) return;",
|
|
||||||
" const ref = vids[0];",
|
|
||||||
" const src = vids[1];",
|
|
||||||
" let syncing = false;",
|
|
||||||
" function align() {",
|
|
||||||
" if (syncing) return;",
|
|
||||||
" syncing = true;",
|
|
||||||
" const target = Math.min(ref.currentTime, Math.max(0, (src.duration || ref.currentTime) - 0.02));",
|
|
||||||
" if (Math.abs(src.currentTime - target) > 0.035) src.currentTime = target;",
|
|
||||||
" if (ref.paused && !src.paused) src.pause();",
|
|
||||||
" if (!ref.paused && src.paused) src.play().catch(() => {});",
|
|
||||||
" syncing = false;",
|
|
||||||
" }",
|
|
||||||
" ref.addEventListener('play', () => { src.currentTime = Math.min(ref.currentTime, Math.max(0, (src.duration || ref.currentTime) - 0.02)); src.play().catch(() => {}); });",
|
|
||||||
" ref.addEventListener('pause', () => src.pause());",
|
|
||||||
" ref.addEventListener('seeked', () => { src.currentTime = Math.min(ref.currentTime, Math.max(0, (src.duration || ref.currentTime) - 0.02)); });",
|
|
||||||
" ref.addEventListener('timeupdate', align);",
|
|
||||||
"}",
|
|
||||||
"document.addEventListener('DOMContentLoaded', () => document.querySelectorAll('.beat-row').forEach(syncBeat));",
|
|
||||||
"</script>"
|
|
||||||
]
|
|
||||||
|
|
||||||
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("<div class='beat-row'>")
|
|
||||||
|
|
||||||
# Info Panel
|
|
||||||
html.append("<div class='info'>")
|
|
||||||
html.append(f"<h3>Beat {beat.beat_id:03d}</h3>")
|
|
||||||
html.append(f"<p><b>Type:</b> {beat.beat_type.name}</p>")
|
|
||||||
html.append(f"<p><b>Trailer:</b> {beat.start_s:.2f}s → {beat.end_s:.2f}s</p>")
|
|
||||||
|
|
||||||
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("<p class='status-match'>MATCHED</p>")
|
|
||||||
else:
|
|
||||||
html.append("<p style='color: #fbbf24; font-weight: bold; font-size: 1.1em;'>PROVISIONAL MATCH</p>")
|
|
||||||
html.append(f"<p><b>Scene ID:</b> {res.scene_id}</p>")
|
|
||||||
html.append(f"<p><b>Movie In:</b> {res.in_point_s:.2f}s</p>")
|
|
||||||
html.append(f"<p><b>Source Dur:</b> {source_duration:.2f}s</p>")
|
|
||||||
if len(segments) > 1:
|
|
||||||
html.append(f"<p><b>Segments:</b> {len(segments)} matched visual islands</p>")
|
|
||||||
if trailer_tail_s > 0:
|
|
||||||
html.append(f"<p><b>Unmatched Tail:</b> {trailer_tail_s:.2f}s placeholder</p>")
|
|
||||||
html.append(f"<p><b>Score:</b> <span class='score'>{res.match_score:.3f}</span></p>")
|
|
||||||
if trailer_tail_s > 0:
|
|
||||||
html.append("<p style='color: #fbbf24; font-size: 0.9em;'>Some trailer frames are still unmatched; report fills only those gaps with placeholder black.</p>")
|
|
||||||
|
|
||||||
# Warn if score is low
|
|
||||||
if res.match_score < 0.80:
|
|
||||||
html.append("<p style='color: #fbbf24; font-size: 0.9em;'>⚠️ Score below 0.80. Verify visually.</p>")
|
|
||||||
|
|
||||||
# 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("<p class='status-miss'>NO MATCH</p>")
|
|
||||||
src_mp4 = None
|
|
||||||
compare_mp4 = None
|
|
||||||
|
|
||||||
html.append(f"<div class='code-hint'>python cli.py rematch --beat {beat.beat_id}</div>")
|
|
||||||
html.append("</div>") # /info
|
|
||||||
|
|
||||||
# Video Panel
|
|
||||||
html.append("<div class='videos'>")
|
|
||||||
if compare_mp4:
|
|
||||||
html.append(f"<div class='compare'><p>Frame-Locked Compare</p><video src='{compare_mp4.name}' controls loop muted autoplay></video></div>")
|
|
||||||
else:
|
|
||||||
html.append("<div class='video-container'>")
|
|
||||||
html.append(f"<div class='video-col'><p>Reference Trailer</p><video src='{ref_mp4.name}' controls loop muted autoplay></video></div>")
|
|
||||||
html.append("<div class='video-col'><p>Matched Source</p><div style='width: 100%; aspect-ratio: 16/9; background: #222; display: flex; align-items: center; justify-content: center; border-radius: 6px; color: #555;'>No Match</div></div>")
|
|
||||||
html.append("</div>") # /video-container
|
|
||||||
html.append("</div>") # /videos
|
|
||||||
html.append("</div>") # /beat-row
|
|
||||||
|
|
||||||
html.append("</body></html>")
|
|
||||||
|
|
||||||
html_path.write_text("\n".join(html), encoding="utf-8")
|
|
||||||
return html_path
|
|
||||||
Reference in New Issue
Block a user