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
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() | ||