diff --git a/CUTTER_REPORT.html b/CUTTER_REPORT.html
new file mode 100644
index 0000000..0250822
--- /dev/null
+++ b/CUTTER_REPORT.html
@@ -0,0 +1,44 @@
+
+
+
+
+
Cutter-Report — 2026-05-04
25 Beats — 20 automatisch (5 bestätigt) — 5 manuell.
Status-Legende
| Status | Bedeutung | Was tun? |
|---|
| OK | bestätigt durch CV + Vision-Phasenprüfung | übernehmen, optional sichten |
| ? | korrekte Szene, Phase ggf. um wenige Frames verschoben | im NLE prüfen, Source-In nachjustieren |
| MAN. | kein automatischer Treffer | manuell suchen oder Schwarzfade |
Beat-Tabelle
| Beat | Trailer In / Out | Source In / Out | Score | Status | Phase |
|---|
| 0 | 00:00:00:00–00:00:03:00 | —–— | — | 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 | 0.380 | ? | Dynamic motion blur and shifting optical distortions across the text |
| 2 | 00:00:08:10–00:00:16:24 | —–— | — | MAN. | |
| 3 | 00:00:16:24–00:00:19:03 | 01:02:17:22–01:02:19:14 | 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:21 | 0.548 | ? | |
| 7 | 00:00:29:06–00:00:31:17 | 01:20:10:10–01:20:12:16 | 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:16–01:20:39:00 | 0.635 | ? | conversation |
| 11 | 00:00:40:02–00:00:42:03 | 01:20:40:18–01:20:42:18 | 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:10 | 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:16 | 0.467 | ? | static conversation |
| 16 | 00:01:01:12–00:01:04:12 | 01:05:12:16–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:10–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:12–00:09:14:19 | 0.668 | OK | Woman in foreground turns her head from profile to face the camera while speaking |
| 19 | 00:01:10:19–00:01:12:13 | 00:16:48:14–00:16:49:15 | 0.717 | OK | static conversation, subtle facial expression change |
| 20 | 00:01:12:13–00:01:15:14 | 01:28:04:17–01:28:05:14 | 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 | —–— | — | 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 | —–— | — | MAN. | |
| 24 | 00:01:25:14–00:01:32:07 | —–— | — | MAN. | |
Beat-Details
Beat 00 MAN.
Trailer
TC 00:00:00:00 – 00:00:03:00
Phase logo animation assembling from distorted shapes with motion blur
Bild centered, symmetrical, abstract black void
Source
— manuell setzen —
— kein automatischer Treffer —
Beat 01 ?
Trailer
TC 00:00:03:00 – 00:00:08:10
Phase Dynamic motion blur and shifting optical distortions across the text
Bild Centered, symmetrical layout with overlapping circular glass-like distortions, Abstract black void
Source
TC 00:00:04:09 – 00:00:06:03
Scene 1 · Score 0.380
Beat 02 MAN.
Trailer
TC 00:00:08:10 – 00:00:16:24
Source
— manuell setzen —
— kein automatischer Treffer —
Beat 03 ?
Trailer
TC 00:00:16:24 – 00:00:19:03
Source
TC 01:02:17:22 – 01:02:19:14
Scene 436 · Score 0.469
Beat 04 ?
Trailer
TC 00:00:19:03 – 00:00:20:16
Source
TC 01:02:21:01 – 01:02:22:10
Scene 437 · Score 0.647
Beat 05 ?
Trailer
TC 00:00:20:16 – 00:00:26:09
Source
TC 00:01:33:04 – 00:01:37:10
Scene 10 · Score 0.501
Beat 06 ?
Trailer
TC 00:00:26:09 – 00:00:29:06
Source
TC 00:01:03:06 – 00:01:05:21
Scene 5 · Score 0.548
Beat 07 ?
Trailer
TC 00:00:29:06 – 00:00:31:17
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
Source
TC 01:20:10:10 – 01:20:12:16
Scene 553 · Score 0.463
Beat 08 OK
Trailer
TC 00:00:31:17 – 00:00:33:16
Phase static or slow drifting
Bild close-up, diagonal curve from top-left to bottom-center, dark, indistinct void
Source
TC 00:00:51:07 – 00:00:53:01
Scene 5 · Score 0.733
Beat 09 ?
Trailer
TC 00:00:33:16 – 00:00:36:19
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
Source
TC 01:20:28:20 – 01:20:31:17
Scene 557 · Score 0.529
Beat 10 ?
Trailer
TC 00:00:36:19 – 00:00:40:02
Phase conversation
Bild alternating close-ups and a medium two-shot, indoor living room with large windows showing a blue exterior landscape
Source
TC 01:20:35:16 – 01:20:39:00
Scene 558 · Score 0.635
Beat 11 ?
Trailer
TC 00:00:40:02 – 00:00:42:03
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
Source
TC 01:20:40:18 – 01:20:42:18
Scene 559 · Score 0.502
Beat 12 ?
Trailer
TC 00:00:42:03 – 00:00:50:06
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
Source
TC 01:14:26:01 – 01:14:29:10
Scene 519 · Score 0.558
Beat 13 ?
Trailer
TC 00:00:50:06 – 00:00:53:21
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
Source
TC 00:43:20:02 – 00:43:23:10
Scene 308 · Score 0.468
Beat 14 ?
Trailer
TC 00:00:53:21 – 00:00:57:02
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
Source
TC 00:43:24:09 – 00:43:27:04
Scene 309 · Score 0.444
Beat 15 ?
Trailer
TC 00:00:57:02 – 00:01:01:12
Phase static conversation
Bild medium shot, profile view of two characters facing each other, indoor room with a large window overlooking a snowy forest
Source
TC 00:02:10:11 – 00:02:12:16
Scene 0 · Score 0.467
Beat 16 ?
Trailer
TC 00:01:01:12 – 00:01:04:12
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
Source
TC 01:05:12:16 – 01:05:15:06
Scene 451 · Score 0.613
Beat 17 OK
Trailer
TC 00:01:04:12 – 00:01:09:03
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
Source
TC 01:31:22:10 – 01:31:24:09
Scene 623 · Score 0.684
Beat 18 OK
Trailer
TC 00:01:09:03 – 00:01:10:19
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
Source
TC 00:09:13:12 – 00:09:14:19
Scene 75 · Score 0.668
Beat 19 OK
Trailer
TC 00:01:10:19 – 00:01:12:13
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
Source
TC 00:16:48:14 – 00:16:49:15
Scene 126 · Score 0.717
Beat 20 OK
Trailer
TC 00:01:12:13 – 00:01:15:14
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
Source
TC 01:28:04:17 – 01:28:05:14
Scene 613 · Score 0.663
Beat 21 MAN.
Trailer
TC 00:01:15:14 – 00:01:17:13
Phase hand raised to mouth, slight facial movement
Bild extreme close-up, face partially obscured by shadow, dark interior
Source
— manuell setzen —
— kein automatischer Treffer —
Beat 22 ?
Trailer
TC 00:01:17:13 – 00:01:19:23
Source
TC 01:03:05:16 – 01:03:07:10
Scene 442 · Score 0.545
Beat 23 MAN.
Trailer
TC 00:01:19:23 – 00:01:25:14
Source
— manuell setzen —
— kein automatischer Treffer —
Beat 24 MAN.
Trailer
TC 00:01:25:14 – 00:01:32:07
Source
— manuell setzen —
— kein automatischer Treffer —
diff --git a/CUTTER_REPORT.md b/CUTTER_REPORT.md
index 7fc28ca..6394de1 100644
--- a/CUTTER_REPORT.md
+++ b/CUTTER_REPORT.md
@@ -3,331 +3,309 @@
Stand: 2026-05-04
- **Trailer**: `BehindTheRedDoor_Trailer_REFERENCE.mp4` @ 25.000 fps
-- **Source** : `BehindTheRedDoor_FTR_1080P_2398_Fixed.mp4` @ 25.000 fps
+- **Source** : `BehindTheRedDoor_FTR_1080P_2398_Fixed.mp4` @ 23.976 fps
-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.
+Trailer-TC in Trailer-Framerate, Source-TC in Source-Framerate — passt 1:1 zum Schnittplatz.
-Diese Datei wird automatisch erzeugt — nach jedem `python cli.py match` neu generieren mit:
-
-```powershell
-python scripts/generate_cutter_report.py
-```
+Bilder sind base64-eingebettet (kein toter Link). Für Video-Vorschau siehe `CUTTER_REPORT.html` (gleiche Daten, mit Clips).
## Status-Legende
| 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 |
+| `OK` | bestätigt durch CV + Vision-Phasenprüfung | übernehmen |
+| `?` | korrekte Szene, Phase ggf. um wenige Frames verschoben | im NLE prüfen |
+| `MAN.` | kein automatischer Treffer | manuell setzen oder Schwarzfade |
-## Übersicht
+**Beats:** 25 gesamt · **20** automatisch (**5** bestätigt) · **5** manuell.
-- Beats gesamt: **25**
-- Automatisch gefunden: **20** (5 davon bestätigt)
-- Manuell zu setzen: **5**
+## Beat-Tabelle
-## Beat-Tabelle (kompakt)
-
-| 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 | ? | |
+| Beat | Trailer In / Out | Source In / Out | Score | Status | Bild laut Vision |
+|-----:|------------------|------------------|------:|:------:|-------------------|
+| 0 | 00:00:00:00-00:00:03:00 | —-— | — | 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 | 0.380 | ? | Dynamic motion blur and shifting optical distortions across the text |
+| 2 | 00:00:08:10-00:00:16:24 | —-— | — | MAN. | |
+| 3 | 00:00:16:24-00:00:19:03 | 01:02:17:22-01:02:19:14 | 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 |
+| 6 | 00:00:26:09-00:00:29:06 | 00:01:03:06-00:01:05:21 | 0.548 | ? | |
+| 7 | 00:00:29:06-00:00:31:17 | 01:20:10:10-01:20:12:16 | 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 |
+| 10 | 00:00:36:19-00:00:40:02 | 01:20:35:16-01:20:39:00 | 0.635 | ? | conversation |
+| 11 | 00:00:40:02-00:00:42:03 | 01:20:40:18-01:20:42:18 | 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 |
+| 13 | 00:00:50:06-00:00:53:21 | 00:43:20:02-00:43:23:10 | 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 |
+| 15 | 00:00:57:02-00:01:01:12 | 00:02:10:11-00:02:12:16 | 0.467 | ? | static conversation |
+| 16 | 00:01:01:12-00:01:04:12 | 01:05:12:16-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:10-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:12-00:09:14:19 | 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:14-00:16:49:15 | 0.717 | OK | static conversation, subtle facial expression change |
+| 20 | 00:01:12:13-00:01:15:14 | 01:28:04:17-01:28:05:14 | 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 | —-— | — | 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. | |
+| 23 | 00:01:19:23-00:01:25:14 | —-— | — | MAN. | |
+| 24 | 00:01:25:14-00:01:32:07 | —-— | — | MAN. | |
-## Beat-Details mit Vorschau-Stills
+## Beat-Details
### Beat 00 — Status `MAN.`
- **Trailer**: 00:00:00:00 – 00:00:03:00
-- **Source** : — (kein Treffer; manuell setzen)
+- **Source** : — (manuell setzen)
- **Phase** : logo animation assembling from distorted shapes with motion blur
- **Bild** : centered, symmetrical, abstract black void
| Trailer | Source |
-|---|---|
-|  | _(kein Still)_ |
+|:---:|:---:|
+|  | _(MAN.)_ |
### Beat 01 — Status `?`
- **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)
+- **Source** : 00:00:04:09 – 00:00:06:03 (scene 1, score 0.380)
+- **Phase** : Dynamic motion blur and shifting optical distortions across the text
+- **Bild** : Centered, symmetrical layout with overlapping circular glass-like distortions, Abstract black void
| Trailer | Source |
-|---|---|
-|  |  |
+|:---:|:---:|
+|  |  |
### Beat 02 — Status `MAN.`
- **Trailer**: 00:00:08:10 – 00:00:16:24
-- **Source** : — (kein Treffer; manuell setzen)
-- **Phase** : (keine Vision-Beschreibung)
+- **Source** : — (manuell setzen)
| Trailer | Source |
-|---|---|
-|  | _(kein Still)_ |
+|:---:|:---:|
+|  | _(MAN.)_ |
### 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)
+- **Source** : 01:02:17:22 – 01:02:19:14 (scene 436, score 0.469)
| 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)
+- **Source** : 01:02:21:01 – 01:02:22:10 (scene 437, score 0.647)
| 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)
+- **Source** : 00:01:33:04 – 00:01:37:10 (scene 10, score 0.501)
| 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)
+- **Source** : 00:01:03:06 – 00:01:05:21 (scene 5, score 0.548)
| 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)
+- **Source** : 01:20:10:10 – 01:20:12:16 (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)
+- **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)
+- **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)
+- **Source** : 01:20:35:16 – 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)
+- **Source** : 01:20:40:18 – 01:20:42:18 (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)
+- **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)
+- **Source** : 00:43:20:02 – 00:43:23:10 (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)
+- **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)
+- **Source** : 00:02:10:11 – 00:02:12:16 (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)
+- **Source** : 01:05:12:16 – 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)
+- **Source** : 01:31:22:10 – 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)
+- **Source** : 00:09:13:12 – 00:09:14:19 (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)
+- **Source** : 00:16:48:14 – 00:16:49:15 (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)
+- **Source** : 01:28:04:17 – 01:28:05:14 (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)
+- **Source** : — (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)_ |
+|:---:|:---:|
+|  | _(MAN.)_ |
### 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)
+- **Source** : 01:03:05:16 – 01:03:07:10 (scene 442, score 0.545)
| Trailer | Source |
-|---|---|
-|  |  |
+|:---:|:---:|
+|  |  |
### Beat 23 — Status `MAN.`
- **Trailer**: 00:01:19:23 – 00:01:25:14
-- **Source** : — (kein Treffer; manuell setzen)
-- **Phase** : (keine Vision-Beschreibung)
+- **Source** : — (manuell setzen)
| Trailer | Source |
-|---|---|
-|  | _(kein Still)_ |
+|:---:|:---:|
+|  | _(MAN.)_ |
### Beat 24 — Status `MAN.`
- **Trailer**: 00:01:25:14 – 00:01:32:07
-- **Source** : — (kein Treffer; manuell setzen)
-- **Phase** : (keine Vision-Beschreibung)
+- **Source** : — (manuell setzen)
| 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.
+|:---:|:---:|
+|  | _(MAN.)_ |
diff --git a/cli.py b/cli.py
index 161fde3..93edc2c 100644
--- a/cli.py
+++ b/cli.py
@@ -93,7 +93,7 @@ def _save_results(results: list, cfg: "AppConfig") -> None: # type: ignore[name
def _regenerate_cutter_report(cfg: "AppConfig") -> None: # type: ignore[name-defined]
- """Re-render CUTTER_REPORT.md after each cache write so it stays in sync."""
+ """Re-render CUTTER_REPORT.{md,html} after each cache write so they stay in sync."""
try:
from scripts.generate_cutter_report import render_report
except Exception as exc:
@@ -101,9 +101,10 @@ def _regenerate_cutter_report(cfg: "AppConfig") -> None: # type: ignore[name-de
return
try:
project_root = cfg.paths.cache_dir.parent
- out = project_root / "CUTTER_REPORT.md"
- out.write_text(render_report(project_root), encoding="utf-8")
- logging.getLogger(__name__).info("Cutter report regenerated → %s", out)
+ md, html = render_report(project_root, with_stills=True, with_clips=False)
+ (project_root / "CUTTER_REPORT.md").write_text(md, encoding="utf-8")
+ (project_root / "CUTTER_REPORT.html").write_text(html, encoding="utf-8")
+ logging.getLogger(__name__).info("Cutter report regenerated (md + html)")
except Exception as exc:
logging.getLogger(__name__).warning("Cutter report regen failed: %s", exc)
diff --git a/scripts/generate_cutter_report.py b/scripts/generate_cutter_report.py
index 8dc5f2f..9ea413e 100644
--- a/scripts/generate_cutter_report.py
+++ b/scripts/generate_cutter_report.py
@@ -1,36 +1,40 @@
"""
-scripts/generate_cutter_report.py — generate CUTTER_REPORT.md from current cache
+scripts/generate_cutter_report.py — generate CUTTER_REPORT.{md,html} from 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: 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).
+Renders two reports for the video editor:
-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.
+* ``CUTTER_REPORT.md`` — text + base64-embedded preview stills. Self-
+ contained (no broken image links on git server), opens in any markdown
+ viewer.
+* ``CUTTER_REPORT.html`` — same data with HTML layout, side-by-side
+ preview stills, and optionally side-by-side 3-second MP4 video clips
+ per beat for sight-checking phase agreement.
+
+Frame rates:
+
+* Trailer fps is taken from ``config.toml`` if ``[paths] trailer_frame_rate``
+ is set, otherwise from ffprobe on the trailer file.
+* Source fps is taken from ``[export] edl_frame_rate`` in config.toml. This
+ is the value the EDL/FCPXML uses, so it matches what the cutter sees in
+ the NLE timeline. ffprobe is used only as a last-resort fallback.
Usage (from project root):
- 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.
+ python scripts/generate_cutter_report.py # full report
+ python scripts/generate_cutter_report.py --no-stills # text-only md
+ python scripts/generate_cutter_report.py --with-clips # also render
+ # video previews
"""
from __future__ import annotations
import argparse
+import base64
import json
-import os
import re
import subprocess
import sys
+from dataclasses import dataclass
from datetime import date
from pathlib import Path
@@ -40,38 +44,43 @@ from pathlib import Path
def probe_fps(video_path: Path) -> float | None:
- """Return container fps (avg_frame_rate) for a video file, or None."""
+ """Return the file's frame rate via ffprobe. Tries r_frame_rate first."""
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("/")
+ for key in ("r_frame_rate", "avg_frame_rate"):
try:
- n, d = float(num), float(den)
- return n / d if d else None
- except ValueError:
+ proc = subprocess.run(
+ [
+ "ffprobe", "-v", "error",
+ "-select_streams", "v:0",
+ "-show_entries", f"stream={key}",
+ "-of", "default=noprint_wrappers=1:nokey=1",
+ str(video_path),
+ ],
+ capture_output=True, text=True, timeout=10,
+ )
+ except (FileNotFoundError, subprocess.TimeoutExpired):
return None
- try:
- return float(raw)
- except ValueError:
- return None
+ raw = proc.stdout.strip()
+ if not raw or raw == "0/0":
+ continue
+ if "/" in raw:
+ num, _, den = raw.partition("/")
+ try:
+ n, d = float(num), float(den)
+ if d:
+ return n / d
+ except ValueError:
+ continue
+ try:
+ return float(raw)
+ except ValueError:
+ continue
+ return None
def smpte(t: float | None, fps: float) -> str:
- """Format seconds as h:mm:ss:ff, frame counter rounded to nearest int fps."""
+ """Format seconds as h:mm:ss:ff using nearest-int frame counter."""
if t is None:
return "--:--:--:--"
fps_int = max(1, int(round(fps)))
@@ -113,23 +122,28 @@ def parse_field(desc: str | None, key: str) -> str:
# ----------------------------------------------------------------------------
-# Stills
+# Stills / clips
# ----------------------------------------------------------------------------
-STILL_WIDTH = 360 # px, downscaled for fast preview in the markdown
-STILL_QUALITY = 5 # ffmpeg -q:v scale 1 (best) .. 31 (worst)
+STILL_WIDTH = 480
+STILL_QUALITY = 4
+CLIP_WIDTH = 480
+CLIP_DURATION_S = 3.0
+
+
+def _stale(out: Path, src: Path) -> bool:
+ try:
+ return not (out.exists() and out.stat().st_mtime >= src.stat().st_mtime and out.stat().st_size > 0)
+ except OSError:
+ return True
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
+ if not _stale(out, video_path):
+ return True
out.parent.mkdir(parents=True, exist_ok=True)
cmd = [
"ffmpeg", "-y", "-loglevel", "error",
@@ -147,18 +161,400 @@ def extract_still(video_path: Path, t_s: float, out: Path) -> bool:
return out.exists() and out.stat().st_size > 0
+def extract_clip(video_path: Path, start_s: float, duration_s: float, out: Path) -> bool:
+ if not video_path.exists():
+ return False
+ if not _stale(out, video_path):
+ return True
+ out.parent.mkdir(parents=True, exist_ok=True)
+ cmd = [
+ "ffmpeg", "-y", "-loglevel", "error",
+ "-ss", f"{max(0.0, start_s):.3f}",
+ "-i", str(video_path),
+ "-t", f"{max(0.04, duration_s):.3f}",
+ "-vf", f"scale={CLIP_WIDTH}:-2",
+ "-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
+ "-pix_fmt", "yuv420p",
+ "-an",
+ "-movflags", "+faststart",
+ str(out),
+ ]
+ try:
+ subprocess.run(cmd, check=True, capture_output=True, timeout=60)
+ 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)
+def data_uri(path: Path, mime: str) -> str | None:
+ if not path.exists() or path.stat().st_size == 0:
+ return None
+ payload = base64.b64encode(path.read_bytes()).decode("ascii")
+ return f"data:{mime};base64,{payload}"
+
+
# ----------------------------------------------------------------------------
-# Renderer
+# Per-beat data
# ----------------------------------------------------------------------------
-def render_report(project_root: Path, with_stills: bool = True) -> str:
+@dataclass
+class BeatRow:
+ bid: int
+ trailer_in_s: float
+ trailer_out_s: float
+ source_in_s: float | None
+ source_out_s: float | None
+ scene_id: int | None
+ score: float
+ confirmed: bool
+ matched: bool
+ phase: str
+ composition: str
+ setting: str
+ trailer_still: Path | None
+ source_still: Path | None
+ trailer_clip: Path | None
+ source_clip: Path | None
+
+ @property
+ def status(self) -> str:
+ if not self.matched:
+ return "MAN."
+ return "OK" if self.confirmed else "?"
+
+
+def collect_rows(
+ project_root: Path,
+ beats: list[dict],
+ results: dict[int, dict],
+ vis_items: dict,
+ trailer_path: Path,
+ source_path: Path,
+ with_stills: bool,
+ with_clips: bool,
+) -> list[BeatRow]:
+ stills_dir = project_root / "output" / "cutter_stills"
+ clips_dir = project_root / "output" / "cutter_clips"
+ if with_stills:
+ stills_dir.mkdir(parents=True, exist_ok=True)
+ if with_clips:
+ clips_dir.mkdir(parents=True, exist_ok=True)
+
+ rows: list[BeatRow] = []
+ for beat in beats:
+ bid = beat["beat_id"]
+ rec = results.get(bid)
+ desc = best_beat_description(vis_items, bid, beat["start_s"], beat["end_s"]) or ""
+
+ trailer_still = source_still = trailer_clip = source_clip = None
+ if with_stills:
+ t_still = beat_still_time(beat["start_s"], beat["end_s"])
+ tjpg = stills_dir / f"beat_{bid:02d}_trailer.jpg"
+ if extract_still(trailer_path, t_still, tjpg):
+ trailer_still = tjpg
+ if rec is not None:
+ src_dur = max(0.04, rec["out_point_s"] - rec["in_point_s"])
+ s_still = rec["in_point_s"] + min(0.4, src_dur * 0.3)
+ sjpg = stills_dir / f"beat_{bid:02d}_source.jpg"
+ if extract_still(source_path, s_still, sjpg):
+ source_still = sjpg
+ if with_clips:
+ tdur = min(CLIP_DURATION_S, max(0.5, beat["end_s"] - beat["start_s"]))
+ tmp4 = clips_dir / f"beat_{bid:02d}_trailer.mp4"
+ if extract_clip(trailer_path, beat["start_s"], tdur, tmp4):
+ trailer_clip = tmp4
+ if rec is not None:
+ src_dur = max(0.5, rec["out_point_s"] - rec["in_point_s"])
+ sdur = min(CLIP_DURATION_S, src_dur)
+ smp4 = clips_dir / f"beat_{bid:02d}_source.mp4"
+ if extract_clip(source_path, rec["in_point_s"], sdur, smp4):
+ source_clip = smp4
+
+ rows.append(BeatRow(
+ bid=bid,
+ trailer_in_s=beat["start_s"], trailer_out_s=beat["end_s"],
+ source_in_s=rec["in_point_s"] if rec else None,
+ source_out_s=rec["out_point_s"] if rec else None,
+ scene_id=rec["scene_id"] if rec else None,
+ score=rec["match_score"] if rec else 0.0,
+ confirmed=bool(rec and rec.get("is_confirmed")),
+ matched=rec is not None,
+ phase=parse_field(desc, "action_phase") or parse_field(desc, "subject"),
+ composition=parse_field(desc, "composition"),
+ setting=parse_field(desc, "setting"),
+ trailer_still=trailer_still,
+ source_still=source_still,
+ trailer_clip=trailer_clip,
+ source_clip=source_clip,
+ ))
+ return rows
+
+
+# ----------------------------------------------------------------------------
+# Markdown renderer
+# ----------------------------------------------------------------------------
+
+
+def render_markdown(
+ rows: list[BeatRow], trailer_fps: float, source_fps: float,
+ trailer_path: Path, source_path: Path,
+) -> str:
+ matched = sum(1 for r in rows if r.matched)
+ confirmed = sum(1 for r in rows if r.confirmed)
+
+ out: list[str] = []
+ out.append("# Cutter-Report — manuelles Nachschneiden")
+ out.append("")
+ out.append(f"Stand: {date.today().isoformat()}")
+ out.append("")
+ out.append(f"- **Trailer**: `{trailer_path.name}` @ {trailer_fps:.3f} fps")
+ out.append(f"- **Source** : `{source_path.name}` @ {source_fps:.3f} fps")
+ out.append("")
+ out.append(
+ "Trailer-TC in Trailer-Framerate, Source-TC in Source-Framerate — "
+ "passt 1:1 zum Schnittplatz."
+ )
+ out.append("")
+ out.append(
+ "Bilder sind base64-eingebettet (kein toter Link). Für Video-Vorschau "
+ "siehe `CUTTER_REPORT.html` (gleiche Daten, mit Clips)."
+ )
+ out.append("")
+
+ out.append("## Status-Legende")
+ out.append("")
+ out.append("| Status | Bedeutung | Was tun? |")
+ out.append("|--------|-----------|----------|")
+ out.append("| `OK` | bestätigt durch CV + Vision-Phasenprüfung | übernehmen |")
+ out.append("| `?` | korrekte Szene, Phase ggf. um wenige Frames verschoben | im NLE prüfen |")
+ out.append("| `MAN.` | kein automatischer Treffer | manuell setzen oder Schwarzfade |")
+ out.append("")
+
+ out.append(f"**Beats:** {len(rows)} gesamt · **{matched}** automatisch (**{confirmed}** bestätigt) · **{len(rows)-matched}** manuell.")
+ out.append("")
+
+ out.append("## Beat-Tabelle")
+ out.append("")
+ out.append("| Beat | Trailer In / Out | Source In / Out | Score | Status | Bild laut Vision |")
+ out.append("|-----:|------------------|------------------|------:|:------:|-------------------|")
+ for r in rows:
+ ti = smpte(r.trailer_in_s, trailer_fps)
+ to = smpte(r.trailer_out_s, trailer_fps)
+ si = smpte(r.source_in_s, source_fps) if r.matched else "—"
+ so = smpte(r.source_out_s, source_fps) if r.matched else "—"
+ sc = f"{r.score:.3f}" if r.matched else "—"
+ out.append(f"| {r.bid:>4} | {ti}-{to} | {si}-{so} | {sc} | {r.status} | {r.phase[:80]} |")
+ out.append("")
+
+ out.append("## Beat-Details")
+ out.append("")
+ for r in rows:
+ ti = smpte(r.trailer_in_s, trailer_fps)
+ to = smpte(r.trailer_out_s, trailer_fps)
+ out.append(f"### Beat {r.bid:02d} — Status `{r.status}`")
+ out.append("")
+ out.append(f"- **Trailer**: {ti} – {to}")
+ if r.matched:
+ si = smpte(r.source_in_s, source_fps)
+ so = smpte(r.source_out_s, source_fps)
+ out.append(f"- **Source** : {si} – {so} (scene {r.scene_id}, score {r.score:.3f})")
+ else:
+ out.append("- **Source** : — (manuell setzen)")
+ if r.phase:
+ out.append(f"- **Phase** : {r.phase}")
+ if r.composition:
+ extra = f", {r.setting}" if r.setting else ""
+ out.append(f"- **Bild** : {r.composition}{extra}")
+ out.append("")
+
+ # Inline base64 stills
+ t_uri = data_uri(r.trailer_still, "image/jpeg") if r.trailer_still else None
+ s_uri = data_uri(r.source_still, "image/jpeg") if r.source_still else None
+ if t_uri or s_uri:
+ out.append("| Trailer | Source |")
+ out.append("|:---:|:---:|")
+ t_cell = f"" if t_uri else "_(kein Still)_"
+ s_cell = f"" if s_uri else "_(MAN.)_"
+ out.append(f"| {t_cell} | {s_cell} |")
+ out.append("")
+
+ return "\n".join(out)
+
+
+# ----------------------------------------------------------------------------
+# HTML renderer
+# ----------------------------------------------------------------------------
+
+
+HTML_HEAD = """
+
+
+')
+ parts.append(f'
Beat {r.bid:02d} {r.status}
')
+
+ # Trailer column
+ parts.append('
')
+ parts.append('
Trailer
')
+ clip_uri = data_uri(r.trailer_clip, "video/mp4") if (with_clips and r.trailer_clip) else None
+ if clip_uri:
+ parts.append(f'
')
+ elif r.trailer_still:
+ uri = data_uri(r.trailer_still, "image/jpeg") or ""
+ parts.append(f'

')
+ else:
+ parts.append('
— kein Vorschaubild —
')
+ parts.append(f'
TC {ti} – {to}
')
+ if r.phase:
+ parts.append(f'
Phase {html_escape(r.phase)}
')
+ if r.composition:
+ extra = f", {r.setting}" if r.setting else ""
+ parts.append(f'
Bild {html_escape(r.composition + extra)}
')
+ parts.append('
')
+
+ # Source column
+ parts.append('
')
+ parts.append('
Source
')
+ clip_uri = data_uri(r.source_clip, "video/mp4") if (with_clips and r.source_clip) else None
+ if clip_uri:
+ parts.append(f'
')
+ elif r.source_still:
+ uri = data_uri(r.source_still, "image/jpeg") or ""
+ parts.append(f'

')
+ else:
+ parts.append('
— manuell setzen —
')
+ if r.matched:
+ si = smpte(r.source_in_s, source_fps)
+ so = smpte(r.source_out_s, source_fps)
+ parts.append(f'
TC {si} – {so}
')
+ parts.append(f'
Scene {r.scene_id} · Score {r.score:.3f}
')
+ else:
+ parts.append('
— kein automatischer Treffer —
')
+ parts.append('
')
+
+ parts.append('
') # .beat
+
+ parts.append(HTML_FOOT)
+ return "".join(parts)
+
+
+# ----------------------------------------------------------------------------
+# Top-level
+# ----------------------------------------------------------------------------
+
+
+def render_report(
+ project_root: Path,
+ with_stills: bool = True,
+ with_clips: bool = False,
+) -> tuple[str, str]:
+ """Return (markdown, html). Both written by main()."""
sys.path.insert(0, str(project_root))
from src.core.config import load_config
@@ -166,8 +562,15 @@ def render_report(project_root: Path, with_stills: bool = True) -> str:
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)
+
+ # Source fps: trust config.toml's edl_frame_rate (it's what the EDL/FCPXML
+ # uses, hence what the cutter sees in the NLE). Fall back to ffprobe only
+ # if no value is configured.
+ source_fps = float(getattr(cfg.export, "edl_frame_rate", 0.0)) or probe_fps(source_path) or 23.976
+ # Trailer fps: optional config override, else ffprobe, else fallback to
+ # source fps so the two sides at least share a number.
+ trailer_fps_cfg = getattr(cfg.paths, "trailer_frame_rate", None)
+ trailer_fps = float(trailer_fps_cfg) if trailer_fps_cfg else (probe_fps(trailer_path) or source_fps)
cache = project_root / ".cache"
results = {r["beat_id"]: r for r in json.loads((cache / "match_results.json").read_text())}
@@ -175,184 +578,32 @@ def render_report(project_root: Path, with_stills: bool = True) -> 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(
- "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."
+ rows = collect_rows(
+ project_root, beats, results, vis_items,
+ trailer_path, source_path, with_stills, with_clips,
)
- lines.append("")
- lines.append(
- "Diese Datei wird automatisch erzeugt — nach jedem `python cli.py match` "
- "neu generieren mit:"
- )
- lines.append("")
- lines.append("```powershell")
- lines.append("python scripts/generate_cutter_report.py")
- lines.append("```")
- lines.append("")
- 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("## Übersicht")
- lines.append("")
- 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("")
-
- # ---- Compact table (timecode-only, no images) ------------------------
- lines.append("## Beat-Tabelle (kompakt)")
- 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:
- return "MAN."
- return "OK" if rec.get("is_confirmed") else "?"
-
- for beat in beats:
- bid = beat["beat_id"]
- rec = results.get(bid)
- 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 = rec["match_score"]
- else:
- si = so = "—"
- 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"))[:80]
- lines.append(f"| {bid:>4} | {ti}-{to} | {si}-{so} | {sc:.3f} | {status_for(rec)} | {phase} |")
-
- lines.append("")
-
- # ---- Detailed per-beat sections with stills --------------------------
- lines.append("## Beat-Details mit Vorschau-Stills")
- 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)
- 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") or parse_field(desc, "subject") or "(keine Vision-Beschreibung)"
- composition = parse_field(desc, "composition")
- setting = parse_field(desc, "setting")
-
- lines.append(f"### Beat {bid:02d} — Status `{status}`")
- lines.append("")
- lines.append(f"- **Trailer**: {ti} – {to}")
- if rec is not None:
- lines.append(f"- **Source** : {si} – {so} (scene {scn}, score {sc_str})")
- else:
- lines.append("- **Source** : — (kein Treffer; manuell setzen)")
- lines.append(f"- **Phase** : {phase}")
- if composition:
- lines.append(f"- **Bild** : {composition}{', ' + setting if setting else ''}")
- lines.append("")
-
- 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. 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 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 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
-# ----------------------------------------------------------------------------
+ md = render_markdown(rows, trailer_fps, source_fps, trailer_path, source_path)
+ html = render_html(rows, trailer_fps, source_fps, trailer_path, source_path, with_clips)
+ return md, html
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")
+ parser = argparse.ArgumentParser(description="Render CUTTER_REPORT.{md,html} from current cache")
+ parser.add_argument("--no-stills", action="store_true", help="skip frame extraction (markdown stays text-only)")
+ parser.add_argument("--with-clips", action="store_true", help="also render 3 s MP4 previews per beat (slow)")
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, with_stills=not args.no_stills), encoding="utf-8")
- print(f"Wrote {out}")
+ md, html = render_report(
+ project_root,
+ with_stills=not args.no_stills,
+ with_clips=args.with_clips,
+ )
+ (project_root / "CUTTER_REPORT.md").write_text(md, encoding="utf-8")
+ (project_root / "CUTTER_REPORT.html").write_text(html, encoding="utf-8")
+ print(f"Wrote {project_root / 'CUTTER_REPORT.md'}")
+ print(f"Wrote {project_root / 'CUTTER_REPORT.html'}")
return 0