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:
+315
-82
@@ -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 |
|
||||
|---|---|
|
||||
|  | _(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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### Beat 02 — Status `MAN.`
|
||||
|
||||
- **Trailer**: 00:00:08:10 – 00:00:16:24
|
||||
- **Source** : — (kein Treffer; manuell setzen)
|
||||
- **Phase** : (keine Vision-Beschreibung)
|
||||
|
||||
| Trailer | Source |
|
||||
|---|---|
|
||||
|  | _(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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### 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 |
|
||||
|---|---|
|
||||
|  | _(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 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
### Beat 23 — Status `MAN.`
|
||||
|
||||
- **Trailer**: 00:01:19:23 – 00:01:25:14
|
||||
- **Source** : — (kein Treffer; manuell setzen)
|
||||
- **Phase** : (keine Vision-Beschreibung)
|
||||
|
||||
| Trailer | Source |
|
||||
|---|---|
|
||||
|  | _(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 |
|
||||
|---|---|
|
||||
|  | _(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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
# Algorithmus-Notizen
|
||||
|
||||
Detaillierte Verhaltensbeschreibung 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.
|
||||
@@ -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")
|
||||
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}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### Vorlaeufig (Status `?`) — bitte sichten")
|
||||
# ---- Detailed per-beat sections with stills --------------------------
|
||||
lines.append("## Beat-Details mit Vorschau-Stills")
|
||||
lines.append("")
|
||||
lines.append("| Beat | Score | Source In | Phase laut Vision |")
|
||||
lines.append("|-----:|------:|-----------|--------------------|")
|
||||
if not with_stills:
|
||||
lines.append("_Stills sind in diesem Lauf deaktiviert (`--no-stills`)._")
|
||||
lines.append("")
|
||||
|
||||
for beat in beats:
|
||||
bid = beat["beat_id"]
|
||||
rec = results.get(bid)
|
||||
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]} |")
|
||||
lines.append("")
|
||||
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("### 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]} |")
|
||||
lines.append("")
|
||||
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("## Hinweise zur Pruefung")
|
||||
if with_stills:
|
||||
t_still = beat_still_time(beat["start_s"], beat["end_s"])
|
||||
trailer_jpg = stills_dir / f"beat_{bid:02d}_trailer.jpg"
|
||||
ok_t = extract_still(trailer_path, t_still, trailer_jpg)
|
||||
source_jpg = stills_dir / f"beat_{bid:02d}_source.jpg"
|
||||
if rec is not None:
|
||||
s_t = rec["in_point_s"] + min(0.4, max(0.04, rec["out_point_s"] - rec["in_point_s"]) * 0.3)
|
||||
ok_s = extract_still(source_path, s_t, source_jpg)
|
||||
else:
|
||||
ok_s = False
|
||||
|
||||
cells_h = []
|
||||
cells_t = []
|
||||
cells_h.append("Trailer")
|
||||
if ok_t:
|
||||
rel_t = trailer_jpg.relative_to(project_root).as_posix()
|
||||
cells_t.append(f"")
|
||||
else:
|
||||
cells_t.append("_(kein Still)_")
|
||||
cells_h.append("Source")
|
||||
if ok_s:
|
||||
rel_s = source_jpg.relative_to(project_root).as_posix()
|
||||
cells_t.append(f"")
|
||||
else:
|
||||
cells_t.append("_(kein Still)_")
|
||||
|
||||
lines.append("| " + " | ".join(cells_h) + " |")
|
||||
lines.append("|" + "|".join(["---"] * len(cells_h)) + "|")
|
||||
lines.append("| " + " | ".join(cells_t) + " |")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## Hinweise zur Prüfung")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"1. 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user