Slim README, move algorithm prose to docs, add stills + per-fps TC to cutter report

README: 550 -> 308 lines. The dense algorithm prose was moved verbatim to
docs/ALGORITHM.md and replaced in the README with a compact "Wenn ein Match
falsch wirkt" troubleshooting table and a link. The cutter-facing intro
points at the new in-report stills instead of the old HTML report.

Cutter report:
- Per-side frame rates: trailer timecodes use the trailer file's fps
  (typically 25), source timecodes use the source file's fps. ffprobe is
  used to detect each side; falls back to edl_frame_rate if unavailable.
- Side-by-side trailer/source preview stills extracted via ffmpeg, taken
  ~30% into the beat / match window. Stored under output/cutter_stills/
  (gitignored). Re-rendered only when the underlying video is newer than
  the cached jpg.
- Compact table at the top, detailed per-beat sections below with the
  stills inline so the cutter can sight-check phase agreement directly.
- New --no-stills flag for fast text-only regeneration.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Melbar
2026-05-04 13:24:19 +02:00
parent 97a8f9e305
commit 5a6ae2175c
4 changed files with 878 additions and 436 deletions
+315 -82
View File
@@ -1,100 +1,333 @@
# Cutter-Report — manuelles Nachschneiden
Stand: 2026-05-04. Frame-Rate: 23.976 fps. Source: BehindTheRedDoor_FTR_1080P_2398_Fixed.mp4 — Trailer: BehindTheRedDoor_Trailer_REFERENCE.mp4.
Stand: 2026-05-04
Diese Datei wird automatisch aus dem Match-Cache erzeugt. Nach jedem `python cli.py match` mit `python scripts/generate_cutter_report.py` neu generieren.
- **Trailer**: `BehindTheRedDoor_Trailer_REFERENCE.mp4` @ 25.000 fps
- **Source** : `BehindTheRedDoor_FTR_1080P_2398_Fixed.mp4` @ 25.000 fps
## Wie diese Tabelle zu lesen ist
Trailer-Timecodes sind in **Trailer-Framerate** angegeben, Source-Timecodes in **Source-Framerate**. So passen sie 1:1 zu dem, was du in deinem NLE auf den jeweiligen Spuren siehst.
- **Beat**: Nummer im Referenz-Trailer.
- **Trailer In/Out**: SMPTE-Position des Beats im Trailer (h:mm:ss:ff).
- **Source In/Out**: vorgeschlagene Position im Quellfilm. Bei `MAN.` selbst aussuchen.
- **Scene**: ID der Source-Szene aus PySceneDetect (nur fuer Debug-Zwecke).
- **Score**: 0..1, je hoeher desto besser. >=0.65 ist als bestaetigt eingestuft.
- **Status**:
- `OK` — bestaetigt durch CV + Vision-Phasenpruefung, kann ohne weitere Pruefung uebernommen werden.
- `?` — vorlaeufig, korrekte Szene aber Score unter 0.65; Bewegungsphase im Vorschauclip pruefen und ggf. um wenige Frames verschieben.
- `MAN.` — kein automatischer Treffer; entweder manuell suchen oder als Schwarzfade/Titel uebernehmen.
- **Phase**: was im Trailerbeat zu sehen ist (aus Vision-Beschreibung). Hilft dir, die richtige Stelle im Source zu finden.
Diese Datei wird automatisch erzeugt — nach jedem `python cli.py match` neu generieren mit:
## Status-Uebersicht
```powershell
python scripts/generate_cutter_report.py
```
- **Beats gesamt**: 25
- **Automatisch gefunden**: 20 (5 davon bestaetigt)
- **Manuell zu setzen**: 5
## Status-Legende
## Beat-Tabelle
| Status | Bedeutung | Was tun? |
|--------|-----------|----------|
| `OK` | bestätigt durch CV + Vision-Phasenprüfung | übernehmen, optional stichprobenartig sichten |
| `?` | korrekte Szene, Phase eventuell um wenige Frames verschoben | im NLE prüfen, Source-In ggf. nachjustieren |
| `MAN.` | kein automatischer Treffer | manuell suchen oder als Schwarzfade/Titel übernehmen |
| Beat | Trailer In / Out | Source In / Out | Scene | Score | Status | Was im Bild zu sehen ist |
|-----:|------------------|------------------|------:|------:|:------:|---------------------------|
| 0 | 00:00:00:00-00:00:03:00 | —-— | — | 0.000 | MAN. | logo animation assembling from distorted shapes with motion blur |
| 1 | 00:00:03:00-00:00:08:10 | 00:00:04:09-00:00:06:03 | 1 | 0.380 | ? | |
| 2 | 00:00:08:10-00:00:16:23 | —-— | — | 0.000 | MAN. | |
| 3 | 00:00:16:23-00:00:19:03 | 01:02:17:22-01:02:19:14 | 436 | 0.469 | ? | |
| 4 | 00:00:19:03-00:00:20:15 | 01:02:21:01-01:02:22:10 | 437 | 0.647 | ? | |
| 5 | 00:00:20:15-00:00:26:09 | 00:01:33:04-00:01:37:10 | 10 | 0.501 | ? | |
| 6 | 00:00:26:09-00:00:29:06 | 00:01:03:06-00:01:05:21 | 5 | 0.548 | ? | |
| 7 | 00:00:29:06-00:00:31:16 | 01:20:10:10-01:20:12:16 | 553 | 0.463 | ? | man appears to be engaged in conversation |
| 8 | 00:00:31:16-00:00:33:15 | 00:00:51:07-00:00:53:01 | 5 | 0.733 | OK | static or slow drifting |
| 9 | 00:00:33:15-00:00:36:18 | 01:20:28:20-01:20:31:17 | 557 | 0.529 | ? | speaking, transitioning from closed eyes to open mouth and focused gaze |
| 10 | 00:00:36:18-00:00:40:02 | 01:20:35:16-01:20:39:00 | 558 | 0.635 | ? | conversation |
| 11 | 00:00:40:02-00:00:42:03 | 01:20:40:18-01:20:42:18 | 559 | 0.502 | ? | static talking head with slight facial expression changes |
| 12 | 00:00:42:03-00:00:50:06 | 01:14:26:01-01:14:29:10 | 519 | 0.558 | ? | static profile shot transitioning to black/darkness |
| 13 | 00:00:50:06-00:00:53:20 | 00:43:20:02-00:43:23:10 | 308 | 0.468 | ? | static conversation; woman on right is standing and holding a cup |
| 14 | 00:00:53:20-00:00:57:02 | 00:43:24:09-00:43:27:04 | 309 | 0.444 | ? | static conversation, subject holding a white cup |
| 15 | 00:00:57:02-00:01:01:12 | 00:02:10:11-00:02:12:16 | 0 | 0.467 | ? | static conversation |
| 16 | 00:01:01:12-00:01:04:12 | 01:05:12:16-01:05:15:06 | 451 | 0.613 | ? | man reaches out and touches the red door with a small object |
| 17 | 00:01:04:12-00:01:09:03 | 01:31:22:10-01:31:24:09 | 623 | 0.684 | OK | Static intimacy transitioning to a spatial arrangement of figures |
| 18 | 00:01:09:03-00:01:10:18 | 00:09:13:12-00:09:14:19 | 75 | 0.668 | OK | Woman in foreground turns her head from profile to face the camera while speaking |
| 19 | 00:01:10:18-00:01:12:12 | 00:16:48:14-00:16:49:15 | 126 | 0.717 | OK | static conversation, subtle facial expression change |
| 20 | 00:01:12:12-00:01:15:13 | 01:28:04:17-01:28:05:14 | 613 | 0.663 | OK | man kisses woman's forehead, then they pull back slightly to face each other |
| 21 | 00:01:15:13-00:01:17:12 | —-— | — | 0.000 | MAN. | hand raised to mouth, slight facial movement |
| 22 | 00:01:17:12-00:01:19:22 | 01:03:05:16-01:03:07:10 | 442 | 0.545 | ? | |
| 23 | 00:01:19:22-00:01:25:13 | —-— | — | 0.000 | MAN. | |
| 24 | 00:01:25:13-00:01:32:07 | —-— | — | 0.000 | MAN. | |
## Übersicht
## Beats die manuelle Aufmerksamkeit brauchen
- Beats gesamt: **25**
- Automatisch gefunden: **20** (5 davon bestätigt)
- Manuell zu setzen: **5**
### Manuell setzen (Status `MAN.`)
## Beat-Tabelle (kompakt)
- **Beat 0** 00:00:00:00-00:00:03:00: logo animation assembling from distorted shapes with motion blur
- **Beat 2** 00:00:08:10-00:00:16:23: keine Vision-Beschreibung — vermutlich Title-Card / Fade / Logo
- **Beat 21** 00:01:15:13-00:01:17:12: hand raised to mouth, slight facial movement
- **Beat 23** 00:01:19:22-00:01:25:13: keine Vision-Beschreibung — vermutlich Title-Card / Fade / Logo
- **Beat 24** 00:01:25:13-00:01:32:07: keine Vision-Beschreibung — vermutlich Title-Card / Fade / Logo
| Beat | Trailer In / Out | Source In / Out | Score | Status | Was im Bild zu sehen ist |
|-----:|------------------|------------------|------:|:------:|---------------------------|
| 0 | 00:00:00:00-00:00:03:00 | —-— | 0.000 | MAN. | logo animation assembling from distorted shapes with motion blur |
| 1 | 00:00:03:00-00:00:08:10 | 00:00:04:10-00:00:06:04 | 0.380 | ? | |
| 2 | 00:00:08:10-00:00:16:24 | —-— | 0.000 | MAN. | |
| 3 | 00:00:16:24-00:00:19:03 | 01:02:17:23-01:02:19:15 | 0.469 | ? | |
| 4 | 00:00:19:03-00:00:20:16 | 01:02:21:01-01:02:22:10 | 0.647 | ? | |
| 5 | 00:00:20:16-00:00:26:09 | 00:01:33:04-00:01:37:10 | 0.501 | ? | |
| 6 | 00:00:26:09-00:00:29:06 | 00:01:03:06-00:01:05:22 | 0.548 | ? | |
| 7 | 00:00:29:06-00:00:31:17 | 01:20:10:11-01:20:12:17 | 0.463 | ? | man appears to be engaged in conversation |
| 8 | 00:00:31:17-00:00:33:16 | 00:00:51:07-00:00:53:01 | 0.733 | OK | static or slow drifting |
| 9 | 00:00:33:16-00:00:36:19 | 01:20:28:20-01:20:31:17 | 0.529 | ? | speaking, transitioning from closed eyes to open mouth and focused gaze |
| 10 | 00:00:36:19-00:00:40:02 | 01:20:35:17-01:20:39:00 | 0.635 | ? | conversation |
| 11 | 00:00:40:02-00:00:42:03 | 01:20:40:19-01:20:42:19 | 0.502 | ? | static talking head with slight facial expression changes |
| 12 | 00:00:42:03-00:00:50:06 | 01:14:26:01-01:14:29:10 | 0.558 | ? | static profile shot transitioning to black/darkness |
| 13 | 00:00:50:06-00:00:53:21 | 00:43:20:02-00:43:23:11 | 0.468 | ? | static conversation; woman on right is standing and holding a cup |
| 14 | 00:00:53:21-00:00:57:02 | 00:43:24:09-00:43:27:04 | 0.444 | ? | static conversation, subject holding a white cup |
| 15 | 00:00:57:02-00:01:01:12 | 00:02:10:11-00:02:12:17 | 0.467 | ? | static conversation |
| 16 | 00:01:01:12-00:01:04:12 | 01:05:12:17-01:05:15:06 | 0.613 | ? | man reaches out and touches the red door with a small object |
| 17 | 00:01:04:12-00:01:09:03 | 01:31:22:11-01:31:24:09 | 0.684 | OK | Static intimacy transitioning to a spatial arrangement of figures |
| 18 | 00:01:09:03-00:01:10:19 | 00:09:13:13-00:09:14:20 | 0.668 | OK | Woman in foreground turns her head from profile to face the camera while speakin |
| 19 | 00:01:10:19-00:01:12:13 | 00:16:48:15-00:16:49:16 | 0.717 | OK | static conversation, subtle facial expression change |
| 20 | 00:01:12:13-00:01:15:14 | 01:28:04:18-01:28:05:15 | 0.663 | OK | man kisses woman's forehead, then they pull back slightly to face each other |
| 21 | 00:01:15:14-00:01:17:13 | —-— | 0.000 | MAN. | hand raised to mouth, slight facial movement |
| 22 | 00:01:17:13-00:01:19:23 | 01:03:05:16-01:03:07:10 | 0.545 | ? | |
| 23 | 00:01:19:23-00:01:25:14 | —-— | 0.000 | MAN. | |
| 24 | 00:01:25:14-00:01:32:07 | —-— | 0.000 | MAN. | |
### Vorlaeufig (Status `?`) — bitte sichten
## Beat-Details mit Vorschau-Stills
| Beat | Score | Source In | Phase laut Vision |
|-----:|------:|-----------|--------------------|
| 1 | 0.380 | 00:00:04:09 | |
| 3 | 0.469 | 01:02:17:22 | |
| 4 | 0.647 | 01:02:21:01 | |
| 5 | 0.501 | 00:01:33:04 | |
| 6 | 0.548 | 00:01:03:06 | |
| 7 | 0.463 | 01:20:10:10 | man appears to be engaged in conversation |
| 9 | 0.529 | 01:20:28:20 | speaking, transitioning from closed eyes to open mouth and focused gaze |
| 10 | 0.635 | 01:20:35:16 | conversation |
| 11 | 0.502 | 01:20:40:18 | static talking head with slight facial expression changes |
| 12 | 0.558 | 01:14:26:01 | static profile shot transitioning to black/darkness |
| 13 | 0.468 | 00:43:20:02 | static conversation; woman on right is standing and holding a cup |
| 14 | 0.444 | 00:43:24:09 | static conversation, subject holding a white cup |
| 15 | 0.467 | 00:02:10:11 | static conversation |
| 16 | 0.613 | 01:05:12:16 | man reaches out and touches the red door with a small object |
| 22 | 0.545 | 01:03:05:16 | |
### Beat 00 — Status `MAN.`
### Bestaetigt (Status `OK`) — kann uebernommen werden
- **Trailer**: 00:00:00:00 00:00:03:00
- **Source** : — (kein Treffer; manuell setzen)
- **Phase** : logo animation assembling from distorted shapes with motion blur
- **Bild** : centered, symmetrical, abstract black void
| Beat | Score | Source In | Phase laut Vision |
|-----:|------:|-----------|--------------------|
| 8 | 0.733 | 00:00:51:07 | static or slow drifting |
| 17 | 0.684 | 01:31:22:10 | Static intimacy transitioning to a spatial arrangement of figures |
| 18 | 0.668 | 00:09:13:12 | Woman in foreground turns her head from profile to face the camera while speaking |
| 19 | 0.717 | 00:16:48:14 | static conversation, subtle facial expression change |
| 20 | 0.663 | 01:28:04:17 | man kisses woman's forehead, then they pull back slightly to face each other |
| Trailer | Source |
|---|---|
| ![Trailer beat 0](output/cutter_stills/beat_00_trailer.jpg) | _(kein Still)_ |
## Hinweise zur Pruefung
### Beat 01 — Status `?`
1. Source-Times sollten zur jeweiligen Trailer-Bewegungsphase passen. Wenn nicht: Source-In innerhalb derselben Source-Szene wenige Frames vor/zurueck verschieben.
2. Wenn der Source-Clip kuerzer ist als der Trailerbeat (Source-Out < Trailer-Out gerechnet ab Source-In), enthaelt der Trailerbeat eine Blende/Titelkarte; im Schnitt mit Schwarzfade oder Source-Tail auffuellen.
3. `OK`-Beats sind durch CV + Vision-Phasenpruefung doppelt verifiziert; trotzdem stichprobenartig sichten.
- **Trailer**: 00:00:03:00 00:00:08:10
- **Source** : 00:00:04:10 00:00:06:04 (scene 1, score 0.380)
- **Phase** : (keine Vision-Beschreibung)
| Trailer | Source |
|---|---|
| ![Trailer beat 1](output/cutter_stills/beat_01_trailer.jpg) | ![Source beat 1](output/cutter_stills/beat_01_source.jpg) |
### Beat 02 — Status `MAN.`
- **Trailer**: 00:00:08:10 00:00:16:24
- **Source** : — (kein Treffer; manuell setzen)
- **Phase** : (keine Vision-Beschreibung)
| Trailer | Source |
|---|---|
| ![Trailer beat 2](output/cutter_stills/beat_02_trailer.jpg) | _(kein Still)_ |
### Beat 03 — Status `?`
- **Trailer**: 00:00:16:24 00:00:19:03
- **Source** : 01:02:17:23 01:02:19:15 (scene 436, score 0.469)
- **Phase** : (keine Vision-Beschreibung)
| Trailer | Source |
|---|---|
| ![Trailer beat 3](output/cutter_stills/beat_03_trailer.jpg) | ![Source beat 3](output/cutter_stills/beat_03_source.jpg) |
### Beat 04 — Status `?`
- **Trailer**: 00:00:19:03 00:00:20:16
- **Source** : 01:02:21:01 01:02:22:10 (scene 437, score 0.647)
- **Phase** : (keine Vision-Beschreibung)
| Trailer | Source |
|---|---|
| ![Trailer beat 4](output/cutter_stills/beat_04_trailer.jpg) | ![Source beat 4](output/cutter_stills/beat_04_source.jpg) |
### Beat 05 — Status `?`
- **Trailer**: 00:00:20:16 00:00:26:09
- **Source** : 00:01:33:04 00:01:37:10 (scene 10, score 0.501)
- **Phase** : (keine Vision-Beschreibung)
| Trailer | Source |
|---|---|
| ![Trailer beat 5](output/cutter_stills/beat_05_trailer.jpg) | ![Source beat 5](output/cutter_stills/beat_05_source.jpg) |
### Beat 06 — Status `?`
- **Trailer**: 00:00:26:09 00:00:29:06
- **Source** : 00:01:03:06 00:01:05:22 (scene 5, score 0.548)
- **Phase** : (keine Vision-Beschreibung)
| Trailer | Source |
|---|---|
| ![Trailer beat 6](output/cutter_stills/beat_06_trailer.jpg) | ![Source beat 6](output/cutter_stills/beat_06_source.jpg) |
### Beat 07 — Status `?`
- **Trailer**: 00:00:29:06 00:00:31:17
- **Source** : 01:20:10:11 01:20:12:17 (scene 553, score 0.463)
- **Phase** : man appears to be engaged in conversation
- **Bild** : man in a light gray sweater and scarf, seated on a white couch, with a window in the background, indoor with a view of the ocean
| Trailer | Source |
|---|---|
| ![Trailer beat 7](output/cutter_stills/beat_07_trailer.jpg) | ![Source beat 7](output/cutter_stills/beat_07_source.jpg) |
### Beat 08 — Status `OK`
- **Trailer**: 00:00:31:17 00:00:33:16
- **Source** : 00:00:51:07 00:00:53:01 (scene 5, score 0.733)
- **Phase** : static or slow drifting
- **Bild** : close-up, diagonal curve from top-left to bottom-center, dark, indistinct void
| Trailer | Source |
|---|---|
| ![Trailer beat 8](output/cutter_stills/beat_08_trailer.jpg) | ![Source beat 8](output/cutter_stills/beat_08_source.jpg) |
### Beat 09 — Status `?`
- **Trailer**: 00:00:33:16 00:00:36:19
- **Source** : 01:20:28:20 01:20:31:17 (scene 557, score 0.529)
- **Phase** : speaking, transitioning from closed eyes to open mouth and focused gaze
- **Bild** : medium close-up, subject positioned right of center, profile/three-quarter view, indoor room next to a large window overlooking a blue horizon/sea
| Trailer | Source |
|---|---|
| ![Trailer beat 9](output/cutter_stills/beat_09_trailer.jpg) | ![Source beat 9](output/cutter_stills/beat_09_source.jpg) |
### Beat 10 — Status `?`
- **Trailer**: 00:00:36:19 00:00:40:02
- **Source** : 01:20:35:17 01:20:39:00 (scene 558, score 0.635)
- **Phase** : conversation
- **Bild** : alternating close-ups and a medium two-shot, indoor living room with large windows showing a blue exterior landscape
| Trailer | Source |
|---|---|
| ![Trailer beat 10](output/cutter_stills/beat_10_trailer.jpg) | ![Source beat 10](output/cutter_stills/beat_10_source.jpg) |
### Beat 11 — Status `?`
- **Trailer**: 00:00:40:02 00:00:42:03
- **Source** : 01:20:40:19 01:20:42:19 (scene 559, score 0.502)
- **Phase** : static talking head with slight facial expression changes
- **Bild** : medium close-up, subject positioned right of center, profile/three-quarter view facing left, indoor room with a large window showing a blue sea/horizon background
| Trailer | Source |
|---|---|
| ![Trailer beat 11](output/cutter_stills/beat_11_trailer.jpg) | ![Source beat 11](output/cutter_stills/beat_11_source.jpg) |
### Beat 12 — Status `?`
- **Trailer**: 00:00:42:03 00:00:50:06
- **Source** : 01:14:26:01 01:14:29:10 (scene 519, score 0.558)
- **Phase** : static profile shot transitioning to black/darkness
- **Bild** : medium close-up, profile view, subject positioned on the right side of the frame, dark outdoor environment, blurred trees in background
| Trailer | Source |
|---|---|
| ![Trailer beat 12](output/cutter_stills/beat_12_trailer.jpg) | ![Source beat 12](output/cutter_stills/beat_12_source.jpg) |
### Beat 13 — Status `?`
- **Trailer**: 00:00:50:06 00:00:53:21
- **Source** : 00:43:20:02 00:43:23:11 (scene 308, score 0.468)
- **Phase** : static conversation; woman on right is standing and holding a cup
- **Bild** : wide shot, two figures positioned on opposite sides of a round dining table, modern glass-walled sunroom or conservatory overlooking a snowy landscape
| Trailer | Source |
|---|---|
| ![Trailer beat 13](output/cutter_stills/beat_13_trailer.jpg) | ![Source beat 13](output/cutter_stills/beat_13_source.jpg) |
### Beat 14 — Status `?`
- **Trailer**: 00:00:53:21 00:00:57:02
- **Source** : 00:43:24:09 00:43:27:04 (scene 309, score 0.444)
- **Phase** : static conversation, subject holding a white cup
- **Bild** : medium shot, subject positioned on the left, vertical window frame dividing the right third of the frame, interior room with a large window overlooking a snowy pine forest
| Trailer | Source |
|---|---|
| ![Trailer beat 14](output/cutter_stills/beat_14_trailer.jpg) | ![Source beat 14](output/cutter_stills/beat_14_source.jpg) |
### Beat 15 — Status `?`
- **Trailer**: 00:00:57:02 00:01:01:12
- **Source** : 00:02:10:11 00:02:12:17 (scene 0, score 0.467)
- **Phase** : static conversation
- **Bild** : medium shot, profile view of two characters facing each other, indoor room with a large window overlooking a snowy forest
| Trailer | Source |
|---|---|
| ![Trailer beat 15](output/cutter_stills/beat_15_trailer.jpg) | ![Source beat 15](output/cutter_stills/beat_15_source.jpg) |
### Beat 16 — Status `?`
- **Trailer**: 00:01:01:12 00:01:04:12
- **Source** : 01:05:12:17 01:05:15:06 (scene 451, score 0.613)
- **Phase** : man reaches out and touches the red door with a small object
- **Bild** : medium side profile shot, subject on left, door on right, indoor dim environment, adjacent to a red wooden door
| Trailer | Source |
|---|---|
| ![Trailer beat 16](output/cutter_stills/beat_16_trailer.jpg) | ![Source beat 16](output/cutter_stills/beat_16_source.jpg) |
### Beat 17 — Status `OK`
- **Trailer**: 00:01:04:12 00:01:09:03
- **Source** : 01:31:22:11 01:31:24:09 (scene 623, score 0.684)
- **Phase** : Static intimacy transitioning to a spatial arrangement of figures
- **Bild** : Medium shot, eye-level. First two frames: static shot of couple in bed. Third frame: wide shot of women among white blocks, Bedroom with bedside table and lamp; transition to a white minimalist interior with pedestals
| Trailer | Source |
|---|---|
| ![Trailer beat 17](output/cutter_stills/beat_17_trailer.jpg) | ![Source beat 17](output/cutter_stills/beat_17_source.jpg) |
### Beat 18 — Status `OK`
- **Trailer**: 00:01:09:03 00:01:10:19
- **Source** : 00:09:13:13 00:09:14:20 (scene 75, score 0.668)
- **Phase** : Woman in foreground turns her head from profile to face the camera while speaking
- **Bild** : Medium shot, three-quarter profile of woman in foreground left, two women positioned behind her to the right, Indoors, minimalist dark background
| Trailer | Source |
|---|---|
| ![Trailer beat 18](output/cutter_stills/beat_18_trailer.jpg) | ![Source beat 18](output/cutter_stills/beat_18_source.jpg) |
### Beat 19 — Status `OK`
- **Trailer**: 00:01:10:19 00:01:12:13
- **Source** : 00:16:48:15 00:16:49:16 (scene 126, score 0.717)
- **Phase** : static conversation, subtle facial expression change
- **Bild** : medium close-up, over-the-shoulder shot with a blurred figure in the foreground right, outdoor dark forest or wooded area
| Trailer | Source |
|---|---|
| ![Trailer beat 19](output/cutter_stills/beat_19_trailer.jpg) | ![Source beat 19](output/cutter_stills/beat_19_source.jpg) |
### Beat 20 — Status `OK`
- **Trailer**: 00:01:12:13 00:01:15:14
- **Source** : 01:28:04:18 01:28:05:15 (scene 613, score 0.663)
- **Phase** : man kisses woman's forehead, then they pull back slightly to face each other
- **Bild** : extreme close-up, profile view, faces facing each other, indoor, blurred background
| Trailer | Source |
|---|---|
| ![Trailer beat 20](output/cutter_stills/beat_20_trailer.jpg) | ![Source beat 20](output/cutter_stills/beat_20_source.jpg) |
### Beat 21 — Status `MAN.`
- **Trailer**: 00:01:15:14 00:01:17:13
- **Source** : — (kein Treffer; manuell setzen)
- **Phase** : hand raised to mouth, slight facial movement
- **Bild** : extreme close-up, face partially obscured by shadow, dark interior
| Trailer | Source |
|---|---|
| ![Trailer beat 21](output/cutter_stills/beat_21_trailer.jpg) | _(kein Still)_ |
### Beat 22 — Status `?`
- **Trailer**: 00:01:17:13 00:01:19:23
- **Source** : 01:03:05:16 01:03:07:10 (scene 442, score 0.545)
- **Phase** : (keine Vision-Beschreibung)
| Trailer | Source |
|---|---|
| ![Trailer beat 22](output/cutter_stills/beat_22_trailer.jpg) | ![Source beat 22](output/cutter_stills/beat_22_source.jpg) |
### Beat 23 — Status `MAN.`
- **Trailer**: 00:01:19:23 00:01:25:14
- **Source** : — (kein Treffer; manuell setzen)
- **Phase** : (keine Vision-Beschreibung)
| Trailer | Source |
|---|---|
| ![Trailer beat 23](output/cutter_stills/beat_23_trailer.jpg) | _(kein Still)_ |
### Beat 24 — Status `MAN.`
- **Trailer**: 00:01:25:14 00:01:32:07
- **Source** : — (kein Treffer; manuell setzen)
- **Phase** : (keine Vision-Beschreibung)
| Trailer | Source |
|---|---|
| ![Trailer beat 24](output/cutter_stills/beat_24_trailer.jpg) | _(kein Still)_ |
## Hinweise zur Prüfung
1. Wenn die Bewegungsphase im Source-Still nicht zum Trailer-Still passt, im NLE den Source-In um wenige Frames verschieben — innerhalb derselben Source-Szene reicht das meistens.
2. Wenn der Source-Clip kürzer ist als der Trailerbeat (Source-Out < Trailer-Out), enthält der Trailerbeat eine Blende oder Titelkarte; im Schnitt mit Schwarzfade oder dem Source-Tail auffüllen.
3. `OK`-Beats sind doppelt verifiziert (CV + Vision-Phase). Trotzdem stichprobenartig sichten.
4. Stills liegen unter `output/cutter_stills/`. Bei Bedarf einzelne neu generieren: einfach die Datei löschen und das Skript erneut laufen lassen.
+26 -268
View File
@@ -30,9 +30,9 @@ Was du bekommst sind zwei Dateien, mit denen du arbeitest:
1. Öffne `CUTTER_REPORT.md` und arbeite die Tabelle von oben nach unten ab.
2. Importiere die FCPXML/EDL ins NLE, lade Trailer und Spielfilm dazu.
3. Bei `OK`-Beats nur stichprobenartig sichten.
4. Bei `?`-Beats den Vorschauclip aus dem Report-HTML (siehe unten) prüfen
und im NLE den Source-In um wenige Frames vor/zurück verschieben, bis die
Bewegungsphase exakt zum Trailer passt.
4. Bei `?`-Beats die beiden Vorschau-Stills im `CUTTER_REPORT.md` direkt
vergleichen (Trailer-Frame neben Source-Frame). Wenn die Bewegungsphase
nicht passt, im NLE den Source-In um wenige Frames vor/zurück verschieben.
5. Bei `MAN.`-Beats selbst die passende Stelle im Spielfilm suchen — die
Beschreibung im Report sagt dir was du suchst.
@@ -139,275 +139,33 @@ python cli.py rematch --beat 5 --threshold 0.50 # Schwelle anpassen (Globaler S
python cli.py rematch --beat 5 --refine # Cached Match per lokalem Bildinhalt-Offset nachschärfen
```
Der HTML-Report regeneriert seine Preview-Clips bei jedem Lauf mit genauer
FFmpeg-Nachsuche und synchronisiert die beiden Video-Player pro Beat. Dadurch
ist der Report zur Frame-Prüfung geeignet und zeigt keine alten gecachten
Preview-Clips.
Source-Previews bekommen bei Trailer-only-Tails denselben schwarzen Tail wie der
Export, damit der Browser nicht einen zu kurzen Source-Clip gegen den längeren
Referenzbeat weiterspult oder loopt.
Zur Synchronprüfung rendert der Report ein einzelnes Frame-Locked-Compare-Video
mit Referenz und Source in demselben MP4-Stream. Dieses Compare-Video ist
maßgeblich, weil zwei getrennte Browser-Videoelemente nie zuverlässig
framegenau synchron bleiben.
### Cutter-Report neu erzeugen
Wenn ein Trailer-Beat am Ende eine Blende, Schwarzfläche oder Textkarte enthält,
die im Source-Film nicht als normaler Shot vorhanden ist, endet der Source-Match
am letzten stabil passenden Frame. Exportierte Timelines behalten trotzdem die
volle Beat-Länge und fügen danach automatisch einen schwarzen Trailer-Tail mit
Marker für Fade/Dissolve ein.
`CUTTER_REPORT.md` wird bei jedem `match`-Lauf automatisch geschrieben.
Manuell neu erzeugen (z. B. nach Edit eines einzelnen Beats):
Gezielte Ein-Beat-Matches nutzen zusätzlich vorhandene automatische Nachbarbeats
aus dem Cache als zeitliche Suchanker. Das hilft bei aufeinanderfolgenden Shots,
ohne manuelle Szenen oder Timecodes zu kuratieren.
Bei `match --beat N` wird ein alter Cache-Treffer für genau diesen Beat entfernt
und nur ein neu gefundener automatischer Treffer wieder eingetragen. Ein
fehlgeschlagener neuer Lauf kann dadurch keinen alten falschen Report-Treffer
stehen lassen.
```powershell
python scripts/generate_cutter_report.py # mit Vorschau-Stills
python scripts/generate_cutter_report.py --no-stills # nur die Tabelle
```
Der globale Bildvergleich arbeitet auf kontrast-normalisierten Luma- und
Kantenfeatures statt auf rohen Farb-Pixeln. Dadurch bleiben Schwarzweiß- oder
anders gegradete Trailerbilder mit dem Source-Material vergleichbar, während
unähnliche Farbshots schlechter ranken.
Die Inpoint-Feinjustage bestimmt den Versatz lokal aus dem Bildinhalt: Um einen
groben Treffer herum werden mehrere Referenzframes gegen mehrere Source-Offsets
verglichen, und der beste gemeinsame Offset wird übernommen. Das ist schneller
als ein erneuter globaler Scan und vermeidet pauschale Frame-Prerolls.
Zusätzlich wird die Bewegungsphase über Frame-zu-Frame-Differenzen verglichen.
Dadurch kann der Matcher innerhalb derselben Source-Szene unterscheiden, ob
zwei Figuren noch sprechen, sich annähern, bereits im Kontakt sind oder sich
wieder voneinander lösen. Ein optisch ähnlicher Standbild-Treffer reicht damit
nicht mehr aus, wenn der Bewegungsverlauf nicht zur Referenz passt.
Schwarze Referenzframes aus Blenden oder Titel-Tails werden für diese
Offset-Messung ausgelassen, damit echte Bildbewegung und nicht die Blende selbst
den Inpoint bestimmt.
`rematch --refine` nutzt denselben lokalen FFmpeg/Pillow-Aligner und schreibt
den korrigierten Inpoint direkt zurück in `.cache/match_results.json`.
Stills landen unter `output/cutter_stills/` und werden nur neu gerendert,
wenn sich das zugrundeliegende Match geändert hat.
### Wenn ein Match falsch wirkt
| Symptom | Was tun |
|---------|---------|
| Source-Clip zeigt richtige Szene, aber falsche Bewegungsphase | `python cli.py rematch --beat N --refine` — schiebt den Inpoint frame-genau aus dem Bildinhalt. |
| Score zu niedrig, andere Szene wäre richtig | `python cli.py match --beat N --vision` — vollständiger Re-Match nur für diesen Beat mit Vision-Phasenprüfung. |
| Match offensichtlich falsche Szene | `python cli.py rematch --beat N --threshold 0.50` — Schwelle absenken, neuer globaler Scan nur für diesen Beat. |
| Beat ist Schwarzbild / Logo / Titel und sollte gar nicht matchen | nichts tun, der Status `MAN.` im `CUTTER_REPORT.md` ist korrekt. |
### Algorithmische Details
Tiefer in die Matcher-Logik (Phasen-Reparatur, segmentierte Beats,
Vision-Seeds, Recovery-Stufe usw.) — siehe [`docs/ALGORITHM.md`](docs/ALGORITHM.md).
Zusätzlich werden aus den besten szenenweiten Luma/Histogramm-Kandidaten
mehrere Inpoint-Suchanker erzeugt. Diese Scene-Seeds verwenden keine harte
pHash-Sperre, weil pHash bei stark anders gegradeten Trailerbildern echte
Matches zu früh ausschließen kann.
Optional kann `python cli.py match --beat N --vision` einen Vision-Layer
zuschalten. Dann werden pro Trailer-Beat und pro wenigen Scene-Level-Kandidaten
je drei Frames (Anfang, Mitte, Ende) von einem visionfähigen OpenAI-kompatiblen
Modell beschrieben. Die Beschreibungen liegen in
`.cache/vision_descriptions.json` und werden wiederverwendet. Vision erzeugt
nur zusätzliche Suchanker; der eigentliche Match muss weiterhin durch CV,
Content-Reranking, Timing und Duration-Coverage bestätigt werden.
Gecachte Szenenbeschreibungen zählen nur, wenn sie vom aktuell konfigurierten
Vision-Modell stammen. Bei langen semantisch passenden Source-Szenen beschreibt
der Vision-Layer zusätzlich wenige lokale Zeitfenster und cached auch diese
Fenster, damit eine grob ähnliche Szene nicht automatisch mit dem falschen
Bewegungs- oder Dialogmoment gleichgesetzt wird.
Dieser lokale Fenster-Probe ist bewusst breiter als die finale Seed-Auswahl:
Eine lange Dialogszene kann in der Gesamtbeschreibung nur als Gespräch
erscheinen, aber an einer späteren Stelle trotzdem genau die gesuchte
Aktionsphase enthalten.
Für diese Probe wird deshalb die grobe Szenenähnlichkeit ohne harte
Aktionsstrafe gerankt; die harte Aktionsprüfung greift erst auf den lokalen
Fenstern und dem finalen Source-Zeitbereich.
Nach dem CV-Match kann derselbe Vision-Layer den konkreten finalen Source-
Zeitbereich nochmals gegen den Trailer-Beat prüfen. Starke Aktionsphasen wie
Annäherung, Kuss/Stirnkontakt, Handbewegungen oder Schneiden müssen dann auch
im Source-Fenster beschrieben sein; fehlt diese Aktionsphase, wird der Treffer
nicht gespeichert, selbst wenn der Low-Level-CV-Score hoch ist.
Wenn die Szene selbst plausibel ist, aber der konkrete Source-Zeitpunkt diese
Aktionsphase verfehlt, sucht der Matcher automatisch dichter innerhalb derselben
Source-Szene nach lokalen Vision-Fenstern mit der passenden Aktion und richtet
den Inpoint mit der Motion-Phase-Prüfung darauf neu aus. Erst wenn auch diese
In-Scene-Reparatur scheitert, wird der Treffer verworfen.
Diese In-Scene-Reparatur läuft auch für semantisch gültige Treffer aus langen
Source-Szenen. Dadurch kann ein grob passender Dialogmoment nicht bestehen
bleiben, wenn ein anderes lokales Fenster derselben Szene die gesuchte
Aktionsphase und Bewegung klarer trifft.
Die Kandidatenbewertung dieser Reparatur vergleicht dabei zwei Kontexte:
den ganzen Beat als semantischen Handlungsrahmen und das konkret sichtbare
Beat-Segment als Phasenprüfung. Ein Source-Zeitpunkt muss also nicht nur
"die Szene mit dem Kuss" enthalten, sondern auch zur aktuellen Bewegungsphase
des sichtbaren Trailerabschnitts passen. Pro Kandidat fließt zusätzlich ein
lokaler Content-/Motion-Frame-Score ein, damit cached Vision-Beschreibungen
keinen sichtbar versetzten Bewegungsmoment überstimmen.
Bei blendigen oder segmentierten Beats nutzt die semantische Action-Suche den
ganzen Trailerbeat als Kontext. Die eigentliche Frame-Ausrichtung bleibt auf das
sichtbare Segment begrenzt; der gefundene Source-Inpoint wird dabei um den
Trailer-Offset des Segments verschoben. So geht die globale Aktionsbeschreibung
eines Beats nicht verloren, nur weil der scorebare Teil erst nach einer Blende
beginnt.
Die Suche nach diesem Action-Window prüft pro Segment zwei Beschreibungen:
zuerst die des konkret sichtbaren Segments (so trifft die Phasensuche genau die
gerade gezeigte Bewegung), als Rückfall die des gesamten Beats. Der Beat-
Kontext gewinnt nur, wenn er deutlich (>0.06) besser scort; sonst bleibt das
Segment-Fenster die Wahl, weil die Beat-Beschreibung Aktionen aus Fade-Bildern
mit aufnehmen kann, die im sichtbaren Segment nicht stattfinden.
Der Trailer-Offset-Shift wird nur angewendet, wenn tatsächlich der Beat-Kontext
benutzt wurde; bei segmentbasierter Wahl ist das gefundene Fenster bereits auf
die sichtbare Aktionsphase ausgerichtet.
Der Segment-Offset zählt dabei nur über vorherige scorebare Bildinseln, nicht
über schwarze oder blendige Lücken. Nach dem Retiming wird die nutzbare
Source-Dauer erneut geschätzt; läuft die Source am Ende in eine sichtbar andere
Aktionsphase, wird der Clip gekürzt und der Rest bleibt Placeholder/Fade statt
einen falschen Bewegungsmoment zu zeigen.
Der gewichtete Vision-Seed-Pfad ersetzt standardmäßig keinen normalen
FFmpeg-Vollscan. Vision-Beschreibungen sind semantische Hinweise, aber keine
Beweise; der volle CV-Scan bleibt deshalb aktiv, damit falsch bewertete
Vision-Szenen echte Treffer nicht verdrängen. Für schnelle Experimente kann
`skip_coarse_scan_with_weighted_seeds = true` gesetzt werden.
Gewichtete Vision-Seeds werden nicht zuerst durch den alten Midpoint-Template
Refine verschoben; sie gehen direkt in die lokale Content-Alignment-Prüfung.
Das schützt wiederholte Gesprächseinstellungen, bei denen ähnliche Momente
mehrfach in derselben Szene vorkommen.
Innerhalb der automatisch von Vision vorgeschlagenen Szenen läuft zusätzlich
eine dichte lokale Bildsequenzsuche. Sie misst den Phasenversatz in kleinen
Zeitschritten direkt am Bildinhalt und bevorzugt Kandidaten mit genügend
Restdauer in derselben Source-Szene. Das ist kein manueller Override: Vision
grenzt nur Suchbereiche ein, die Auswahl bleibt Content-, Timing- und
Coverage-getrieben.
Nach einem dichten Vision-Treffer darf der spätere lokale Aligner nur noch im
Bereich dieses Scan-Schritts nachjustieren. So kann ein korrekt gefundener
Bewegungsmoment nicht wieder um viele Frames in eine ähnlich aussehende Phase
derselben Szene verschoben werden.
Für Vision-Action-Fenster nutzt die finale Retiming-Prüfung eine gemeinsame
Content-und-Motion-Suche pro Frame. Content und Bewegungsphase werden dabei
nicht mehr als zwei getrennte Korrekturschritte angewendet; das verhindert,
dass eine kurze Geste erst korrekt erkannt und anschließend in eine spätere
ähnliche Körperhaltung verschoben wird.
Wenn mehrere Vision-Kandidaten in derselben Source-Szene ähnlich gut scoren
und die Beat-Dauer abdecken, bevorzugt der Matcher die frühere Phase. Das
verhindert, dass ein späterer, minimal stärkerer Standbildtreffer die
Bewegungsphase des Trailers sichtbar überholt.
Enthält ein Trailerbeat selbst einen harten Umschnitt, werden Kandidaten an
angrenzenden Source-Szenengrenzen zusätzlich als zusammenhängender Multi-Shot-
Span geprüft. Ein Match darf dann über eine Source-Szenengrenze laufen, aber
nur wenn die relative Source-Grenze zeitlich zu einem erkannten Trailer-Umschnitt
passt. So kann ein Beat aus Frage/Antwort-Shots vollständig erfasst werden,
ohne Szenen willkürlich zusammenzukleben.
Auch der lokale Content-Aligner darf einen Inpoint nur noch übernehmen, wenn
die feste Whole-Frame-/Spatial-Validation dadurch besser wird.
Vor dem teuren Frame-Refine wird der gesamte Kandidatenpool mit einer schnellen
festen Inhaltsprüfung neu sortiert. Dadurch können korrekte Treffer aus
wiederholten Einstellungen einer Szene nach oben kommen, auch wenn ein freier
Template-Peak an anderer Stelle numerisch stärker war. Suchanker bleiben im
Pool erhalten, dürfen aber erst nach der Inhaltsprüfung nach oben rücken. Wenn
ein Kandidat visuell plausibel ist, aber wegen Trailerblende oder kurzem
Source-Span die normale Coverage knapp verfehlt, wird er als provisional Match
behalten statt als `NO MATCH` verworfen.
Dieses Reranking berücksichtigt zusätzlich die verbleibende Szenenlänge ab dem
Kandidaten-Inpoint. Dadurch werden zu späte ähnliche Gesprächsphasen innerhalb
derselben Szene nicht mehr vor frühere, tragfähigere Phasen sortiert.
Das Inhalts-Reranking nutzt bewusst nur wenige repräsentative Referenzframes und
eine begrenzte Kandidatenzahl. So bleiben wiederholte Szenen auffindbar, ohne
dass der Lauf durch tausende Random-Seeks minutenlang festhängt.
Confirmed Matches werden zusätzlich durch eine feste nahezu-Whole-Frame-Prüfung
aus Luma, Kanten, Farbhistogramm und räumlichen 4x4-Farbhistogrammen gedeckelt.
Dadurch kann ein freier Template-Hit mit ähnlicher Fenster-/Gesichtsstruktur
nicht mehr als sicherer Match gelten, wenn die Gesamtkomposition oder die
Bewegungsphase sichtbar eine andere Szene ist.
Für gewichtete Vision-Kandidaten gibt es zusätzlich eine eigene Provisional-
Bewertung aus Content-Score, Restdauer und Seed-Stärke. Dadurch können echte,
aber durch Trailer-Grading/Crop numerisch schwache Treffer im Report landen,
ohne als confirmed Match durchzugehen.
Die Cache-Normalisierung für Report/Export verwendet dieselbe niedrigere
Content-Untergrenze für nicht bestätigte Vision-Provisional-Treffer, damit ein
gerade gefundener automatischer Match nicht beim Report-Aufbau wieder
weggefiltert wird.
Sie übernimmt auch die Multi-Shot-Coverage-Regel: gecachte Treffer, die passend
zu internen Trailer-Umschnitten über angrenzende Source-Szenen laufen, werden
nicht mehr auf die erste Source-Szene zurückgekürzt.
Gezielte Einzel-Beat-Matches gewichten außerdem die automatisch aus Nachbarbeats
abgeleiteten Continuity-Seeds. Wenn ein Beat direkt an einen bereits passenden
Vorgänger anschließt, kann ein späterer ähnlich aussehender Moment derselben
Dialogszene den erwarteten Anschluss nicht mehr nur wegen eines höheren
Standbildscores verdrängen.
Diese Continuity-Seeds sind aber nur Suchanker: in derselben Szene darf ein
späterer Inpoint gewinnen, wenn die mehrframeige Content-Prüfung die
Bewegungsphase klar besser trifft. Dadurch bleiben Anschlussmatches stabil,
ohne Hand-/Kopfbewegungen auf einen falschen Zeitpunkt festzunageln.
Continuity- und Vision-Seeds allein schalten den globalen FFmpeg-Scan
standardmäßig nicht ab. Sie sind Suchanker, keine Beweise; der volle CV-Scan
bleibt aktiv, damit semantisch plausible, aber falsche Vision-Treffer echte
Bildmatches nicht verdrängen.
Bei aktivierter Vision wird für gezielte Match-Läufe trotzdem zuerst ein
schneller seed-basierter CV-Prepass ausgeführt. Er überspringt den vollen
FFmpeg-Stream nur vorläufig und akzeptiert einen Treffer erst nach derselben
Bild-/Phasenvalidierung wie der normale Matcher. Nur nicht gelöste Beats fallen
danach auf den vollständigen Scan zurück. Die Qualitätsparameter für lokale
Vision-Szenenscans und Refine-Kandidaten bleiben dabei erhalten; der Prepass ist
eine Reihenfolge-Optimierung, kein Qualitätsdeckel.
Provisional Treffer aus diesem schnellen Prepass sind nicht endgültig: wenn sie
unterhalb der Confirmed-Schwelle bleiben, läuft zusätzlich der vollständige
CV-Scan und darf den besseren oder bestätigten Treffer übernehmen.
OpenRouter-/Vision-Rate-Limits werden mit progressiv längeren Pausen erneut
versucht. Billing-, Credit- oder Token-Guthaben-Fehler werden dagegen sofort als
echter Blocker gemeldet, weil Warten dort nicht hilft.
Auch Netzfehler beim Lesen der Antwort (Timeouts, Verbindungsabbrüche während
einer DSL-Trennung) werden als retrybar behandelt, nicht nur Verbindungsfehler
beim Verbindungsaufbau. Schlägt die Vision-Verifikation während der finalen
Filter-/Repair-Stufe trotzdem dauerhaft fehl, wird der bisherige gecachte
Treffer für diesen Beat behalten statt verworfen — ein Netzproblem darf keinen
schon korrekt gefundenen Match aus dem Cache löschen.
Die Phasen-Reparatur an gefundenen Treffern läuft nicht mehr nur in „langen"
Source-Szenen, sondern überall dort, wo die Szene mehr als nur das
Segment-Fenster trägt. Eine korrigierte Position wird übernommen, sobald sie
das Bildinhalt-Validate besteht UND nicht spürbar schlechter scort als das
Original (≤ 0.02 Verlust). Bereits bestätigte Treffer in eng zugeschnittenen
Szenen werden bewusst nicht angefasst, damit ein guter Match nicht durch eine
nominell gleichwertige Alternative ausgetauscht wird.
Beats, die nach dem CV-Lauf weder als Vollmatch noch als Segmentmatch landen,
durchlaufen anschließend eine Recovery-Stufe: Vibe-Check (Histogramm/pHash)
liefert Top-K Kandidatenszenen, die semantische Action-Window-Suche prüft
darin die Phase des sichtbaren Trailerbeat-Anteils, und der CV-Aligner setzt
den Inpoint frame-genau. Übernommen wird nur ein Kandidat, der dieselbe
Vision-Phasenvalidierung wie der Hauptpfad besteht. Beats ohne sichtbares
Bildmaterial (Logos, Titel-Karten, durchgehende Fades) werden gar nicht erst
gesucht — sie sind bewusst kein Match.
Lange Trailerbeats werden nicht mehr automatisch über ihre gesamte Beat-Länge
gegen einen einzigen Source-Clip validiert. Sobald nach einem sichtbaren
Source-Abschnitt eine anhaltende Schwarzblende oder Titel-/Credit-Insel beginnt,
endet der matchbare Referenzbereich dort; zwei aufeinanderfolgende dunkle
Samples reichen dafür. Spätere Text-/Creditbilder im selben Beat gehen damit
nicht mehr in Reranking, Validation oder Span-Schätzung ein.
Wenn nach einer Blende wieder sichtbares, matchbares Material kommt, wird der
Beat nicht mehr als „Source + schwarzer Tail“ behandelt. Der CLI-Match speichert
zusätzliche `MatchSegment`-Einträge für jede automatisch erkannte sichtbare
Insel; der HTML-Report setzt diese Source-Segmente frame-lockend zusammen und
füllt nur echte Zwischenlücken mit Schwarz. Dadurch können per Blende verbundene
Trailer-Einstellungen innerhalb eines Beats getrennt gematcht werden, ohne die
globale Scene Detection aggressiver oder beat-spezifisch zu kuratieren.
Beats mit mehreren sichtbaren Inseln werden direkt segmentiert gesucht, statt
zuerst als ein künstlich zusammenhängender Source-Clip über den ganzen Film zu
laufen. Jede Insel nutzt dieselbe gestufte Vision-/CV-Validierung wie ein
normaler Beat; der zusammengesetzte Report bleibt beat-synchron. Wenn der
schnelle validierte Vision-Prepass für eine Insel keinen Treffer liefert, darf
diese Insel weiterhin in den vollständigen Scan fallen.
Falls ein kompletter Beat keinen belastbaren Einzelclip ergibt, versucht der
Matcher dieselbe Segmentlogik automatisch als Fallback: sichtbare Inseln werden
einzeln global gesucht und anschließend wieder zu einem Beat-Ergebnis
zusammengesetzt. Sehr kurze Inseln dürfen zusätzlich in den Source-Szenen
benachbarter bereits gematchter Beats lokal nach ihrer Bewegungsphase suchen.
Das ist weiterhin nur ein allgemeiner Continuity-Anker, kein manueller Override
für bestimmte Beat-Nummern oder Szenen.
Besteht ein Beat nach automatischer Fade-/Titel-Filterung nur aus einer
einzigen sichtbaren Insel, wird diese Insel direkt als primäres Suchziel
verwendet. Dadurch scannt der Matcher denselben Bildinhalt nicht erst als
vollen Beat und danach noch einmal als Segment; der Report behält trotzdem die
korrekte Beat-Position und füllt echte Randlücken mit Schwarz.
Gecachte segmentierte Treffer werden ebenfalls gegen die automatisch sichtbare
Referenzdauer normalisiert, nicht gegen Schwarz-/Blendränder des gesamten Beats.
Ein korrekt gematchter kurzer Bildinhalt wird dadurch beim Report-Aufbau nicht
nachträglich als zu kurz verworfen.
Zusätzlich werden sehr dunkle, kontrastarme oder noch nicht sauber
auf-/abgeblendete Referenzframes aus Score, Inhalts-Reranking,
Phasen-Alignment und Motion-Templates herausgenommen. Blenden sollen bestimmen,
wie der Clip später exportiert wird, aber nicht, ob der Bildinhalt als Match
gilt.
Sichtbare Fade-Rampen werden nur in eine matchbare Insel hinein erweitert, wenn
sie strukturell stark zum ersten bzw. letzten scorebaren Frame derselben
Einstellung passen. Doppelbelichtungen aus Cross-Dissolves bleiben dadurch
Übergangsmaterial und werden nicht als einzelner Quellclip erzwungen.
Treffer unter `provisional_content_threshold` werden gar nicht mehr gespeichert
oder aus alten Cache-Ergebnissen übernommen. Das verhindert, dass offensichtlich
falsche Szenen im Report als Match-Kandidat weiterleben.
### Log-Level
+298
View File
@@ -0,0 +1,298 @@
# Algorithmus-Notizen
Detaillierte Verhaltens­beschreibung und Designentscheidungen des Matchers.
Für die normale Bedienung reicht die [README](../README.md) — dieses Dokument
ist die Referenz für den Tool-Verantwortlichen, wenn etwas im Verhalten
unklar wird oder ein Match systematisch falsch landet.
## HTML-Report und Vorschauclips
Der HTML-Report regeneriert seine Preview-Clips bei jedem Lauf mit genauer
FFmpeg-Nachsuche und synchronisiert die beiden Video-Player pro Beat. Dadurch
ist der Report zur Frame-Prüfung geeignet und zeigt keine alten gecachten
Preview-Clips. Source-Previews bekommen bei Trailer-only-Tails denselben
schwarzen Tail wie der Export, damit der Browser nicht einen zu kurzen
Source-Clip gegen den längeren Referenzbeat weiterspult oder loopt.
Zur Synchronprüfung rendert der Report ein einzelnes Frame-Locked-Compare-Video
mit Referenz und Source in demselben MP4-Stream. Dieses Compare-Video ist
maßgeblich, weil zwei getrennte Browser-Videoelemente nie zuverlässig
framegenau synchron bleiben.
## Trailer-Tails ohne Source-Pendant
Wenn ein Trailer-Beat am Ende eine Blende, Schwarzfläche oder Textkarte
enthält, die im Source-Film nicht als normaler Shot vorhanden ist, endet der
Source-Match am letzten stabil passenden Frame. Exportierte Timelines behalten
trotzdem die volle Beat-Länge und fügen danach automatisch einen schwarzen
Trailer-Tail mit Marker für Fade/Dissolve ein.
## Targeted single-beat re-matches
Gezielte Ein-Beat-Matches nutzen zusätzlich vorhandene automatische Nachbarbeats
aus dem Cache als zeitliche Suchanker. Das hilft bei aufeinanderfolgenden
Shots, ohne manuelle Szenen oder Timecodes zu kuratieren. Bei `match --beat N`
wird ein alter Cache-Treffer für genau diesen Beat entfernt und nur ein neu
gefundener automatischer Treffer wieder eingetragen. Ein fehlgeschlagener
neuer Lauf kann dadurch keinen alten falschen Report-Treffer stehen lassen.
## Bildvergleich auf Luma + Kanten
Der globale Bildvergleich arbeitet auf kontrast-normalisierten Luma- und
Kantenfeatures statt auf rohen Farb-Pixeln. Dadurch bleiben Schwarzweiß-
oder anders gegradete Trailerbilder mit dem Source-Material vergleichbar,
während unähnliche Farbshots schlechter ranken.
Die Inpoint-Feinjustage bestimmt den Versatz lokal aus dem Bildinhalt: um
einen groben Treffer herum werden mehrere Referenzframes gegen mehrere
Source-Offsets verglichen, und der beste gemeinsame Offset wird übernommen.
Das ist schneller als ein erneuter globaler Scan und vermeidet pauschale
Frame-Prerolls.
## Bewegungsphasen-Vergleich
Zusätzlich wird die Bewegungsphase über Frame-zu-Frame-Differenzen verglichen.
Dadurch kann der Matcher innerhalb derselben Source-Szene unterscheiden, ob
zwei Figuren noch sprechen, sich annähern, bereits im Kontakt sind oder sich
wieder voneinander lösen. Ein optisch ähnlicher Standbild-Treffer reicht damit
nicht mehr aus, wenn der Bewegungsverlauf nicht zur Referenz passt.
Schwarze Referenzframes aus Blenden oder Titel-Tails werden für diese
Offset-Messung ausgelassen, damit echte Bildbewegung und nicht die Blende
selbst den Inpoint bestimmt. `rematch --refine` nutzt denselben lokalen
FFmpeg/Pillow-Aligner und schreibt den korrigierten Inpoint direkt zurück in
`.cache/match_results.json`.
## Vision-Layer (optional)
Optional kann `python cli.py match --beat N --vision` einen Vision-Layer
zuschalten. Dann werden pro Trailer-Beat und pro wenigen Scene-Level-
Kandidaten je drei Frames (Anfang, Mitte, Ende) von einem visionfähigen
OpenAI-kompatiblen Modell beschrieben. Die Beschreibungen liegen in
`.cache/vision_descriptions.json` und werden wiederverwendet. Vision erzeugt
nur zusätzliche Suchanker; der eigentliche Match muss weiterhin durch CV,
Content-Reranking, Timing und Duration-Coverage bestätigt werden.
Gecachte Szenenbeschreibungen zählen nur, wenn sie vom aktuell konfigurierten
Vision-Modell stammen. Bei langen semantisch passenden Source-Szenen
beschreibt der Vision-Layer zusätzlich wenige lokale Zeitfenster und cached
auch diese Fenster, damit eine grob ähnliche Szene nicht automatisch mit dem
falschen Bewegungs- oder Dialogmoment gleichgesetzt wird. Dieser lokale
Fenster-Probe ist bewusst breiter als die finale Seed-Auswahl: eine lange
Dialogszene kann in der Gesamtbeschreibung nur als Gespräch erscheinen, aber
an einer späteren Stelle trotzdem genau die gesuchte Aktionsphase enthalten.
Für diese Probe wird die grobe Szenenähnlichkeit ohne harte Aktionsstrafe
gerankt; die harte Aktionsprüfung greift erst auf den lokalen Fenstern und
dem finalen Source-Zeitbereich.
## Aktionsphase-Verifikation nach dem CV-Match
Nach dem CV-Match kann derselbe Vision-Layer den konkreten finalen Source-
Zeitbereich nochmals gegen den Trailer-Beat prüfen. Starke Aktionsphasen wie
Annäherung, Kuss/Stirnkontakt, Handbewegungen oder Schneiden müssen dann auch
im Source-Fenster beschrieben sein; fehlt diese Aktionsphase, wird der
Treffer nicht gespeichert, selbst wenn der Low-Level-CV-Score hoch ist.
Wenn die Szene selbst plausibel ist, aber der konkrete Source-Zeitpunkt
diese Aktionsphase verfehlt, sucht der Matcher automatisch dichter innerhalb
derselben Source-Szene nach lokalen Vision-Fenstern mit der passenden Aktion
und richtet den Inpoint mit der Motion-Phase-Prüfung darauf neu aus. Erst
wenn auch diese In-Scene-Reparatur scheitert, wird der Treffer verworfen.
Diese In-Scene-Reparatur läuft auch für semantisch gültige Treffer aus langen
Source-Szenen.
Die Kandidatenbewertung dieser Reparatur vergleicht zwei Kontexte: den ganzen
Beat als semantischen Handlungsrahmen und das konkret sichtbare Beat-Segment
als Phasenprüfung. Ein Source-Zeitpunkt muss nicht nur „die Szene mit dem
Kuss" enthalten, sondern auch zur aktuellen Bewegungsphase des sichtbaren
Trailerabschnitts passen. Pro Kandidat fließt zusätzlich ein lokaler
Content-/Motion-Frame-Score ein, damit cached Vision-Beschreibungen keinen
sichtbar versetzten Bewegungsmoment überstimmen.
## Segmentierte Beats (Beats mit Blenden / Inseln)
Bei blendigen oder segmentierten Beats nutzt die semantische Action-Suche den
ganzen Trailerbeat als Kontext. Die eigentliche Frame-Ausrichtung bleibt auf
das sichtbare Segment begrenzt; der gefundene Source-Inpoint wird um den
Trailer-Offset des Segments verschoben. So geht die globale Aktionsbeschreibung
eines Beats nicht verloren, nur weil der scorebare Teil erst nach einer Blende
beginnt.
Die Suche nach diesem Action-Window prüft pro Segment zwei Beschreibungen:
zuerst die des konkret sichtbaren Segments (so trifft die Phasensuche genau
die gerade gezeigte Bewegung), als Rückfall die des gesamten Beats. Der
Beat-Kontext gewinnt nur, wenn er deutlich (>0.06) besser scort; sonst bleibt
das Segment-Fenster die Wahl, weil die Beat-Beschreibung Aktionen aus
Fade-Bildern mit aufnehmen kann, die im sichtbaren Segment nicht stattfinden.
Der Trailer-Offset-Shift wird nur angewendet, wenn tatsächlich der Beat-
Kontext benutzt wurde; bei segmentbasierter Wahl ist das gefundene Fenster
bereits auf die sichtbare Aktionsphase ausgerichtet.
Der Segment-Offset zählt nur über vorherige scorebare Bildinseln, nicht über
schwarze oder blendige Lücken. Nach dem Retiming wird die nutzbare Source-
Dauer erneut geschätzt; läuft die Source am Ende in eine sichtbar andere
Aktionsphase, wird der Clip gekürzt und der Rest bleibt Placeholder/Fade
statt einen falschen Bewegungsmoment zu zeigen.
## Vision-Seeds vs. Vollscan
Der gewichtete Vision-Seed-Pfad ersetzt standardmäßig keinen normalen
FFmpeg-Vollscan. Vision-Beschreibungen sind semantische Hinweise, aber keine
Beweise; der volle CV-Scan bleibt aktiv, damit falsch bewertete Vision-Szenen
echte Treffer nicht verdrängen. Für schnelle Experimente kann
`skip_coarse_scan_with_weighted_seeds = true` gesetzt werden.
Gewichtete Vision-Seeds werden nicht zuerst durch den alten Midpoint-Template
Refine verschoben; sie gehen direkt in die lokale Content-Alignment-Prüfung.
Das schützt wiederholte Gesprächseinstellungen, bei denen ähnliche Momente
mehrfach in derselben Szene vorkommen.
Innerhalb der automatisch von Vision vorgeschlagenen Szenen läuft zusätzlich
eine dichte lokale Bildsequenzsuche. Sie misst den Phasenversatz in kleinen
Zeitschritten direkt am Bildinhalt und bevorzugt Kandidaten mit genügend
Restdauer in derselben Source-Szene. Das ist kein manueller Override: Vision
grenzt nur Suchbereiche ein, die Auswahl bleibt Content-, Timing- und
Coverage-getrieben. Nach einem dichten Vision-Treffer darf der spätere
lokale Aligner nur noch im Bereich dieses Scan-Schritts nachjustieren. So
kann ein korrekt gefundener Bewegungsmoment nicht wieder um viele Frames in
eine ähnlich aussehende Phase derselben Szene verschoben werden.
Für Vision-Action-Fenster nutzt die finale Retiming-Prüfung eine gemeinsame
Content-und-Motion-Suche pro Frame. Content und Bewegungsphase werden nicht
mehr als zwei getrennte Korrekturschritte angewendet; das verhindert, dass
eine kurze Geste erst korrekt erkannt und anschließend in eine spätere
ähnliche Körperhaltung verschoben wird. Wenn mehrere Vision-Kandidaten in
derselben Source-Szene ähnlich gut scoren und die Beat-Dauer abdecken,
bevorzugt der Matcher die frühere Phase.
## Multi-Shot-Beats
Enthält ein Trailerbeat selbst einen harten Umschnitt, werden Kandidaten an
angrenzenden Source-Szenengrenzen zusätzlich als zusammenhängender Multi-Shot-
Span geprüft. Ein Match darf dann über eine Source-Szenengrenze laufen, aber
nur wenn die relative Source-Grenze zeitlich zu einem erkannten Trailer-
Umschnitt passt. So kann ein Beat aus Frage/Antwort-Shots vollständig erfasst
werden, ohne Szenen willkürlich zusammenzukleben.
## Reranking-Pipeline
Vor dem teuren Frame-Refine wird der gesamte Kandidatenpool mit einer
schnellen festen Inhaltsprüfung neu sortiert. Dadurch können korrekte Treffer
aus wiederholten Einstellungen einer Szene nach oben kommen, auch wenn ein
freier Template-Peak an anderer Stelle numerisch stärker war. Suchanker
bleiben im Pool erhalten, dürfen aber erst nach der Inhaltsprüfung nach oben
rücken. Wenn ein Kandidat visuell plausibel ist, aber wegen Trailerblende oder
kurzem Source-Span die normale Coverage knapp verfehlt, wird er als
provisional Match behalten statt als `NO MATCH` verworfen.
Dieses Reranking berücksichtigt zusätzlich die verbleibende Szenenlänge ab
dem Kandidaten-Inpoint, nutzt bewusst nur wenige repräsentative Referenzframes
und eine begrenzte Kandidatenzahl. Confirmed Matches werden zusätzlich durch
eine feste nahezu-Whole-Frame-Prüfung aus Luma, Kanten, Farbhistogramm und
räumlichen 4×4-Farbhistogrammen gedeckelt. Auch der lokale Content-Aligner
darf einen Inpoint nur noch übernehmen, wenn die feste Whole-Frame-/Spatial-
Validation dadurch besser wird.
Für gewichtete Vision-Kandidaten gibt es zusätzlich eine eigene Provisional-
Bewertung aus Content-Score, Restdauer und Seed-Stärke. Die Cache-
Normalisierung für Report/Export verwendet dieselbe niedrigere
Content-Untergrenze für nicht bestätigte Vision-Provisional-Treffer und
übernimmt die Multi-Shot-Coverage-Regel: gecachte Treffer, die passend zu
internen Trailer-Umschnitten über angrenzende Source-Szenen laufen, werden
nicht mehr auf die erste Source-Szene zurückgekürzt.
## Continuity-Seeds
Gezielte Einzel-Beat-Matches gewichten außerdem die automatisch aus
Nachbarbeats abgeleiteten Continuity-Seeds. Wenn ein Beat direkt an einen
bereits passenden Vorgänger anschließt, kann ein späterer ähnlich aussehender
Moment derselben Dialogszene den erwarteten Anschluss nicht mehr nur wegen
eines höheren Standbildscores verdrängen. Diese Continuity-Seeds sind aber
nur Suchanker: in derselben Szene darf ein späterer Inpoint gewinnen, wenn
die mehrframeige Content-Prüfung die Bewegungsphase klar besser trifft.
## Vision-Prepass für gezielte Match-Läufe
Bei aktivierter Vision wird für gezielte Match-Läufe zuerst ein schneller
seed-basierter CV-Prepass ausgeführt. Er überspringt den vollen FFmpeg-Stream
nur vorläufig und akzeptiert einen Treffer erst nach derselben Bild-/
Phasenvalidierung wie der normale Matcher. Nur nicht gelöste Beats fallen
danach auf den vollständigen Scan zurück.
Provisional Treffer aus diesem schnellen Prepass sind nicht endgültig: wenn
sie unterhalb der Confirmed-Schwelle bleiben, läuft zusätzlich der
vollständige CV-Scan und darf den besseren oder bestätigten Treffer
übernehmen.
## Fehlertoleranz bei Vision-API
OpenRouter-/Vision-Rate-Limits werden mit progressiv längeren Pausen erneut
versucht. Auch Netzfehler beim Lesen der Antwort (Timeouts,
Verbindungsabbrüche während einer DSL-Trennung) werden als retrybar
behandelt, nicht nur Verbindungsfehler beim Verbindungsaufbau.
Billing-, Credit- oder Token-Guthaben-Fehler werden dagegen sofort als
echter Blocker gemeldet, weil Warten dort nicht hilft.
Schlägt die Vision-Verifikation während der finalen Filter-/Repair-Stufe
trotzdem dauerhaft fehl, wird der bisherige gecachte Treffer für diesen
Beat behalten statt verworfen — ein Netzproblem darf keinen schon korrekt
gefundenen Match aus dem Cache löschen.
## Phasen-Reparatur und Recovery
Die Phasen-Reparatur an gefundenen Treffern läuft nicht nur in „langen"
Source-Szenen, sondern überall dort, wo die Szene mehr als nur das
Segment-Fenster trägt. Eine korrigierte Position wird übernommen, sobald sie
das Bildinhalt-Validate besteht UND nicht spürbar schlechter scort als das
Original (≤ 0.02 Verlust). Bereits bestätigte Treffer in eng zugeschnittenen
Szenen werden bewusst nicht angefasst, damit ein guter Match nicht durch
eine nominell gleichwertige Alternative ausgetauscht wird.
Beats, die nach dem CV-Lauf weder als Vollmatch noch als Segmentmatch
landen, durchlaufen anschließend eine Recovery-Stufe: Vibe-Check
(Histogramm/pHash) liefert Top-K Kandidatenszenen, die semantische
Action-Window-Suche prüft darin die Phase des sichtbaren Trailerbeat-
Anteils, und der CV-Aligner setzt den Inpoint frame-genau. Übernommen wird
nur ein Kandidat, der dieselbe Vision-Phasenvalidierung wie der Hauptpfad
besteht. Beats ohne sichtbares Bildmaterial (Logos, Titel-Karten,
durchgehende Fades) werden gar nicht erst gesucht — sie sind bewusst kein
Match.
## Behandlung von Blenden und Schwarzfeldern
Lange Trailerbeats werden nicht automatisch über ihre gesamte Beat-Länge
gegen einen einzigen Source-Clip validiert. Sobald nach einem sichtbaren
Source-Abschnitt eine anhaltende Schwarzblende oder Titel-/Credit-Insel
beginnt, endet der matchbare Referenzbereich dort; zwei aufeinanderfolgende
dunkle Samples reichen dafür. Spätere Text-/Creditbilder im selben Beat
gehen nicht mehr in Reranking, Validation oder Span-Schätzung ein.
Wenn nach einer Blende wieder sichtbares, matchbares Material kommt, wird
der Beat nicht mehr als „Source + schwarzer Tail" behandelt. Der CLI-Match
speichert zusätzliche `MatchSegment`-Einträge für jede automatisch erkannte
sichtbare Insel; der HTML-Report setzt diese Source-Segmente frame-lockend
zusammen und füllt nur echte Zwischenlücken mit Schwarz.
Beats mit mehreren sichtbaren Inseln werden direkt segmentiert gesucht,
statt zuerst als ein künstlich zusammenhängender Source-Clip über den
ganzen Film zu laufen. Falls ein kompletter Beat keinen belastbaren
Einzelclip ergibt, versucht der Matcher dieselbe Segmentlogik automatisch
als Fallback. Sehr kurze Inseln dürfen zusätzlich in den Source-Szenen
benachbarter bereits gematchter Beats lokal nach ihrer Bewegungsphase
suchen.
Besteht ein Beat nach automatischer Fade-/Titel-Filterung nur aus einer
einzigen sichtbaren Insel, wird diese Insel direkt als primäres Suchziel
verwendet. Gecachte segmentierte Treffer werden gegen die automatisch
sichtbare Referenzdauer normalisiert, nicht gegen Schwarz-/Blendränder
des gesamten Beats.
Sehr dunkle, kontrastarme oder noch nicht sauber auf-/abgeblendete
Referenzframes werden aus Score, Inhalts-Reranking, Phasen-Alignment und
Motion-Templates herausgenommen. Sichtbare Fade-Rampen werden nur in eine
matchbare Insel hinein erweitert, wenn sie strukturell stark zum ersten
bzw. letzten scorebaren Frame derselben Einstellung passen.
Treffer unter `provisional_content_threshold` werden nicht mehr gespeichert
oder aus alten Cache-Ergebnissen übernommen.
+236 -83
View File
@@ -1,39 +1,93 @@
"""
scripts/generate_cutter_report.py — generate CUTTER_REPORT.md from current cache
Regenerates CUTTER_REPORT.md from .cache/match_results.json,
.cache/trailer_beats.json and .cache/vision_descriptions.json. The report is a
hand-off document for a video editor (Cutter) doing the manual recut: it lists,
per beat, the trailer position, the proposed source position in SMPTE
timecodes, the match score, and what the vision model saw in the trailer beat.
Regenerates ``CUTTER_REPORT.md`` from ``.cache/match_results.json``,
``.cache/trailer_beats.json`` and ``.cache/vision_descriptions.json``. The
report is a hand-off document for a video editor (Cutter) doing the manual
recut: per beat it lists trailer timecode, the proposed source timecode, the
match score, what the vision model saw in the trailer beat, and side-by-side
preview stills (extracted via ffmpeg).
Important: trailer and source can have different frame rates (e.g. trailer
25 fps, source 23.976 fps). This script probes each file with ffprobe and
renders trailer timecodes in trailer fps and source timecodes in source fps,
so the timecode matches what the cutter sees in the NLE.
Usage (from project root):
python scripts/generate_cutter_report.py
Run this any time after `python cli.py match` to keep CUTTER_REPORT.md in sync
with the latest cache.
python scripts/generate_cutter_report.py # text + stills
python scripts/generate_cutter_report.py --no-stills # text only
Stills go to ``output/cutter_stills/beat_NN_{trailer,source}.jpg`` and are
referenced from the markdown. They are only re-rendered when the underlying
match position has changed — fast on repeat runs.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from datetime import date
from pathlib import Path
# ----------------------------------------------------------------------------
# Frame-rate handling
# ----------------------------------------------------------------------------
def smpte(t: float | None, fps: int) -> str:
def probe_fps(video_path: Path) -> float | None:
"""Return container fps (avg_frame_rate) for a video file, or None."""
if not video_path.exists():
return None
try:
proc = subprocess.run(
[
"ffprobe", "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=avg_frame_rate",
"-of", "default=noprint_wrappers=1:nokey=1",
str(video_path),
],
capture_output=True, text=True, timeout=10,
)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
raw = proc.stdout.strip()
if "/" in raw:
num, _, den = raw.partition("/")
try:
n, d = float(num), float(den)
return n / d if d else None
except ValueError:
return None
try:
return float(raw)
except ValueError:
return None
def smpte(t: float | None, fps: float) -> str:
"""Format seconds as h:mm:ss:ff, frame counter rounded to nearest int fps."""
if t is None:
return "--:--:--:--"
total = int(round(t * fps))
h = total // (3600 * fps)
m = (total // (60 * fps)) % 60
s = (total // fps) % 60
f = total % fps
fps_int = max(1, int(round(fps)))
total = int(round(t * fps_int))
h = total // (3600 * fps_int)
m = (total // (60 * fps_int)) % 60
s = (total // fps_int) % 60
f = total % fps_int
return f"{h:02d}:{m:02d}:{s:02d}:{f:02d}"
# ----------------------------------------------------------------------------
# Vision-description helpers
# ----------------------------------------------------------------------------
def best_beat_description(items: dict, beat_id: int, start_s: float, end_s: float) -> str | None:
best, best_diff = None, 1e9
for key, value in items.items():
@@ -58,12 +112,62 @@ def parse_field(desc: str | None, key: str) -> str:
return match.group(1) if match else ""
def render_report(project_root: Path) -> str:
# ----------------------------------------------------------------------------
# Stills
# ----------------------------------------------------------------------------
STILL_WIDTH = 360 # px, downscaled for fast preview in the markdown
STILL_QUALITY = 5 # ffmpeg -q:v scale 1 (best) .. 31 (worst)
def extract_still(video_path: Path, t_s: float, out: Path) -> bool:
"""Extract one JPEG frame at t_s. Skip if out is newer than video."""
if not video_path.exists():
return False
try:
if out.exists() and out.stat().st_mtime >= video_path.stat().st_mtime and out.stat().st_size > 0:
return True
except OSError:
pass
out.parent.mkdir(parents=True, exist_ok=True)
cmd = [
"ffmpeg", "-y", "-loglevel", "error",
"-ss", f"{max(0.0, t_s):.3f}",
"-i", str(video_path),
"-frames:v", "1",
"-vf", f"scale={STILL_WIDTH}:-2",
"-q:v", str(STILL_QUALITY),
str(out),
]
try:
subprocess.run(cmd, check=True, capture_output=True, timeout=30)
except (FileNotFoundError, subprocess.CalledProcessError, subprocess.TimeoutExpired):
return False
return out.exists() and out.stat().st_size > 0
def beat_still_time(start_s: float, end_s: float) -> float:
"""Pick a representative time inside the beat (~30% in, but at least 0.4 s)."""
duration = max(0.04, end_s - start_s)
return start_s + min(0.4, duration * 0.3)
# ----------------------------------------------------------------------------
# Renderer
# ----------------------------------------------------------------------------
def render_report(project_root: Path, with_stills: bool = True) -> str:
sys.path.insert(0, str(project_root))
from src.core.config import load_config
cfg = load_config(project_root / "config.toml")
fps = int(round(cfg.export.edl_frame_rate))
trailer_path = Path(cfg.paths.reference_trailer)
source_path = Path(cfg.paths.source_movie)
trailer_fps = probe_fps(trailer_path) or 25.0
source_fps = probe_fps(source_path) or float(cfg.export.edl_frame_rate)
cache = project_root / ".cache"
results = {r["beat_id"]: r for r in json.loads((cache / "match_results.json").read_text())}
@@ -71,45 +175,56 @@ def render_report(project_root: Path) -> str:
vis_path = cache / "vision_descriptions.json"
vis_items = json.loads(vis_path.read_text())["items"] if vis_path.exists() else {}
stills_dir = project_root / "output" / "cutter_stills"
if with_stills:
stills_dir.mkdir(parents=True, exist_ok=True)
lines: list[str] = []
lines.append("# Cutter-Report — manuelles Nachschneiden")
lines.append("")
lines.append(f"Stand: {date.today().isoformat()}")
lines.append("")
lines.append(f"- **Trailer**: `{trailer_path.name}` @ {trailer_fps:.3f} fps")
lines.append(f"- **Source** : `{source_path.name}` @ {source_fps:.3f} fps")
lines.append("")
lines.append(
f"Stand: {date.today().isoformat()}. Frame-Rate: {cfg.export.edl_frame_rate} fps. "
f"Source: {Path(cfg.paths.source_movie).name} — Trailer: {Path(cfg.paths.reference_trailer).name}."
"Trailer-Timecodes sind in **Trailer-Framerate** angegeben, "
"Source-Timecodes in **Source-Framerate**. So passen sie 1:1 zu dem, "
"was du in deinem NLE auf den jeweiligen Spuren siehst."
)
lines.append("")
lines.append(
"Diese Datei wird automatisch aus dem Match-Cache erzeugt. "
"Nach jedem `python cli.py match` mit `python scripts/generate_cutter_report.py` neu generieren."
"Diese Datei wird automatisch erzeugt — nach jedem `python cli.py match` "
"neu generieren mit:"
)
lines.append("")
lines.append("## Wie diese Tabelle zu lesen ist")
lines.append("```powershell")
lines.append("python scripts/generate_cutter_report.py")
lines.append("```")
lines.append("")
lines.append("- **Beat**: Nummer im Referenz-Trailer.")
lines.append("- **Trailer In/Out**: SMPTE-Position des Beats im Trailer (h:mm:ss:ff).")
lines.append("- **Source In/Out**: vorgeschlagene Position im Quellfilm. Bei `MAN.` selbst aussuchen.")
lines.append("- **Scene**: ID der Source-Szene aus PySceneDetect (nur fuer Debug-Zwecke).")
lines.append("- **Score**: 0..1, je hoeher desto besser. >=0.65 ist als bestaetigt eingestuft.")
lines.append("- **Status**:")
lines.append(" - `OK` — bestaetigt durch CV + Vision-Phasenpruefung, kann ohne weitere Pruefung uebernommen werden.")
lines.append(" - `?` — vorlaeufig, korrekte Szene aber Score unter 0.65; Bewegungsphase im Vorschauclip pruefen und ggf. um wenige Frames verschieben.")
lines.append(" - `MAN.` — kein automatischer Treffer; entweder manuell suchen oder als Schwarzfade/Titel uebernehmen.")
lines.append("- **Phase**: was im Trailerbeat zu sehen ist (aus Vision-Beschreibung). Hilft dir, die richtige Stelle im Source zu finden.")
lines.append("## Status-Legende")
lines.append("")
lines.append("| Status | Bedeutung | Was tun? |")
lines.append("|--------|-----------|----------|")
lines.append("| `OK` | bestätigt durch CV + Vision-Phasenprüfung | übernehmen, optional stichprobenartig sichten |")
lines.append("| `?` | korrekte Szene, Phase eventuell um wenige Frames verschoben | im NLE prüfen, Source-In ggf. nachjustieren |")
lines.append("| `MAN.` | kein automatischer Treffer | manuell suchen oder als Schwarzfade/Titel übernehmen |")
lines.append("")
matched = sum(1 for b in beats if b["beat_id"] in results)
confirmed = sum(1 for b in beats if b["beat_id"] in results and results[b["beat_id"]]["is_confirmed"])
lines.append("## Status-Uebersicht")
lines.append("## Übersicht")
lines.append("")
lines.append(f"- **Beats gesamt**: {len(beats)}")
lines.append(f"- **Automatisch gefunden**: {matched} ({confirmed} davon bestaetigt)")
lines.append(f"- **Manuell zu setzen**: {len(beats) - matched}")
lines.append(f"- Beats gesamt: **{len(beats)}**")
lines.append(f"- Automatisch gefunden: **{matched}** ({confirmed} davon bestätigt)")
lines.append(f"- Manuell zu setzen: **{len(beats) - matched}**")
lines.append("")
lines.append("## Beat-Tabelle")
# ---- Compact table (timecode-only, no images) ------------------------
lines.append("## Beat-Tabelle (kompakt)")
lines.append("")
lines.append("| Beat | Trailer In / Out | Source In / Out | Scene | Score | Status | Was im Bild zu sehen ist |")
lines.append("|-----:|------------------|------------------|------:|------:|:------:|---------------------------|")
lines.append("| Beat | Trailer In / Out | Source In / Out | Score | Status | Was im Bild zu sehen ist |")
lines.append("|-----:|------------------|------------------|------:|:------:|---------------------------|")
def status_for(rec: dict | None) -> str:
if rec is None:
@@ -119,86 +234,124 @@ def render_report(project_root: Path) -> str:
for beat in beats:
bid = beat["beat_id"]
rec = results.get(bid)
ti, to = smpte(beat["start_s"], fps), smpte(beat["end_s"], fps)
ti = smpte(beat["start_s"], trailer_fps)
to = smpte(beat["end_s"], trailer_fps)
if rec is not None:
si, so = smpte(rec["in_point_s"], fps), smpte(rec["out_point_s"], fps)
scn = rec["scene_id"]
si = smpte(rec["in_point_s"], source_fps)
so = smpte(rec["out_point_s"], source_fps)
sc = rec["match_score"]
else:
si = so = ""
scn = ""
sc = 0.0
desc = best_beat_description(vis_items, bid, beat["start_s"], beat["end_s"]) or ""
phase = (parse_field(desc, "action_phase") or parse_field(desc, "subject"))[:90]
lines.append(f"| {bid:>4} | {ti}-{to} | {si}-{so} | {scn} | {sc:.3f} | {status_for(rec)} | {phase} |")
phase = (parse_field(desc, "action_phase") or parse_field(desc, "subject"))[:80]
lines.append(f"| {bid:>4} | {ti}-{to} | {si}-{so} | {sc:.3f} | {status_for(rec)} | {phase} |")
lines.append("")
lines.append("## Beats die manuelle Aufmerksamkeit brauchen")
# ---- Detailed per-beat sections with stills --------------------------
lines.append("## Beat-Details mit Vorschau-Stills")
lines.append("")
lines.append("### Manuell setzen (Status `MAN.`)")
lines.append("")
for beat in beats:
bid = beat["beat_id"]
if bid in results:
continue
ti, to = smpte(beat["start_s"], fps), smpte(beat["end_s"], fps)
desc = best_beat_description(vis_items, bid, beat["start_s"], beat["end_s"]) or ""
phase = parse_field(desc, "action_phase")
note = phase or "keine Vision-Beschreibung — vermutlich Title-Card / Fade / Logo"
lines.append(f"- **Beat {bid}** {ti}-{to}: {note}")
if not with_stills:
lines.append("_Stills sind in diesem Lauf deaktiviert (`--no-stills`)._")
lines.append("")
lines.append("### Vorlaeufig (Status `?`) — bitte sichten")
lines.append("")
lines.append("| Beat | Score | Source In | Phase laut Vision |")
lines.append("|-----:|------:|-----------|--------------------|")
for beat in beats:
bid = beat["beat_id"]
rec = results.get(bid)
if rec is None or rec.get("is_confirmed"):
continue
ti = smpte(beat["start_s"], trailer_fps)
to = smpte(beat["end_s"], trailer_fps)
if rec is not None:
si = smpte(rec["in_point_s"], source_fps)
so = smpte(rec["out_point_s"], source_fps)
sc_str = f"{rec['match_score']:.3f}"
scn = rec["scene_id"]
else:
si = so = ""
sc_str = ""
scn = ""
status = status_for(rec)
desc = best_beat_description(vis_items, bid, beat["start_s"], beat["end_s"]) or ""
phase = parse_field(desc, "action_phase")
lines.append(f"| {bid:>4} | {rec['match_score']:.3f} | {smpte(rec['in_point_s'], fps)} | {phase[:90]} |")
phase = parse_field(desc, "action_phase") or parse_field(desc, "subject") or "(keine Vision-Beschreibung)"
composition = parse_field(desc, "composition")
setting = parse_field(desc, "setting")
lines.append(f"### Beat {bid:02d} — Status `{status}`")
lines.append("")
lines.append(f"- **Trailer**: {ti} {to}")
if rec is not None:
lines.append(f"- **Source** : {si} {so} (scene {scn}, score {sc_str})")
else:
lines.append("- **Source** : — (kein Treffer; manuell setzen)")
lines.append(f"- **Phase** : {phase}")
if composition:
lines.append(f"- **Bild** : {composition}{', ' + setting if setting else ''}")
lines.append("")
lines.append("### Bestaetigt (Status `OK`) — kann uebernommen werden")
lines.append("")
lines.append("| Beat | Score | Source In | Phase laut Vision |")
lines.append("|-----:|------:|-----------|--------------------|")
for beat in beats:
bid = beat["beat_id"]
rec = results.get(bid)
if rec is None or not rec.get("is_confirmed"):
continue
desc = best_beat_description(vis_items, bid, beat["start_s"], beat["end_s"]) or ""
phase = parse_field(desc, "action_phase")
lines.append(f"| {bid:>4} | {rec['match_score']:.3f} | {smpte(rec['in_point_s'], fps)} | {phase[:90]} |")
if with_stills:
t_still = beat_still_time(beat["start_s"], beat["end_s"])
trailer_jpg = stills_dir / f"beat_{bid:02d}_trailer.jpg"
ok_t = extract_still(trailer_path, t_still, trailer_jpg)
source_jpg = stills_dir / f"beat_{bid:02d}_source.jpg"
if rec is not None:
s_t = rec["in_point_s"] + min(0.4, max(0.04, rec["out_point_s"] - rec["in_point_s"]) * 0.3)
ok_s = extract_still(source_path, s_t, source_jpg)
else:
ok_s = False
cells_h = []
cells_t = []
cells_h.append("Trailer")
if ok_t:
rel_t = trailer_jpg.relative_to(project_root).as_posix()
cells_t.append(f"![Trailer beat {bid}]({rel_t})")
else:
cells_t.append("_(kein Still)_")
cells_h.append("Source")
if ok_s:
rel_s = source_jpg.relative_to(project_root).as_posix()
cells_t.append(f"![Source beat {bid}]({rel_s})")
else:
cells_t.append("_(kein Still)_")
lines.append("| " + " | ".join(cells_h) + " |")
lines.append("|" + "|".join(["---"] * len(cells_h)) + "|")
lines.append("| " + " | ".join(cells_t) + " |")
lines.append("")
lines.append("## Hinweise zur Pruefung")
lines.append("## Hinweise zur Prüfung")
lines.append("")
lines.append(
"1. Source-Times sollten zur jeweiligen Trailer-Bewegungsphase passen. "
"Wenn nicht: Source-In innerhalb derselben Source-Szene wenige Frames vor/zurueck verschieben."
"1. Wenn die Bewegungsphase im Source-Still nicht zum Trailer-Still passt, im NLE den Source-In um wenige Frames verschieben — innerhalb derselben Source-Szene reicht das meistens."
)
lines.append(
"2. Wenn der Source-Clip kuerzer ist als der Trailerbeat (Source-Out < Trailer-Out gerechnet ab Source-In), "
"enthaelt der Trailerbeat eine Blende/Titelkarte; im Schnitt mit Schwarzfade oder Source-Tail auffuellen."
"2. Wenn der Source-Clip kürzer ist als der Trailerbeat (Source-Out < Trailer-Out), enthält der Trailerbeat eine Blende oder Titelkarte; im Schnitt mit Schwarzfade oder dem Source-Tail auffüllen."
)
lines.append(
"3. `OK`-Beats sind durch CV + Vision-Phasenpruefung doppelt verifiziert; trotzdem stichprobenartig sichten."
"3. `OK`-Beats sind doppelt verifiziert (CV + Vision-Phase). Trotzdem stichprobenartig sichten."
)
lines.append(
"4. Stills liegen unter `output/cutter_stills/`. Bei Bedarf einzelne neu generieren: einfach die Datei löschen und das Skript erneut laufen lassen."
)
lines.append("")
return "\n".join(lines)
# ----------------------------------------------------------------------------
# CLI entry
# ----------------------------------------------------------------------------
def main() -> int:
parser = argparse.ArgumentParser(description="Render CUTTER_REPORT.md from current cache")
parser.add_argument("--no-stills", action="store_true", help="skip frame extraction")
args = parser.parse_args()
here = Path(__file__).resolve().parent
project_root = here.parent
out = project_root / "CUTTER_REPORT.md"
out.write_text(render_report(project_root), encoding="utf-8")
out.write_text(render_report(project_root, with_stills=not args.no_stills), encoding="utf-8")
print(f"Wrote {out}")
return 0