ffmpeg-skill 0.8.0 → 0.8.2
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/SKILL.md +52 -269
- package/bin/install.js +1 -1
- package/package.json +3 -1
- package/references/devices.md +24 -0
- package/references/scripts.md +294 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/audio.py +5 -4
- package/scripts/caption.py +8 -1
- package/scripts/check.py +20 -7
- package/scripts/look.py +11 -2
- package/scripts/probe.py +3 -1
- package/scripts/render.py +2 -2
package/SKILL.md
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ffmpeg-skill
|
|
3
|
-
description:
|
|
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
|
-
|
|
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,17 +30,40 @@ 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
|
|
33
|
+
for the destination the user named. Each row is marked `format` or
|
|
34
|
+
`judgement`. Format rows (codec, pixel format, size, true peak, colour
|
|
35
|
+
tags, VFR) are safe to fix mechanically. Judgement rows change the content:
|
|
36
|
+
duration (cut loses material, speed changes motion), aspect (crop loses
|
|
37
|
+
edges), fps (drops motion), loudness (ambience must not be boosted). Fix
|
|
38
|
+
those only when the user's request already implies the answer, otherwise
|
|
39
|
+
state the choice and its cost in one line. Mention WARNs; do not chase them.
|
|
40
40
|
6. **Verify the output.** Run `probe.py` on each result and confirm duration,
|
|
41
41
|
resolution, fps and audio match what was requested. Report those numbers to
|
|
42
42
|
the user (e.g. "final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo").
|
|
43
43
|
7. **Keep the user's originals.** Never overwrite the source file. Write new
|
|
44
44
|
files next to the input or where the user asked.
|
|
45
|
-
8. **Look at the picture.**
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
45
|
+
8. **Look at the picture.** Whenever the picture changed (captions, overlays,
|
|
46
|
+
graphics, crop/pad, resize, colour, transitions) run `look.py OUTPUT`
|
|
47
|
+
(contact sheet) or `look.py OUTPUT --at T`, view the PNG, and judge it like
|
|
48
|
+
an editor: text inside the frame and not over faces, logos where asked,
|
|
49
|
+
crops keeping the subject, colours not washed out, transitions landing
|
|
50
|
+
where intended. The job is not finished until the report's `Look:` line
|
|
51
|
+
names that PNG; a probe alone cannot see a caption sitting on someone's
|
|
52
|
+
face. Audio-only jobs (sync, loudness, silence) write `Look: not needed`.
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
## Before you run anything: what to ask, what to assume
|
|
56
|
+
|
|
57
|
+
Ask one short question only when the answer changes the output materially and the request does not imply it:
|
|
58
|
+
|
|
59
|
+
- **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.
|
|
60
|
+
- **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.
|
|
61
|
+
- **Captions** without a text source: use `--transcribe` if a local whisper exists, otherwise ask for the text or a timed file; never invent dialogue.
|
|
62
|
+
- **Fonts and brand**: if the user mentions a brand, colours or "our font", ask for or create `brand.json` once and reuse it.
|
|
63
|
+
- **CJK / non-Latin text**: check that a font exists before rendering (`fc-list :lang=ja file` / `:lang=ko` / `:lang=zh`); pass it with `--font "Name"` or `--font-file /path.ttf`. Tofu boxes are a failed job, not a style.
|
|
64
|
+
- Anything else (crop position, transition type, caption style): pick the conventional default, say what you picked, and offer the alternative in one line.
|
|
65
|
+
|
|
66
|
+
Do not ask for things `probe.py` can tell you.
|
|
50
67
|
|
|
51
68
|
## Request → script
|
|
52
69
|
|
|
@@ -94,269 +111,35 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
|
|
|
94
111
|
| "TikTok-style captions with the words popping / highlighted" | `caption.py input.mp4 --text cues.txt --animate pop --karaoke` |
|
|
95
112
|
| "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
113
|
|
|
97
|
-
## Scripts
|
|
98
114
|
|
|
99
|
-
|
|
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.
|
|
115
|
+
## Report format
|
|
105
116
|
|
|
106
|
-
|
|
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.
|
|
117
|
+
Finish every job with this shape (numbers from `probe.py`/`check.py`, not memory):
|
|
129
118
|
|
|
130
|
-
### silence.py — remove dead air / jump cuts
|
|
131
119
|
```
|
|
132
|
-
|
|
120
|
+
Done: final.mp4 — 59.98 s, 1080x1920, 30 fps, H.264, AAC stereo, -14.1 LUFS
|
|
121
|
+
Steps: cut 0:12-1:12 (lossless) -> fit 9:16 crop -> captions (pop, karaoke) -> loudness -14 -> export reels
|
|
122
|
+
Check: reels — all 12 checks pass
|
|
123
|
+
Look: final_sheet.png (captions inside the safe area, logo top-right)
|
|
124
|
+
Notes: source was VFR, conformed to 30 fps; audio was mono, made stereo
|
|
133
125
|
```
|
|
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
126
|
|
|
141
|
-
|
|
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.
|
|
127
|
+
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
128
|
|
|
151
|
-
|
|
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`. Cut detection is a one-frame spike test (benchmark on
|
|
171
|
-
hard cuts between real single takes: precision 0.95, recall 1.00 at the default
|
|
172
|
-
threshold; raise `--threshold` to 12 for 0.98 precision at 0.94 recall).
|
|
173
|
-
Dissolves and very slow fades are not cuts and will be missed. Highlights are
|
|
174
|
-
a proposal engine, not a judgement of content: tell the user what it picked
|
|
175
|
-
and why (energy, scene length).
|
|
176
|
-
|
|
177
|
-
### check.py — pre-delivery compliance
|
|
178
|
-
```
|
|
179
|
-
check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|broadcast|podcast|custom [--no-loudness] [--json]
|
|
180
|
-
[--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
|
|
181
|
-
```
|
|
182
|
-
PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
|
|
183
|
-
step before reporting a deliverable; fix FAILs, mention WARNs.
|
|
184
|
-
|
|
185
|
-
### batch.py — same recipe over a folder, cached
|
|
186
|
-
```
|
|
187
|
-
batch.py FOLDER --recipe batch.json [--force] [--watch SECONDS] [--json]
|
|
188
|
-
```
|
|
189
|
-
`batch.json` holds either `steps` (a list of script argv with `{in}`/`{out}`
|
|
190
|
-
placeholders, chained) or `project` (a render project applied per file).
|
|
191
|
-
Outputs land in `output_dir` with `suffix`; a content-hash cache skips files
|
|
192
|
-
already done with the same recipe. Use `--dry-run` to preview the plan.
|
|
193
|
-
|
|
194
|
-
### caption.py --transcribe — optional local speech-to-text
|
|
195
|
-
If `whisper-cli` (whisper.cpp), `faster-whisper` or `whisper` is installed,
|
|
196
|
-
`caption.py input.mp4 --transcribe [--language ja] [--model base]` writes the
|
|
197
|
-
SRT from the audio and burns it (combine with `--animate pop --karaoke`).
|
|
198
|
-
Nothing is downloaded and nothing is required: without an engine it prints
|
|
199
|
-
install hints and the user can supply `--text` cues instead. Always tell the
|
|
200
|
-
user which engine was used, and treat the transcript as a draft to review.
|
|
201
|
-
|
|
202
|
-
### MCP server — the toolkit for any MCP client
|
|
203
|
-
`python3 mcp/server.py` speaks MCP over stdio; each script is a tool taking
|
|
204
|
-
named args (flags without dashes, underscores for hyphens) or `argv`. Config
|
|
205
|
-
for Claude Desktop / Claude Code:
|
|
206
|
-
`{"mcpServers": {"ffmpeg-skill": {"command": "python3", "args": ["~/.claude/skills/ffmpeg-skill/mcp/server.py"]}}}`.
|
|
207
|
-
Inside this skill, call the scripts directly; the server is for other hosts.
|
|
208
|
-
|
|
209
|
-
### graphics.py — motion-graphics templates
|
|
210
|
-
```
|
|
211
|
-
graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug [--name] [--title] [--subtitle]
|
|
212
|
-
[--from N] [--start S] [--end E] [--position CORNER] [--brand brand.json] [--primary RRGGBB] [--scale 1.0] [-o OUT]
|
|
213
|
-
```
|
|
214
|
-
Drawn with drawbox/drawtext/overlay — no PNG assets needed. Sizes scale with
|
|
215
|
-
the frame's short side; colours, font and safe margin come from `--brand`.
|
|
216
|
-
Lower-third slides in over 0.4 s and out over 0.3 s; title/chapter/bug fade.
|
|
217
|
-
|
|
218
|
-
### brand.json — one file for fonts, colours, logo, margins
|
|
219
|
-
```json
|
|
220
|
-
{"font": "Noto Sans CJK JP", "font_file": "fonts/NotoSansCJK-Bold.ttc",
|
|
221
|
-
"colors": {"primary": "FF6A00", "text": "FFFFFF", "outline": "000000", "background": "0B1D2A"},
|
|
222
|
-
"logo": "logo.png", "logo_position": "top-right", "logo_scale": 160, "logo_opacity": 0.9,
|
|
223
|
-
"safe_margin": 48, "caption": {"size": 28, "position": "bottom", "animate": "pop", "karaoke": true, "bold": true}}
|
|
224
|
-
```
|
|
225
|
-
`caption.py --brand`, `overlay.py --brand --logo`, `graphics.py --brand`, and
|
|
226
|
-
`"brand": "brand.json"` in a render project. Explicit flags still win. When a
|
|
227
|
-
user mentions brand guidelines, colours, "our font" or a logo, ask for or
|
|
228
|
-
write a brand.json once and reuse it across every output.
|
|
129
|
+
## Things that look right but are wrong
|
|
229
130
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
```
|
|
243
|
-
All inputs are aligned to the first one by audio (same engine as `sync.py`,
|
|
244
|
-
`--fix-drift` for long takes). `--switch` names which camera is on screen for
|
|
245
|
-
each range of the reference timeline (gaps fall back to camera 0), `--auto N`
|
|
246
|
-
simply alternates every N seconds. Audio comes from the reference unless
|
|
247
|
-
`--audio` picks another input, e.g. an external recorder that has no video.
|
|
248
|
-
`--offsets-only` reports offsets and confidence without rendering.
|
|
249
|
-
|
|
250
|
-
### verify.py — real-footage verification kit
|
|
251
|
-
```
|
|
252
|
-
verify.py FILES_OR_FOLDERS [--quick] [--report verify.md] [--out DIR --keep] [--seconds 6] [--json]
|
|
253
|
-
```
|
|
254
|
-
Runs the toolchain on the user's own files (phone HDR, GoPro, OBS, Log, Zoom)
|
|
255
|
-
and prints a PASS/FAIL table per step (probe, copy cut, accurate cut, fit,
|
|
256
|
-
caption, overlay, look, export, loudness, silence, plus `color --to-sdr` for
|
|
257
|
-
HDR and `audio --downmix` for >2 channels). Exit code 1 if anything fails.
|
|
258
|
-
Run this first when a user hands over footage from a device you have not
|
|
259
|
-
seen before, and fix or report what fails.
|
|
260
|
-
|
|
261
|
-
### look.py — see the result
|
|
262
|
-
```
|
|
263
|
-
look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes
|
|
264
|
-
look.py INPUT --at 2.5 [--at 7] [-o basename] # single frames -> basename_2.500s.png
|
|
265
|
-
look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame
|
|
266
|
-
```
|
|
267
|
-
Outputs PNG. View it with the Read tool (or any image viewer) and judge the
|
|
268
|
-
frame like an editor would. Use `--compare` to show before/after to the user.
|
|
269
|
-
|
|
270
|
-
### caption.py — subtitles (static, animated, karaoke)
|
|
271
|
-
```
|
|
272
|
-
caption.py INPUT --srt FILE | --ass FILE | --text CUES.txt [--write-srt OUT.srt]
|
|
273
|
-
[--font NAME] [--fonts-dir DIR] [--size N] [--color RRGGBB] [--outline N] [--outline-color RRGGBB]
|
|
274
|
-
[--bold] [--box] [--position bottom|top|center|top-left|...] [--margin N]
|
|
275
|
-
[--animate none|fade|pop|slide] [--karaoke [--highlight-color RRGGBB]] [--write-ass OUT.ass] [-o OUT]
|
|
276
|
-
caption.py --text CUES.txt --write-srt OUT.srt # generate the SRT only
|
|
277
|
-
```
|
|
278
|
-
Text cue format, one per line: `0:00-0:03 Hello`, `00:00:03.500 --> 00:00:06 Two | lines`.
|
|
279
|
-
Lines without a time run for `--auto-seconds` (3 s) after the previous cue. `|` is a line break.
|
|
280
|
-
`--animate`/`--karaoke` generate a styled ASS (PlayRes = video size) from the
|
|
281
|
-
SRT/text cues: `pop` is the short-form "bouncy" entrance, `--karaoke` fills each
|
|
282
|
-
word from `--color` to `--highlight-color` evenly across the cue (word timing
|
|
283
|
-
is distributed, not transcribed). The ASS is kept next to the output so the
|
|
284
|
-
user can hand-tune timings and re-run with `--ass`.
|
|
285
|
-
|
|
286
|
-
### overlay.py — logo, image, title
|
|
287
|
-
```
|
|
288
|
-
overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--font-file F.ttf] [--font-size N] [--box]
|
|
289
|
-
[--position top-right|bottom-left|center|X,Y] [--margin N] [--start T] [--end T] [--fade S] [--opacity 0-1] [-o OUT]
|
|
290
|
-
```
|
|
291
|
-
Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps playing.
|
|
292
|
-
|
|
293
|
-
### sync.py — offset detection, alignment, drift correction
|
|
294
|
-
```
|
|
295
|
-
sync.py REFERENCE SECOND [--json] [--max-offset 30] [--analyze-seconds 120] [--fix-drift [--drift-window 60]]
|
|
296
|
-
[--replace-audio | --trim-second] [-o OUT]
|
|
297
|
-
```
|
|
298
|
-
Cross-correlates loudness envelopes: coarse FFT search (20 ms), then a direct
|
|
299
|
-
1 ms refinement (pure Python, a 2-minute window takes ~1-3 s). Positive offset
|
|
300
|
-
= the second recording started later. `--replace-audio` writes the reference
|
|
301
|
-
video with the second file's audio aligned (video stream copied).
|
|
302
|
-
`--trim-second` writes the second file shifted to the reference timeline.
|
|
303
|
-
`--fix-drift` measures the offset again near the end of the overlap, reports
|
|
304
|
-
the clock difference in ppm, and resamples the second file so a 60-minute
|
|
305
|
-
take stays in sync (typical consumer devices drift 20-500 ppm = up to 1.8 s/h).
|
|
306
|
-
Use it whenever the recording is longer than ~10 minutes. Check `confidence`
|
|
307
|
-
(0–1, normalised correlation with a runner-up penalty); below 0.3 the match is
|
|
308
|
-
doubtful. Benchmark on real dialogue/music (±30 s offsets, gain, noise, EQ):
|
|
309
|
-
with the default 120 s window 40/40 within 10 ms (max 1.1 ms); with a 60 s
|
|
310
|
-
window 95 %, misses flagged below 0.3. Keep `--analyze-seconds` at least 4×
|
|
311
|
-
`--max-offset` (default 120 s vs 30 s): lags with under 35 % overlap are
|
|
312
|
-
ignored, so an offset larger than ~60 % of the window cannot be found.
|
|
313
|
-
|
|
314
|
-
### color.py — HDR to SDR, LUTs, colour tags, Dolby Vision
|
|
315
|
-
```
|
|
316
|
-
color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|bt2390] [--peak 1000] [--desat 0] [-o OUT]
|
|
317
|
-
color.py INPUT --lut grade.cube [--lut-strength 0..1] [-o OUT]
|
|
318
|
-
color.py INPUT --retag bt709|bt2020-pq|bt2020-hlg|bt601 [-o OUT] # metadata only, stream copy
|
|
319
|
-
color.py INPUT --strip-dovi [-o OUT] # drop the Dolby Vision RPU, keep the HLG/HDR10 base layer (stream copy)
|
|
320
|
-
```
|
|
321
|
-
iPhone "HDR" video is Dolby Vision profile 8.4 on an HLG base layer:
|
|
322
|
-
`probe.py` reports `hdr_format: Dolby Vision profile 8` and `--to-sdr`
|
|
323
|
-
tone-maps it from the HLG base layer. When the user wants to keep HDR but
|
|
324
|
-
players mis-render the DV layer, `--strip-dovi` removes it losslessly.
|
|
325
|
-
`--to-sdr` does a real conversion: linearise (zscale, PQ or HLG), tone-map
|
|
326
|
-
(default `hable`, `mobius` keeps more highlight detail, `bt2390` is the
|
|
327
|
-
broadcast standard), then BT.709 gamma + matrix. Refuses when probe says the
|
|
328
|
-
input is not HDR unless `--force`. `--lut` applies a 3D .cube with
|
|
329
|
-
tetrahedral interpolation (Log→709 conversions, creative looks); blend with
|
|
330
|
-
`--lut-strength`. Everything else in the skill assumes SDR BT.709, so run this
|
|
331
|
-
first on HDR or Log sources.
|
|
332
|
-
|
|
333
|
-
### audio.py — clean-up, music, ducking, layout
|
|
334
|
-
```
|
|
335
|
-
audio.py INPUT [--voice | --denoise [--denoise-strength 25]] [--gain dB]
|
|
336
|
-
[--music FILE [--music-volume -14] [--duck [--duck-amount 12]] [--music-loop]]
|
|
337
|
-
[--fade-in S] [--fade-out S] [--stereo | --mono | --downmix] [--replace FILE] [-o OUT]
|
|
338
|
-
```
|
|
339
|
-
`--voice` = highpass 80 Hz → de-esser → FFT denoise → gentle compressor, the
|
|
340
|
-
standard talking-head chain. `--duck` uses a sidechain compressor keyed by the
|
|
341
|
-
speech so music dips under dialogue and swells in pauses. `--downmix` uses the
|
|
342
|
-
ITU centre/LFE weights for 5.1/7.1 → stereo. Video is always stream-copied.
|
|
343
|
-
Run `loudness.py` after this for final levels.
|
|
344
|
-
|
|
345
|
-
### loudness.py — EBU R128 normalisation
|
|
346
|
-
```
|
|
347
|
-
loudness.py INPUT [-I -14] [--tp -1] [--lra 11] [--measure-only] [-o OUT]
|
|
348
|
-
```
|
|
349
|
-
Two-pass `loudnorm`: measure, then apply with measured values (linear mode when
|
|
350
|
-
the true-peak ceiling allows). Video is stream-copied; audio becomes AAC in
|
|
351
|
-
video containers or the codec matching the extension (.wav → PCM, .flac, .mp3).
|
|
352
|
-
|
|
353
|
-
### export.py — delivery presets
|
|
354
|
-
```
|
|
355
|
-
export.py INPUT --preset youtube|youtube4k|reels|x|prores|h265|gif [--fit pad|crop] [--no-scale] [--allow-long] [--crf N] [-o OUT]
|
|
356
|
-
export.py --list
|
|
357
|
-
```
|
|
358
|
-
Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
|
|
359
|
-
trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
|
|
131
|
+
- 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.
|
|
132
|
+
- 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.
|
|
133
|
+
- 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.
|
|
134
|
+
- "Normalised" audio that still clips: check true peak, not just LUFS (`check.py` does both).
|
|
135
|
+
- Normalising ambience or near-silence to a speech target: a clip measured at
|
|
136
|
+
-40 LUFS or below is room tone, wind or nothing; raising it 25 dB raises the
|
|
137
|
+
noise, not the content. Leave the level, say so, and offer music or narration.
|
|
138
|
+
- Captions burned before a crop/resize: text lands off-frame. Frame changes first, then text.
|
|
139
|
+
- Anything chained by hand through three re-encodes: use `render.py` so the plan is one file and the user can change one number.
|
|
140
|
+
- `--fit crop` to reach 9:16 from 16:9 throws away 70 % of the width: a wide shot loses people at the edges. Check the sheet; pad (bars) or a reframe is often the honest answer.
|
|
141
|
+
- Conforming 60 fps to 30 halves the motion samples: fine for a talking head, visibly choppy for sports, gaming, drone pans. Keep 60 when the platform allows it.
|
|
142
|
+
- "Make it 60 seconds" on a 3-minute talk by speed change is unwatchable (3×); by trim it drops two thirds of the words. Ask which, or propose a highlight cut with `scenes.py`.
|
|
360
143
|
|
|
361
144
|
## Gotchas
|
|
362
145
|
|
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.8.
|
|
3
|
+
"version": "0.8.2",
|
|
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,12 +18,14 @@
|
|
|
18
18
|
"bin/",
|
|
19
19
|
"scripts/",
|
|
20
20
|
"mcp/",
|
|
21
|
+
"references/",
|
|
21
22
|
"SKILL.md",
|
|
22
23
|
"README.md",
|
|
23
24
|
"LICENSE"
|
|
24
25
|
],
|
|
25
26
|
"scripts": {
|
|
26
27
|
"test": "python3 tests/test_all.py",
|
|
28
|
+
"release-check": "bash tests/release_check.sh",
|
|
27
29
|
"demo": "bash examples/make_demo.sh"
|
|
28
30
|
},
|
|
29
31
|
"engines": {
|
|
@@ -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
|
+
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/scripts/audio.py
CHANGED
|
@@ -6,7 +6,7 @@ Examples:
|
|
|
6
6
|
python3 audio.py interview.mp4 --denoise # FFT noise reduction
|
|
7
7
|
python3 audio.py interview.mp4 --voice # highpass + de-esser + compressor + denoise
|
|
8
8
|
python3 audio.py talk.mp4 --music bed.mp3 --duck # music under speech, auto-ducked
|
|
9
|
-
python3 audio.py talk.mp4 --music bed.mp3 --music-volume -18 --fade-out 3
|
|
9
|
+
python3 audio.py talk.mp4 --music bed.mp3 --music-volume -18 --music-fade-out 3 # bed fades, voice does not
|
|
10
10
|
python3 audio.py clip.mp4 --fade-in 0.5 --fade-out 1 --stereo
|
|
11
11
|
python3 audio.py surround.mov --downmix # 5.1 -> stereo with proper centre/LFE weights
|
|
12
12
|
python3 audio.py clip.mp4 --replace narration.wav # swap the audio track entirely
|
|
@@ -37,7 +37,8 @@ def main() -> int:
|
|
|
37
37
|
music.add_argument("--music-loop", action="store_true", help="loop the music if shorter than the video")
|
|
38
38
|
fades = ap.add_argument_group("fades / layout")
|
|
39
39
|
fades.add_argument("--fade-in", type=float, default=0.0, help="seconds")
|
|
40
|
-
fades.add_argument("--fade-out", type=float, default=0.0, help="seconds")
|
|
40
|
+
fades.add_argument("--fade-out", type=float, default=0.0, help="seconds; fades the whole final mix (voice included)")
|
|
41
|
+
music.add_argument("--music-fade-out", type=float, default=0.0, help="seconds; fades only the music bed at the end, voice untouched")
|
|
41
42
|
fades.add_argument("--stereo", action="store_true", help="force 2-channel output (mono is duplicated to both sides)")
|
|
42
43
|
fades.add_argument("--mono", action="store_true", help="force 1-channel output")
|
|
43
44
|
fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo using standard weights")
|
|
@@ -90,8 +91,8 @@ def main() -> int:
|
|
|
90
91
|
m = f"{idx}:a:0"
|
|
91
92
|
idx += 1
|
|
92
93
|
mfx = [f"volume={args.music_volume:g}dB", f"atrim=0:{dur:.3f}" if dur else "anull"]
|
|
93
|
-
if args.
|
|
94
|
-
mfx.append(f"afade=t=out:st={max(0.0, dur - args.
|
|
94
|
+
if args.music_fade_out and dur:
|
|
95
|
+
mfx.append(f"afade=t=out:st={max(0.0, dur - args.music_fade_out):.3f}:d={args.music_fade_out:g}")
|
|
95
96
|
graph.append(f"[{m}]{','.join(mfx)}[music]")
|
|
96
97
|
if args.duck:
|
|
97
98
|
graph.append("[main]asplit=2[mainA][sc]")
|
package/scripts/caption.py
CHANGED
|
@@ -344,7 +344,14 @@ def main() -> int:
|
|
|
344
344
|
args.text = None
|
|
345
345
|
if args.text:
|
|
346
346
|
cues = parse_text_cues(args.text, args.auto_seconds, args.gap)
|
|
347
|
-
|
|
347
|
+
if args.write_srt:
|
|
348
|
+
srt_path = args.write_srt
|
|
349
|
+
elif args.input:
|
|
350
|
+
# keep generated files next to the output, not in the user's source folder
|
|
351
|
+
out_guess = args.output or default_output(args.input, "captioned")
|
|
352
|
+
srt_path = os.path.splitext(out_guess)[0] + ".srt"
|
|
353
|
+
else:
|
|
354
|
+
srt_path = os.path.splitext(args.text)[0] + ".srt"
|
|
348
355
|
write_srt(cues, srt_path)
|
|
349
356
|
info(f"wrote {srt_path} ({len(cues)} cues)")
|
|
350
357
|
if not args.input:
|
package/scripts/check.py
CHANGED
|
@@ -86,12 +86,17 @@ def main() -> int:
|
|
|
86
86
|
v, a = meta.get("video") or {}, meta.get("audio") or {}
|
|
87
87
|
rows: List[Dict[str, Any]] = []
|
|
88
88
|
|
|
89
|
+
JUDGEMENT = {"duration", "aspect", "loudness", "fps", "resolution"}
|
|
90
|
+
|
|
89
91
|
def row(name: str, status: str, value: Any, expect: Any, fix: str = "") -> None:
|
|
90
|
-
|
|
92
|
+
# "format" rows are safe to fix mechanically; "judgement" rows change the content
|
|
93
|
+
# (what is cut, what is cropped, how loud ambience gets) and need a decision
|
|
94
|
+
rows.append({"check": name, "status": status, "value": value, "expected": expect, "fix": fix,
|
|
95
|
+
"kind": "judgement" if name in JUDGEMENT else "format"})
|
|
91
96
|
|
|
92
97
|
dur = meta.get("duration") or 0.0
|
|
93
98
|
if spec["max_duration"]:
|
|
94
|
-
row("duration", "PASS" if dur <= spec["max_duration"] else "FAIL", f"{dur:.2f}s", f"<= {spec['max_duration']:g}s", "fit.py --duration
|
|
99
|
+
row("duration", "PASS" if dur <= spec["max_duration"] else "FAIL", f"{dur:.2f}s", f"<= {spec['max_duration']:g}s", "decide with the user: cut.py keeps quality but drops content; fit.py --duration speeds up (audio pitch-preserved, motion faster)")
|
|
95
100
|
else:
|
|
96
101
|
row("duration", "PASS", f"{dur:.2f}s", "any")
|
|
97
102
|
|
|
@@ -101,7 +106,7 @@ def main() -> int:
|
|
|
101
106
|
w, h = h, w
|
|
102
107
|
asp = aspect_name(w, h)
|
|
103
108
|
if spec["aspects"]:
|
|
104
|
-
row("aspect", "PASS" if asp in spec["aspects"] else "FAIL", asp, "/".join(spec["aspects"]), f"fit.py --aspect {spec['aspects'][0]} --fit pad
|
|
109
|
+
row("aspect", "PASS" if asp in spec["aspects"] else "FAIL", asp, "/".join(spec["aspects"]), f"fit.py --aspect {spec['aspects'][0]} --fit pad (keeps everything, adds bars) or crop (fills the frame, loses the edges: check the subject with look.py)")
|
|
105
110
|
else:
|
|
106
111
|
row("aspect", "PASS", asp, "any")
|
|
107
112
|
short = min(w, h)
|
|
@@ -109,7 +114,7 @@ def main() -> int:
|
|
|
109
114
|
row("resolution", "PASS" if short >= spec["min_height"] else "WARN", f"{w}x{h}", f"short side >= {spec['min_height']}", "upscaling will not add detail; re-export from the master")
|
|
110
115
|
fps = v.get("fps") or 0
|
|
111
116
|
if spec["fps_max"]:
|
|
112
|
-
row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30")
|
|
117
|
+
row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30 (drops half the frames of 60 fps motion; fine for talking heads, visible on sports/gaming)")
|
|
113
118
|
row("vfr", "PASS" if not v.get("variable_frame_rate_suspected") else "WARN", "variable" if v.get("variable_frame_rate_suspected") else "constant", "constant", "fit.py --fps N (any re-encode conforms it)")
|
|
114
119
|
if spec["codecs"]:
|
|
115
120
|
row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + args.platform.replace("shorts", "reels").replace("tiktok", "reels").replace("linkedin", "youtube"))
|
|
@@ -120,8 +125,14 @@ def main() -> int:
|
|
|
120
125
|
row("colour", "FAIL", v.get("hdr_format"), "SDR BT.709", "color.py --to-sdr")
|
|
121
126
|
else:
|
|
122
127
|
tags = (v.get("color_primaries"), v.get("color_transfer"))
|
|
123
|
-
|
|
124
|
-
|
|
128
|
+
untagged = not tags[0] and not tags[1]
|
|
129
|
+
if v.get("hdr") or tags == ("bt709", "bt709") or args.platform in ("podcast", "custom"):
|
|
130
|
+
row("colour", "PASS", f"{tags[0]}/{tags[1]}" + (f" ({v.get('hdr_format')})" if v.get("hdr") else ""), "bt709/bt709 tagged (or HDR)")
|
|
131
|
+
elif untagged and (v.get("bit_depth") or 8) == 8:
|
|
132
|
+
# untagged 8-bit video is treated as BT.709 by every player and platform; nothing to fix
|
|
133
|
+
row("colour", "PASS", "untagged (players assume bt709)", "bt709/bt709 tagged (or HDR)")
|
|
134
|
+
else:
|
|
135
|
+
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
136
|
elif args.platform not in ("podcast", "custom"):
|
|
126
137
|
row("video", "FAIL", "none", "video stream", "")
|
|
127
138
|
|
|
@@ -137,7 +148,7 @@ def main() -> int:
|
|
|
137
148
|
lm = measure_loudness(args.input)
|
|
138
149
|
if lm:
|
|
139
150
|
diff = abs(lm["lufs"] - spec["lufs"])
|
|
140
|
-
row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g}")
|
|
151
|
+
row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g} for speech or music; leave ambience/near-silence (<= -40 LUFS) alone and say so")
|
|
141
152
|
row("true peak", "PASS" if lm["tp"] <= spec["tp"] + 0.05 else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP", f"loudness.py --tp {spec['tp']:g}")
|
|
142
153
|
elif args.platform in ("podcast",):
|
|
143
154
|
row("audio", "FAIL", "none", "audio stream", "audio.py --replace")
|
|
@@ -151,6 +162,8 @@ def main() -> int:
|
|
|
151
162
|
print(f"{args.input} — {args.platform}")
|
|
152
163
|
for r in rows:
|
|
153
164
|
line = f" {r['status']:4s} {r['check']:{width}s} {r['value']} (expected {r['expected']})"
|
|
165
|
+
if r["status"] != "PASS" and r["kind"] == "judgement":
|
|
166
|
+
line += " [judgement]"
|
|
154
167
|
if r["status"] != "PASS" and r["fix"]:
|
|
155
168
|
line += f" -> {r['fix']}"
|
|
156
169
|
print(line)
|
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
|
-
|
|
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
|
-
|
|
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:
|
package/scripts/probe.py
CHANGED
|
@@ -12,7 +12,7 @@ Examples:
|
|
|
12
12
|
import argparse
|
|
13
13
|
import sys
|
|
14
14
|
|
|
15
|
-
from _common import analyze_levels, print_json, probe
|
|
15
|
+
from _common import add_common, analyze_levels, apply_common, print_json, probe
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def main() -> int:
|
|
@@ -21,7 +21,9 @@ def main() -> int:
|
|
|
21
21
|
ap.add_argument("--compact", action="store_true", help="one human-readable line per file instead of JSON")
|
|
22
22
|
ap.add_argument("--field", help="print only this top-level field (e.g. duration) or dotted path (video.fps)")
|
|
23
23
|
ap.add_argument("--analyze", action="store_true", help="also sample picture levels (first 20 s) and flag Log-looking footage")
|
|
24
|
+
add_common(ap) # --json / --dry-run / --fast / --progress accepted for uniformity; output is JSON already
|
|
24
25
|
args = ap.parse_args()
|
|
26
|
+
apply_common(args)
|
|
25
27
|
|
|
26
28
|
results = [probe(p) for p in args.inputs]
|
|
27
29
|
if args.analyze:
|
package/scripts/render.py
CHANGED
|
@@ -24,7 +24,7 @@ Project format (all keys optional except clips):
|
|
|
24
24
|
{"logo": true},
|
|
25
25
|
{"text": "Episode 12", "position": "bottom", "start": 1, "end": 5, "fade": 0.3, "box": true}
|
|
26
26
|
],
|
|
27
|
-
"audio": {"voice": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "
|
|
27
|
+
"audio": {"voice": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "music_fade_out": 2},
|
|
28
28
|
"loudness": {"lufs": -14, "tp": -1},
|
|
29
29
|
"fit": {"duration": 60},
|
|
30
30
|
"export": {"preset": "reels"},
|
|
@@ -289,7 +289,7 @@ def main() -> int:
|
|
|
289
289
|
for k, flag in (("music", "--music"), ("replace", "--replace")):
|
|
290
290
|
if au.get(k):
|
|
291
291
|
argv += [flag, rel(au[k])]
|
|
292
|
-
for k, flag in (("music_volume", "--music-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount")):
|
|
292
|
+
for k, flag in (("music_volume", "--music-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("music_fade_out", "--music-fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount")):
|
|
293
293
|
if au.get(k) is not None:
|
|
294
294
|
argv += [flag, str(au[k])]
|
|
295
295
|
for k, flag in (("voice", "--voice"), ("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
|