diff --git a/CUTTER_REPORT.md b/CUTTER_REPORT.md index 2bf758d..7fc28ca 100644 --- a/CUTTER_REPORT.md +++ b/CUTTER_REPORT.md @@ -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. diff --git a/README.md b/README.md index 678b1ae..a54b100 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/ALGORITHM.md b/docs/ALGORITHM.md new file mode 100644 index 0000000..4ccd53e --- /dev/null +++ b/docs/ALGORITHM.md @@ -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. diff --git a/scripts/generate_cutter_report.py b/scripts/generate_cutter_report.py index 8865ba1..8dc5f2f 100644 --- a/scripts/generate_cutter_report.py +++ b/scripts/generate_cutter_report.py @@ -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"![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 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