hlassiege/darylpublic Fork
⑂ baa3967
Commits
⬇ Clone ▾
git clone https://git.rickub.com/hlassiege/daryl.git
git clone ssh://git@rickub.com/hlassiege/daryl.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Download ZIP

feat(skills): add snatch-cut skill

Analyze a sequence and propose a Guy Ritchie-style snatch cut (burst of
object close-ups with snap zooms and punchy sound design, then a return
to normal editing), with a preview render.

- analyze.py: contact sheets, cut detection, per-frame motion and
  loudness, onsets, word-level transcript on normalized audio
- render.py: JSON breakdown (shots, zooms, sounds) to a preview MP4,
  rendered shot by shot with bounded ffmpeg runs
- references: frame-by-frame Snatch breakdown and a validated example spec
hlassiege committed 2026-10-13T14:18:17+02:00 Browse files
baa3967 parent: 5c16cb4
added .claude/skills/snatch-cut/SKILL.md +101 -0
new file mode 100644
@@ -0,0 +1,101 @@
1+---
2+name: snatch-cut
3+description: Analyze a video sequence and propose a Guy Ritchie-style "snatch cut" (a burst of object close-ups with snap zooms and punchy sound design, then a return to normal editing), with a preview render. Use when the user mentions a snatch cut, Guy Ritchie-style fast editing, a burst of shots with zooms and sound effects, or wants to energize an action sequence or a pre-intro.
4+---
5+
6+# Snatch cut
7+
8+A **burst of 4 to 7 object close-ups**, 0.5–0.7 s each, each shot punctuated by a snap zoom and a sound, telling an action through its details. The sound climax lands at the end of the burst, then the edit **returns to normal** (dialogue, long shots, no zoom). The reference analysis is in [references/snatch-breakdown.md](references/snatch-breakdown.md): read it before proposing a breakdown.
9+
10+Hard rule: **the AI proposes, the user decides.** Never modify the user's files, timeline or media: produce previews alongside, under new names.
11+
12+## Workflow
13+
14+### 1. Analysis
15+
16+```bash
17+cd C:/Dev/src/youtube/studio
18+TMP=$(mktemp -d) # working folder outside data/
19+.venv/Scripts/python -I .claude/skills/snatch-cut/scripts/analyze.py "<video>" "$TMP/analysis" --dense 0:6 --step 2
20+```
21+
22+- **Contact sheets**: `overview_*.png` at 5 fps, `dense_*.png` frame by frame over the burst. **Look at them** (Read): that is where you see the approach, the action and the dead time in each shot.
23+- **`frames.tsv`**: the difference from the previous frame pinpoints the exact moment of an action (click, impact). A jump in luminance marks a light turning on, which cut detection sometimes reports as a cut.
24+- **`audio.tsv` and `spectrum.png`**: per-frame level and onsets. Find usable original sounds (clicks, handles), voices that are too quiet and buried breaths.
25+- **`transcript.txt`**: word by word, after loudness normalization. Without it, Whisper hallucinates text on a quiet voice.
26+- **The project's 4K rushes** (`data/projects/<id>/ressources`, `rushes`): if they exist for a shot, zooms will be much sharper from them.
27+
28+### 2. Diagnosis and proposal (no render yet)
29+
30+Present a shot-by-shot table:
31+- current duration and target duration;
32+- source frames `in` and `out`, with the action placed around the middle of the shot;
33+- the zoom (zoom frame, amount, anchor point);
34+- the sounds (original, Epidemic, our library) with their levels.
35+
36+End with a list of **open questions**: lines cut or kept, original sound or sound effect, trailing black, whooshes. Wait for approval before rendering.
37+
38+Guidelines:
39+- **Burst shots**: 15–20 frames at 30 fps, 26 at most for the first shot. Cut out the whole approach (the hand coming in) and the dead time.
40+- **The zoom**: +15 to +25 % over 3 frames, easing out, **on the action frame**, together with the sound. For an establishing first shot, a slow +8 % push over the whole shot.
41+- **Silence before the impact**: 3–8 frames of near-silence before the big sounds, especially before the final click. Never absolute digital silence: a very low room tone runs under the whole sequence.
42+- **The climax** on the last shot of the burst: click plus a low thud. The voice of the dialogue part can start 2–3 frames before its picture.
43+- **The dialogue part**: real time, no zoom, voice raised and compressed if it is weak.
44+- **A single punchy shot after the dialogue** (a click that cuts someone off) reuses the burst grammar: near-silence, then zoom and a dry sound.
45+- **The release** (sigh, look): long shot. When boosting a breath, boost **only its windows**, otherwise the room hiss comes up with it.
46+
47+### 3. Sounds
48+
49+User preferences:
50+- Our whooshes (`data/library/sfx/whoosh.wav`, `whoosh-2.wav`), not Epidemic's, which he finds too long.
51+- Low levels. No long sound on a short action.
52+
53+Search Epidemic through the MCP (`SearchSoundEffects`, duration filter), then download as WAV into a dedicated folder (`$TMP/sfx`). Then find the **attack** of each sound (`silencedetect` or envelope) and align the attack with the picture, not the start of the file: that is the `sync` field of the breakdown. Include the preview links (`lqmp3Url`) in the proposal.
54+
55+Sounds already used (Epidemic IDs):
56+
57+| Use | Title | ID | Attack |
58+|---|---|---|---|
59+| Door handle | Doors, Hardware, Door, Open, Knob, Lock Mechanism | bf8d4a75-5557-465f-9315-85aec64a7cdd | rattle 0.1–0.9 s, latch 1.05 s |
60+| Light switch | Mechanical, Switch, Light Switch, On, Off | 2b2e2214-2f82-4614-a840-75eb7bff59b7 | 0.0 s (keep 0–0.5 s only) |
61+| Lamp | Mechanical, Switch, Light Switch, Turn On, Off, Apartment 02 | ccc6c7f4-bf41-451a-a49e-7ac716284623 | 0.195 s |
62+| Mouse click | Computers, Keyboard & Mouse, Mouse, Apple, Mighty Mouse, Click | 78e23a2d-c645-491d-b826-c5ca15d1efdd | 0.0 s |
63+| Low thud | Designed, Impact, Cinematic Hit, Impact, Thud, Thin, Reverb | 866b9c73-3ad6-4a2e-93f8-27c118b9e462 | 0.0 s (cut at 0.8 s) |
64+| Night room tone | Ambience, Room Tone, Big House, Interior, Room, Very Quiet… Night | 58b13873-07ae-4c23-b4b0-da455d8e39fe | 308 s file: extract only 15 s |
65+| Sigh (fallback) | Voices, Male, Sigh, Tired | 026d325c-9ac9-40db-973b-3667105f8e12 | — |
66+
67+Starting levels in the breakdown, before master gain:
68+
69+| Sound | Level |
70+|---|---|
71+| Sound effects (door, switch, lamp, mouse) | −9 to −14 dB |
72+| Low thud | −17 dB |
73+| Whoosh | −24 dB |
74+| Room tone | −28 dB |
75+| Weak voice (≈ −42 dB mean) | +10 dB and a compressor |
76+
77+### 4. Preview render
78+
79+Write the breakdown as JSON in `$TMP`. The format is documented at the top of `scripts/render.py`. A complete example, validated on the "anonymat" pre-intro, is in [references/example-spec.json](references/example-spec.json); its paths are relative to the JSON file's folder. Times are given per shot, `{"shot": i, "frame": k}`, so nothing has to be recomputed when a duration changes. Then run:
80+
81+```bash
82+.venv/Scripts/python -I .claude/skills/snatch-cut/scripts/render.py "$TMP/spec.json"
83+```
84+
85+- It takes about 20–30 s for 15 s of video. If it runs longer than a minute, something is broken: do not wait.
86+- The render comes out at −16 LUFS with a −1.5 dB peak (master gain 16 dB, limiter at 0.79). Adjust `master_gain_db` if the printed measurement drifts.
87+- Check the result before announcing it: a contact sheet of the rendered MP4, and the level at the key moments (no −180 dB silence in the middle).
88+- Copy the render to `data/projects/<id>/rendu/<name>-snatch-vN.mp4`, under a new name, never overwriting.
89+
90+### 5. After approval
91+
92+Only once the user has approved it by ear: copy the selected Epidemic sounds into `data/projects/<id>/assets/sfx/`. Integrating it into the edit is up to the user.
93+
94+## ffmpeg pitfalls (costly in time)
95+
96+- **`-nostdin` everywhere**: run in the background without it, ffmpeg waits for keyboard input and hangs forever.
97+- **A single `zoompan` in a large graph with `trim` never ends**, and `[0:v]` reused without `split` duplicates the first shot. Hence the shot-by-shot render with `-frames:v`, then concatenation. Always bound the output with `-t`.
98+- **`alimiter` raises the level up to 0 dB by default**: always set `level=0`.
99+- **`drawtext`**: the font is copied locally (`font.ttf`), because the `:` in `C:/Windows/...` breaks the filter syntax.
100+- **Python on Windows does not see `/tmp`**: pass relative paths, or the ones returned by `mktemp -d`.
101+- **An MP4 with "moov atom not found"** was copied while the export was still writing it. Re-import it once the export has finished.
new file mode 100644
@@ -0,0 +1,101 @@
1+---
2+name: snatch-cut
3+description: Analyze a video sequence and propose a Guy Ritchie-style "snatch cut" (a burst of object close-ups with snap zooms and punchy sound design, then a return to normal editing), with a preview render. Use when the user mentions a snatch cut, Guy Ritchie-style fast editing, a burst of shots with zooms and sound effects, or wants to energize an action sequence or a pre-intro.
4+---
5+
6+# Snatch cut
7+
8+A **burst of 4 to 7 object close-ups**, 0.5–0.7 s each, each shot punctuated by a snap zoom and a sound, telling an action through its details. The sound climax lands at the end of the burst, then the edit **returns to normal** (dialogue, long shots, no zoom). The reference analysis is in [references/snatch-breakdown.md](references/snatch-breakdown.md): read it before proposing a breakdown.
9+
10+Hard rule: **the AI proposes, the user decides.** Never modify the user's files, timeline or media: produce previews alongside, under new names.
11+
12+## Workflow
13+
14+### 1. Analysis
15+
16+```bash
17+cd C:/Dev/src/youtube/studio
18+TMP=$(mktemp -d) # working folder outside data/
19+.venv/Scripts/python -I .claude/skills/snatch-cut/scripts/analyze.py "<video>" "$TMP/analysis" --dense 0:6 --step 2
20+```
21+
22+- **Contact sheets**: `overview_*.png` at 5 fps, `dense_*.png` frame by frame over the burst. **Look at them** (Read): that is where you see the approach, the action and the dead time in each shot.
23+- **`frames.tsv`**: the difference from the previous frame pinpoints the exact moment of an action (click, impact). A jump in luminance marks a light turning on, which cut detection sometimes reports as a cut.
24+- **`audio.tsv` and `spectrum.png`**: per-frame level and onsets. Find usable original sounds (clicks, handles), voices that are too quiet and buried breaths.
25+- **`transcript.txt`**: word by word, after loudness normalization. Without it, Whisper hallucinates text on a quiet voice.
26+- **The project's 4K rushes** (`data/projects/<id>/ressources`, `rushes`): if they exist for a shot, zooms will be much sharper from them.
27+
28+### 2. Diagnosis and proposal (no render yet)
29+
30+Present a shot-by-shot table:
31+- current duration and target duration;
32+- source frames `in` and `out`, with the action placed around the middle of the shot;
33+- the zoom (zoom frame, amount, anchor point);
34+- the sounds (original, Epidemic, our library) with their levels.
35+
36+End with a list of **open questions**: lines cut or kept, original sound or sound effect, trailing black, whooshes. Wait for approval before rendering.
37+
38+Guidelines:
39+- **Burst shots**: 15–20 frames at 30 fps, 26 at most for the first shot. Cut out the whole approach (the hand coming in) and the dead time.
40+- **The zoom**: +15 to +25 % over 3 frames, easing out, **on the action frame**, together with the sound. For an establishing first shot, a slow +8 % push over the whole shot.
41+- **Silence before the impact**: 3–8 frames of near-silence before the big sounds, especially before the final click. Never absolute digital silence: a very low room tone runs under the whole sequence.
42+- **The climax** on the last shot of the burst: click plus a low thud. The voice of the dialogue part can start 2–3 frames before its picture.
43+- **The dialogue part**: real time, no zoom, voice raised and compressed if it is weak.
44+- **A single punchy shot after the dialogue** (a click that cuts someone off) reuses the burst grammar: near-silence, then zoom and a dry sound.
45+- **The release** (sigh, look): long shot. When boosting a breath, boost **only its windows**, otherwise the room hiss comes up with it.
46+
47+### 3. Sounds
48+
49+User preferences:
50+- Our whooshes (`data/library/sfx/whoosh.wav`, `whoosh-2.wav`), not Epidemic's, which he finds too long.
51+- Low levels. No long sound on a short action.
52+
53+Search Epidemic through the MCP (`SearchSoundEffects`, duration filter), then download as WAV into a dedicated folder (`$TMP/sfx`). Then find the **attack** of each sound (`silencedetect` or envelope) and align the attack with the picture, not the start of the file: that is the `sync` field of the breakdown. Include the preview links (`lqmp3Url`) in the proposal.
54+
55+Sounds already used (Epidemic IDs):
56+
57+| Use | Title | ID | Attack |
58+|---|---|---|---|
59+| Door handle | Doors, Hardware, Door, Open, Knob, Lock Mechanism | bf8d4a75-5557-465f-9315-85aec64a7cdd | rattle 0.1–0.9 s, latch 1.05 s |
60+| Light switch | Mechanical, Switch, Light Switch, On, Off | 2b2e2214-2f82-4614-a840-75eb7bff59b7 | 0.0 s (keep 0–0.5 s only) |
61+| Lamp | Mechanical, Switch, Light Switch, Turn On, Off, Apartment 02 | ccc6c7f4-bf41-451a-a49e-7ac716284623 | 0.195 s |
62+| Mouse click | Computers, Keyboard & Mouse, Mouse, Apple, Mighty Mouse, Click | 78e23a2d-c645-491d-b826-c5ca15d1efdd | 0.0 s |
63+| Low thud | Designed, Impact, Cinematic Hit, Impact, Thud, Thin, Reverb | 866b9c73-3ad6-4a2e-93f8-27c118b9e462 | 0.0 s (cut at 0.8 s) |
64+| Night room tone | Ambience, Room Tone, Big House, Interior, Room, Very Quiet… Night | 58b13873-07ae-4c23-b4b0-da455d8e39fe | 308 s file: extract only 15 s |
65+| Sigh (fallback) | Voices, Male, Sigh, Tired | 026d325c-9ac9-40db-973b-3667105f8e12 | — |
66+
67+Starting levels in the breakdown, before master gain:
68+
69+| Sound | Level |
70+|---|---|
71+| Sound effects (door, switch, lamp, mouse) | −9 to −14 dB |
72+| Low thud | −17 dB |
73+| Whoosh | −24 dB |
74+| Room tone | −28 dB |
75+| Weak voice (≈ −42 dB mean) | +10 dB and a compressor |
76+
77+### 4. Preview render
78+
79+Write the breakdown as JSON in `$TMP`. The format is documented at the top of `scripts/render.py`. A complete example, validated on the "anonymat" pre-intro, is in [references/example-spec.json](references/example-spec.json); its paths are relative to the JSON file's folder. Times are given per shot, `{"shot": i, "frame": k}`, so nothing has to be recomputed when a duration changes. Then run:
80+
81+```bash
82+.venv/Scripts/python -I .claude/skills/snatch-cut/scripts/render.py "$TMP/spec.json"
83+```
84+
85+- It takes about 20–30 s for 15 s of video. If it runs longer than a minute, something is broken: do not wait.
86+- The render comes out at −16 LUFS with a −1.5 dB peak (master gain 16 dB, limiter at 0.79). Adjust `master_gain_db` if the printed measurement drifts.
87+- Check the result before announcing it: a contact sheet of the rendered MP4, and the level at the key moments (no −180 dB silence in the middle).
88+- Copy the render to `data/projects/<id>/rendu/<name>-snatch-vN.mp4`, under a new name, never overwriting.
89+
90+### 5. After approval
91+
92+Only once the user has approved it by ear: copy the selected Epidemic sounds into `data/projects/<id>/assets/sfx/`. Integrating it into the edit is up to the user.
93+
94+## ffmpeg pitfalls (costly in time)
95+
96+- **`-nostdin` everywhere**: run in the background without it, ffmpeg waits for keyboard input and hangs forever.
97+- **A single `zoompan` in a large graph with `trim` never ends**, and `[0:v]` reused without `split` duplicates the first shot. Hence the shot-by-shot render with `-frames:v`, then concatenation. Always bound the output with `-t`.
98+- **`alimiter` raises the level up to 0 dB by default**: always set `level=0`.
99+- **`drawtext`**: the font is copied locally (`font.ttf`), because the `:` in `C:/Windows/...` breaks the filter syntax.
100+- **Python on Windows does not see `/tmp`**: pass relative paths, or the ones returned by `mktemp -d`.
101+- **An MP4 with "moov atom not found"** was copied while the export was still writing it. Re-import it once the export has finished.
added .claude/skills/snatch-cut/references/example-spec.json +30 -0
new file mode 100644
@@ -0,0 +1,30 @@
1+{
2+ "source": "in.mp4", "output": "test_render.mp4", "fps": 30, "tail_black": 15,
3+ "master_gain_db": 16, "limit": 0.79,
4+ "shots": [
5+ {"in": 14, "out": 40, "zoom": {"at": 0, "dur": 26, "amount": 0.08, "anchor": [0.5, 0.5]}, "keep_audio": true},
6+ {"in": 64, "out": 82, "zoom": {"at": 10, "dur": 3, "amount": 0.15, "anchor": [0.5, 0.4]}, "keep_audio": true},
7+ {"in": 122, "out": 146, "zoom": {"at": 12, "dur": 3, "amount": 0.15, "anchor": [0.6, 0.4]}, "keep_audio": true},
8+ {"in": 160, "out": 178, "zoom": {"at": 8, "dur": 3, "amount": 0.20, "anchor": [0.6, 0.6]}, "keep_audio": true},
9+ {"in": 186, "out": 270},
10+ {"in": 270, "out": 374},
11+ {"in": 426, "out": 446, "zoom": {"at": 8, "dur": 3, "amount": 0.25, "anchor": [0.15, 0.85]}},
12+ {"in": 477, "out": 566}
13+ ],
14+ "audio": [
15+ {"file": "sfx/roomtone.wav", "in": 0, "out": 13.3, "at": 0, "gain_db": -28},
16+ {"file": "source", "in": 6.10, "out": 12.45, "at": {"shot": 4, "frame": -3}, "gain_db": 10,
17+ "filters": "acompressor=threshold=-30dB:ratio=3:attack=5:release=120", "fade_out": 0.1},
18+ {"file": "source", "in": 15.90, "out": 18.85, "at": {"shot": 7}, "filters": "afftdn=nr=25:nf=-55",
19+ "boost": {"windows": [[0.15, 0.8], [1.25, 1.7]], "in_db": 22, "out_db": 3}},
20+ {"file": "sfx/door.wav", "in": 0.15, "out": 1.25, "at": {"shot": 0}, "gain_db": -14},
21+ {"file": "sfx/whoosh-2.wav", "in": 0, "out": 0.66, "at": {"shot": 1}, "sync": 0.26, "gain_db": -24},
22+ {"file": "sfx/switch.wav", "in": 0, "out": 0.5, "at": {"shot": 1, "frame": 10}, "gain_db": -11},
23+ {"file": "sfx/whoosh-2.wav", "in": 0, "out": 0.66, "at": {"shot": 2}, "sync": 0.26, "gain_db": -24},
24+ {"file": "sfx/lamp.wav", "in": 0, "out": 0.9, "at": {"shot": 2, "frame": 12}, "sync": 0.195, "gain_db": -9},
25+ {"file": "sfx/whoosh-2.wav", "in": 0, "out": 0.66, "at": {"shot": 3}, "sync": 0.26, "gain_db": -24},
26+ {"file": "sfx/mouse.wav", "in": 0, "out": 0.2, "at": {"shot": 3, "frame": 8}, "gain_db": -11},
27+ {"file": "sfx/hit.wav", "in": 0, "out": 0.8, "at": {"shot": 3, "frame": 8}, "gain_db": -17},
28+ {"file": "sfx/mouse.wav", "in": 0, "out": 0.2, "at": {"shot": 6, "frame": 8}, "gain_db": -11}
29+ ]
30+}
new file mode 100644
@@ -0,0 +1,30 @@
1+{
2+ "source": "in.mp4", "output": "test_render.mp4", "fps": 30, "tail_black": 15,
3+ "master_gain_db": 16, "limit": 0.79,
4+ "shots": [
5+ {"in": 14, "out": 40, "zoom": {"at": 0, "dur": 26, "amount": 0.08, "anchor": [0.5, 0.5]}, "keep_audio": true},
6+ {"in": 64, "out": 82, "zoom": {"at": 10, "dur": 3, "amount": 0.15, "anchor": [0.5, 0.4]}, "keep_audio": true},
7+ {"in": 122, "out": 146, "zoom": {"at": 12, "dur": 3, "amount": 0.15, "anchor": [0.6, 0.4]}, "keep_audio": true},
8+ {"in": 160, "out": 178, "zoom": {"at": 8, "dur": 3, "amount": 0.20, "anchor": [0.6, 0.6]}, "keep_audio": true},
9+ {"in": 186, "out": 270},
10+ {"in": 270, "out": 374},
11+ {"in": 426, "out": 446, "zoom": {"at": 8, "dur": 3, "amount": 0.25, "anchor": [0.15, 0.85]}},
12+ {"in": 477, "out": 566}
13+ ],
14+ "audio": [
15+ {"file": "sfx/roomtone.wav", "in": 0, "out": 13.3, "at": 0, "gain_db": -28},
16+ {"file": "source", "in": 6.10, "out": 12.45, "at": {"shot": 4, "frame": -3}, "gain_db": 10,
17+ "filters": "acompressor=threshold=-30dB:ratio=3:attack=5:release=120", "fade_out": 0.1},
18+ {"file": "source", "in": 15.90, "out": 18.85, "at": {"shot": 7}, "filters": "afftdn=nr=25:nf=-55",
19+ "boost": {"windows": [[0.15, 0.8], [1.25, 1.7]], "in_db": 22, "out_db": 3}},
20+ {"file": "sfx/door.wav", "in": 0.15, "out": 1.25, "at": {"shot": 0}, "gain_db": -14},
21+ {"file": "sfx/whoosh-2.wav", "in": 0, "out": 0.66, "at": {"shot": 1}, "sync": 0.26, "gain_db": -24},
22+ {"file": "sfx/switch.wav", "in": 0, "out": 0.5, "at": {"shot": 1, "frame": 10}, "gain_db": -11},
23+ {"file": "sfx/whoosh-2.wav", "in": 0, "out": 0.66, "at": {"shot": 2}, "sync": 0.26, "gain_db": -24},
24+ {"file": "sfx/lamp.wav", "in": 0, "out": 0.9, "at": {"shot": 2, "frame": 12}, "sync": 0.195, "gain_db": -9},
25+ {"file": "sfx/whoosh-2.wav", "in": 0, "out": 0.66, "at": {"shot": 3}, "sync": 0.26, "gain_db": -24},
26+ {"file": "sfx/mouse.wav", "in": 0, "out": 0.2, "at": {"shot": 3, "frame": 8}, "gain_db": -11},
27+ {"file": "sfx/hit.wav", "in": 0, "out": 0.8, "at": {"shot": 3, "frame": 8}, "gain_db": -17},
28+ {"file": "sfx/mouse.wav", "in": 0, "out": 0.2, "at": {"shot": 6, "frame": 8}, "gain_db": -11}
29+ ]
30+}
added .claude/skills/snatch-cut/references/snatch-breakdown.md +29 -0
new file mode 100644
@@ -0,0 +1,29 @@
1+# Snatch (Guy Ritchie, 2000) — frame-by-frame analysis
2+
3+Excerpt: Avi's trip from New York to London (9.3 s, 25 fps). Analyzed frame by frame, with per-frame loudness and a spectrogram.
4+
5+## Breakdown
6+
7+| # | Frames | Duration | Shot | Function | Sound |
8+|---|---|---|---|---|---|
9+| 1 | 0–42 | 1.72 s | Split screen, two men on the phone: "I'm off to London!" | Trigger | Dialogue |
10+| 2 | 43–55 | 0.52 s | Hand hanging up: wide for 5 frames → tight on the hand | Decision | Silence (−45 dB), then the receiver *clack* 4 frames after the cut |
11+| 3 | 56–67 | 0.48 s | "N.Y.C. TAXI" door: 4-frame slide, then a frozen frame | Departure | Low engine or door sound |
12+| 4 | 68–106 | 1.56 s | Airline meal tray from above → fast tilt to the face downing a drink | Journey | Whoosh during the tilt, then cabin hiss |
13+| 5 | 107–123 | 0.68 s | Concorde growing and passing over the camera, filling the frame | Transport | **Loudest sound of the sequence** (−14 dB), the jet roar |
14+| 6 | 124–140 | 0.68 s | "HEATHROW" stamp slammed down, motion blur on arrival | Arrival | The roar spills over this shot, then two stamp hits |
15+| 7 | 141–153 | 0.52 s | London taxi sign switching off | Destination, end | Sustained sound fading to near-silence |
16+| 8+ | 154 → | normal | Back to the dialogue scene | Real time | Dialogue |
17+
18+## Techniques
19+
20+1. **Metonymy**: never film the whole action, only symbolic objects. One readable action per shot, in under a second.
21+2. **A pace of 12–17 frames per shot (25 fps)**, i.e. **15–20 frames at 30 fps**. A shot with nervous internal movement can run longer.
22+3. **Attack then stop**: a fast movement (slide, drop, object rushing in) followed by a near-frozen frame. Frame-to-frame difference drops from ≈9 to ≈0.4.
23+4. **Silence before the impact**: before each big sound, the level dips to −45 dB for 3–8 frames. That gap is what gives the impact its punch.
24+5. **Sound offset from the cut**: it lands 2–4 frames after the cut, or spills over the next shot, which ties the shots together.
25+6. **Climax before the exit**: the strongest impact comes right before the return to normal pace.
26+7. **Color and movement link the shots**: warm colors at departure, cold at arrival, with a hue match between shots. Movements chain in the same direction (vertical here).
27+8. **The slowdown is not slow motion**: no duplicated or interpolated frames. A static last shot, a light switching off and the sound dropping away bring the edit back to normal.
28+
29+The duplicated frames every 24–25 frames are just the 24 → 25 fps conversion, not an effect.
new file mode 100644
@@ -0,0 +1,29 @@
1+# Snatch (Guy Ritchie, 2000) — frame-by-frame analysis
2+
3+Excerpt: Avi's trip from New York to London (9.3 s, 25 fps). Analyzed frame by frame, with per-frame loudness and a spectrogram.
4+
5+## Breakdown
6+
7+| # | Frames | Duration | Shot | Function | Sound |
8+|---|---|---|---|---|---|
9+| 1 | 0–42 | 1.72 s | Split screen, two men on the phone: "I'm off to London!" | Trigger | Dialogue |
10+| 2 | 43–55 | 0.52 s | Hand hanging up: wide for 5 frames → tight on the hand | Decision | Silence (−45 dB), then the receiver *clack* 4 frames after the cut |
11+| 3 | 56–67 | 0.48 s | "N.Y.C. TAXI" door: 4-frame slide, then a frozen frame | Departure | Low engine or door sound |
12+| 4 | 68–106 | 1.56 s | Airline meal tray from above → fast tilt to the face downing a drink | Journey | Whoosh during the tilt, then cabin hiss |
13+| 5 | 107–123 | 0.68 s | Concorde growing and passing over the camera, filling the frame | Transport | **Loudest sound of the sequence** (−14 dB), the jet roar |
14+| 6 | 124–140 | 0.68 s | "HEATHROW" stamp slammed down, motion blur on arrival | Arrival | The roar spills over this shot, then two stamp hits |
15+| 7 | 141–153 | 0.52 s | London taxi sign switching off | Destination, end | Sustained sound fading to near-silence |
16+| 8+ | 154 → | normal | Back to the dialogue scene | Real time | Dialogue |
17+
18+## Techniques
19+
20+1. **Metonymy**: never film the whole action, only symbolic objects. One readable action per shot, in under a second.
21+2. **A pace of 12–17 frames per shot (25 fps)**, i.e. **15–20 frames at 30 fps**. A shot with nervous internal movement can run longer.
22+3. **Attack then stop**: a fast movement (slide, drop, object rushing in) followed by a near-frozen frame. Frame-to-frame difference drops from ≈9 to ≈0.4.
23+4. **Silence before the impact**: before each big sound, the level dips to −45 dB for 3–8 frames. That gap is what gives the impact its punch.
24+5. **Sound offset from the cut**: it lands 2–4 frames after the cut, or spills over the next shot, which ties the shots together.
25+6. **Climax before the exit**: the strongest impact comes right before the return to normal pace.
26+7. **Color and movement link the shots**: warm colors at departure, cold at arrival, with a hue match between shots. Movements chain in the same direction (vertical here).
27+8. **The slowdown is not slow motion**: no duplicated or interpolated frames. A static last shot, a light switching off and the sound dropping away bring the edit back to normal.
28+
29+The duplicated frames every 24–25 frames are just the 24 → 25 fps conversion, not an effect.
added .claude/skills/snatch-cut/scripts/analyze.py +148 -0
new file mode 100644
@@ -0,0 +1,148 @@
1+"""Analyze a video sequence to prepare a snatch cut.
2+
3+Writes to OUT_DIR:
4+ - overview_NN.png contact sheets at 5 fps (timecode + source frame number)
5+ - dense_NN.png frame-by-frame sheets (or 1 in --step) over --dense start:end (seconds)
6+ - cuts.txt detected cuts (seconds and frame)
7+ - frames.tsv per frame: difference from the previous one, luminance (motion / light-on cues)
8+ - audio.tsv per frame: level in dB, spectral centroid; onsets in the header
9+ - spectrum.png spectrogram of the whole duration
10+ - transcript.txt word-level transcript (normalized audio, Whisper on CPU) unless --no-transcribe
11+
12+Usage:
13+ .venv/Scripts/python -I analyze.py VIDEO OUT_DIR [--dense 0:6.2] [--step 2] [--no-transcribe]
14+"""
15+import argparse
16+import json
17+import re
18+import shutil
19+import subprocess
20+import sys
21+from pathlib import Path
22+
23+import numpy as np
24+
25+FONT = Path("C:/Windows/Fonts/arial.ttf")
26+
27+
28+def ff(*args, capture=False):
29+ cmd = ["ffmpeg", "-nostdin", "-hide_banner", "-y", *args]
30+ r = subprocess.run(cmd, capture_output=True, text=True)
31+ if r.returncode and not capture:
32+ sys.exit(r.stderr[-2000:])
33+ return r.stderr
34+
35+
36+def probe(video):
37+ r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
38+ "stream=r_frame_rate,width,height,nb_frames:format=duration", "-of", "json", str(video)],
39+ capture_output=True, text=True)
40+ j = json.loads(r.stdout)
41+ num, den = j["streams"][0]["r_frame_rate"].split("/")
42+ return float(num) / float(den), float(j["format"]["duration"])
43+
44+
45+def sheets(video, out, prefix, vf_select, label, cols=6, rows=5):
46+ # drawtext needs a font path without ':' -> local copy
47+ shutil.copy(FONT, out / "font.ttf")
48+ vf = (f"{vf_select},scale=384:-1,drawtext=fontfile=font.ttf:text='{label}':x=5:y=5:fontsize=20:"
49+ f"fontcolor=yellow:box=1:boxcolor=black,tile={cols}x{rows}")
50+ subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", str(video.resolve()), "-vf", vf,
51+ "-fps_mode", "passthrough", f"{prefix}_%02d.png"], cwd=out, check=True)
52+
53+
54+def frame_stats(video, fps):
55+ W, H = 192, 108
56+ raw = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-i", str(video), "-vf", f"scale={W}:{H},format=gray",
57+ "-f", "rawvideo", "-"], capture_output=True).stdout
58+ f = np.frombuffer(raw, np.uint8).reshape(-1, H, W).astype(np.float32)
59+ diff = np.concatenate([[0], np.abs(np.diff(f, axis=0)).mean(axis=(1, 2))])
60+ return diff, f.mean(axis=(1, 2))
61+
62+
63+def audio_stats(video, fps):
64+ sr = 22050
65+ raw = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-i", str(video), "-ac", "1", "-ar", str(sr),
66+ "-f", "s16le", "-"], capture_output=True).stdout
67+ x = np.frombuffer(raw, np.int16).astype(np.float32) / 32768
68+ hop = int(round(sr / fps))
69+ n = len(x) // hop
70+ blocks = x[: n * hop].reshape(n, hop)
71+ db = 20 * np.log10(np.sqrt((blocks ** 2).mean(1)) + 1e-9)
72+ spec = np.abs(np.fft.rfft(blocks * np.hanning(hop), axis=1))
73+ cent = (spec * np.fft.rfftfreq(hop, 1 / sr)).sum(1) / (spec.sum(1) + 1e-9)
74+ # onsets: spectral flux over 1024-sample windows, hop 256
75+ N, h = 1024, 256
76+ win = np.lib.stride_tricks.sliding_window_view(x, N)[::h] * np.hanning(N)
77+ S = np.log1p(np.abs(np.fft.rfft(win, axis=1)) * 10)
78+ flux = np.maximum(0, np.diff(S, axis=0)).sum(1)
79+ thr = np.median(flux) + 2.5 * flux.std()
80+ onsets = [round((i + 1) * h / sr, 2) for i in range(1, len(flux) - 1)
81+ if flux[i] > thr and flux[i] >= flux[i - 1] and flux[i] >= flux[i + 1]]
82+ return db, cent, onsets
83+
84+
85+def transcribe(video, out):
86+ wav = out / "voice.wav"
87+ # voices are often very quiet: normalize first, otherwise Whisper hallucinates
88+ ff("-i", str(video), "-ac", "1", "-ar", "16000", "-af", "highpass=f=80,dynaudnorm=f=150:g=15:m=30", str(wav))
89+ from faster_whisper import WhisperModel # CPU: the cuBLAS DLL is not always available
90+ model = WhisperModel("large-v3", device="cpu", compute_type="int8")
91+ segs, _ = model.transcribe(str(wav), language="fr", word_timestamps=True)
92+ lines = []
93+ for s in segs:
94+ lines.append(f"[{s.start:6.2f}-{s.end:6.2f}] {s.text.strip()}")
95+ lines.append(" " + " ".join(f"{w.word.strip()}@{w.start:.2f}" for w in s.words))
96+ (out / "transcript.txt").write_text("\n".join(lines), encoding="utf-8")
97+ wav.unlink()
98+
99+
100+def main():
101+ sys.stdout.reconfigure(encoding="utf-8")
102+ ap = argparse.ArgumentParser()
103+ ap.add_argument("video")
104+ ap.add_argument("out")
105+ ap.add_argument("--dense", help="start:end in seconds for the frame-by-frame sheets")
106+ ap.add_argument("--step", type=int, default=1, help="keep 1 frame in N in the dense sheets")
107+ ap.add_argument("--scene", type=float, default=0.12, help="cut detection threshold")
108+ ap.add_argument("--no-transcribe", action="store_true")
109+ a = ap.parse_args()
110+ video, out = Path(a.video).resolve(), Path(a.out)
111+ out.mkdir(parents=True, exist_ok=True)
112+ fps, dur = probe(video)
113+ print(f"{video.name}: {dur:.2f}s, {fps:g} fps")
114+
115+ sheets(video, out, "overview", "fps=5", f"%{{pts\\:hms}} f%{{eif\\:t*{fps:g}\\:d}}")
116+ if a.dense:
117+ t0, t1 = (float(v) for v in a.dense.split(":"))
118+ f0, f1 = int(t0 * fps), int(t1 * fps)
119+ sheets(video, out, "dense", f"select='between(n\\,{f0}\\,{f1})*not(mod(n\\,{a.step}))'",
120+ f"f%{{eif\\:t*{fps:g}\\:d}}")
121+
122+ log = ff("-i", str(video), "-vf", f"select='gt(scene,{a.scene})',showinfo", "-f", "null", "-", capture=True)
123+ cuts = [float(t) for t in re.findall(r"pts_time:([0-9.]+)", log)]
124+ (out / "cuts.txt").write_text("\n".join(f"{t:.3f}s f{round(t * fps)}" for t in cuts), encoding="utf-8")
125+
126+ diff, lum = frame_stats(video, fps)
127+ with open(out / "frames.tsv", "w", encoding="utf-8") as f:
128+ f.write("frame\ttime\tdiff\tlum\n")
129+ for i, (d, l) in enumerate(zip(diff, lum)):
130+ f.write(f"{i}\t{i / fps:.3f}\t{d:.1f}\t{l:.1f}\n")
131+
132+ db, cent, onsets = audio_stats(video, fps)
133+ with open(out / "audio.tsv", "w", encoding="utf-8") as f:
134+ f.write(f"# onsets (s): {onsets}\nframe\ttime\tdb\tcentroid_hz\n")
135+ for i, (d, c) in enumerate(zip(db, cent)):
136+ f.write(f"{i}\t{i / fps:.3f}\t{d:.1f}\t{c:.0f}\n")
137+ ff("-i", str(video), "-lavfi", "showspectrumpic=s=1600x400:legend=1:stop=8000", str(out / "spectrum.png"))
138+
139+ print(f"cuts: {[round(c, 2) for c in cuts]}")
140+ print(f"onsets: {onsets}")
141+ if not a.no_transcribe:
142+ transcribe(video, out)
143+ print((out / "transcript.txt").read_text(encoding="utf-8"))
144+ print(f"-> {out}")
145+
146+
147+if __name__ == "__main__":
148+ main()
new file mode 100644
@@ -0,0 +1,148 @@
1+"""Analyze a video sequence to prepare a snatch cut.
2+
3+Writes to OUT_DIR:
4+ - overview_NN.png contact sheets at 5 fps (timecode + source frame number)
5+ - dense_NN.png frame-by-frame sheets (or 1 in --step) over --dense start:end (seconds)
6+ - cuts.txt detected cuts (seconds and frame)
7+ - frames.tsv per frame: difference from the previous one, luminance (motion / light-on cues)
8+ - audio.tsv per frame: level in dB, spectral centroid; onsets in the header
9+ - spectrum.png spectrogram of the whole duration
10+ - transcript.txt word-level transcript (normalized audio, Whisper on CPU) unless --no-transcribe
11+
12+Usage:
13+ .venv/Scripts/python -I analyze.py VIDEO OUT_DIR [--dense 0:6.2] [--step 2] [--no-transcribe]
14+"""
15+import argparse
16+import json
17+import re
18+import shutil
19+import subprocess
20+import sys
21+from pathlib import Path
22+
23+import numpy as np
24+
25+FONT = Path("C:/Windows/Fonts/arial.ttf")
26+
27+
28+def ff(*args, capture=False):
29+ cmd = ["ffmpeg", "-nostdin", "-hide_banner", "-y", *args]
30+ r = subprocess.run(cmd, capture_output=True, text=True)
31+ if r.returncode and not capture:
32+ sys.exit(r.stderr[-2000:])
33+ return r.stderr
34+
35+
36+def probe(video):
37+ r = subprocess.run(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
38+ "stream=r_frame_rate,width,height,nb_frames:format=duration", "-of", "json", str(video)],
39+ capture_output=True, text=True)
40+ j = json.loads(r.stdout)
41+ num, den = j["streams"][0]["r_frame_rate"].split("/")
42+ return float(num) / float(den), float(j["format"]["duration"])
43+
44+
45+def sheets(video, out, prefix, vf_select, label, cols=6, rows=5):
46+ # drawtext needs a font path without ':' -> local copy
47+ shutil.copy(FONT, out / "font.ttf")
48+ vf = (f"{vf_select},scale=384:-1,drawtext=fontfile=font.ttf:text='{label}':x=5:y=5:fontsize=20:"
49+ f"fontcolor=yellow:box=1:boxcolor=black,tile={cols}x{rows}")
50+ subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", str(video.resolve()), "-vf", vf,
51+ "-fps_mode", "passthrough", f"{prefix}_%02d.png"], cwd=out, check=True)
52+
53+
54+def frame_stats(video, fps):
55+ W, H = 192, 108
56+ raw = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-i", str(video), "-vf", f"scale={W}:{H},format=gray",
57+ "-f", "rawvideo", "-"], capture_output=True).stdout
58+ f = np.frombuffer(raw, np.uint8).reshape(-1, H, W).astype(np.float32)
59+ diff = np.concatenate([[0], np.abs(np.diff(f, axis=0)).mean(axis=(1, 2))])
60+ return diff, f.mean(axis=(1, 2))
61+
62+
63+def audio_stats(video, fps):
64+ sr = 22050
65+ raw = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-i", str(video), "-ac", "1", "-ar", str(sr),
66+ "-f", "s16le", "-"], capture_output=True).stdout
67+ x = np.frombuffer(raw, np.int16).astype(np.float32) / 32768
68+ hop = int(round(sr / fps))
69+ n = len(x) // hop
70+ blocks = x[: n * hop].reshape(n, hop)
71+ db = 20 * np.log10(np.sqrt((blocks ** 2).mean(1)) + 1e-9)
72+ spec = np.abs(np.fft.rfft(blocks * np.hanning(hop), axis=1))
73+ cent = (spec * np.fft.rfftfreq(hop, 1 / sr)).sum(1) / (spec.sum(1) + 1e-9)
74+ # onsets: spectral flux over 1024-sample windows, hop 256
75+ N, h = 1024, 256
76+ win = np.lib.stride_tricks.sliding_window_view(x, N)[::h] * np.hanning(N)
77+ S = np.log1p(np.abs(np.fft.rfft(win, axis=1)) * 10)
78+ flux = np.maximum(0, np.diff(S, axis=0)).sum(1)
79+ thr = np.median(flux) + 2.5 * flux.std()
80+ onsets = [round((i + 1) * h / sr, 2) for i in range(1, len(flux) - 1)
81+ if flux[i] > thr and flux[i] >= flux[i - 1] and flux[i] >= flux[i + 1]]
82+ return db, cent, onsets
83+
84+
85+def transcribe(video, out):
86+ wav = out / "voice.wav"
87+ # voices are often very quiet: normalize first, otherwise Whisper hallucinates
88+ ff("-i", str(video), "-ac", "1", "-ar", "16000", "-af", "highpass=f=80,dynaudnorm=f=150:g=15:m=30", str(wav))
89+ from faster_whisper import WhisperModel # CPU: the cuBLAS DLL is not always available
90+ model = WhisperModel("large-v3", device="cpu", compute_type="int8")
91+ segs, _ = model.transcribe(str(wav), language="fr", word_timestamps=True)
92+ lines = []
93+ for s in segs:
94+ lines.append(f"[{s.start:6.2f}-{s.end:6.2f}] {s.text.strip()}")
95+ lines.append(" " + " ".join(f"{w.word.strip()}@{w.start:.2f}" for w in s.words))
96+ (out / "transcript.txt").write_text("\n".join(lines), encoding="utf-8")
97+ wav.unlink()
98+
99+
100+def main():
101+ sys.stdout.reconfigure(encoding="utf-8")
102+ ap = argparse.ArgumentParser()
103+ ap.add_argument("video")
104+ ap.add_argument("out")
105+ ap.add_argument("--dense", help="start:end in seconds for the frame-by-frame sheets")
106+ ap.add_argument("--step", type=int, default=1, help="keep 1 frame in N in the dense sheets")
107+ ap.add_argument("--scene", type=float, default=0.12, help="cut detection threshold")
108+ ap.add_argument("--no-transcribe", action="store_true")
109+ a = ap.parse_args()
110+ video, out = Path(a.video).resolve(), Path(a.out)
111+ out.mkdir(parents=True, exist_ok=True)
112+ fps, dur = probe(video)
113+ print(f"{video.name}: {dur:.2f}s, {fps:g} fps")
114+
115+ sheets(video, out, "overview", "fps=5", f"%{{pts\\:hms}} f%{{eif\\:t*{fps:g}\\:d}}")
116+ if a.dense:
117+ t0, t1 = (float(v) for v in a.dense.split(":"))
118+ f0, f1 = int(t0 * fps), int(t1 * fps)
119+ sheets(video, out, "dense", f"select='between(n\\,{f0}\\,{f1})*not(mod(n\\,{a.step}))'",
120+ f"f%{{eif\\:t*{fps:g}\\:d}}")
121+
122+ log = ff("-i", str(video), "-vf", f"select='gt(scene,{a.scene})',showinfo", "-f", "null", "-", capture=True)
123+ cuts = [float(t) for t in re.findall(r"pts_time:([0-9.]+)", log)]
124+ (out / "cuts.txt").write_text("\n".join(f"{t:.3f}s f{round(t * fps)}" for t in cuts), encoding="utf-8")
125+
126+ diff, lum = frame_stats(video, fps)
127+ with open(out / "frames.tsv", "w", encoding="utf-8") as f:
128+ f.write("frame\ttime\tdiff\tlum\n")
129+ for i, (d, l) in enumerate(zip(diff, lum)):
130+ f.write(f"{i}\t{i / fps:.3f}\t{d:.1f}\t{l:.1f}\n")
131+
132+ db, cent, onsets = audio_stats(video, fps)
133+ with open(out / "audio.tsv", "w", encoding="utf-8") as f:
134+ f.write(f"# onsets (s): {onsets}\nframe\ttime\tdb\tcentroid_hz\n")
135+ for i, (d, c) in enumerate(zip(db, cent)):
136+ f.write(f"{i}\t{i / fps:.3f}\t{d:.1f}\t{c:.0f}\n")
137+ ff("-i", str(video), "-lavfi", "showspectrumpic=s=1600x400:legend=1:stop=8000", str(out / "spectrum.png"))
138+
139+ print(f"cuts: {[round(c, 2) for c in cuts]}")
140+ print(f"onsets: {onsets}")
141+ if not a.no_transcribe:
142+ transcribe(video, out)
143+ print((out / "transcript.txt").read_text(encoding="utf-8"))
144+ print(f"-> {out}")
145+
146+
147+if __name__ == "__main__":
148+ main()
added .claude/skills/snatch-cut/scripts/render.py +137 -0
new file mode 100644
@@ -0,0 +1,137 @@
1+"""Render a snatch cut preview from a JSON breakdown.
2+
3+Each shot is rendered on its own with a fixed frame count, then everything is concatenated:
4+a zoompan inside a single graph with trim never ends (infinite stream).
5+
6+Usage:
7+ .venv/Scripts/python -I render.py SPEC.json
8+
9+Spec ("at" times: seconds, or {"shot": i, "frame": k} relative to the start of shot i in the output):
10+{
11+ "source": "path/sequence.mp4", # source video of the shots
12+ "output": "path/preview.mp4",
13+ "fps": 30,
14+ "tail_black": 15, # black frames at the end (0 = none)
15+ "master_gain_db": 16, "limit": 0.79, # master gain then limiter (linear, 0.79 ≈ −2 dB)
16+ "shots": [
17+ {"in": 64, "out": 82, # source frames [in, out)
18+ "zoom": {"at": 10, "dur": 3, "amount": 0.15, "anchor": [0.5, 0.4]}, # ease-out zoom at frame "at" of the shot
19+ "keep_audio": true} # keep the shot's source audio (micro-fades)
20+ ],
21+ "audio": [
22+ {"file": "source", "in": 6.10, "out": 12.45, "at": {"shot": 4, "frame": -3},
23+ "gain_db": 10, "filters": "acompressor=threshold=-30dB:ratio=3"},
24+ {"file": "sfx/switch.wav", "in": 0, "out": 0.5, "at": {"shot": 1, "frame": 10},
25+ "sync": 0.0, "gain_db": -11}, # sync = position of the attack in the excerpt
26+ {"file": "source", "in": 15.9, "out": 18.85, "at": {"shot": 7, "frame": 0},
27+ "filters": "afftdn=nr=25:nf=-55", "boost": {"windows": [[0.15, 0.8], [1.25, 1.7]], "in_db": 22, "out_db": 3}}
28+ ]
29+}
30+"""
31+import json
32+import subprocess
33+import sys
34+from pathlib import Path
35+
36+
37+def run(cmd):
38+ r = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-y", *cmd], capture_output=True, text=True)
39+ if r.returncode:
40+ sys.exit(r.stderr[-3000:])
41+
42+
43+def main():
44+ sys.stdout.reconfigure(encoding="utf-8")
45+ spec_path = Path(sys.argv[1]).resolve()
46+ spec = json.loads(spec_path.read_text(encoding="utf-8"))
47+ base = spec_path.parent
48+ path = lambda p: str((base / p).resolve()) if p != "source" else str((base / spec["source"]).resolve())
49+ fps = spec.get("fps", 30)
50+ out = Path(path(spec["output"]))
51+ work = out.parent / (out.stem + "_shots")
52+ work.mkdir(parents=True, exist_ok=True)
53+
54+ starts, files, cursor = [], [], 0
55+ for i, s in enumerate(spec["shots"]):
56+ a, b = s["in"], s["out"]
57+ vf = f"select='between(n\\,{a}\\,{b - 1})',setpts=N/{fps}/TB"
58+ z = s.get("zoom")
59+ if z:
60+ ax, ay = z.get("anchor", [0.5, 0.5])
61+ ez = f"1+{z['amount']}*(1-pow(1-clip((in-{z['at']})/{max(z['dur'], 1)}\\,0\\,1)\\,3))"
62+ # upscale before zoompan, otherwise the crop jumps pixel by pixel
63+ vf += (f",scale=3840:-1,zoompan=z='{ez}':x='(iw-iw/zoom)*{ax}':y='(ih-ih/zoom)*{ay}'"
64+ f":d=1:s=1920x1080:fps={fps}")
65+ else:
66+ vf += ",scale=1920:1080"
67+ vf += ",format=yuv420p,setsar=1"
68+ fn = work / f"s{i:02d}.mp4"
69+ run(["-i", path("source"), "-vf", vf, "-frames:v", str(b - a), "-r", str(fps), "-an",
70+ "-c:v", "libx264", "-crf", "12", "-preset", "fast", str(fn)])
71+ files.append(fn)
72+ starts.append(cursor)
73+ cursor += b - a
74+ tail = spec.get("tail_black", 0)
75+ total = (cursor + tail) / fps
76+
77+ def at(v):
78+ if isinstance(v, dict):
79+ return (starts[v["shot"]] + v.get("frame", 0)) / fps
80+ return float(v)
81+
82+ clips = []
83+ for i, s in enumerate(spec["shots"]):
84+ if s.get("keep_audio"):
85+ d = (s["out"] - s["in"]) / fps
86+ clips.append({"file": "source", "in": s["in"] / fps, "out": s["out"] / fps, "at": starts[i] / fps,
87+ "filters": f"afade=t=in:d=0.01,afade=t=out:st={d - 0.02:.3f}:d=0.02"})
88+ clips += spec.get("audio", [])
89+
90+ inputs = ["-i", path("source")]
91+ for fn in files:
92+ inputs += ["-i", str(fn)]
93+ graph = ["".join(f"[{k + 1}:v]" for k in range(len(files)))
94+ + f"concat=n={len(files)}:v=1:a=0" + (f",tpad=stop={tail}:color=black" if tail else "") + "[vout]"]
95+ extra = {}
96+ for j, c in enumerate(clips):
97+ if c["file"] == "source":
98+ idx = 0
99+ else:
100+ p = path(c["file"])
101+ if p not in extra:
102+ extra[p] = len(files) + 1 + len(extra)
103+ inputs += ["-i", p]
104+ idx = extra[p]
105+ chain = [f"aformat=sample_rates=48000:channel_layouts=stereo",
106+ f"atrim={c.get('in', 0)}:{c['out']}", "asetpts=PTS-STARTPTS"]
107+ if c.get("filters"):
108+ chain.append(c["filters"])
109+ if c.get("boost"):
110+ b = c["boost"]
111+ cond = "+".join(f"between(t,{w0},{w1})" for w0, w1 in b["windows"])
112+ chain.append(f"volume=eval=frame:volume='if({cond},{10 ** (b['in_db'] / 20):.3f},"
113+ f"{10 ** (b.get('out_db', 0) / 20):.3f})'")
114+ chain.append(f"volume={c.get('gain_db', 0)}dB")
115+ ln = c["out"] - c.get("in", 0)
116+ fo = c.get("fade_out", 0.08 if c["file"] != "source" else 0)
117+ if fo:
118+ chain.append(f"afade=t=out:st={max(0, ln - fo):.3f}:d={fo}")
119+ ms = max(0, round((at(c["at"]) - c.get("sync", 0)) * 1000))
120+ chain.append(f"adelay={ms}|{ms}")
121+ graph.append(f"[{idx}:a]" + ",".join(chain) + f"[a{j}]")
122+ graph.append("".join(f"[a{j}]" for j in range(len(clips)))
123+ + f"amix=inputs={len(clips)}:normalize=0:duration=longest,apad,atrim=0:{total:.3f},"
124+ + f"volume={spec.get('master_gain_db', 0)}dB,alimiter=limit={spec.get('limit', 0.6)}:attack=2:release=50:level=0[aout]")
125+
126+ run([*inputs, "-filter_complex", ";".join(graph), "-map", "[vout]", "-map", "[aout]",
127+ "-c:v", "libx264", "-crf", "18", "-preset", "medium", "-c:a", "aac", "-b:a", "192k",
128+ "-t", f"{total:.3f}", str(out)])
129+ r = subprocess.run(["ffmpeg", "-nostdin", "-i", str(out), "-af", "ebur128=peak=true", "-f", "null", "-"],
130+ capture_output=True, text=True).stderr
131+ loud = [l.strip() for l in r.splitlines() if l.strip().startswith(("I:", "Peak:"))][-2:]
132+ print(json.dumps({"output": str(out), "duration": round(total, 3),
133+ "shot_starts_frames": starts, "loudness": loud}, ensure_ascii=False))
134+
135+
136+if __name__ == "__main__":
137+ main()
new file mode 100644
@@ -0,0 +1,137 @@
1+"""Render a snatch cut preview from a JSON breakdown.
2+
3+Each shot is rendered on its own with a fixed frame count, then everything is concatenated:
4+a zoompan inside a single graph with trim never ends (infinite stream).
5+
6+Usage:
7+ .venv/Scripts/python -I render.py SPEC.json
8+
9+Spec ("at" times: seconds, or {"shot": i, "frame": k} relative to the start of shot i in the output):
10+{
11+ "source": "path/sequence.mp4", # source video of the shots
12+ "output": "path/preview.mp4",
13+ "fps": 30,
14+ "tail_black": 15, # black frames at the end (0 = none)
15+ "master_gain_db": 16, "limit": 0.79, # master gain then limiter (linear, 0.79 ≈ −2 dB)
16+ "shots": [
17+ {"in": 64, "out": 82, # source frames [in, out)
18+ "zoom": {"at": 10, "dur": 3, "amount": 0.15, "anchor": [0.5, 0.4]}, # ease-out zoom at frame "at" of the shot
19+ "keep_audio": true} # keep the shot's source audio (micro-fades)
20+ ],
21+ "audio": [
22+ {"file": "source", "in": 6.10, "out": 12.45, "at": {"shot": 4, "frame": -3},
23+ "gain_db": 10, "filters": "acompressor=threshold=-30dB:ratio=3"},
24+ {"file": "sfx/switch.wav", "in": 0, "out": 0.5, "at": {"shot": 1, "frame": 10},
25+ "sync": 0.0, "gain_db": -11}, # sync = position of the attack in the excerpt
26+ {"file": "source", "in": 15.9, "out": 18.85, "at": {"shot": 7, "frame": 0},
27+ "filters": "afftdn=nr=25:nf=-55", "boost": {"windows": [[0.15, 0.8], [1.25, 1.7]], "in_db": 22, "out_db": 3}}
28+ ]
29+}
30+"""
31+import json
32+import subprocess
33+import sys
34+from pathlib import Path
35+
36+
37+def run(cmd):
38+ r = subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-y", *cmd], capture_output=True, text=True)
39+ if r.returncode:
40+ sys.exit(r.stderr[-3000:])
41+
42+
43+def main():
44+ sys.stdout.reconfigure(encoding="utf-8")
45+ spec_path = Path(sys.argv[1]).resolve()
46+ spec = json.loads(spec_path.read_text(encoding="utf-8"))
47+ base = spec_path.parent
48+ path = lambda p: str((base / p).resolve()) if p != "source" else str((base / spec["source"]).resolve())
49+ fps = spec.get("fps", 30)
50+ out = Path(path(spec["output"]))
51+ work = out.parent / (out.stem + "_shots")
52+ work.mkdir(parents=True, exist_ok=True)
53+
54+ starts, files, cursor = [], [], 0
55+ for i, s in enumerate(spec["shots"]):
56+ a, b = s["in"], s["out"]
57+ vf = f"select='between(n\\,{a}\\,{b - 1})',setpts=N/{fps}/TB"
58+ z = s.get("zoom")
59+ if z:
60+ ax, ay = z.get("anchor", [0.5, 0.5])
61+ ez = f"1+{z['amount']}*(1-pow(1-clip((in-{z['at']})/{max(z['dur'], 1)}\\,0\\,1)\\,3))"
62+ # upscale before zoompan, otherwise the crop jumps pixel by pixel
63+ vf += (f",scale=3840:-1,zoompan=z='{ez}':x='(iw-iw/zoom)*{ax}':y='(ih-ih/zoom)*{ay}'"
64+ f":d=1:s=1920x1080:fps={fps}")
65+ else:
66+ vf += ",scale=1920:1080"
67+ vf += ",format=yuv420p,setsar=1"
68+ fn = work / f"s{i:02d}.mp4"
69+ run(["-i", path("source"), "-vf", vf, "-frames:v", str(b - a), "-r", str(fps), "-an",
70+ "-c:v", "libx264", "-crf", "12", "-preset", "fast", str(fn)])
71+ files.append(fn)
72+ starts.append(cursor)
73+ cursor += b - a
74+ tail = spec.get("tail_black", 0)
75+ total = (cursor + tail) / fps
76+
77+ def at(v):
78+ if isinstance(v, dict):
79+ return (starts[v["shot"]] + v.get("frame", 0)) / fps
80+ return float(v)
81+
82+ clips = []
83+ for i, s in enumerate(spec["shots"]):
84+ if s.get("keep_audio"):
85+ d = (s["out"] - s["in"]) / fps
86+ clips.append({"file": "source", "in": s["in"] / fps, "out": s["out"] / fps, "at": starts[i] / fps,
87+ "filters": f"afade=t=in:d=0.01,afade=t=out:st={d - 0.02:.3f}:d=0.02"})
88+ clips += spec.get("audio", [])
89+
90+ inputs = ["-i", path("source")]
91+ for fn in files:
92+ inputs += ["-i", str(fn)]
93+ graph = ["".join(f"[{k + 1}:v]" for k in range(len(files)))
94+ + f"concat=n={len(files)}:v=1:a=0" + (f",tpad=stop={tail}:color=black" if tail else "") + "[vout]"]
95+ extra = {}
96+ for j, c in enumerate(clips):
97+ if c["file"] == "source":
98+ idx = 0
99+ else:
100+ p = path(c["file"])
101+ if p not in extra:
102+ extra[p] = len(files) + 1 + len(extra)
103+ inputs += ["-i", p]
104+ idx = extra[p]
105+ chain = [f"aformat=sample_rates=48000:channel_layouts=stereo",
106+ f"atrim={c.get('in', 0)}:{c['out']}", "asetpts=PTS-STARTPTS"]
107+ if c.get("filters"):
108+ chain.append(c["filters"])
109+ if c.get("boost"):
110+ b = c["boost"]
111+ cond = "+".join(f"between(t,{w0},{w1})" for w0, w1 in b["windows"])
112+ chain.append(f"volume=eval=frame:volume='if({cond},{10 ** (b['in_db'] / 20):.3f},"
113+ f"{10 ** (b.get('out_db', 0) / 20):.3f})'")
114+ chain.append(f"volume={c.get('gain_db', 0)}dB")
115+ ln = c["out"] - c.get("in", 0)
116+ fo = c.get("fade_out", 0.08 if c["file"] != "source" else 0)
117+ if fo:
118+ chain.append(f"afade=t=out:st={max(0, ln - fo):.3f}:d={fo}")
119+ ms = max(0, round((at(c["at"]) - c.get("sync", 0)) * 1000))
120+ chain.append(f"adelay={ms}|{ms}")
121+ graph.append(f"[{idx}:a]" + ",".join(chain) + f"[a{j}]")
122+ graph.append("".join(f"[a{j}]" for j in range(len(clips)))
123+ + f"amix=inputs={len(clips)}:normalize=0:duration=longest,apad,atrim=0:{total:.3f},"
124+ + f"volume={spec.get('master_gain_db', 0)}dB,alimiter=limit={spec.get('limit', 0.6)}:attack=2:release=50:level=0[aout]")
125+
126+ run([*inputs, "-filter_complex", ";".join(graph), "-map", "[vout]", "-map", "[aout]",
127+ "-c:v", "libx264", "-crf", "18", "-preset", "medium", "-c:a", "aac", "-b:a", "192k",
128+ "-t", f"{total:.3f}", str(out)])
129+ r = subprocess.run(["ffmpeg", "-nostdin", "-i", str(out), "-af", "ebur128=peak=true", "-f", "null", "-"],
130+ capture_output=True, text=True).stderr
131+ loud = [l.strip() for l in r.splitlines() if l.strip().startswith(("I:", "Peak:"))][-2:]
132+ print(json.dumps({"output": str(out), "duration": round(total, 3),
133+ "shot_starts_frames": starts, "loudness": loud}, ensure_ascii=False))
134+
135+
136+if __name__ == "__main__":
137+ main()