ffmpeg-skill 2.6.0 → 2.8.0
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 +5 -5
- package/SKILL.md +7 -7
- package/docs/contract.md +49 -8
- package/package.json +1 -1
- package/references/gotchas.md +27 -5
- package/references/scripts.md +121 -13
- package/scripts/_common/__init__.py +6 -6
- package/scripts/_common/asr.py +747 -94
- package/scripts/_common/decision.py +13 -0
- package/scripts/_common/probe.py +92 -0
- package/scripts/_common/runner.py +35 -12
- package/scripts/_contract.py +67 -12
- package/scripts/batch.py +28 -11
- package/scripts/caption.py +22 -6
- package/scripts/cut.py +1311 -53
- package/scripts/silence.py +21 -6
package/README.md
CHANGED
|
@@ -82,7 +82,7 @@ An agent that "knows FFmpeg" still guesses: it assumes a frame rate, picks a cod
|
|
|
82
82
|
- **Structured tools, not shell strings.** Each operation is a script with typed arguments. Nothing runs through a shell; no filter graph is accepted from the caller.
|
|
83
83
|
- **A contract the agent can read.** `contract --json` states, for every tool, what it takes, what it writes, which FFmpeg components it needs and how the result is verified. The MCP surface is derived from it.
|
|
84
84
|
- **Verification after execution.** The result is probed, checked against the destination's spec and, when the picture changed, looked at as a contact sheet.
|
|
85
|
-
- **Local first.** No cloud, no API keys, no Python dependencies. Optional local transcription is used when a whisper is installed, never required — by `caption.py --transcribe` and, since 1.17, by `silence.py --filler --transcribe`; both take a transcript you already have instead, and both refuse with the install lines rather than guessing.
|
|
85
|
+
- **Local first.** No cloud, no API keys, no Python dependencies. Optional local transcription is used when a speech engine (Parakeet or a whisper) is installed, never required — by `caption.py --transcribe` and, since 1.17, by `silence.py --filler --transcribe`; both take a transcript you already have instead, and both refuse with the install lines rather than guessing. The engine runs on your machine and the audio is never uploaded; parakeet-mlx, faster-whisper and openai-whisper download their model weights the first time they run (parakeet-mlx's default from Hugging Face).
|
|
86
86
|
|
|
87
87
|
## Quick start
|
|
88
88
|
|
|
@@ -202,7 +202,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
202
202
|
|
|
203
203
|
| Tool | What it does |
|
|
204
204
|
|---|---|
|
|
205
|
-
| `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact video and sample-exact audio; reports `precision`; `--snap beats` moves the in/out points onto a measured beat, or refuses when there is no measurable pulse |
|
|
205
|
+
| `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact video and sample-exact audio, `--keep-hevc` to re-encode an HEVC source as HEVC, `--edit-list` to keep an `.mp4` copy's start exact, `--vfr-guard` to choose the VFR check; reports `precision`; `--snap beats` moves the in/out points onto a measured beat, or refuses when there is no measurable pulse |
|
|
206
206
|
| `join.py` | Concatenate clips with xfade transitions, normalising size, fps, sample rate and channel layout (the widest clip's, or `--channels`); audio-only inputs are joined as audio; `--list parts.txt` reads generated segments (TTS lines) and checks every one first — missing, empty or unreadable parts are all named in one refusal, or skipped and reported with `--on-missing skip` |
|
|
207
207
|
| `silence.py` | Detect and remove dead air (jump cuts) with a margin around speech; list or export the cut list; `--filler` also removes filler words, but only where a speech engine timed them; `--speech-aware` keeps a breath inside a sentence and cuts only at sentence-boundary pauses, composing with `--filler` through the same `keep_ranges()` |
|
|
208
208
|
| `fit.py` | Fit to a duration (pitch-preserving speed change or trim, smooth slow-mo) and/or aspect ratio (pad, crop or `--fit blur`'s blurred, darkened fill, with `--crop-x`/`--crop-y` to keep an off-centre subject) and/or exact `--width`/`--height`; rotate 90/180/270, flip h/v; force constant fps |
|
|
@@ -362,8 +362,8 @@ npx ffmpeg-skill doctor --json # available / missing / missing_optional / unkn
|
|
|
362
362
|
|
|
363
363
|
The short list for humans. The agent-facing version, with the reasoning, is the "Things that look right but are wrong" and "Gotchas" sections of [SKILL.md](SKILL.md).
|
|
364
364
|
|
|
365
|
-
- **Variable frame rate (phone and screen recordings).** `probe.py` flags it; every re-encoding tool conforms to a constant rate automatically
|
|
366
|
-
- **Lossless cuts snap to keyframes.** A stream-copy cut can start up to one GOP earlier than asked. `cut.py` re-encodes when the snap exceeds 0.5 s (`--tolerance` changes the limit). For a strictly lossless file pass `--tolerance -1
|
|
365
|
+
- **Variable frame rate (phone and screen recordings).** `probe.py` flags it; every re-encoding tool conforms to a constant rate automatically. `cut.py` switches to frame-accurate mode on it, because copy-cuts on VFR land on the wrong frame, and samples the frame timing to say whether that was needed (`--vfr-guard sampled` decides on the sample instead; `--vfr-guard off` keeps the copy). Choose the rate yourself with `fit.py input.mp4 --fps 30` when the measured average is odd.
|
|
366
|
+
- **Lossless cuts snap to keyframes.** A stream-copy cut can start up to one GOP earlier than asked. `cut.py` re-encodes when the snap exceeds 0.5 s (`--tolerance` changes the limit). For a strictly lossless file pass `--tolerance -1` (plus `--vfr-guard off` on a variable-frame-rate source), and expect the cut to land on the nearest earlier keyframe; the JSON result lists them under `nearest_keyframes`. A `--segments` join can still be re-cut: open-GOP video, a later segment of B-frame `.mp4`/`.mov` video that starts between keyframes (start it on a keyframe, or write `.mkv`), or a join that fails its check. For a single `.mp4`/`.mov` cut, `--edit-list` keeps the copy and starts the picture where asked: the keyframe's pre-roll is stored but hidden by an MP4 edit list, which a player that ignores edit lists shows.
|
|
367
367
|
- **HDR stays HDR.** When the probe reports HDR (HDR10, HLG, Dolby Vision, BT.2020), the tools keep it rather than flatten it. Convert deliberately with `color.py --to-sdr` before H.264 deliverables or LUT work. `export.py` platform presets are SDR and warn on HDR input.
|
|
368
368
|
- **Loudness targets.** −14 LUFS / −1 dBTP for YouTube and social platforms (the `loudness.py` default), `-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast. A clip measured at −40 LUFS or below is room tone, not content; raising it raises the noise. Check true peak as well as LUFS: `check.py file --platform podcast` measures both.
|
|
369
369
|
- **Frame changes first, text second.** Captions and overlays burned before a crop or resize end up off-frame. Reframe, then caption.
|
|
@@ -371,7 +371,7 @@ The short list for humans. The agent-facing version, with the reasoning, is the
|
|
|
371
371
|
- **Phrase-aware caption breaking (1.16).** `caption.py`/`graphics.py --wrap phrase` (the default) never breaks inside a word or on the wrong side of a hyphen, never leaves a lone digit, kana or punctuation pair on a line, prefers Japanese sentence ends and particles over a mid-word break, and never ends a line on an article or preposition. All four are penalties over break positions that already fit, so no line is widened and the line count never changes; `--wrap measured` restores 1.15's width-only wrap. The text itself is never rewritten or shortened. Since 1.16.1 a Thai run and a katakana word are never broken inside (Thai writes no space inside a phrase and there is no dictionary: the break goes where you put a space or `|`), and a line wider than the safe width is reported as `overlong` with the fix named.
|
|
372
372
|
- **Caption size fitted to the cue (1.17, reachable from the templates since 1.17.1).** At a platform caption size a line holds about six em, so an ordinary sentence needs four lines and `--max-lines 2` used to cut it into consecutive cues — half the sentence arriving late. `caption.py --fit-size` (default `auto`) walks the size down until every cue fits, *before* laying the cues out, with a legibility floor of 4.5 % of the frame height (`--min-size`, default 13 ASS units). An explicit `--size` or a `brand.json` size is a statement about the look and is never overridden — which in 1.17.0 also silenced the fitter on every `render.py --template` run, since a template fills the size from the platform table; 1.17.1 marks that size as the default it is (`"fit_size": "on"` in the filled project), so the type shrinks and no cue is split. `--fit-size off` restores that earlier behaviour byte for byte, and the caption text is still never rewritten to make it fit.
|
|
373
373
|
- **Beat-synced cuts (1.17).** `scenes.py --beats` reports the measured grid — tempo, beat times, and a confidence built from how far the winning autocorrelation lag stands above the others and how many onsets land on it. `cut.py --snap beats` (and a `"snap"` block in a `render.py` project) moves in/out points to the nearest beat within `--snap-tolerance` — and only onto the grid points a measured onset actually marks, never onto the regular grid's continuation through a passage with no music in it. Below `--min-confidence` it **refuses**: a cut point may move to a measured beat and may not appear from one, so speech and ambience get an honest "no steady pulse here" instead of an invented grid.
|
|
374
|
-
- **Filler words (1.17).** `silence.py --filler --words transcript.json` removes "um" and "uh" through the same cut graph the silences use. Never without measured word timings — there is no heuristic that finds an "um" without them that would not also cut real speech — and `like`, `tipo` and `cioè` are deliberately not in the built-in lists, because a discourse marker is a content word. Whisper stays optional: `--transcribe` with no engine installed refuses and names the
|
|
374
|
+
- **Filler words (1.17).** `silence.py --filler --words transcript.json` removes "um" and "uh" through the same cut graph the silences use. Never without measured word timings — there is no heuristic that finds an "um" without them that would not also cut real speech — and `like`, `tipo` and `cioè` are deliberately not in the built-in lists, because a discourse marker is a content word. Whisper stays optional: `--transcribe` with no engine installed refuses and names the five installs.
|
|
375
375
|
- **Throughput (1.17).** `batch.py --jobs N` runs several files at once, capped at `min(N, cpu_count, 8)` and sharing one `--timeout` budget rather than one per item; the per-item table keeps its order. `render.py --cache DIR` reuses stages whose inputs and arguments did not change, so swapping an export preset re-runs export only. The cache is opt-in with no default directory, and the ffmpeg, skill and contract versions are part of every key, so a cache is never reused across them.
|
|
376
376
|
- **Audiogram (1.16).** `waveform.py --image cover.png` (or `render.py --template audiogram`) puts the waveform over a still plate for an episode that has no picture, with `--platform` for the frame, `--title` and burnt-in captions. The image is a local file you give: nothing is fetched and no cover art is ever invented.
|
|
377
377
|
- **Emoji in captions and titles (1.15).** `caption.py`/`graphics.py --emoji-assets DIR` composites a PNG per emoji (Twemoji/Noto naming, `1f389.png`) on top of the text, because drawtext cannot load a colour emoji font at all and an installed one does not prove libass will draw it in colour — `doctor --json`'s `fonts.emoji` answers that from a render probe. Without assets the run still succeeds and reports `mode: mono`. Nothing is ever downloaded.
|
package/SKILL.md
CHANGED
|
@@ -7,7 +7,7 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
|
|
|
7
7
|
|
|
8
8
|
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as this one; open one only for a question you have: `references/scripts.md` (every flag of all 42 scripts), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end). MCP: `tools/list` shows only the core 12 by default; the other 30 (per this table) stay callable by name via `tools/call`, described by `contract --json`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
|
|
9
9
|
|
|
10
|
-
Shared flags (every script): `--dry-run`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`; prefer on writing steps); `--fast` (preview quality); `--progress` (percent/ETA); `--timeout SECONDS` (`kind: timeout`, default 1800); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses if an input changed). Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264, HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
|
|
10
|
+
Shared flags (every script): `--dry-run`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`; prefer on writing steps); `--fast` (preview quality); `--progress` (percent/ETA); `--timeout SECONDS` (`kind: timeout`, default 1800); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses if an input changed). Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264 (`cut.py --keep-hevc`: HEVC source stays HEVC), HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
|
|
11
11
|
|
|
12
12
|
Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) may still run ffmpeg/ffprobe, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`); `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
|
|
13
13
|
|
|
@@ -32,7 +32,7 @@ Ask a short question only when the answer changes the output materially and the
|
|
|
32
32
|
|
|
33
33
|
- **Destination** decides aspect, length limit, loudness and codec; "for Reels" answers all four and names a template. No destination and a plain cut/caption: keep the source format and say so. If the user says "export", "post" or "deliver", ask where.
|
|
34
34
|
- **Duration** ("make it 60 s") without a method: speed up for ≤1.5× changes, trim otherwise, say which. Ask when the content is a talk (trimming loses words) and the change is large.
|
|
35
|
-
- **Captions** without a text source: `--transcribe` if a local
|
|
35
|
+
- **Captions** without a text source: `--transcribe` if a local engine exists, else ask for the text or a timed file; never invent dialogue.
|
|
36
36
|
- **Fonts and brand**: if the user mentions a brand, colours or "our font", ask for or create `brand.json` once, reuse it.
|
|
37
37
|
- **CJK / non-Latin text**: let the tool pick the font by script (`--font` turns it off); `--lang ja|ko` for Han-only text. `doctor --json` `.fonts.scripts` says what renders here. Tofu is a failed job.
|
|
38
38
|
- **Crop position** for `--fit crop`: centre by default, but when the request or the source names an off-centre subject ("keep the product on the right", someone visibly off-centre in the sheet) use `--crop-x`/`--crop-y` (0=left/top, 1=right/bottom) instead of guessing centre, or ask which edge to keep.
|
|
@@ -52,7 +52,7 @@ This skill cuts, joins, measures, syncs, exports and checks files — it execute
|
|
|
52
52
|
|
|
53
53
|
The line: same input + same explicit parameters always producing the same verifiable output belongs here; anything depending on taste, understanding or what looks or sounds good belongs to whoever makes that judgement.
|
|
54
54
|
|
|
55
|
-
If a request needs an FFmpeg feature none of the 42 scripts expose, say so and name the closest built-in option — never guess a raw `ffmpeg`/`ffprobe` invocation outside `scripts/*.py`. It bypasses every guarantee this skill makes, so never a fallback
|
|
55
|
+
If a request needs an FFmpeg feature none of the 42 scripts expose, say so and name the closest built-in option — never guess a raw `ffmpeg`/`ffprobe` invocation outside `scripts/*.py`. It bypasses every guarantee this skill makes, so it is never a fallback.
|
|
56
56
|
|
|
57
57
|
## Request → script
|
|
58
58
|
|
|
@@ -65,7 +65,7 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
|
|
|
65
65
|
| "cut from 1:20 to 2:05", "trim the first 10 s" | `cut.py input.mp4 --start 1:20 --end 2:05` |
|
|
66
66
|
| "keep only these parts", "remove the middle" | `cut.py input.mp4 --segments 0-1:00,1:30-2:00` |
|
|
67
67
|
| "make it exactly 60 seconds" | `fit.py input.mp4 --duration 60` (speed) or `--method trim` |
|
|
68
|
-
| "cut this and make it HEVC / AV1 / ProRes" (output codec named) | `cut.py input.mp4 --start 0:10 --end 0:40 --codec hevc` (
|
|
68
|
+
| "cut this and make it HEVC / AV1 / ProRes" (output codec named) | `cut.py input.mp4 --start 0:10 --end 0:40 --codec hevc` (ProRes needs `-o NAME.mov`) |
|
|
69
69
|
| "cut out the pauses", "jump cuts" | `silence.py input.mp4 [--threshold -40 --min-silence 0.8]` |
|
|
70
70
|
| "cut the ums and uhs", "remove the filler words" | `silence.py input.mp4 --filler --words words.json` (measured word timings; `--transcribe` makes them) |
|
|
71
71
|
| "don't cut inside a sentence, just the real pauses" | `silence.py input.mp4 --speech-aware` — a breath under `--min-silence` inside a sentence is kept, only sentence-boundary pauses cut; composes with `--filler` into one list |
|
|
@@ -97,7 +97,7 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
|
|
|
97
97
|
| "caption it with these lines" (text with times) | `caption.py input.mp4 --text cues.txt` |
|
|
98
98
|
| "keep the subtitles toggleable", "mux in an SRT" | `caption.py input.mp4 --srt subs.srt --mode mux`; repeat `--srt file:lang` for several languages, `.mkv` for more than two |
|
|
99
99
|
| "the captions are tiny / three lines on a Short", "don't chop the sentence" | `caption.py` shrinks the size until the cue fits `--max-lines` before splitting it (`--fit-size off` keeps `--size` and splits instead, `--min-size` the floor). Keep the template's `--max-lines` (2 on a vertical) and let the size drop; raising it to dodge a shrink stacks two words per line. A word still wider than the column at the floor is sliced at the edge (hyphen preferred), never rewritten, automatic; `broken_inside_word` in the JSON counts it |
|
|
100
|
-
| "transcribe it and caption it" | `caption.py input.mp4 --transcribe --animate pop --karaoke` (
|
|
100
|
+
| "transcribe it and caption it" | `caption.py input.mp4 --transcribe --animate pop --karaoke` (Parakeet/whisper; else `--text`) |
|
|
101
101
|
| "TikTok-style captions with the words popping" | `caption.py input.mp4 --text cues.txt --animate pop --karaoke` |
|
|
102
102
|
| "our logo top-right", "a watermark" | `overlay.py input.mp4 --image logo.png --position top-right --scale 200` |
|
|
103
103
|
| "a title for the first 4 seconds" | `overlay.py input.mp4 --text "Title" --position top --start 0 --end 4 --fade 0.4` |
|
|
@@ -206,8 +206,8 @@ One line each; open the linked `references/gotchas.md` section when the job is i
|
|
|
206
206
|
|
|
207
207
|
- HDR (iPhone, HDR10) re-encoded through an SDR path goes flat; the scripts keep HDR; `hdr: true` is a real PQ/HLG/DV signal, `bt2020_or_hdr: true` also BT.2020 SDR. -> [#hdr-and-colour](references/gotchas.md#hdr-and-colour)
|
|
208
208
|
- Log footage (S-Log/V-Log/C-Log) is tagged SDR and looks grey: `probe.py --analyze`, then `color.py --lut` first. -> [#log-footage](references/gotchas.md#log-footage)
|
|
209
|
-
- A `-c copy` cut can start on a wrong
|
|
210
|
-
- VFR phone/screen
|
|
209
|
+
- A `-c copy` cut can start on a wrong/frozen frame; `cut.py` re-encodes past a 0.5 s snap, respect it; `--edit-list` hides an .mp4's pre-roll. -> [#keyframe-cuts](references/gotchas.md#keyframe-cuts)
|
|
210
|
+
- VFR phone/screen clips: re-encodes go CFR; `cut.py` re-encodes (`--vfr-guard sampled`: only measured VFR); odd rate: `fit.py --fps`. -> [#variable-frame-rate](references/gotchas.md#variable-frame-rate)
|
|
211
211
|
- Sync/multicam `confidence` under 0.3 (or a huge offset) is suspect — check every camera; these align audio, never lip sync. -> [#sync-multicam-and-drift](references/gotchas.md#sync-multicam-and-drift)
|
|
212
212
|
- "Normalised" audio can still clip (check true peak); ambience at -40 LUFS or below must never be raised to a speech target. -> [#loudness-and-ambience](references/gotchas.md#loudness-and-ambience)
|
|
213
213
|
- Captions burned before a crop/resize land off-frame; burned small then upscaled by `export.py` come out soft. -> [#captions-fonts-and-text-order](references/gotchas.md#captions-fonts-and-text-order)
|
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`2.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.8.0`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -119,11 +119,15 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
119
119
|
"contract_version": "1.0",
|
|
120
120
|
"deprecated": [],
|
|
121
121
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
122
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.
|
|
122
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.8.0", "execution_mode": "local", "kind": "execution",
|
|
123
123
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
124
124
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
125
125
|
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
126
|
-
"execution": {"shell": false, "arbitrary_executables": false, "network": false,
|
|
126
|
+
"execution": {"shell": false, "arbitrary_executables": false, "network": false,
|
|
127
|
+
"model_downloads": {"when": "...", "in_process": {"faster-whisper": "..."},
|
|
128
|
+
"child_process": {"parakeet-mlx": "...", "openai-whisper": "..."},
|
|
129
|
+
"none": ["whisper.cpp", "parakeet.cpp"], "offline": "..."},
|
|
130
|
+
"input_mutation": false}
|
|
127
131
|
}
|
|
128
132
|
```
|
|
129
133
|
|
|
@@ -212,7 +216,9 @@ BT.709 for SDR; av1 is SVT-AV1 with libaom as the fallback; prores is 422 HQ and
|
|
|
212
216
|
`.mov`/`.mkv` output; h264 refuses an HDR source (`kind: input`). `--quality` is the CRF scale;
|
|
213
217
|
its default is the tool's own (18, `proxy.py` 30). 1.x also accepted `--crf` as an alias; 2.0
|
|
214
218
|
removed it (`export.py` keeps its own `--crf`). Without `--codec` the encoder is what it always was (x264 for SDR, x265
|
|
215
|
-
Main10 for HDR), so the flags add no behaviour to a caller that does not pass them.
|
|
219
|
+
Main10 for HDR), so the flags add no behaviour to a caller that does not pass them.
|
|
220
|
+
`cut.py --keep-hevc` (opt-in) re-encodes an SDR HEVC source as x265 8-bit BT.709 instead of x264; an HDR or
|
|
221
|
+
BT.2020 source keeps the Main10 line with its own colour tags, with or without the flag. The
|
|
216
222
|
encoder each value needs is listed under the tool's optional capabilities (`--codec hevc` and
|
|
217
223
|
so on). `export.py` refuses `--codec`: its presets decide the codec.
|
|
218
224
|
|
|
@@ -349,10 +355,10 @@ A project may now carry `"template"` (the name it was filled from) and `"frame":
|
|
|
349
355
|
## Capabilities
|
|
350
356
|
|
|
351
357
|
Names: `ffmpeg`, `ffprobe`, `encoder:<name>`, `filter:<name>`, `bsf:<name>`,
|
|
352
|
-
`external:whisper`. `capabilities.required` is the union of every tool's required list;
|
|
358
|
+
`external:whisper`, `external:parakeet`. `capabilities.required` is the union of every tool's required list;
|
|
353
359
|
`optional` the union of the conditional ones. With detection (the default)
|
|
354
360
|
`available`, `missing` and `missing_optional` are added from `doctor`, which reads
|
|
355
|
-
`ffmpeg -encoders / -filters / -bsfs` and looks for a local whisper. Pass `--static`
|
|
361
|
+
`ffmpeg -encoders / -filters / -bsfs` and looks for a local whisper or Parakeet. Pass `--static`
|
|
356
362
|
to omit detection. Nothing from the environment other than those lists and the
|
|
357
363
|
ffmpeg/ffprobe/python versions is printed; no environment variables, no paths (`doctor`'s
|
|
358
364
|
`hw.default_on` is a boolean derived from `FFMPEG_SKILL_HW`, never its value).
|
|
@@ -360,8 +366,29 @@ ffmpeg/ffprobe/python versions is printed; no environment variables, no paths (`
|
|
|
360
366
|
`external:whisper` is **optional** for two tools since 1.17: `caption.py`
|
|
361
367
|
(`--transcribe`) and `silence.py` (`--filler --transcribe`). Neither requires
|
|
362
368
|
it — both take a transcript the caller already has (`--srt`/`--words`), and
|
|
363
|
-
both refuse with the same
|
|
369
|
+
both refuse with the same install lines when asked to make one with no
|
|
364
370
|
engine present. Whisper is never a dependency of this skill.
|
|
371
|
+
`external:parakeet` is the same kind of optional capability for the same two flags:
|
|
372
|
+
parakeet-mlx, or parakeet-cli with a `.gguf` model it can find. Either engine family
|
|
373
|
+
satisfies `--transcribe`; `--engine auto` (the default, or `FFMPEG_SKILL_ASR_ENGINE`) runs
|
|
374
|
+
Parakeet for English speech and Whisper for every other language. When no `--language` is given
|
|
375
|
+
and no detector can name it, a Whisper engine that can run takes it (whisper.cpp is passed
|
|
376
|
+
`-l auto`: its own default is English). Parakeet then runs only on English *assumed* -- when no
|
|
377
|
+
Whisper engine is installed, or every one failed, or `--model` names an English-only Parakeet
|
|
378
|
+
model -- and the result's top-level `notes` says the transcript is wrong if the speech is not
|
|
379
|
+
English; a multilingual (v3) Parakeet model assumes nothing and gets no note. The result's
|
|
380
|
+
`transcription` says which engine, model and routing decision produced the cues (in every
|
|
381
|
+
`--mode`, `mux` included).
|
|
382
|
+
Every engine transcribes on the machine running the skill, and the audio is never uploaded. The
|
|
383
|
+
skill's own code opens no network connection (`execution.network: false`), but an engine may
|
|
384
|
+
fetch its model weights on first use: parakeet-mlx downloads its default
|
|
385
|
+
`mlx-community/parakeet-tdt-0.6b-v2` from Hugging Face and openai-whisper its model from OpenAI,
|
|
386
|
+
each in its own child process. faster-whisper is a Python library the skill runs inside its own
|
|
387
|
+
process, so its first-run download from Hugging Face is made by the skill's process;
|
|
388
|
+
`HF_HUB_OFFLINE=1` keeps it (and parakeet-mlx) on the cached copy after that. parakeet.cpp and
|
|
389
|
+
whisper.cpp read a model file the user downloaded. `execution.model_downloads` says the same in the
|
|
390
|
+
contract, engine by engine, so a caller that sandboxes the network can tell before a first
|
|
391
|
+
`--transcribe` that faster-whisper's fetch comes from the skill's own process.
|
|
365
392
|
|
|
366
393
|
`doctor` has three states per capability. `available` and `missing` come from a listing
|
|
367
394
|
that was read; `unknown` means the listing that would prove the capability could not be
|
|
@@ -546,11 +573,25 @@ Per-tool keys added after 2.2.2, all additive:
|
|
|
546
573
|
| `short_segments`, `duplicates` | `join.py` (every run) | warnings, never refusals, and the join command is unchanged: `short_segments: [{index, path, duration}]` names each measured input shorter than 2 frames at the join's fps (an audio-only join: shorter than 0.05 s); `duplicates: [{path, indices}]` names a path (compared resolved) listed more than once — repeating a clip can be intended. `[]` when none; each non-empty one adds a `notes` line |
|
|
547
574
|
| `silent` | `waveform.py` (every run) | `true` when the input audio's whole-file peak is at or below `--silence-threshold` (default -50 dBFS), which draws a flat line; `--on-silent warn` (default) renders it anyway with a `notes` line, `fail` refuses (`kind: input`) before ffmpeg runs. `false` when audible, `null` under `--dry-run` (nothing measured). `verified` is unaffected |
|
|
548
575
|
| `encoder`, `hw`, `notes` | every re-encoding tool, `export.py`, `render.py`, `batch.py` (and its rows), `waveform.py` | `encoder`: the video encoder the last command ran (after any GPU→CPU fallback; under `--dry-run`, the planned one), `copy` for a stream copy; absent when no command encoded video. A tool that runs other tools reports its stages': `render.py` its stages and an executed plan's tool, `waveform.py` the title/caption stage that writes its file (its `commands` include that stage's). `hw` is present when VideoToolbox was asked for (`--hw`, `FFMPEG_SKILL_HW=1`, or a stage or `batch.py` item that reported it) and on `export.py` when `FFMPEG_SKILL_HW=1` is set but its delivery preset does not take it: `{requested, source: flag\|env, used, fallback, notes}`. `requested` says whether this run's encodes were asked onto VideoToolbox (`false` for that export), `used` whether the encoder that ran last is VideoToolbox (`null` under `--dry-run`, which ran nothing, and when the tool encoded nothing itself: `batch.py`, whose top-level `hw` gathers its items' `requested`/`source`/`fallback`/`notes` while each `results` row carries the item's own `encoder`, and `hw`/`notes` when VideoToolbox was asked for), `fallback` whether a job VideoToolbox refused was re-encoded on the CPU, and `notes` why. Only a VideoToolbox failure (the encoder could not open, or its compression session failed) is retried on the CPU; any other failure is reported as it happened, once. Every VideoToolbox encode adds a top-level `notes` line: its quality is not CRF-equivalent (`--quality` / a preset's CRF maps to `-q:v` approximately), and `verified` stays what it is for every tool, the measured properties of the output. A `kind: ffmpeg` failure under `--hw` / `FFMPEG_SKILL_HW` carries `encoder` and `hw` for the command that ran last (a stage's, when `render.py`/`waveform.py` re-raise it); when the command itself failed inside the run (not a tool's own check afterwards, such as `cut.py`'s), its message also names that encoder |
|
|
576
|
+
| `transcription` | `caption.py --transcribe` (every `--mode`); inside `filler` for `silence.py --filler --transcribe` | `{routing, detected_language?, engine, model, language}`: the engine and model that made the transcript; `language` is the language the engine was told -- the one named (`--language`, `--filler-lang`), else the one auto's detector named -- and `null` when the engine identified it itself; `detected_language` is present when auto's whisper.cpp detector named one; `routing` says why `--engine auto` chose the engine (`"requested"` for a named engine), ending `, no Parakeet engine transcribed it` when auto chose Parakeet and a Whisper engine made the transcript after every Parakeet engine failed |
|
|
577
|
+
| `notes` | `silence.py --filler --transcribe` (new for `silence.py`; `caption.py` already had it) | present when `--engine auto` ran the English-only Parakeet model on English *assumed* (no language given or detectable, and no Whisper engine installed or none that succeeded): the transcript is wrong if the speech is not English. `caption.py --transcribe` puts the same line in its `notes` |
|
|
578
|
+
| `reencode_reason`, `segment_precision`, `least_exact_precision`, `start_snapped` | `cut.py` (every run) | `reencode_reason`: why anything was re-encoded, distinct values in first-seen order, `[]` for a clean copy: `requested` (`--accurate`), `codec` (`--codec`), `vfr`, `vfr_inconclusive`, `pcm_container`, `copy_failed`, `tolerance`, `concat_fallback` (a `--segments` join that could not stay a stream copy, which now also sets `reencoded: true` and `mode: hybrid`; it reported `copy` before). `segment_precision`: each `--segments` segment's precision (`null` for one segment); `least_exact_precision`: the least exact of them, on every run. `precision` and `keyframe_snapped` keep their meaning (`keyframe_snapped` is `precision == "packet"`, a stream copy end to end); `least_exact_precision` differs from `precision` only for an audio-only join of copied and re-encoded parts. `start_snapped`: measured, `true` when a copied segment's picture starts more than one frame from its requested start (or its start could not be measured: an unknown start is never claimed exact), `false` when every start is where asked or everything re-encoded, `null` under `--dry-run`. `requested_segments`/`requested_duration` keep their meaning, the request clamped to the media's duration: a segment ended with the video (less than a frame of sound trimmed) shows in `duration_delta_seconds` and a `notes` line |
|
|
579
|
+
| `edit_list`, `stored_preroll_seconds`, `av_start_skew_seconds`, `notes` | `cut.py` (every run) | `edit_list`: `true` only for a single-segment `.mp4`/`.m4v`/`.mov` stream copy run with `--edit-list`, which keeps the MP4 edit list the copy writes instead of `-avoid_negative_ts make_zero` (the default, unchanged): the keyframe's pre-roll is stored but hidden, so the picture starts at `--start`. `stored_preroll_seconds`: with `edit_list`, the seconds decoded from the keyframe the copy's seek lands on (measured with the seek ffmpeg makes) up to the first presented frame; `null` otherwise. Under `--edit-list` a copy is judged by its video's length (a `notes` line says so when the container is longer), and one that starts where asked but ends past `--tolerance` re-encodes (`tolerance`) without a `lossless_alternative`. `av_start_skew_seconds`: the output's audio start minus its video start (`null` when either is missing or under `--dry-run`); on a stream copy, when it is more than max(2 frames, 0.1 s) away from the source's own audio/video offset where the copy's picture starts (the keyframe it began from; `--start` under `--edit-list`), a `notes` line names `--accurate`, and `--edit-list` for a single `.mp4`/`.mov` copy whose start snapped. A re-encode carries no such note. The skew is reported, never repaired. `notes`: what the run did that a caller should know, in plain sentences (`[]` when nothing) |
|
|
580
|
+
| `video.start_time`, `audio.start_time` | `probe.py` (and every `probe` block a tool returns) | where each stream starts, in seconds on the file's own clock (ffprobe's stream `start_time`; `null` when unknown). A video stream that starts after its audio, or the reverse, is how a lossless cut's A/V skew shows; `cut.py` reads them for `av_start_skew_seconds` |
|
|
581
|
+
| `cut_reencode_reasons` | `batch.py` (and its `results` rows) | a row whose recipe ran `cut.py` lists each `reencode_reason` its `cut.py` calls named, once per call, beside `cut_reencoded`; the top level counts them across the folder (`{}` when no call re-encoded, `null` when no `cut.py` step ran) on a completed batch, as `cut_stream_copy` does: a failure document carries `results`, whose rows hold each row's reasons; a re-encoded call in a row served from a cache an earlier 2.x batch wrote, which recorded no reasons, counts as `unknown`. `cut_stream_copy` is unchanged |
|
|
582
|
+
| `vfr_check` | `cut.py` (a video input that could still stream-copy) | the sampled frame timing, `{heuristic: true, measured, method: "sampled", guard, windows, deltas}`; `measured` is `sampled_cfr`, `vfr` or `inconclusive`. `--vfr-guard average` (the default) keeps the 2.x guard: a source whose nominal and average frame rates differ (`variable_frame_rate_suspected`) re-encodes as `--accurate` (reason `vfr`, stderr says "variable-frame-rate"), and only then is the timing sampled and reported, with a `notes` line naming `--vfr-guard sampled` when it measured `sampled_cfr`; the key is absent otherwise. `--vfr-guard sampled` samples before every copy and re-encodes on `vfr` (reason `vfr`) or `inconclusive` (fewer than 20 intervals; `vfr_inconclusive`); `--vfr-guard off` samples and keeps the copy, with a `notes` line. Absent for audio-only input or output, `--accurate`, `--codec` and a pending `--dry-run` input |
|
|
583
|
+
| `join_check`, `segment_end_snap_seconds` | `cut.py --segments` | `join_check`: a video stream-copy join measured by demuxing, `{packets, expected_packets, max_step_seconds, audio_offset_ms, audio_parts_checked, ok}`. `expected_packets` counts the source's video packets from each part's start keyframe to its end (or the end of the video); `max_step_seconds` is the largest presentation step, `null` when `vfr_check` measured anything but `sampled_cfr` (a `--vfr-guard off` join of variable timing: count only), and each step at a join must be the step its parts predict within 1 ms. `audio_offset_ms`: per part, its sound's shift minus its picture's in ms (+ = sound late), from audio packets matched by CRC32 to the source stream shaped like the joined audio (FFmpeg's default selection may copy one other than `a:0`; an MPEG-TS source's AAC is hashed without its ADTS headers); `null` for a part with no unique match (digital silence, a periodic tone), and as a whole when the source has no audio or the video check failed. `audio_parts_checked` counts the measured parts. `ok` is the conjunction (count, steps, every measured part within 5 ms); `ok: false` means the join was re-cut (`concat_fallback`) and this is the failed measurement. `null` for a single segment, audio, `--dry-run`, any re-encode forced for every segment, a segment that re-encoded, or a join re-cut before a copy was tried (open GOPs, a later B-frame segment starting between keyframes, parts that differ, a concat error). `segment_end_snap_seconds`: per segment, the signed seconds its end moved to the keyframe the copied part stops at, the first at or after the requested end (`null` for a part that reaches the end of the video, a part of a source with no B-frames, cut to its requested end as in 2.5.1, or a Matroska/MPEG-TS part, which is not snapped); `null` as a whole unless the output is those copied parts |
|
|
549
584
|
|
|
550
585
|
`caption.py --transcribe` and `silence.py --filler --transcribe`: when a local speech engine ran
|
|
551
586
|
and produced no cue, the refusal is `"<engine> found no speech in <input>"` with `kind: input`,
|
|
552
587
|
`reason: "no_speech"` and `engine` in the failure document — distinct from the "no local
|
|
553
|
-
speech-to-text engine found" refusal, which now only means no engine was found.
|
|
588
|
+
speech-to-text engine found" refusal, which now only means no engine was found. When `--engine
|
|
589
|
+
auto` found engines and none transcribed, the refusal is `kind: input` with `reason:
|
|
590
|
+
"engine_failed"` (one ran and failed) or `"english_only"` (the only engines found were Parakeet
|
|
591
|
+
engines auto passed over for a language that is not English), `engine` (the first one it names)
|
|
592
|
+
and `engines: [{engine, reason, detail}]`, where `detail` carries an engine's own error line. A
|
|
593
|
+
named `--engine` that is not installed is `kind: missing_tool`; one that is installed and failed
|
|
594
|
+
is `kind: input`, `reason: "engine_failed"`, with `engine` and `detail`.
|
|
554
595
|
|
|
555
596
|
`check.py` also gains an informational `subtitles` row on **every** platform:
|
|
556
597
|
`PASS` when every soft subtitle stream carries a language tag, `WARN` when one
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.0",
|
|
4
4
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
package/references/gotchas.md
CHANGED
|
@@ -37,17 +37,39 @@ S-Log, V-Log and C-Log look grey and low-contrast but are tagged SDR. Run
|
|
|
37
37
|
|
|
38
38
|
### Keyframe cuts
|
|
39
39
|
A lossless `cut.py` result may start up to one GOP (often 1–10 s) earlier than
|
|
40
|
-
requested; the script re-encodes automatically when the
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
requested (`start_snapped: true`); the script re-encodes automatically when the
|
|
41
|
+
deviation exceeds 0.5 s. With `--edit-list`, a single-segment `.mp4`/`.m4v`/`.mov` copy
|
|
42
|
+
keeps the MP4 edit list instead: the pre-roll back to the keyframe is stored but
|
|
43
|
+
hidden, so the picture starts at the requested time (`edit_list`,
|
|
44
|
+
`stored_preroll_seconds`). A player or tool that ignores edit lists shows that
|
|
45
|
+
pre-roll. The **end** still lands on a packet boundary, a few frames long, and
|
|
46
|
+
those extra frames can skip. `.mkv` output has no edit list, and a `--segments`
|
|
47
|
+
join shows each part from its keyframe. On B-frame video an `.mp4`/`.mov`
|
|
48
|
+
`--segments` part's end moves to the first keyframe at or after it, within the
|
|
49
|
+
same tolerance (`segment_end_snap_seconds`); no-B-frame and `.mkv`/`.ts` parts
|
|
50
|
+
are not snapped. Every copy join is checked (frames, steps, each part's sound
|
|
51
|
+
against its picture) and re-cut when wrong. Open-GOP HEVC (iPhone "High Efficiency")
|
|
52
|
+
always re-encodes a `--segments` join, even with `--tolerance -1`, and so does a B-frame `.mp4`/`.mov`
|
|
53
|
+
join whose later segment starts between keyframes (start it on a keyframe, or write `.mkv`). If the user insists on lossless output, pass
|
|
54
|
+
`--tolerance -1` and tell them the cut lands on the nearest earlier keyframe.
|
|
55
|
+
Where a copy begins is measured with the seek ffmpeg makes: on B-frame `.mp4`/`.mov`
|
|
56
|
+
a start a few frames before a keyframe can begin at that keyframe or one GOP
|
|
57
|
+
earlier (`start_snapped: true` either way).
|
|
58
|
+
`av_start_skew_seconds` warns when a copy's sound and picture start apart
|
|
59
|
+
(Core Media HEVC once gave 3.7 s of sound with no picture); it is reported, not
|
|
60
|
+
repaired: `--accurate` is the fix, or `--edit-list` for a lossless `.mp4`/`.m4v`/`.mov`. A `-c copy` cut on VFR or a
|
|
43
61
|
non-keyframe boundary produces a file that "works" but starts on a frozen or
|
|
44
62
|
wrong frame — respect the automatic re-encode rather than forcing the copy.
|
|
45
63
|
|
|
46
64
|
### Variable frame rate
|
|
47
65
|
`probe.py` sets `variable_frame_rate_suspected` when `r_frame_rate` and
|
|
48
66
|
`avg_frame_rate` disagree (phone and screen recordings). Every re-encoding
|
|
49
|
-
script then adds `-fps_mode cfr` at the source's average rate
|
|
50
|
-
|
|
67
|
+
script then adds `-fps_mode cfr` at the source's average rate. That flag is a
|
|
68
|
+
whole-file average, and a phone clip at 29.98 against a nominal 30 trips it, so
|
|
69
|
+
`cut.py` re-encodes on it by default and reports packet timestamps sampled in up
|
|
70
|
+
to five windows (`vfr_check`), with a note when they are constant;
|
|
71
|
+
`--vfr-guard sampled` switches to `--accurate` only when they are irregular or too
|
|
72
|
+
few to judge, and `--vfr-guard off` keeps the copy anyway. Pick the rate
|
|
51
73
|
explicitly with `fit.py --fps 30|60` when the average is odd (e.g. 23.4 fps from
|
|
52
74
|
dropped frames).
|
|
53
75
|
|
package/references/scripts.md
CHANGED
|
@@ -56,24 +56,91 @@ between tools.
|
|
|
56
56
|
```
|
|
57
57
|
probe.py INPUT... [--compact] [--field duration|video.fps|...]
|
|
58
58
|
```
|
|
59
|
-
JSON with `duration`, `video{codec,width,height,fps,pix_fmt,color_space,rotation,variable_frame_rate_suspected,hdr,hdr_signal,bt2020_or_hdr,hdr_format}`
|
|
59
|
+
JSON with `duration`, `video{codec,width,height,fps,pix_fmt,color_space,rotation,variable_frame_rate_suspected,hdr,hdr_signal,bt2020_or_hdr,hdr_format,start_time}`
|
|
60
60
|
(`hdr`, and `hdr_signal` equal to it, is true only for a PQ / HLG transfer or Dolby Vision -- since 2.0;
|
|
61
61
|
`bt2020_or_hdr` also counts BT.2020 primaries on an SDR transfer, which `hdr_format` names "BT.2020 SDR"),
|
|
62
|
-
`audio{codec,channels,sample_rate}
|
|
62
|
+
`audio{codec,channels,sample_rate,start_time}` (`start_time`: where the stream starts on the file's
|
|
63
|
+
own clock, `null` when unknown). `--compact` gives one line per file.
|
|
63
64
|
|
|
64
65
|
### cut.py — cut / join segments
|
|
65
66
|
```
|
|
66
|
-
cut.py INPUT [--start T] [--end T | --duration T] [--segments A-B,C-D,...] [--accurate] [-o OUT]
|
|
67
|
+
cut.py INPUT [--start T] [--end T | --duration T] [--segments A-B,C-D,...] [--accurate] [--edit-list] [--keep-hevc] [--vfr-guard average|sampled|off] [-o OUT]
|
|
67
68
|
cut.py INPUT --start T --end T --snap beats [--snap-tolerance 0.12] [--snap-source FILE] [--min-confidence 0.5]
|
|
68
69
|
```
|
|
69
70
|
Times accept `12.5`, `1:30`, `00:01:30.250`. Default is `-c copy` (snaps to
|
|
70
71
|
keyframes, instant, lossless); if the snapped result deviates more than
|
|
71
72
|
`--tolerance` (0.5 s) from the request, that segment is re-encoded automatically
|
|
72
|
-
(
|
|
73
|
+
(CRF 18: x264, or x265 Main10 with the source's own tags for an HDR or BT.2020 source;
|
|
74
|
+
`--keep-hevc` re-encodes an SDR HEVC source (not BT.2020: that keeps its Main10 line and colour tags) as x265 8-bit BT.709, on VideoToolbox under `--hw`;
|
|
75
|
+
`--codec` overrides all of them). `--accurate` always re-encodes; `--tolerance -1` never does for a keyframe snap
|
|
76
|
+
(a `--segments` join of open-GOP video, one whose later B-frame `.mp4`/`.mov` segment starts
|
|
77
|
+
between keyframes, or one that fails its check, still re-cuts).
|
|
73
78
|
Multiple segments are concatenated in the order given. stderr reports whether
|
|
74
79
|
the result was "lossless stream copy" or "re-encoded"; when the snap forced a
|
|
75
80
|
re-encode, the result's `lossless_alternative` names the nearest keyframe
|
|
76
81
|
`--start` that would stream-copy instead, so the trade can be offered.
|
|
82
|
+
A single `.mp4`/`.mov` stream copy shifts its timestamps to zero
|
|
83
|
+
(`-avoid_negative_ts make_zero`), so the picture starts at the keyframe.
|
|
84
|
+
`--edit-list` keeps its MP4 edit list instead, so the picture starts at
|
|
85
|
+
`--start`: `edit_list` and `stored_preroll_seconds` say so, `notes` warns that a
|
|
86
|
+
player which ignores edit lists shows the pre-roll, and the copy's length is
|
|
87
|
+
judged by its video (a copy that starts where asked but ends past `--tolerance`
|
|
88
|
+
re-encodes without offering another `--start`). `av_start_skew_seconds` is
|
|
89
|
+
audio start minus video start; a stream copy gets a note when it is more than
|
|
90
|
+
max(2 frames, 0.1 s) away from the source's own offset where the picture starts
|
|
91
|
+
(the keyframe the copy began from; `--start` under `--edit-list`). It is
|
|
92
|
+
reported, not repaired. `-ss`/`-t` are passed to the microsecond; `--accurate`
|
|
93
|
+
seeks a second early and trims on the output side, so frames just before a
|
|
94
|
+
keyframe are kept. `keyframe_snapped` is true for every stream copy (`precision`
|
|
95
|
+
`packet`); `start_snapped` is measured: true when a copied picture starts more
|
|
96
|
+
than a frame from `--start`, or when its start could not be measured (an unknown start is
|
|
97
|
+
never claimed exact); `null` under `--dry-run`.
|
|
98
|
+
`reencode_reason` lists every cause of a re-encode (`requested`, `codec`, `vfr`,
|
|
99
|
+
`vfr_inconclusive`, `pcm_container`, `copy_failed`, `tolerance`, `concat_fallback`);
|
|
100
|
+
`--segments` adds `segment_precision`, and `least_exact_precision` is the least exact one.
|
|
101
|
+
A `--segments` video copy join adds `join_check` (`packets`, `expected_packets`,
|
|
102
|
+
`max_step_seconds`, `audio_offset_ms` per part, `audio_parts_checked`, `ok`: the
|
|
103
|
+
written file measured against the source) and `segment_end_snap_seconds` (where
|
|
104
|
+
each part's end moved to its keyframe).
|
|
105
|
+
|
|
106
|
+
**VFR guard (`--vfr-guard`).** `average` (default): a source whose nominal and
|
|
107
|
+
average frame rates differ (`variable_frame_rate_suspected`) re-encodes as
|
|
108
|
+
`--accurate` (reason `vfr`), and `cut.py` then samples its packet timestamps (up
|
|
109
|
+
to five 6 s windows; no decoding) into `vfr_check` (`measured`: `sampled_cfr`,
|
|
110
|
+
`vfr` or `inconclusive`, plus `guard`); a `sampled_cfr` there adds a note naming
|
|
111
|
+
`--vfr-guard sampled`. `sampled` samples before every copy and re-encodes only on
|
|
112
|
+
`vfr` / `inconclusive` (reasons `vfr` / `vfr_inconclusive`), so a phone clip at
|
|
113
|
+
29.98 against a nominal 30 copies; `off` samples and keeps the copy with a note.
|
|
114
|
+
Irregular timing between the windows is not seen; `--accurate` is always available.
|
|
115
|
+
|
|
116
|
+
A source with no B-frames (iPhone "Most Compatible" H.264) cuts its parts as
|
|
117
|
+
every 2.x release did: from the keyframe at (or before) each start to the end
|
|
118
|
+
asked for, each part within `--tolerance`. On B-frame video a copied
|
|
119
|
+
`.mp4`/`.mov` part runs from the keyframe at (or before) its start to the first
|
|
120
|
+
keyframe at or after its end (never an earlier one), each end within
|
|
121
|
+
`--tolerance`, so the join is the source's own frames with no gaps; a later
|
|
122
|
+
segment that starts between keyframes there is re-cut (its hidden pre-roll
|
|
123
|
+
cannot cross a join). A source with **open GOPs** (x265's default, iPhone "High
|
|
124
|
+
Efficiency" HEVC) cannot be copied exactly across a join and is re-cut
|
|
125
|
+
(`concat_fallback`, named in `notes`). Every copy join is measured after it is
|
|
126
|
+
written (`join_check`): the frame count, each step at a join against the step
|
|
127
|
+
its parts predict, and each part's sound against its picture (audio packets
|
|
128
|
+
matched to the source; more than 5 ms off fails, a part with no unique match
|
|
129
|
+
is unmeasured). One that fails is re-cut. Only windows around the segments
|
|
130
|
+
(about 10 s each side, widened as needed) are read from the source, so a long
|
|
131
|
+
source costs what its segments do.
|
|
132
|
+
The parts are joined by stream copy only when they all copied and match: the
|
|
133
|
+
same streams with the same codec parameters, rotation, colour tags and
|
|
134
|
+
extradata. Otherwise every segment is **re-cut from
|
|
135
|
+
the source** into one re-encode (`concat_fallback`): frame-exact boundaries, the
|
|
136
|
+
audio's offset from the video kept, subtitles dropped and reported, and at most
|
|
137
|
+
32 segments per ffmpeg call. A segment shorter than one video frame is refused.
|
|
138
|
+
With `--accurate` the segments are always cut that way, in one encode (a part
|
|
139
|
+
encoded on its own ran an AAC frame past its picture, leaving a hole at every
|
|
140
|
+
join). A segment other than the last that runs past the end of the video is
|
|
141
|
+
ended with the video when it runs less than a frame past it, and otherwise keeps
|
|
142
|
+
its sound with the last frame held -- which forces the re-cut (`concat_fallback`,
|
|
143
|
+
named in `notes`). The last segment keeps the source's own sound-only tail.
|
|
77
144
|
|
|
78
145
|
**`--snap beats` (1.17)** moves each in/out point to the nearest *measured*
|
|
79
146
|
beat within `--snap-tolerance` seconds (default 0.12, about a quarter of a beat
|
|
@@ -490,7 +557,8 @@ each shorter clip's last frame (with silence) out to the longest.
|
|
|
490
557
|
silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.15] [--min-keep 0.2] [--list] [--edl keep.txt] [--speech-aware] [-o OUT]
|
|
491
558
|
silence.py INPUT --filler --words transcript.json [--filler-lang auto|en|ja|es|de|fr|pt|it]
|
|
492
559
|
[--filler-words FILE] [--filler-extra W,W] [--filler-keep W,W] [--filler-pad 0.02]
|
|
493
|
-
[--transcribe] [--
|
|
560
|
+
[--transcribe] [--engine auto|parakeet-mlx|parakeet.cpp|whisper.cpp|faster-whisper|openai-whisper]
|
|
561
|
+
[--filler-list] [--max-cuts 400]
|
|
494
562
|
```
|
|
495
563
|
Runs `silencedetect`, keeps `--margin` seconds of air around speech, drops
|
|
496
564
|
gaps shorter than `--min-silence`, and re-encodes once with `select`/`aselect`
|
|
@@ -533,7 +601,10 @@ Three refusals, all `kind: input`, all before any encode:
|
|
|
533
601
|
|
|
534
602
|
- `--filler` with neither `--words` nor `--transcribe` → names both flags.
|
|
535
603
|
- `--transcribe` with no engine on PATH → the same message `caption.py` gives,
|
|
536
|
-
with
|
|
604
|
+
with its install lines (Parakeet and whisper engines). Engines that were found
|
|
605
|
+
but did not transcribe are named instead, with why (`reason: engine_failed`,
|
|
606
|
+
or `english_only` for a Parakeet engine auto passed over for `--filler-lang`
|
|
607
|
+
that is not English; see `caption.py --transcribe` below).
|
|
537
608
|
- `--transcribe` where the engine runs but its build produces no word-level
|
|
538
609
|
timings → names that engine, says some builds do not support word timestamps,
|
|
539
610
|
and points at `--words`. (`--transcribe` drives whichever engine is installed
|
|
@@ -546,7 +617,11 @@ Three refusals, all `kind: input`, all before any encode:
|
|
|
546
617
|
|
|
547
618
|
Results: `filler.removed_count`, `filler.removed_seconds`, `filler.removed`
|
|
548
619
|
(one entry per span), `filler.lang`, `filler.list`, `filler.word_timings` and
|
|
549
|
-
`filler.warnings`.
|
|
620
|
+
`filler.warnings`. With `--transcribe`, `filler.source` is `whisper:ENGINE` or
|
|
621
|
+
`parakeet:ENGINE` and `filler.transcription` says which engine, model, language
|
|
622
|
+
and routing made the words; a top-level `notes` line says when `--engine auto`
|
|
623
|
+
ran the English-only Parakeet model on English assumed (no `--filler-lang`, and
|
|
624
|
+
no Whisper engine that could take it). The existing `removed_seconds` keeps exactly the meaning it
|
|
550
625
|
has always had — the seconds of **silence** this run removed, the figure the
|
|
551
626
|
same run would report without `--filler` — and `removed_seconds_total` is the
|
|
552
627
|
additive sibling covering everything that went. A filler word quiet enough to
|
|
@@ -897,6 +972,13 @@ batch.py FOLDER --recipe batch.json [--force] [--watch SECONDS] [--jobs N|auto]
|
|
|
897
972
|
placeholders, chained) or `project` (a render project applied per file).
|
|
898
973
|
Outputs land in `output_dir` with `suffix`; a content-hash cache skips files
|
|
899
974
|
already done with the same recipe. Use `--dry-run` to preview the plan.
|
|
975
|
+
A recipe that runs `cut.py` rolls its calls up: each of those `results` rows carries
|
|
976
|
+
`cut_reencoded` (each call's `reencoded`) and `cut_reencode_reasons` (each
|
|
977
|
+
`reencode_reason` its calls named, once per call),
|
|
978
|
+
and the top level `cut_stream_copy` (`{calls, stream_copy, reencoded,
|
|
979
|
+
stream_copy_rate}`) and `cut_reencode_reasons` (counts per reason; `unknown` for a
|
|
980
|
+
re-encoded call cached by an earlier 2.x batch); both are `null`
|
|
981
|
+
when no `cut.py` step ran.
|
|
900
982
|
|
|
901
983
|
**`--jobs N` (1.17)** processes N files at once (threads: the work is
|
|
902
984
|
subprocess waiting). Capped at `min(N, cpu_count, 8)` — every item is itself an
|
|
@@ -928,12 +1010,38 @@ above the cap is clamped with a note, not refused, and both `jobs` and
|
|
|
928
1010
|
- `--watch` composes: each pass is parallel, passes are sequential.
|
|
929
1011
|
|
|
930
1012
|
### caption.py --transcribe — optional local speech-to-text
|
|
931
|
-
If `
|
|
932
|
-
`
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
1013
|
+
If `parakeet-mlx`, `parakeet-cli` (parakeet.cpp, with a `.gguf`), `whisper-cli` (whisper.cpp),
|
|
1014
|
+
`faster-whisper` or `whisper` is installed,
|
|
1015
|
+
`caption.py input.mp4 --transcribe [--language ja] [--model base] [--engine auto]` writes the
|
|
1016
|
+
SRT from the audio and burns it (combine with `--animate pop --karaoke`). `--engine auto` (default,
|
|
1017
|
+
or `FFMPEG_SKILL_ASR_ENGINE`) runs Parakeet for English speech -- an English `--language`, else
|
|
1018
|
+
English from whisper.cpp's language detector -- and Whisper for any other language. When neither
|
|
1019
|
+
can name the language, a Whisper engine that can run goes first; Parakeet runs on English
|
|
1020
|
+
*assumed* only when no Whisper engine is installed or every one failed, and the result's
|
|
1021
|
+
top-level `notes` then says so: that transcript is wrong for non-English speech, so pass `--language`
|
|
1022
|
+
(`--filler-lang` for `silence.py`) when the speech may not be English.
|
|
1023
|
+
`--engine parakeet-mlx|parakeet.cpp|whisper.cpp|faster-whisper|openai-whisper` forces one (a
|
|
1024
|
+
missing one is `kind: missing_tool`; an installed one that fails is `kind: input`, `reason:
|
|
1025
|
+
engine_failed`, with its own error line in `detail`). Without a language whisper.cpp is passed
|
|
1026
|
+
`-l auto` (its own default is English), else the named or detected language. `--model` is the
|
|
1027
|
+
whisper model; a Parakeet engine takes it only when it names a Parakeet model
|
|
1028
|
+
(`mlx-community/parakeet-*` or a `.gguf`), else `PARAKEET_MODEL` / `PARAKEET_CPP_MODEL`, else the
|
|
1029
|
+
English `tdt-0.6b-v2`. A non-English `--language` on an English-only Parakeet model is refused; a
|
|
1030
|
+
multilingual (v3) model assumes no language, so it gets no English-assumed note. The result's
|
|
1031
|
+
`transcription` names the engine, model and routing. `silence.py --filler --transcribe` takes
|
|
1032
|
+
the same `--engine`.
|
|
1033
|
+
Nothing is required: without an engine it prints install hints and the user can supply `--text`
|
|
1034
|
+
cues instead. When engines are installed but none transcribed, the refusal names each and why
|
|
1035
|
+
instead (`reason: engine_failed`, or `english_only` for a Parakeet engine auto passed over for
|
|
1036
|
+
another language). The skill's own code downloads nothing, and every engine transcribes on this
|
|
1037
|
+
machine -- the audio is never uploaded. An engine may fetch its *model weights* the
|
|
1038
|
+
first time it runs: parakeet-mlx downloads `mlx-community/parakeet-tdt-0.6b-v2` (or the `--model` /
|
|
1039
|
+
`PARAKEET_MODEL` repo) from Hugging Face into its cache; faster-whisper (also from Hugging Face)
|
|
1040
|
+
and openai-whisper (from OpenAI) fetch theirs on first use too. faster-whisper is a library that
|
|
1041
|
+
runs inside the skill's own process, so its download is made from that process.
|
|
1042
|
+
`HF_HUB_OFFLINE=1` keeps the Hugging Face ones on the cached copy afterwards. parakeet.cpp and
|
|
1043
|
+
whisper.cpp only read a model file you downloaded. Always tell the user which engine was used,
|
|
1044
|
+
and treat the transcript as a draft to review.
|
|
937
1045
|
|
|
938
1046
|
### MCP server — the toolkit for any MCP client
|
|
939
1047
|
`python3 mcp/server.py` speaks MCP over stdio; each script is a tool taking
|
|
@@ -59,7 +59,7 @@ from _common.runner import (
|
|
|
59
59
|
ffmpeg_version, flush_drawtext_textfiles, INSTALL_HINTS, install_signal_handlers, _is_ffmpeg, _limit_for,
|
|
60
60
|
_odd_dimension_retry, _on_signal, _OutputLock, _pid_dead, place_output, PROBE_TIMEOUT, _progress_line,
|
|
61
61
|
read_text_or_die, refuse_output_is_input, _remember_output, require_tool, run, run_analysis, _run_captured,
|
|
62
|
-
run_keeping_subtitles, run_tool, _run_with_progress, shell_quote, _SIGNALS_INSTALLED, _stage_existing_output,
|
|
62
|
+
run_keeping_subtitles, run_tool, _run_with_progress, shell_quote, sibling_temp, _SIGNALS_INSTALLED, _stage_existing_output,
|
|
63
63
|
STATE, _timed_out, _unwatch, _watch, X264_PRESETS
|
|
64
64
|
)
|
|
65
65
|
from _common.emit import (
|
|
@@ -68,7 +68,7 @@ from _common.emit import (
|
|
|
68
68
|
)
|
|
69
69
|
from _common.probe import (
|
|
70
70
|
analyze_levels, _aspect_string, _bit_depth, decode_pcm_mono, detect_scenes, detect_silences, fingerprint,
|
|
71
|
-
_fraction, keyframes_near, SCORE_RE, SIL_RE, decode_gray_frames,
|
|
71
|
+
_fraction, keyframes_near, classify_frame_timing, measure_frame_timing, SCORE_RE, SIL_RE, decode_gray_frames,
|
|
72
72
|
measured_level_dbfs, AUDIO_MEDIA_EXT, MEDIA_EXT, _output_failed, probe, rms_envelope, _to_float, _to_int, verify_output
|
|
73
73
|
)
|
|
74
74
|
from _common.decision import (
|
|
@@ -83,7 +83,7 @@ from _common.decision import (
|
|
|
83
83
|
_onset_strength, _pick_onsets, _autocorrelation_peak, _grid_score,
|
|
84
84
|
brand_states_font, cfr_args, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
|
|
85
85
|
fmt_secs, fmt_smpte_time, fmt_srt_time, frame_size, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
|
|
86
|
-
signed_time_arg, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
|
|
86
|
+
signed_time_arg, source_codec_video_args, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
|
|
87
87
|
)
|
|
88
88
|
from _common.asr import (
|
|
89
89
|
ASR_ENGINES, ASR_INSTALL_HINT, _asr_run, die_no_engine, parse_srt, transcribe, _transcribe_in,
|
|
@@ -200,7 +200,7 @@ __all__ = [
|
|
|
200
200
|
"font_family_for_script", "font_family_of_file", "FONT_FLAG_HINT", "font_for_script", "FONT_INSTALL_HINT",
|
|
201
201
|
"fonts_dir_covers_script", "_fraction", "has_emoji", "info", "INSTALL_HINTS", "install_signal_handlers",
|
|
202
202
|
"is_audio_output", "_is_emoji_base", "_is_emoji_char", "_is_ffmpeg", "_is_mark", "_KEYCAP", "_KEYCAP_BASES",
|
|
203
|
-
"keyframes_near", "LANGUAGE_NAMES", "LATIN_EM", "LEADING_VOWELS", "_libass_color_probe", "_limit_for",
|
|
203
|
+
"keyframes_near", "classify_frame_timing", "measure_frame_timing", "LANGUAGE_NAMES", "LATIN_EM", "LEADING_VOWELS", "_libass_color_probe", "_limit_for",
|
|
204
204
|
"load_brand", "measured_level_dbfs", "AUDIO_MEDIA_EXT", "MEDIA_EXT", "MissingFpsError", "needs_shaping", "NO_SPACE_SCRIPTS",
|
|
205
205
|
"_odd_dimension_retry", "_on_signal", "_output_failed", "_OutputLock", "pad_filters", "parse_time", "_pid_dead",
|
|
206
206
|
"place_output", "_plan_at_exit", "_plan_inputs", "_PLAN_STRIP", "PLAN_VERSION", "PREFERRED_FAMILIES",
|
|
@@ -209,10 +209,10 @@ __all__ = [
|
|
|
209
209
|
"decode_gray_frames", "frame_flow", "label_shot_flow", "MOTION_GRID", "MOTION_SEARCH", "MOTION_STATIC_PX", "MOTION_PAN_SPREAD",
|
|
210
210
|
"_run_captured", "run_keeping_subtitles", "run_tool", "_run_with_progress", "_SCRIPT_FONT_CACHE",
|
|
211
211
|
"_script_font_entry", "script_font_for_text", "script_font_status", "_script_font_uncached", "_SCRIPT_RANGES",
|
|
212
|
-
"SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote",
|
|
212
|
+
"SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote", "sibling_temp",
|
|
213
213
|
"_SIGNALS_INSTALLED", "signed_time_arg", "_stage_existing_output", "STATE", "SVT_PRESET", "text_width_em",
|
|
214
214
|
"time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "validate_color", "verify_output",
|
|
215
|
-
"video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
|
|
215
|
+
"source_codec_video_args", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
|
|
216
216
|
"_x264_raw", "_ZWJ",
|
|
217
217
|
"ASR_ENGINES", "ASR_INSTALL_HINT", "_asr_run", "die_no_engine", "parse_srt", "transcribe",
|
|
218
218
|
"_transcribe_in", "transcribe_words", "_words_from_openai_whisper_json",
|