ffmpeg-skill 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -124,6 +124,22 @@ More examples: [examples/README.md](examples/README.md). To see everything run e
124
124
 
125
125
  All scripts: Python 3.9+, standard library only, `--help`, non-zero exit + stderr message on failure.
126
126
 
127
+ ## Measured, not assumed
128
+
129
+ `tests/corpus.py` downloads public real-device footage (GoPro, DJI, iPhone incl. Dolby Vision, Android screen recordings, HDR10, 24p, Tears of Steel) and runs the toolchain on it; `tests/bench_sync.py`, `bench_silence.py` and `bench_scenes.py` score the algorithms against known ground truth.
130
+
131
+ | What | Result (0.8.0, local ffmpeg 6.1) |
132
+ |---|---|
133
+ | Real-device corpus, 10 files | 92 verify steps, all pass after fixes |
134
+ | sync.py, ±30 s offsets, gain/noise/EQ, real dialogue+music | 120 s windows (the documented rule): 40/40 within 10 ms, max 1.1 ms. 60 s stress windows: 95 % within 10 ms, 4 of 5 misses flagged by confidence |
135
+ | silence.py, 20 cases, known gaps | 0 missed gaps, ≤ 1 ms leftover silence |
136
+ | scenes.py, 53 hard cuts between single takes (GoPro/DJI/iPhone/…) | precision 0.95, recall 1.00, F1 0.97 at the default threshold |
137
+
138
+ ```bash
139
+ python3 tests/corpus.py --fetch --verify # ~1.4 GB download, then verify (slow on 4K)
140
+ python3 tests/bench_sync.py --cases 100
141
+ ```
142
+
127
143
  ## MCP
128
144
 
129
145
  ```json
package/SKILL.md CHANGED
@@ -1,17 +1,11 @@
1
1
  ---
2
2
  name: ffmpeg-skill
3
- description: Professional video editing with local FFmpeg MCP server, batch folders, declarative project rendering, brand kits, motion-graphics templates (lower-thirds, titles, countdowns), HTML delivery reports, scene detection and highlight picks, delivery compliance checks, cut, silence removal, transitions, multicam, captions (animated/karaoke timed to speech), fit to duration/aspect, audio sync with drift correction, HDR/HLG/Dolby Vision to SDR, LUTs, audio clean-up and ducking, loudness, overlays, platform exports, frame inspection and a real-footage verification kit; Python stdlib scripts, no cloud or API keys.
3
+ description: Edit video and audio with local FFmpeg from natural-language requests: cut, trim, join, resize/reframe (9:16, 1:1), speed change, captions and subtitles (SRT/ASS, animated, karaoke), logos and text overlays, lower-thirds and titles, silence removal, multicam and external-mic sync, loudness normalisation, HDR/Dolby Vision to SDR, LUTs, background music with ducking, platform exports (YouTube, Reels, TikTok, X), compliance checks, scene detection and highlight reels, contact sheets to inspect results, and whole-edit project files. Use this skill whenever the user mentions a video or audio file (mp4, mov, mkv, wav, m4a), footage, a clip, captions, subtitles, a reel or short, YouTube/Instagram/TikTok delivery, LUFS, sync, transcoding, ffmpeg, or asks to make something "60 seconds", "vertical", "louder", "captioned" even when they do not say "edit". Python 3.9 standard library only, no cloud, no API keys.
4
4
  ---
5
5
 
6
6
  # ffmpeg-skill
7
7
 
8
- You are editing video for the user with FFmpeg through the scripts in `scripts/`.
9
- Everything runs locally. Nothing is uploaded, no keys are needed, and the only
10
- requirements are `ffmpeg`/`ffprobe` on PATH and Python 3.9+.
11
-
12
- Run scripts with `python3 <skill-dir>/scripts/<name>.py ...`. Every script has
13
- `--help`, exits non-zero on failure with the reason on stderr, prints the output
14
- path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
8
+ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run` (print the ffmpeg commands, run nothing), `--json` (structured result with a probe of the output), `--fast` (preview quality) and `--progress`. Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
15
9
 
16
10
  ## Workflow (always follow this order)
17
11
 
@@ -36,7 +30,10 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
36
30
  at CRF 18 (the default) and only use `export.py` for the last step; for
37
31
  anything with more than two steps use `render.py` with a project.json.
38
32
  5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X`
39
- for the destination the user named; fix FAILs, mention WARNs.
33
+ for the destination the user named. Fix FAILs about format (aspect, fps,
34
+ codec, size, true peak, colour). A loudness FAIL is a judgement call: fix
35
+ it for speech and music, but not for ambience or near-silence (see the
36
+ pitfalls below). Mention WARNs; do not chase them.
40
37
  6. **Verify the output.** Run `probe.py` on each result and confirm duration,
41
38
  resolution, fps and audio match what was requested. Report those numbers to
42
39
  the user (e.g. "final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo").
@@ -48,6 +45,19 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
48
45
  crops keeping the subject, colours not washed out. Fix and re-run before
49
46
  reporting. Numbers from probe are not enough.
50
47
 
48
+
49
+ ## Before you run anything: what to ask, what to assume
50
+
51
+ Ask one short question only when the answer changes the output materially and the request does not imply it:
52
+
53
+ - **Destination** decides aspect, length limit, loudness and codec. "For Reels" answers all four. If no destination is named and the edit is a plain cut/caption, keep the source format and say so; if the user asks to "export", "post" or "deliver", ask where.
54
+ - **Duration** ("make it 60 s") without a method: speed up for ≤1.5× changes, trim otherwise, and state which you chose. Ask if the content is a talk (trimming loses words) and the change is large.
55
+ - **Captions** without a text source: use `--transcribe` if a local whisper exists, otherwise ask for the text or a timed file; never invent dialogue.
56
+ - **Fonts and brand**: if the user mentions a brand, colours or "our font", ask for or create `brand.json` once and reuse it.
57
+ - Anything else (crop position, transition type, caption style): pick the conventional default, say what you picked, and offer the alternative in one line.
58
+
59
+ Do not ask for things `probe.py` can tell you.
60
+
51
61
  ## Request → script
52
62
 
53
63
  | User says | Do |
@@ -94,260 +104,32 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
94
104
  | "TikTok-style captions with the words popping / highlighted" | `caption.py input.mp4 --text cues.txt --animate pop --karaoke` |
95
105
  | "it's a phone video with variable frame rate" | nothing extra: every re-encoding script conforms VFR to constant fps automatically; `fit.py --fps 30` to pick the rate |
96
106
 
97
- ## Scripts
98
107
 
99
- ### probe.py — inspect
100
- ```
101
- probe.py INPUT... [--compact] [--field duration|video.fps|...]
102
- ```
103
- JSON with `duration`, `video{codec,width,height,fps,pix_fmt,color_space,rotation,variable_frame_rate_suspected}`,
104
- `audio{codec,channels,sample_rate}`. `--compact` gives one line per file.
108
+ ## Report format
105
109
 
106
- ### cut.py cut / join segments
107
- ```
108
- cut.py INPUT [--start T] [--end T | --duration T] [--segments A-B,C-D,...] [--accurate] [-o OUT]
109
- ```
110
- Times accept `12.5`, `1:30`, `00:01:30.250`. Default is `-c copy` (snaps to
111
- keyframes, instant, lossless); if the snapped result deviates more than
112
- `--tolerance` (0.5 s) from the request, that segment is re-encoded automatically
113
- (x264 CRF 18). `--accurate` always re-encodes; `--tolerance -1` never does.
114
- Multiple segments are concatenated in the order given. stderr reports whether
115
- the result was "lossless stream copy" or "re-encoded".
116
-
117
- ### fit.py — target duration and/or aspect
118
- ```
119
- fit.py INPUT [--duration T --method speed|trim [--from-center] [--max-speed 4]]
120
- [--aspect 16:9|9:16|1:1|4:5|W:H --fit pad|crop [--width W] [--pad-color black]]
121
- [--fps N] [-o OUT]
122
- ```
123
- `speed` retimes video and audio together (pitch-preserving `atempo`); it
124
- refuses factors beyond `--max-speed`. For slow motion add `--smooth blend`
125
- (frame blending, fast) or `--smooth interpolate` (motion-compensated
126
- `minterpolate`, fluid but roughly 10-20x slower than realtime). `trim` keeps
127
- the head (or the middle with `--from-center`). `--fps` forces a constant frame
128
- rate; VFR sources are conformed automatically even without it.
110
+ Finish every job with this shape (numbers from `probe.py`/`check.py`, not memory):
129
111
 
130
- ### silence.py — remove dead air / jump cuts
131
112
  ```
132
- silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.15] [--min-keep 0.2] [--list] [--edl keep.txt] [-o OUT]
113
+ Done: final.mp4 59.98 s, 1080x1920, 30 fps, H.264, AAC stereo, -14.1 LUFS
114
+ Steps: cut 0:12-1:12 (lossless) -> fit 9:16 crop -> captions (pop, karaoke) -> loudness -14 -> export reels
115
+ Check: reels — all 12 checks pass
116
+ Look: final_sheet.png (captions inside the safe area, logo top-right)
117
+ Notes: source was VFR, conformed to 30 fps; audio was mono, made stereo
133
118
  ```
134
- Runs `silencedetect`, keeps `--margin` seconds of air around speech, drops
135
- gaps shorter than `--min-silence`, and re-encodes once with `select`/`aselect`
136
- (frame accurate). `--list` prints silences, kept ranges and seconds removed
137
- without rendering; `--edl` saves the kept ranges in `cut.py --segments` format
138
- so the user can edit the list by hand. Quiet rooms need `--threshold -40`
139
- to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
140
119
 
141
- ### join.py concatenate with transitions
142
- ```
143
- join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadeblack|fadewhite|circleopen|none]
144
- [--duration 0.5] [--width W --height H] [--fps N] [--fit pad|crop] [-o OUT]
145
- ```
146
- Normalises every clip to one frame size, fps, `yuv420p` and 48 kHz stereo
147
- (silent track generated for clips without audio), then chains `xfade` +
148
- `acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
149
- longer than 2 × the transition. Use `--transition none` for a plain cut.
120
+ Keep it to those five lines plus anything the user must decide. Attach the contact sheet when the edit touched the picture. Never report success without the probe of the output; never describe a fix you did not run.
150
121
 
151
- ### render.py the whole edit in one project.json
152
- ```
153
- render.py --init project.json # starter file
154
- render.py project.json [--fast] [--dry-run] [--stop-after STAGE] [--work DIR --keep]
155
- ```
156
- Stages: clips (cut, optional speed) → join (transition) → silence → fit →
157
- captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
158
- CLI flags of each script (see the docstring). Use it whenever an edit has
159
- more than two steps or the user is likely to ask for changes: edit the JSON,
160
- re-render, and the result is reproducible. `--dry-run --json` prints the
161
- complete command plan for review.
162
-
163
- ### scenes.py — scene changes and highlight candidates
164
- ```
165
- scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15]] [--edl picks.txt] [--sheet scenes.png] [--json]
166
- ```
167
- Lists scenes with audio energy, the loudest moments, and (with
168
- `--highlights`) proposes N ranges that add up to `--target` seconds, biased to
169
- the loudest window of each scene. Review the sheet + JSON, adjust the EDL, then
170
- `cut.py --segments`. It is a proposal engine, not a judgement of content:
171
- tell the user what it picked and why (energy, scene length).
172
-
173
- ### check.py — pre-delivery compliance
174
- ```
175
- check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|broadcast|podcast|custom [--no-loudness] [--json]
176
- [--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
177
- ```
178
- PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
179
- step before reporting a deliverable; fix FAILs, mention WARNs.
180
-
181
- ### batch.py — same recipe over a folder, cached
182
- ```
183
- batch.py FOLDER --recipe batch.json [--force] [--watch SECONDS] [--json]
184
- ```
185
- `batch.json` holds either `steps` (a list of script argv with `{in}`/`{out}`
186
- placeholders, chained) or `project` (a render project applied per file).
187
- Outputs land in `output_dir` with `suffix`; a content-hash cache skips files
188
- already done with the same recipe. Use `--dry-run` to preview the plan.
189
-
190
- ### caption.py --transcribe — optional local speech-to-text
191
- If `whisper-cli` (whisper.cpp), `faster-whisper` or `whisper` is installed,
192
- `caption.py input.mp4 --transcribe [--language ja] [--model base]` writes the
193
- SRT from the audio and burns it (combine with `--animate pop --karaoke`).
194
- Nothing is downloaded and nothing is required: without an engine it prints
195
- install hints and the user can supply `--text` cues instead. Always tell the
196
- user which engine was used, and treat the transcript as a draft to review.
197
-
198
- ### MCP server — the toolkit for any MCP client
199
- `python3 mcp/server.py` speaks MCP over stdio; each script is a tool taking
200
- named args (flags without dashes, underscores for hyphens) or `argv`. Config
201
- for Claude Desktop / Claude Code:
202
- `{"mcpServers": {"ffmpeg-skill": {"command": "python3", "args": ["~/.claude/skills/ffmpeg-skill/mcp/server.py"]}}}`.
203
- Inside this skill, call the scripts directly; the server is for other hosts.
204
-
205
- ### graphics.py — motion-graphics templates
206
- ```
207
- graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug [--name] [--title] [--subtitle]
208
- [--from N] [--start S] [--end E] [--position CORNER] [--brand brand.json] [--primary RRGGBB] [--scale 1.0] [-o OUT]
209
- ```
210
- Drawn with drawbox/drawtext/overlay — no PNG assets needed. Sizes scale with
211
- the frame's short side; colours, font and safe margin come from `--brand`.
212
- Lower-third slides in over 0.4 s and out over 0.3 s; title/chapter/bug fade.
213
-
214
- ### brand.json — one file for fonts, colours, logo, margins
215
- ```json
216
- {"font": "Noto Sans CJK JP", "font_file": "fonts/NotoSansCJK-Bold.ttc",
217
- "colors": {"primary": "FF6A00", "text": "FFFFFF", "outline": "000000", "background": "0B1D2A"},
218
- "logo": "logo.png", "logo_position": "top-right", "logo_scale": 160, "logo_opacity": 0.9,
219
- "safe_margin": 48, "caption": {"size": 28, "position": "bottom", "animate": "pop", "karaoke": true, "bold": true}}
220
- ```
221
- `caption.py --brand`, `overlay.py --brand --logo`, `graphics.py --brand`, and
222
- `"brand": "brand.json"` in a render project. Explicit flags still win. When a
223
- user mentions brand guidelines, colours, "our font" or a logo, ask for or
224
- write a brand.json once and reuse it across every output.
122
+ ## Things that look right but are wrong
225
123
 
226
- ### report.py HTML delivery report
227
- ```
228
- report.py --after FINAL [--before SOURCE] [--platform youtube] [--commands cmds.txt] [--notes notes.md] [--title T] [--no-sheets] [-o report.html]
229
- ```
230
- One self-contained HTML: before/after facts and contact sheets, loudness,
231
- compliance table with fixes, commands. Produce it for any multi-step job and
232
- hand the path to the user together with the numbers.
233
-
234
- ### multicam.py align several cameras and switch between them
235
- ```
236
- multicam.py REF CAM2 [CAM3 ...] [--switch "START-END:CAM,..."] | [--auto N] [--audio IDX] [--fix-drift]
237
- [--offsets-only] [--width W --height H --fps N] [-o OUT]
238
- ```
239
- All inputs are aligned to the first one by audio (same engine as `sync.py`,
240
- `--fix-drift` for long takes). `--switch` names which camera is on screen for
241
- each range of the reference timeline (gaps fall back to camera 0), `--auto N`
242
- simply alternates every N seconds. Audio comes from the reference unless
243
- `--audio` picks another input, e.g. an external recorder that has no video.
244
- `--offsets-only` reports offsets and confidence without rendering.
245
-
246
- ### verify.py — real-footage verification kit
247
- ```
248
- verify.py FILES_OR_FOLDERS [--quick] [--report verify.md] [--out DIR --keep] [--seconds 6] [--json]
249
- ```
250
- Runs the toolchain on the user's own files (phone HDR, GoPro, OBS, Log, Zoom)
251
- and prints a PASS/FAIL table per step (probe, copy cut, accurate cut, fit,
252
- caption, overlay, look, export, loudness, silence, plus `color --to-sdr` for
253
- HDR and `audio --downmix` for >2 channels). Exit code 1 if anything fails.
254
- Run this first when a user hands over footage from a device you have not
255
- seen before, and fix or report what fails.
256
-
257
- ### look.py — see the result
258
- ```
259
- look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes
260
- look.py INPUT --at 2.5 [--at 7] [-o basename] # single frames -> basename_2.500s.png
261
- look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame
262
- ```
263
- Outputs PNG. View it with the Read tool (or any image viewer) and judge the
264
- frame like an editor would. Use `--compare` to show before/after to the user.
265
-
266
- ### caption.py — subtitles (static, animated, karaoke)
267
- ```
268
- caption.py INPUT --srt FILE | --ass FILE | --text CUES.txt [--write-srt OUT.srt]
269
- [--font NAME] [--fonts-dir DIR] [--size N] [--color RRGGBB] [--outline N] [--outline-color RRGGBB]
270
- [--bold] [--box] [--position bottom|top|center|top-left|...] [--margin N]
271
- [--animate none|fade|pop|slide] [--karaoke [--highlight-color RRGGBB]] [--write-ass OUT.ass] [-o OUT]
272
- caption.py --text CUES.txt --write-srt OUT.srt # generate the SRT only
273
- ```
274
- Text cue format, one per line: `0:00-0:03 Hello`, `00:00:03.500 --> 00:00:06 Two | lines`.
275
- Lines without a time run for `--auto-seconds` (3 s) after the previous cue. `|` is a line break.
276
- `--animate`/`--karaoke` generate a styled ASS (PlayRes = video size) from the
277
- SRT/text cues: `pop` is the short-form "bouncy" entrance, `--karaoke` fills each
278
- word from `--color` to `--highlight-color` evenly across the cue (word timing
279
- is distributed, not transcribed). The ASS is kept next to the output so the
280
- user can hand-tune timings and re-run with `--ass`.
281
-
282
- ### overlay.py — logo, image, title
283
- ```
284
- overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--font-file F.ttf] [--font-size N] [--box]
285
- [--position top-right|bottom-left|center|X,Y] [--margin N] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
286
- ```
287
- Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps playing.
288
-
289
- ### sync.py — offset detection, alignment, drift correction
290
- ```
291
- sync.py REFERENCE SECOND [--json] [--max-offset 30] [--analyze-seconds 120] [--fix-drift [--drift-window 60]]
292
- [--replace-audio | --trim-second] [-o OUT]
293
- ```
294
- Cross-correlates loudness envelopes: coarse FFT search (20 ms), then a direct
295
- 1 ms refinement (pure Python, a 2-minute window takes ~1-3 s). Positive offset
296
- = the second recording started later. `--replace-audio` writes the reference
297
- video with the second file's audio aligned (video stream copied).
298
- `--trim-second` writes the second file shifted to the reference timeline.
299
- `--fix-drift` measures the offset again near the end of the overlap, reports
300
- the clock difference in ppm, and resamples the second file so a 60-minute
301
- take stays in sync (typical consumer devices drift 20-500 ppm = up to 1.8 s/h).
302
- Use it whenever the recording is longer than ~10 minutes. Check `confidence`
303
- (0–1); below ~0.3 the match is doubtful — use a window with a clear event.
304
-
305
- ### color.py — HDR to SDR, LUTs, colour tags, Dolby Vision
306
- ```
307
- color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|bt2390] [--peak 1000] [--desat 0] [-o OUT]
308
- color.py INPUT --lut grade.cube [--lut-strength 0..1] [-o OUT]
309
- color.py INPUT --retag bt709|bt2020-pq|bt2020-hlg|bt601 [-o OUT] # metadata only, stream copy
310
- color.py INPUT --strip-dovi [-o OUT] # drop the Dolby Vision RPU, keep the HLG/HDR10 base layer (stream copy)
311
- ```
312
- iPhone "HDR" video is Dolby Vision profile 8.4 on an HLG base layer:
313
- `probe.py` reports `hdr_format: Dolby Vision profile 8` and `--to-sdr`
314
- tone-maps it from the HLG base layer. When the user wants to keep HDR but
315
- players mis-render the DV layer, `--strip-dovi` removes it losslessly.
316
- `--to-sdr` does a real conversion: linearise (zscale, PQ or HLG), tone-map
317
- (default `hable`, `mobius` keeps more highlight detail, `bt2390` is the
318
- broadcast standard), then BT.709 gamma + matrix. Refuses when probe says the
319
- input is not HDR unless `--force`. `--lut` applies a 3D .cube with
320
- tetrahedral interpolation (Log→709 conversions, creative looks); blend with
321
- `--lut-strength`. Everything else in the skill assumes SDR BT.709, so run this
322
- first on HDR or Log sources.
323
-
324
- ### audio.py — clean-up, music, ducking, layout
325
- ```
326
- audio.py INPUT [--voice | --denoise [--denoise-strength 25]] [--gain dB]
327
- [--music FILE [--music-volume -14] [--duck [--duck-amount 12]] [--music-loop]]
328
- [--fade-in S] [--fade-out S] [--stereo | --mono | --downmix] [--replace FILE] [-o OUT]
329
- ```
330
- `--voice` = highpass 80 Hz → de-esser → FFT denoise → gentle compressor, the
331
- standard talking-head chain. `--duck` uses a sidechain compressor keyed by the
332
- speech so music dips under dialogue and swells in pauses. `--downmix` uses the
333
- ITU centre/LFE weights for 5.1/7.1 → stereo. Video is always stream-copied.
334
- Run `loudness.py` after this for final levels.
335
-
336
- ### loudness.py — EBU R128 normalisation
337
- ```
338
- loudness.py INPUT [-I -14] [--tp -1] [--lra 11] [--measure-only] [-o OUT]
339
- ```
340
- Two-pass `loudnorm`: measure, then apply with measured values (linear mode when
341
- the true-peak ceiling allows). Video is stream-copied; audio becomes AAC in
342
- video containers or the codec matching the extension (.wav → PCM, .flac, .mp3).
343
-
344
- ### export.py — delivery presets
345
- ```
346
- export.py INPUT --preset youtube|youtube4k|reels|x|prores|h265|gif [--fit pad|crop] [--no-scale] [--allow-long] [--crf N] [-o OUT]
347
- export.py --list
348
- ```
349
- Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
350
- trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
124
+ - Re-encoding an HDR (iPhone, HDR10) source through the SDR path: colours go flat. The scripts keep HDR; if you hand-write ffmpeg, do not tag BT.709 on BT.2020 pixels.
125
+ - Lossless `-c copy` cuts on VFR or non-keyframe boundaries: the file "works" but starts on a frozen or wrong frame. `cut.py` re-encodes automatically when the snap exceeds 0.5 s; respect that.
126
+ - A sync with `confidence` under 0.3, or an offset larger than 60 % of the analysis window: probably wrong; enlarge `--analyze-seconds` or find a clap.
127
+ - "Normalised" audio that still clips: check true peak, not just LUFS (`check.py` does both).
128
+ - Normalising ambience or near-silence to a speech target: a clip measured at
129
+ -40 LUFS or below is room tone, wind or nothing; raising it 25 dB raises the
130
+ noise, not the content. Leave the level, say so, and offer music or narration.
131
+ - Captions burned before a crop/resize: text lands off-frame. Frame changes first, then text.
132
+ - Anything chained by hand through three re-encodes: use `render.py` so the plan is one file and the user can change one number.
351
133
 
352
134
  ## Gotchas
353
135
 
package/bin/install.js CHANGED
@@ -22,7 +22,7 @@ const { spawnSync } = require('child_process');
22
22
 
23
23
  const SKILL_NAME = 'ffmpeg-skill';
24
24
  const ROOT = path.resolve(__dirname, '..');
25
- const PAYLOAD = ['SKILL.md', 'scripts', 'mcp'];
25
+ const PAYLOAD = ['SKILL.md', 'scripts', 'references', 'mcp'];
26
26
 
27
27
  const args = process.argv.slice(2);
28
28
  const has = (flag) => args.includes(flag);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: MCP server, batch processing, declarative project rendering, brand kits, motion-graphics templates, HTML delivery reports, scene detection, delivery checks, cut, silence removal, transitions, multicam, captions, sync with drift correction, HDR to SDR, LUTs, audio clean-up and ducking, loudness, platform exports. No API keys, no cloud, no dependencies.",
5
5
  "keywords": ["ffmpeg", "video", "agent-skill", "claude-code", "cursor", "codex", "skill", "video-editing"],
6
6
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
  "bin/",
19
19
  "scripts/",
20
20
  "mcp/",
21
+ "references/",
21
22
  "SKILL.md",
22
23
  "README.md",
23
24
  "LICENSE"
@@ -0,0 +1,24 @@
1
+ # Device and format notes (from the real-device corpus)
2
+
3
+ What `probe.py` will show and what to do about it. Learned from running the
4
+ toolchain on real files, not from spec sheets.
5
+
6
+ | Source | What you see | What it means for the edit |
7
+ |---|---|---|
8
+ | iPhone (iOS 15+) HDR video | `hdr_format: Dolby Vision profile 8`, HLG transfer, 10-bit HEVC, `variable_frame_rate_suspected: true`, `rotation` -90/90 for portrait, extra timecode/metadata tracks | Every re-encode keeps HDR automatically (HEVC Main10 + tags). Use `color.py --to-sdr` first only when the destination is SDR-only (X, LinkedIn, most web players). Only the first audio track is used. Lossless cuts fall back to accurate cuts because of VFR. |
9
+ | iPhone SDR / older | HEVC or H.264 4K60, mono AAC | Mono audio: `audio.py --stereo` before music/ducking if the deliverable expects stereo. |
10
+ | GoPro HERO | HEVC 4K 10-bit **SDR** (`hdr: false`, `yuv420p10le`), stereo, GPMF data track | 10-bit does not mean HDR; do not tone-map. Re-encodes are 8-bit H.264 unless the user wants a 10-bit master (`export.py --preset prores`). |
11
+ | DJI drone | HEVC 4K 50/60, **no audio** | Audio-dependent steps (sync, silence, loudness, ducking) do not apply; add narration or music with `audio.py --replace/--music`. D-Log profiles look flat: `probe.py --analyze` -> `looks_like_log` -> `color.py --lut`. |
12
+ | Android screen recording | H.264, odd sizes (720x1600, 1298x1080), 18-120 fps, strongly VFR | Conform with `fit.py --fps 30` (or 60) before anything else; check aspect with `check.py`. |
13
+ | Zoom / Teams recordings | H.264 720p-1080p, low bitrate, long, often VFR | `silence.py` and `scenes.py` work; expect long processing on hour-long files; `--fast` for previews. |
14
+ | OBS (mkv) | H.264/HEVC, sometimes multiple audio tracks, VFR when the source dropped frames | Only the first audio track is used; remux to mp4 first if a client needs it (`export.py`). |
15
+ | HDR10 masters / test patterns | PQ (`smpte2084`), BT.2020, MaxCLL/MDL metadata, 10-bit | `color.py --to-sdr --tonemap hable` (or `bt2390` for broadcast); tone-mapping 4K runs about 1x realtime, so cut first. |
16
+ | Broadcast / ProRes .mov | ProRes 422/4444, PCM audio, 24p or 25p | Stream copy where possible; `export.py --preset prores` for masters; `check.py --platform broadcast` (EBU R128 -23 LUFS). |
17
+ | Film / web downloads | H.264 with MP3 or AAC, 24p, letterboxed (1280x534) | `fit.py --aspect` pads; `check.py` flags non-standard aspect. |
18
+
19
+ Rules of thumb that came out of this:
20
+
21
+ - 10-bit is a container property, HDR is a colour property. Trust `hdr`, not `bit_depth`.
22
+ - Anything from a phone or a screen recorder is VFR until proven otherwise; every re-encoding script conforms it, but plan accurate cuts.
23
+ - 4K 10-bit re-encodes cost 20-25 s per 6 s of footage on a laptop-class CPU: cut first, then process, and use `--fast` while iterating.
24
+ - Files longer than 10 minutes: never run a whole-file tone-map or interpolation; work on cuts.
@@ -0,0 +1,294 @@
1
+ # Script reference
2
+
3
+ Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `-o OUT`.
4
+
5
+ ## Contents
6
+ - probe.py — inspect
7
+ - cut.py — cut / join segments
8
+ - fit.py — target duration and/or aspect
9
+ - silence.py — remove dead air / jump cuts
10
+ - join.py — concatenate with transitions
11
+ - render.py — the whole edit in one project.json
12
+ - scenes.py — scene changes and highlight candidates
13
+ - check.py — pre-delivery compliance
14
+ - batch.py — same recipe over a folder, cached
15
+ - caption.py --transcribe — optional local speech-to-text
16
+ - MCP server — the toolkit for any MCP client
17
+ - graphics.py — motion-graphics templates
18
+ - brand.json — one file for fonts, colours, logo, margins
19
+ - report.py — HTML delivery report
20
+ - multicam.py — align several cameras and switch between them
21
+ - verify.py — real-footage verification kit
22
+ - look.py — see the result
23
+ - caption.py — subtitles (static, animated, karaoke)
24
+ - overlay.py — logo, image, title
25
+ - sync.py — offset detection, alignment, drift correction
26
+ - color.py — HDR to SDR, LUTs, colour tags, Dolby Vision
27
+ - audio.py — clean-up, music, ducking, layout
28
+ - loudness.py — EBU R128 normalisation
29
+ - export.py — delivery presets
30
+
31
+ ## Scripts
32
+
33
+ ### probe.py — inspect
34
+ ```
35
+ probe.py INPUT... [--compact] [--field duration|video.fps|...]
36
+ ```
37
+ JSON with `duration`, `video{codec,width,height,fps,pix_fmt,color_space,rotation,variable_frame_rate_suspected}`,
38
+ `audio{codec,channels,sample_rate}`. `--compact` gives one line per file.
39
+
40
+ ### cut.py — cut / join segments
41
+ ```
42
+ cut.py INPUT [--start T] [--end T | --duration T] [--segments A-B,C-D,...] [--accurate] [-o OUT]
43
+ ```
44
+ Times accept `12.5`, `1:30`, `00:01:30.250`. Default is `-c copy` (snaps to
45
+ keyframes, instant, lossless); if the snapped result deviates more than
46
+ `--tolerance` (0.5 s) from the request, that segment is re-encoded automatically
47
+ (x264 CRF 18). `--accurate` always re-encodes; `--tolerance -1` never does.
48
+ Multiple segments are concatenated in the order given. stderr reports whether
49
+ the result was "lossless stream copy" or "re-encoded".
50
+
51
+ ### fit.py — target duration and/or aspect
52
+ ```
53
+ fit.py INPUT [--duration T --method speed|trim [--from-center] [--max-speed 4]]
54
+ [--aspect 16:9|9:16|1:1|4:5|W:H --fit pad|crop [--width W] [--pad-color black]]
55
+ [--fps N] [-o OUT]
56
+ ```
57
+ `speed` retimes video and audio together (pitch-preserving `atempo`); it
58
+ refuses factors beyond `--max-speed`. For slow motion add `--smooth blend`
59
+ (frame blending, fast) or `--smooth interpolate` (motion-compensated
60
+ `minterpolate`, fluid but roughly 10-20x slower than realtime). `trim` keeps
61
+ the head (or the middle with `--from-center`). `--fps` forces a constant frame
62
+ rate; VFR sources are conformed automatically even without it.
63
+
64
+ ### silence.py — remove dead air / jump cuts
65
+ ```
66
+ silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.15] [--min-keep 0.2] [--list] [--edl keep.txt] [-o OUT]
67
+ ```
68
+ Runs `silencedetect`, keeps `--margin` seconds of air around speech, drops
69
+ gaps shorter than `--min-silence`, and re-encodes once with `select`/`aselect`
70
+ (frame accurate). `--list` prints silences, kept ranges and seconds removed
71
+ without rendering; `--edl` saves the kept ranges in `cut.py --segments` format
72
+ so the user can edit the list by hand. Quiet rooms need `--threshold -40`
73
+ to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
74
+
75
+ ### join.py — concatenate with transitions
76
+ ```
77
+ join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadeblack|fadewhite|circleopen|none]
78
+ [--duration 0.5] [--width W --height H] [--fps N] [--fit pad|crop] [-o OUT]
79
+ ```
80
+ Normalises every clip to one frame size, fps, `yuv420p` and 48 kHz stereo
81
+ (silent track generated for clips without audio), then chains `xfade` +
82
+ `acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
83
+ longer than 2 × the transition. Use `--transition none` for a plain cut.
84
+
85
+ ### render.py — the whole edit in one project.json
86
+ ```
87
+ render.py --init project.json # starter file
88
+ render.py project.json [--fast] [--dry-run] [--stop-after STAGE] [--work DIR --keep]
89
+ ```
90
+ Stages: clips (cut, optional speed) → join (transition) → silence → fit →
91
+ captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
92
+ CLI flags of each script (see the docstring). Use it whenever an edit has
93
+ more than two steps or the user is likely to ask for changes: edit the JSON,
94
+ re-render, and the result is reproducible. `--dry-run --json` prints the
95
+ complete command plan for review.
96
+
97
+ ### scenes.py — scene changes and highlight candidates
98
+ ```
99
+ scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15]] [--edl picks.txt] [--sheet scenes.png] [--json]
100
+ ```
101
+ Lists scenes with audio energy, the loudest moments, and (with
102
+ `--highlights`) proposes N ranges that add up to `--target` seconds, biased to
103
+ the loudest window of each scene. Review the sheet + JSON, adjust the EDL, then
104
+ `cut.py --segments`. Cut detection is a one-frame spike test (benchmark on
105
+ hard cuts between real single takes: precision 0.95, recall 1.00 at the default
106
+ threshold; raise `--threshold` to 12 for 0.98 precision at 0.94 recall).
107
+ Dissolves and very slow fades are not cuts and will be missed. Highlights are
108
+ a proposal engine, not a judgement of content: tell the user what it picked
109
+ and why (energy, scene length).
110
+
111
+ ### check.py — pre-delivery compliance
112
+ ```
113
+ check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|broadcast|podcast|custom [--no-loudness] [--json]
114
+ [--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
115
+ ```
116
+ PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
117
+ step before reporting a deliverable; fix FAILs, mention WARNs.
118
+
119
+ ### batch.py — same recipe over a folder, cached
120
+ ```
121
+ batch.py FOLDER --recipe batch.json [--force] [--watch SECONDS] [--json]
122
+ ```
123
+ `batch.json` holds either `steps` (a list of script argv with `{in}`/`{out}`
124
+ placeholders, chained) or `project` (a render project applied per file).
125
+ Outputs land in `output_dir` with `suffix`; a content-hash cache skips files
126
+ already done with the same recipe. Use `--dry-run` to preview the plan.
127
+
128
+ ### caption.py --transcribe — optional local speech-to-text
129
+ If `whisper-cli` (whisper.cpp), `faster-whisper` or `whisper` is installed,
130
+ `caption.py input.mp4 --transcribe [--language ja] [--model base]` writes the
131
+ SRT from the audio and burns it (combine with `--animate pop --karaoke`).
132
+ Nothing is downloaded and nothing is required: without an engine it prints
133
+ install hints and the user can supply `--text` cues instead. Always tell the
134
+ user which engine was used, and treat the transcript as a draft to review.
135
+
136
+ ### MCP server — the toolkit for any MCP client
137
+ `python3 mcp/server.py` speaks MCP over stdio; each script is a tool taking
138
+ named args (flags without dashes, underscores for hyphens) or `argv`. Config
139
+ for Claude Desktop / Claude Code:
140
+ `{"mcpServers": {"ffmpeg-skill": {"command": "python3", "args": ["~/.claude/skills/ffmpeg-skill/mcp/server.py"]}}}`.
141
+ Inside this skill, call the scripts directly; the server is for other hosts.
142
+
143
+ ### graphics.py — motion-graphics templates
144
+ ```
145
+ graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug [--name] [--title] [--subtitle]
146
+ [--from N] [--start S] [--end E] [--position CORNER] [--brand brand.json] [--primary RRGGBB] [--scale 1.0] [-o OUT]
147
+ ```
148
+ Drawn with drawbox/drawtext/overlay — no PNG assets needed. Sizes scale with
149
+ the frame's short side; colours, font and safe margin come from `--brand`.
150
+ Lower-third slides in over 0.4 s and out over 0.3 s; title/chapter/bug fade.
151
+
152
+ ### brand.json — one file for fonts, colours, logo, margins
153
+ ```json
154
+ {"font": "Noto Sans CJK JP", "font_file": "fonts/NotoSansCJK-Bold.ttc",
155
+ "colors": {"primary": "FF6A00", "text": "FFFFFF", "outline": "000000", "background": "0B1D2A"},
156
+ "logo": "logo.png", "logo_position": "top-right", "logo_scale": 160, "logo_opacity": 0.9,
157
+ "safe_margin": 48, "caption": {"size": 28, "position": "bottom", "animate": "pop", "karaoke": true, "bold": true}}
158
+ ```
159
+ `caption.py --brand`, `overlay.py --brand --logo`, `graphics.py --brand`, and
160
+ `"brand": "brand.json"` in a render project. Explicit flags still win. When a
161
+ user mentions brand guidelines, colours, "our font" or a logo, ask for or
162
+ write a brand.json once and reuse it across every output.
163
+
164
+ ### report.py — HTML delivery report
165
+ ```
166
+ report.py --after FINAL [--before SOURCE] [--platform youtube] [--commands cmds.txt] [--notes notes.md] [--title T] [--no-sheets] [-o report.html]
167
+ ```
168
+ One self-contained HTML: before/after facts and contact sheets, loudness,
169
+ compliance table with fixes, commands. Produce it for any multi-step job and
170
+ hand the path to the user together with the numbers.
171
+
172
+ ### multicam.py — align several cameras and switch between them
173
+ ```
174
+ multicam.py REF CAM2 [CAM3 ...] [--switch "START-END:CAM,..."] | [--auto N] [--audio IDX] [--fix-drift]
175
+ [--offsets-only] [--width W --height H --fps N] [-o OUT]
176
+ ```
177
+ All inputs are aligned to the first one by audio (same engine as `sync.py`,
178
+ `--fix-drift` for long takes). `--switch` names which camera is on screen for
179
+ each range of the reference timeline (gaps fall back to camera 0), `--auto N`
180
+ simply alternates every N seconds. Audio comes from the reference unless
181
+ `--audio` picks another input, e.g. an external recorder that has no video.
182
+ `--offsets-only` reports offsets and confidence without rendering.
183
+
184
+ ### verify.py — real-footage verification kit
185
+ ```
186
+ verify.py FILES_OR_FOLDERS [--quick] [--report verify.md] [--out DIR --keep] [--seconds 6] [--json]
187
+ ```
188
+ Runs the toolchain on the user's own files (phone HDR, GoPro, OBS, Log, Zoom)
189
+ and prints a PASS/FAIL table per step (probe, copy cut, accurate cut, fit,
190
+ caption, overlay, look, export, loudness, silence, plus `color --to-sdr` for
191
+ HDR and `audio --downmix` for >2 channels). Exit code 1 if anything fails.
192
+ Run this first when a user hands over footage from a device you have not
193
+ seen before, and fix or report what fails.
194
+
195
+ ### look.py — see the result
196
+ ```
197
+ look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes
198
+ look.py INPUT --at 2.5 [--at 7] [-o basename] # single frames -> basename_2.500s.png
199
+ look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame
200
+ ```
201
+ Outputs PNG. View it with the Read tool (or any image viewer) and judge the
202
+ frame like an editor would. Use `--compare` to show before/after to the user.
203
+
204
+ ### caption.py — subtitles (static, animated, karaoke)
205
+ ```
206
+ caption.py INPUT --srt FILE | --ass FILE | --text CUES.txt [--write-srt OUT.srt]
207
+ [--font NAME] [--fonts-dir DIR] [--size N] [--color RRGGBB] [--outline N] [--outline-color RRGGBB]
208
+ [--bold] [--box] [--position bottom|top|center|top-left|...] [--margin N]
209
+ [--animate none|fade|pop|slide] [--karaoke [--highlight-color RRGGBB]] [--write-ass OUT.ass] [-o OUT]
210
+ caption.py --text CUES.txt --write-srt OUT.srt # generate the SRT only
211
+ ```
212
+ Text cue format, one per line: `0:00-0:03 Hello`, `00:00:03.500 --> 00:00:06 Two | lines`.
213
+ Lines without a time run for `--auto-seconds` (3 s) after the previous cue. `|` is a line break.
214
+ `--animate`/`--karaoke` generate a styled ASS (PlayRes = video size) from the
215
+ SRT/text cues: `pop` is the short-form "bouncy" entrance, `--karaoke` fills each
216
+ word from `--color` to `--highlight-color` evenly across the cue (word timing
217
+ is distributed, not transcribed). The ASS is kept next to the output so the
218
+ user can hand-tune timings and re-run with `--ass`.
219
+
220
+ ### overlay.py — logo, image, title
221
+ ```
222
+ overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--font-file F.ttf] [--font-size N] [--box]
223
+ [--position top-right|bottom-left|center|X,Y] [--margin N] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
224
+ ```
225
+ Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps playing.
226
+
227
+ ### sync.py — offset detection, alignment, drift correction
228
+ ```
229
+ sync.py REFERENCE SECOND [--json] [--max-offset 30] [--analyze-seconds 120] [--fix-drift [--drift-window 60]]
230
+ [--replace-audio | --trim-second] [-o OUT]
231
+ ```
232
+ Cross-correlates loudness envelopes: coarse FFT search (20 ms), then a direct
233
+ 1 ms refinement (pure Python, a 2-minute window takes ~1-3 s). Positive offset
234
+ = the second recording started later. `--replace-audio` writes the reference
235
+ video with the second file's audio aligned (video stream copied).
236
+ `--trim-second` writes the second file shifted to the reference timeline.
237
+ `--fix-drift` measures the offset again near the end of the overlap, reports
238
+ the clock difference in ppm, and resamples the second file so a 60-minute
239
+ take stays in sync (typical consumer devices drift 20-500 ppm = up to 1.8 s/h).
240
+ Use it whenever the recording is longer than ~10 minutes. Check `confidence`
241
+ (0–1, normalised correlation with a runner-up penalty); below 0.3 the match is
242
+ doubtful. Benchmark on real dialogue/music (±30 s offsets, gain, noise, EQ):
243
+ with the default 120 s window 40/40 within 10 ms (max 1.1 ms); with a 60 s
244
+ window 95 %, misses flagged below 0.3. Keep `--analyze-seconds` at least 4×
245
+ `--max-offset` (default 120 s vs 30 s): lags with under 35 % overlap are
246
+ ignored, so an offset larger than ~60 % of the window cannot be found.
247
+
248
+ ### color.py — HDR to SDR, LUTs, colour tags, Dolby Vision
249
+ ```
250
+ color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|bt2390] [--peak 1000] [--desat 0] [-o OUT]
251
+ color.py INPUT --lut grade.cube [--lut-strength 0..1] [-o OUT]
252
+ color.py INPUT --retag bt709|bt2020-pq|bt2020-hlg|bt601 [-o OUT] # metadata only, stream copy
253
+ color.py INPUT --strip-dovi [-o OUT] # drop the Dolby Vision RPU, keep the HLG/HDR10 base layer (stream copy)
254
+ ```
255
+ iPhone "HDR" video is Dolby Vision profile 8.4 on an HLG base layer:
256
+ `probe.py` reports `hdr_format: Dolby Vision profile 8` and `--to-sdr`
257
+ tone-maps it from the HLG base layer. When the user wants to keep HDR but
258
+ players mis-render the DV layer, `--strip-dovi` removes it losslessly.
259
+ `--to-sdr` does a real conversion: linearise (zscale, PQ or HLG), tone-map
260
+ (default `hable`, `mobius` keeps more highlight detail, `bt2390` is the
261
+ broadcast standard), then BT.709 gamma + matrix. Refuses when probe says the
262
+ input is not HDR unless `--force`. `--lut` applies a 3D .cube with
263
+ tetrahedral interpolation (Log→709 conversions, creative looks); blend with
264
+ `--lut-strength`. Everything else in the skill assumes SDR BT.709, so run this
265
+ first on HDR or Log sources.
266
+
267
+ ### audio.py — clean-up, music, ducking, layout
268
+ ```
269
+ audio.py INPUT [--voice | --denoise [--denoise-strength 25]] [--gain dB]
270
+ [--music FILE [--music-volume -14] [--duck [--duck-amount 12]] [--music-loop]]
271
+ [--fade-in S] [--fade-out S] [--stereo | --mono | --downmix] [--replace FILE] [-o OUT]
272
+ ```
273
+ `--voice` = highpass 80 Hz → de-esser → FFT denoise → gentle compressor, the
274
+ standard talking-head chain. `--duck` uses a sidechain compressor keyed by the
275
+ speech so music dips under dialogue and swells in pauses. `--downmix` uses the
276
+ ITU centre/LFE weights for 5.1/7.1 → stereo. Video is always stream-copied.
277
+ Run `loudness.py` after this for final levels.
278
+
279
+ ### loudness.py — EBU R128 normalisation
280
+ ```
281
+ loudness.py INPUT [-I -14] [--tp -1] [--lra 11] [--measure-only] [-o OUT]
282
+ ```
283
+ Two-pass `loudnorm`: measure, then apply with measured values (linear mode when
284
+ the true-peak ceiling allows). Video is stream-copied; audio becomes AAC in
285
+ video containers or the codec matching the extension (.wav → PCM, .flac, .mp3).
286
+
287
+ ### export.py — delivery presets
288
+ ```
289
+ export.py INPUT --preset youtube|youtube4k|reels|x|prores|h265|gif [--fit pad|crop] [--no-scale] [--allow-long] [--crf N] [-o OUT]
290
+ export.py --list
291
+ ```
292
+ Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
293
+ trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
294
+
package/scripts/check.py CHANGED
@@ -120,8 +120,14 @@ def main() -> int:
120
120
  row("colour", "FAIL", v.get("hdr_format"), "SDR BT.709", "color.py --to-sdr")
121
121
  else:
122
122
  tags = (v.get("color_primaries"), v.get("color_transfer"))
123
- ok = v.get("hdr") or tags == ("bt709", "bt709") or (args.platform in ("podcast", "custom"))
124
- row("colour", "PASS" if ok else "WARN", f"{tags[0]}/{tags[1]}" + (f" ({v.get('hdr_format')})" if v.get("hdr") else ""), "bt709/bt709 tagged (or HDR)", "color.py --retag bt709 when the picture really is 709")
123
+ untagged = not tags[0] and not tags[1]
124
+ if v.get("hdr") or tags == ("bt709", "bt709") or args.platform in ("podcast", "custom"):
125
+ row("colour", "PASS", f"{tags[0]}/{tags[1]}" + (f" ({v.get('hdr_format')})" if v.get("hdr") else ""), "bt709/bt709 tagged (or HDR)")
126
+ elif untagged and (v.get("bit_depth") or 8) == 8:
127
+ # untagged 8-bit video is treated as BT.709 by every player and platform; nothing to fix
128
+ row("colour", "PASS", "untagged (players assume bt709)", "bt709/bt709 tagged (or HDR)")
129
+ else:
130
+ row("colour", "WARN", f"{tags[0]}/{tags[1]}", "bt709/bt709 tagged (or HDR)", "color.py --retag bt709 when the picture really is 709; color.py --to-sdr when it is HDR")
125
131
  elif args.platform not in ("podcast", "custom"):
126
132
  row("video", "FAIL", "none", "video stream", "")
127
133
 
package/scripts/look.py CHANGED
@@ -20,6 +20,12 @@ from _common import add_common, apply_common, die, emit, escape_drawtext, ffmpeg
20
20
  FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
21
21
 
22
22
 
23
+ def fmt_hms(sec: float) -> str:
24
+ h, rem = divmod(sec, 3600)
25
+ m, s_ = divmod(rem, 60)
26
+ return f"{int(h):02d}:{int(m):02d}:{s_:06.3f}"
27
+
28
+
23
29
  def timecode_filter() -> str:
24
30
  return f"drawtext=text='%{{pts\\:hms}}':{FONT}"
25
31
 
@@ -61,7 +67,9 @@ def main() -> int:
61
67
  sec = parse_time(t)
62
68
  out = args.output or os.path.join(outdir, f"{stem}_vs_{Path(args.compare).stem}_{sec:.3f}s.png")
63
69
  half = args.width // 2
64
- fc = (f"[0:v]scale={half}:-2{tc}[a];[1:v]scale={half}:-2{tc}[b];"
70
+ stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{FONT}"
71
+ tcs = tc.replace("," + timecode_filter(), "") + stamp
72
+ fc = (f"[0:v]scale={half}:-2{tcs}[a];[1:v]scale={half}:-2{tcs}[b];"
65
73
  f"[a][b]scale2ref=w=iw:h=ih[a2][b2];[a2][b2]hstack=inputs=2[out]")
66
74
  cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-ss", f"{sec:.3f}", "-i", args.compare,
67
75
  "-filter_complex", fc, "-map", "[out]", "-frames:v", "1", out]
@@ -73,7 +81,8 @@ def main() -> int:
73
81
  if dur and sec > dur:
74
82
  die(f"--at {t} is beyond the duration ({dur:.2f}s)")
75
83
  out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
76
- cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc}", "-frames:v", "1", out]
84
+ stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{FONT}"
85
+ cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(), '')}{stamp}", "-frames:v", "1", out]
77
86
  run(cmd)
78
87
  outputs.append(out)
79
88
  else:
@@ -34,7 +34,9 @@ def measure(path: str, I: float, tp: float, lra: float) -> dict:
34
34
  data = json.loads(m.group(0))
35
35
  for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset"):
36
36
  if data.get(k) in (None, "-inf", "inf", "nan"):
37
- die(f"loudnorm returned unusable value for {k}: {data.get(k)} (silent input?)")
37
+ data["silent"] = True
38
+ return data
39
+ data["silent"] = False
38
40
  return data
39
41
 
40
42
 
@@ -57,6 +59,12 @@ def main() -> int:
57
59
  die("input has no audio stream")
58
60
 
59
61
  stats = measure(args.input, args.lufs, args.tp, args.lra)
62
+ if stats.get("silent"):
63
+ info("audio is silent (integrated loudness -inf); nothing to normalise")
64
+ if args.measure_only:
65
+ print(json.dumps({"silent": True, "input_i": "-inf"}, indent=2))
66
+ return 0
67
+ die("input audio is silent; loudness normalisation is meaningless (use audio.py --replace to add a track)")
60
68
  info(f"measured: {float(stats['input_i']):.1f} LUFS, TP {float(stats['input_tp']):.1f} dBTP, LRA {float(stats['input_lra']):.1f} LU")
61
69
  if args.measure_only:
62
70
  print(json.dumps({k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")}, indent=2))
@@ -79,7 +87,8 @@ def main() -> int:
79
87
  run(cmd)
80
88
 
81
89
  after = measure(output, args.lufs, args.tp, args.lra)
82
- info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS)")
90
+ if not after.get("silent"):
91
+ info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS)")
83
92
  emit(output)
84
93
  return 0
85
94
 
package/scripts/scenes.py CHANGED
@@ -23,17 +23,49 @@ from typing import Dict, List, Tuple
23
23
 
24
24
  from _common import add_common, apply_common, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run
25
25
 
26
- SCENE_RE = re.compile(r"lavfi\.scd\.time=([0-9.]+)")
26
+ SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
27
27
 
28
28
 
29
- def detect_scenes(path: str, threshold: float, min_len: float, duration: float) -> List[float]:
29
+ def detect_scenes(path: str, threshold: float, min_len: float, duration: float, ratio: float = 3.0) -> List[float]:
30
+ """Scene cuts = frames whose scdet score is above `threshold` AND stands out from its
31
+ neighbourhood (score > ratio x median of the surrounding +-12 frames). Sustained motion,
32
+ flashes and fast pans raise the score on many consecutive frames and are rejected;
33
+ a real cut is a one-frame spike. On real footage this roughly doubles precision at
34
+ equal recall compared with the raw scdet threshold."""
30
35
  ffmpeg = require_tool("ffmpeg")
31
36
  proc = subprocess.run([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
32
- f"scale=320:-2,scdet=threshold={threshold}:sc_pass=1,metadata=print:file=-", "-f", "null", "-"],
37
+ "scale=320:-2,scdet=threshold=0:sc_pass=1,metadata=print:file=-", "-f", "null", "-"],
33
38
  stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
34
- times = [float(t) for t in SCENE_RE.findall(proc.stdout)]
39
+ times: List[float] = []
40
+ scores: List[float] = []
41
+ cur_t = None
42
+ for line in proc.stdout.splitlines():
43
+ m = SCORE_RE.match(line)
44
+ if m:
45
+ cur_t = float(m.group(2))
46
+ continue
47
+ if line.startswith("lavfi.scd.score=") and cur_t is not None:
48
+ try:
49
+ times.append(cur_t)
50
+ scores.append(float(line.split("=", 1)[1]))
51
+ except ValueError:
52
+ pass
35
53
  cuts = [0.0]
36
- for t in times:
54
+ if not scores:
55
+ return cuts
56
+ w = 12
57
+ for i, sc in enumerate(scores):
58
+ if sc < threshold:
59
+ continue
60
+ lo, hi = max(0, i - w), min(len(scores), i + w + 1)
61
+ neigh = sorted(scores[lo:i] + scores[i + 1:hi])
62
+ med = neigh[len(neigh) // 2] if neigh else 0.0
63
+ if sc < ratio * max(med, 0.5):
64
+ continue
65
+ # keep only the local maximum inside +-2 frames
66
+ if any(scores[j] > sc for j in range(max(0, i - 2), min(len(scores), i + 3)) if j != i):
67
+ continue
68
+ t = times[i]
37
69
  if t - cuts[-1] >= min_len:
38
70
  cuts.append(t)
39
71
  if duration - cuts[-1] < min_len and len(cuts) > 1:
@@ -60,7 +92,8 @@ def audio_envelope(path: str, step_s: float) -> List[float]:
60
92
  def main() -> int:
61
93
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
62
94
  ap.add_argument("input")
63
- ap.add_argument("--threshold", type=float, default=10.0, help="scdet threshold 0-100 (default 10; lower = more cuts)")
95
+ ap.add_argument("--threshold", type=float, default=8.0, help="minimum scdet score for a cut, 0-100 (default 8)")
96
+ ap.add_argument("--ratio", type=float, default=3.0, help="a cut must exceed this multiple of the neighbouring frames' median score (default 3; lower = more cuts)")
64
97
  ap.add_argument("--min-scene", type=float, default=1.0, help="ignore cuts closer than this in seconds (default 1)")
65
98
  ap.add_argument("--highlights", type=int, default=0, help="number of highlight ranges to propose")
66
99
  ap.add_argument("--target", type=float, help="with --highlights: total seconds the picks should add up to (trims long scenes)")
@@ -75,7 +108,7 @@ def main() -> int:
75
108
  if not meta.get("video"):
76
109
  die("input has no video stream")
77
110
  dur = meta.get("duration") or 0.0
78
- cuts = detect_scenes(args.input, args.threshold, args.min_scene, dur)
111
+ cuts = detect_scenes(args.input, args.threshold, args.min_scene, dur, args.ratio)
79
112
  bounds = cuts + [dur]
80
113
  step_s = 0.5
81
114
  env = audio_envelope(args.input, step_s) if meta.get("audio") else []
package/scripts/sync.py CHANGED
@@ -92,6 +92,14 @@ def ifft(a: List[complex]) -> List[complex]:
92
92
 
93
93
 
94
94
  def cross_correlate(ref: List[float], other: List[float], max_lag: int):
95
+ """Normalised cross-correlation over the overlapping region only.
96
+
97
+ The raw FFT correlation sum grows with the overlap length, so with a 60 s window a
98
+ correct 28 s offset (32 s overlap) loses to a wrong 2 s offset (58 s overlap) on
99
+ music-like material. Dividing each lag by the energy of the overlapping parts
100
+ (prefix sums, O(1) per lag) makes lags comparable and turns the peak value into a
101
+ real similarity score in 0..1 that doubles as the confidence.
102
+ """
95
103
  n = 1
96
104
  while n < len(ref) + len(other):
97
105
  n <<= 1
@@ -99,15 +107,50 @@ def cross_correlate(ref: List[float], other: List[float], max_lag: int):
99
107
  fb = fft([complex(x) for x in other] + [0j] * (n - len(other)))
100
108
  prod = [x * y.conjugate() for x, y in zip(fa, fb)]
101
109
  corr = ifft(prod)
102
- # corr[k] = sum ref[i+k]*other[i] -> lag k means 'other' is delayed by k relative to ref? see below
103
- best_lag, best_val = 0, -float("inf")
110
+ # prefix sums of squares for overlap energy
111
+ def prefix(v: List[float]) -> List[float]:
112
+ out = [0.0]
113
+ acc = 0.0
114
+ for x in v:
115
+ acc += x * x
116
+ out.append(acc)
117
+ return out
118
+ pr, po = prefix(ref), prefix(other)
119
+ lr, lo = len(ref), len(other)
104
120
  max_lag = min(max_lag, n // 2 - 1)
121
+ best_lag, best_val, second = 0, -float("inf"), -float("inf")
122
+ # ignore lags with less than 35 % overlap: with the documented rule (analysis window >= 4x the
123
+ # largest expected offset) true offsets always keep >= 75 % overlap, while short-overlap lags are
124
+ # where coincidental matches on quasi-periodic material (music, tone beds) live
125
+ min_overlap = max(10, int(0.35 * min(lr, lo)))
126
+ scores = []
105
127
  for lag in range(-max_lag, max_lag + 1):
106
- val = corr[lag % n].real
128
+ # corr[lag] = sum_i ref[i] * other[i - lag] -> ref index range and other index range overlap:
129
+ r0, r1 = max(0, lag), min(lr, lo + lag)
130
+ if r1 - r0 < min_overlap:
131
+ continue
132
+ e_ref = pr[r1] - pr[r0]
133
+ e_oth = po[r1 - lag] - po[r0 - lag]
134
+ denom = math.sqrt(e_ref * e_oth)
135
+ if denom <= 0:
136
+ continue
137
+ val = corr[lag % n].real / denom
138
+ # mild preference for longer overlaps: a perfect match over 55 % of the window must not tie
139
+ # with a perfect match over 100 % (quasi-periodic material). Exponent 0.5: with the window rule (>= 4x offset) a true match keeps >= 75 % overlap (x0.87) while a coincidental 55 % match drops to x0.74; keeps large true
140
+ # offsets (28 s in 60 s = 53 % overlap -> x0.94) competitive while still breaking exact ties.
141
+ val *= ((r1 - r0) / min(lr, lo)) ** 0.5
142
+ scores.append((val, lag))
107
143
  if val > best_val:
144
+ second = best_val
108
145
  best_val, best_lag = val, lag
109
- energy = math.sqrt(sum(x * x for x in ref) * sum(x * x for x in other)) or 1.0
110
- return best_lag, best_val / energy
146
+ elif val > second and abs(lag - best_lag) > 5:
147
+ second = val
148
+ # confidence: peak similarity, penalised when a distant runner-up is nearly as good
149
+ conf = max(0.0, min(1.0, best_val))
150
+ if second > -float("inf") and best_val > 0:
151
+ margin = (best_val - second) / best_val
152
+ conf *= min(1.0, 0.5 + margin)
153
+ return best_lag, conf
111
154
 
112
155
 
113
156
  def refine(ref_s: List[float], oth_s: List[float], coarse_offset: float, fine_step: int, window_s: float) -> float:
package/scripts/verify.py CHANGED
@@ -118,7 +118,7 @@ def main() -> int:
118
118
  plan.append(("overlay text", ["overlay.py", cut, "--text", "verify", "--position", "top-left", "-o", f"{stem}_ovl.mp4"] + fast))
119
119
  plan.append(("look sheet", ["look.py", cut, "-o", f"{stem}_sheet.png"]))
120
120
  if (meta.get("video") or {}).get("hdr"):
121
- plan.append(("color to-sdr", ["color.py", str(f), "--to-sdr", "-o", f"{stem}_sdr.mp4"] + fast))
121
+ plan.append(("color to-sdr", ["color.py", f"{stem}_acc.mp4", "--to-sdr", "-o", f"{stem}_sdr.mp4"] + fast))
122
122
  plan.append(("hdr preserved", ["__check_hdr__", f"{stem}_acc.mp4"]))
123
123
  plan.append(("probe analyze", ["probe.py", cut, "--analyze"]))
124
124
  plan.append(("export x", ["export.py", cut, "--preset", "x", "-o", f"{stem}_x.mp4"]))