ffmpeg-skill 2.10.7 → 2.10.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -358,13 +358,15 @@ The server also advertises a `prompts` capability: five canned workflow recipes
358
358
 
359
359
  ```bash
360
360
  npx ffmpeg-skill doctor # human-readable
361
- npx ffmpeg-skill doctor --json # available / missing / missing_optional / unknown / detection / errors / tools / gpu_encoders
361
+ npx ffmpeg-skill doctor --json # available / missing / missing_optional / unknown / detection / errors / tools / gpu_encoders / ffmpeg_security
362
362
  ```
363
363
 
364
364
  `doctor` reads `ffmpeg -encoders`, `-filters` and `-bsfs` and resolves every capability the contract declares against this machine's build. Three states per capability: `available`, `missing`, `unknown`. Exit 0 when everything required is available, 1 when something required is missing, 2 when nothing is proven missing but a required capability is unknown. With detection on (the default), `contract --json` carries the same lists under `capabilities`. `doctor` also reports fonts: the default drawtext family, and `fonts.scripts` — one `available`/`missing`/`unknown` per writing system (ja, zh, ko, ar, he, hi, th, ru, el) with the file it would use — so "can this machine render Korean captions" is answered before the job, not after. `doctor --json`'s `tools` field folds that down to one answer per tool — `{"caption": {"usable": "no", "missing": ["filter:subtitles"], "fix": "..."}, ...}` — so "is `doctor` overall `ok`" and "can I run `caption.py` on this machine" are answered separately: a plain Homebrew `ffmpeg` is `ok` for tools that don't need `subtitles`/`drawtext`/`zscale`, while `caption`'s own `usable` is `"no"`.
365
365
 
366
366
  `doctor --json`'s `gpu_encoders` reports which GPU-backed encoders (`nvenc`, `videotoolbox`, `qsv`, `vaapi`, `amf`) this ffmpeg *build* was compiled with — read from `-encoders` alone, so it proves the capability shipped, not that the GPU/driver on this machine will actually accept a job (that needs a real encode, which `doctor`'s introspection never runs). It never affects `ok` or any tool's `usable`. The one GPU path a tool uses is Apple VideoToolbox, opt-in: `--hw` on every re-encoding tool (h264/hevc/prores on the GPU, AV1 stays on SVT-AV1), `FFMPEG_SKILL_HW=1` to make it this machine's default for everything except `export.py`'s delivery presets (those take `--hw` explicitly, or `render.py --hw` for a whole project). It runs only on Apple Silicon and never by default, except on a build with no `libx264`/`libx265` (an LGPL ffmpeg that has only `h264_videotoolbox`/`hevc_videotoolbox`): there it is the only encoder, so every re-encoding tool and `export.py`'s presets use it without `--hw`, the result says `hw.source: "auto"`, and `doctor` lists the missing x264/x265 under `covered_by` instead of calling the tools unusable (`--no-hw` still asks for the CPU). It falls back to the CPU encoder — reported in the result's `hw.fallback`/`hw.notes` — only when VideoToolbox itself refuses a job (any other ffmpeg failure is reported as it happened, not retried); every result names the `encoder` that actually ran (`batch.py`, which encodes nothing itself: each `results` row), and a GPU encode adds a `notes` line that its quality is not CRF-equivalent. Measured on an M4 Max, 2–7× faster than x264/x265 `medium`; at matched SSIM (`tests/bench_vt.py`) 1.1–2.9× the bytes on SDR and 1.9–3.5× on HDR, which is why it is a flag and not the default (`docs/design-decisions.md`). `doctor --json`'s `hw` says whether this machine can run it (`platform_ok`) and whether it is the default here (`default_on`).
367
367
 
368
+ `doctor` also compares the FFmpeg version with a small, dated table of high-severity FFmpeg CVEs (`doctor --json`'s `ffmpeg_security`; source [ffmpeg.org/security.html](https://ffmpeg.org/security.html), checked 2026-10-22). ffmpeg-skill does not ship FFmpeg, so a decoder bug in the build you have stays yours until you upgrade it. When the version is older than the first upstream release on its branch that carries a fix (today: CVE-2026-8461, the MagicYUV decoder heap write, fixed in 8.0.3, 8.1.2 and 9.0), `doctor` prints one `warning:` line on stderr with the update command and lists it under `ffmpeg_security.warnings`. It is advice, never a failure: `ok`, each tool's `usable` and the exit code do not change. A distribution package (Ubuntu `8.0.1-3ubuntu2`, Debian `+deb13u1`) may carry the fix as a backport under the old version number, so the line says the build is older than the fix, not that it is vulnerable. A version it cannot read (a git snapshot such as `N-12345-g…`) gets no warning, and nothing is fetched: the table ships with the skill.
369
+
368
370
  ## Gotchas and best practices
369
371
 
370
372
  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).
@@ -395,9 +397,13 @@ The short list for humans. The agent-facing version, with the reasoning, is the
395
397
 
396
398
  Each operation is a script whose flags are its argparse parser, and that parser is what `contract --json` and the MCP `inputSchema` are built from. CI fails the build when those drift from the scripts. A caller-supplied filter graph, `-vf` string, or other ffmpeg option is not an argument any script accepts. `doctor` reads `ffmpeg -filters` (and `-encoders`, `-bsfs`) and reports a filter a tool needs before a job runs into it. MCP does accept an `argv` array, and that array is the named script's own CLI flags, still bound to that script: the script's flags, never an ffmpeg command line and never a shell.
397
399
 
398
- ### How does this differ from Remotion or HyperFrames? Can they be used together?
400
+ ### How does ffmpeg-skill compare with Remotion?
401
+
402
+ ffmpeg-skill edits footage you already have, on this machine, with FFmpeg tools whose flags are a machine-readable contract (`contract --json`). Remotion renders a video from React components. They complement each other: this repository does not call Remotion, and a file Remotion writes is an ordinary input (`probe.py`, `cut.py`, `fit.py`, `export.py`, `check.py`). HyperFrames renders a video from HTML and is the same kind of neighbour: a file it writes is an ordinary input too.
403
+
404
+ ### Does it validate the filter graph before running?
399
405
 
400
- Remotion renders a video from React components. HyperFrames renders one from HTML. This repository does neither, and it does not call either of them. It edits a media file you already have, on the machine, with ffmpeg and ffprobe, then probes the file it wrote. A render from either tool is an ordinary input: `probe.py`, `cut.py`, `fit.py`, `export.py` and `check.py` take the file the same way they take any other clip.
406
+ No. `--dry-run` prints the ffmpeg command a writing tool would run and does not start ffmpeg, so ffmpeg never parses that filter graph. ffprobe still runs when the tool probes an input that exists. Unless `--overwrite` is passed, an existing output is refused (`kind: input`) before ffmpeg. An output that is the same file as an input is refused either way. No media file is written, and `verified` is false; `--plan FILE` does write the plan JSON. A path that is not on disk yet is not measured: the probe is a stub (0×0, duration 0), so a number taken from it is not a measurement. The measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) still run their measurements; only the artifact is skipped, and `silence` with `--filler --transcribe` also runs the speech engine, which may download a model. `verify` accepts `--dry-run` and ignores it: its steps run. `--plan FILE` is that dry run written down — the planned argv, the command lines, and a fingerprint of each existing input (size, plus a sha256 of the first and last 8 MiB). `render.py` and `verify.py` refuse `--plan`. `render.py FILE` runs a plan only when those fingerprints still match, by running the tool again with the planned argv, not by executing the recorded ffmpeg lines; `--dry-run` on that run only prints them. `doctor` reports whether the filters, encoders and bitstream filters the contract names are in this build (`ffmpeg -filters`, `-encoders`, `-bsfs`) and which fonts are installed. A name in that listing is not a check of the graph this command would build, and a listed GPU encoder is not a successful encode.
401
407
 
402
408
  ### Can it remove burned-in subtitles or logos?
403
409
 
@@ -413,7 +419,7 @@ The encode path a tool will actually take is Apple VideoToolbox, for h264, hevc
413
419
 
414
420
  ## FFmpeg compatibility
415
421
 
416
- The tools need FFmpeg 5.0 or later and Python 3.9 or later (standard library only). What CI actually exercises on every pull request is FFmpeg 5.1.1 (static build), 6.1 (Ubuntu apt), 7.1 (Debian trixie apt), 8.x (macOS Homebrew) and 9.x (Windows gyan.dev), on Python 3.9 and 3.13 (the two ends of the supported range). The capability parser has been run against the listings of these builds:
422
+ The tools need FFmpeg 5.0 or later and Python 3.9 or later (standard library only); Python 3.10+ is recommended: 3.9 still works but is deprecated since 2.11.0 (CPython 3.9 is end of life), `doctor` and the `npx` installer warn on it, and 3.0 drops it. What CI actually exercises on every pull request is FFmpeg 5.1.1 (static build), 6.1 (Ubuntu apt), 7.1 (Debian trixie apt), 8.x (macOS Homebrew) and 9.x (Windows gyan.dev), on Python 3.9 and 3.13 (the two ends of the supported range). The capability parser has been run against the listings of these builds:
417
423
 
418
424
  | FFmpeg | `-filters` row layout | Source |
419
425
  |---|---|---|
@@ -521,7 +527,7 @@ FFmpeg itself:
521
527
  ## Requirements
522
528
 
523
529
  - FFmpeg 5.0+. Always required: `libx264`, `aac`, and the `drawtext`, `subtitles` (libass), `loudnorm`, `xfade`, `acrossfade`, `scdet`, `silencedetect` and `tile` filters. Needed only by the flags that use them: `libx265`, `prores_ks`, `libzimg` / `zscale`, `libmp3lame`, `libopus`, `libvorbis`, the `ass` filter. `doctor` tells you which are present. The apt and gyan.dev builds carry all of them; some Homebrew bottles lack `libass` / `libfreetype` / `libzimg`, which `doctor` reports as missing.
524
- - Python 3.9+, standard library only
530
+ - Python 3.10+ recommended, standard library only. 3.9 still works but is deprecated (end of life upstream; support ends in 3.0)
525
531
  - Node 16+ only for the `npx` installer
526
532
 
527
533
  `doctor`'s own introspection calls (`ffmpeg -filters`/`-encoders`/`-bsfs`/`-version`) time out after 10s and report `failed` rather than hanging forever — those are meant to be fast. Every tool's actual media-processing `ffmpeg` invocation (cut, fit, caption, ...) runs under `--timeout` (default 1800 s, `FFMPEG_SKILL_TIMEOUT`, `0` = none): past the limit the process is killed, its partial output removed, and the failure reported as `kind: timeout` (exit 124) — a legitimately long `--accurate` re-encode should raise the limit rather than run unbounded. `-nostdin` is always passed, so a hung ffmpeg process waiting on stdin cannot happen.
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ffmpeg-skill
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.'
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.10+ standard library only (3.9 deprecated), no cloud, no API keys.'
4
4
  ---
5
5
 
6
6
  # ffmpeg-skill
package/bin/install.js CHANGED
@@ -44,27 +44,54 @@ if (has('--help') || has('-h')) {
44
44
  // Windows Python installers commonly expose `python`/`py`, not `python3`. The Microsoft Store
45
45
  // app-execution alias is a real python3.exe that spawns (no ENOENT) and exits non-zero, so a
46
46
  // missing `error` is not proof an interpreter ran. Probe with a version check, then fall through.
47
- const PYTHON_PROBE = ['-c', 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)'];
47
+ // The probe also prints major.minor, so the installer can warn about a deprecated interpreter.
48
+ const PYTHON_PROBE = ['-c', "import sys; sys.stdout.write('%d.%d' % sys.version_info[:2]); raise SystemExit(0 if sys.version_info >= (3, 9) else 1)"];
49
+
50
+ // 2.11.0 deprecates Python 3.9 (docs/contract.md "Deprecation policy"; `contract --json`'s
51
+ // `deprecated`). The same line as scripts/_contract.py's PY39_DEPRECATION (a test keeps them equal).
52
+ const PY39_DEPRECATION = 'warning: Python 3.9 is deprecated since ffmpeg-skill 2.11.0 (CPython 3.9 reached end of life in October 2025 and gets no security fixes); it still works in 2.x and support ends in 3.0.0: use Python 3.10+.';
53
+
54
+ function pythonDeprecation(version) {
55
+ return version === '3.9' ? PY39_DEPRECATION : null;
56
+ }
48
57
 
49
58
  function pythonCandidates(platform) {
50
59
  return platform === 'win32' ? ['python3', 'python', 'py'] : ['python3'];
51
60
  }
52
61
 
53
- function runWithPython(scriptArgs, platform, spawn) {
62
+ // The first candidate that runs and is 3.9+: { cmd, version } (version "3.9", or null when the
63
+ // probe printed nothing), or { missing: true, ... } when none is.
64
+ function findPython(platform, spawn) {
54
65
  const candidates = pythonCandidates(platform);
55
66
  const run = spawn || spawnSync;
56
67
  let last = null;
57
68
  for (const cmd of candidates) {
58
- const probe = run(cmd, PYTHON_PROBE, { stdio: 'ignore', timeout: 15000 });
69
+ const probe = run(cmd, PYTHON_PROBE, { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8', timeout: 15000 });
59
70
  if (probe.error || probe.status !== 0) {
60
71
  last = probe;
61
72
  continue;
62
73
  }
63
- return run(cmd, scriptArgs, { stdio: 'inherit' });
74
+ const out = typeof probe.stdout === 'string' ? probe.stdout.trim() : '';
75
+ return { cmd, version: /^\d+\.\d+$/.test(out) ? out : null };
64
76
  }
65
77
  return { missing: true, error: last && last.error, status: last && last.status, candidates };
66
78
  }
67
79
 
80
+ function runWithPython(scriptArgs, platform, spawn) {
81
+ const py = findPython(platform, spawn);
82
+ if (py.missing) return py;
83
+ return (spawn || spawnSync)(py.cmd, scriptArgs, { stdio: 'inherit' });
84
+ }
85
+
86
+ // After an install: the one-line deprecation warning when the Python the skill will run on is 3.9.
87
+ // `doctor` prints the same line itself (from Python), so it is not repeated for that subcommand.
88
+ function warnDeprecatedPython(platform, spawn, warn) {
89
+ const py = findPython(platform, spawn);
90
+ const line = py.missing ? null : pythonDeprecation(py.version);
91
+ if (line) (warn || console.warn)(`\n ${line}`);
92
+ return line;
93
+ }
94
+
68
95
  function main() {
69
96
  // `contract` / `doctor` are answered by scripts/_contract.py; everything else installs.
70
97
  if (args[0] === 'contract' || args[0] === 'doctor') {
@@ -174,6 +201,7 @@ for (const t of targets) {
174
201
 
175
202
  if (!has('--uninstall')) {
176
203
  checkFfmpeg();
204
+ warnDeprecatedPython(process.platform, spawnSync);
177
205
  console.log('\nDone. Ask your agent things like "make this video 60 seconds and add captions".');
178
206
  }
179
207
  process.exit(failed ? 1 : 0);
@@ -183,4 +211,4 @@ if (require.main === module) {
183
211
  main();
184
212
  }
185
213
 
186
- module.exports = { pythonCandidates, runWithPython };
214
+ module.exports = { pythonCandidates, runWithPython, findPython, pythonDeprecation, warnDeprecatedPython, PY39_DEPRECATION };
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.10.7`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.10.9`) | 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`
@@ -81,8 +81,14 @@ on, the line says so.
81
81
 
82
82
  `contract --json` carries a top-level `deprecated` list, next to `contract_version`: one entry per
83
83
  thing the next major removes, `{"what", "since", "replacement", "removed_in", "where"}` with `where`
84
- naming the surface (`cli`, `json`, `mcp`, `behaviour`). It is empty in 2.0.0. `tests/test_contract.py`
85
- refuses a package.json major bump that still lists an entry that major should remove, or that
84
+ naming the surface (`cli`, `json`, `mcp`, `behaviour`). It was empty in 2.0.0. Since 2.11.0 it lists one
85
+ entry: **running on Python 3.9** (`where: behaviour`, `removed_in: 3.0.0`). CPython 3.9 reached end of life
86
+ in October 2025 and gets no security fixes, but raising the floor is a break, so 2.x keeps running on 3.9
87
+ (`requirements.python` stays `>=3.9`, and CI keeps testing it). It is deprecated, and Python 3.10+ is
88
+ recommended: on 3.9, `doctor` and the `npx` installer print one stderr line,
89
+ `warning: Python 3.9 is deprecated since ffmpeg-skill 2.11.0 (...); it still works in 2.x and support ends in 3.0.0: use Python 3.10+.`
90
+ Like every deprecation warning it is stderr only: `doctor --json`'s document has no key for it.
91
+ `tests/test_contract.py` refuses a package.json major bump that still lists an entry that major should remove, or that
86
92
  comes before step 2's window has passed -- unless the waiver is written here, as 2.0.0's is.
87
93
 
88
94
  **2.0.0 waived the 90-day half of step 2.** Its deprecations date from 1.10.0 (2026-09-13), so
@@ -117,12 +123,12 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
117
123
  ```json
118
124
  {
119
125
  "contract_version": "1.0",
120
- "deprecated": [],
126
+ "deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.11.0", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
121
127
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
122
- "skill": {"id": "ffmpeg-skill", "version": "2.10.7", "execution_mode": "local", "kind": "execution",
128
+ "skill": {"id": "ffmpeg-skill", "version": "2.10.9", "execution_mode": "local", "kind": "execution",
123
129
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
124
130
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
125
- "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
131
+ "requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.11.0, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
126
132
  "execution": {"shell": false, "arbitrary_executables": false, "network": false,
127
133
  "model_downloads": {"when": "...", "in_process": {"faster-whisper": "..."},
128
134
  "child_process": {"parakeet-mlx": "...", "openai-whisper": "..."},
@@ -471,6 +477,30 @@ ffmpeg-full` does not put it on PATH and the lean build has no drawtext/subtitle
471
477
  else is substituted. `ffmpeg` and `ffprobe` (version strings) keep their meaning; the four keys are
472
478
  additive.
473
479
 
480
+ `doctor`'s `ffmpeg_security` field compares the `ffmpeg` and `ffprobe` versions with a small table of
481
+ high-severity FFmpeg CVEs and the first upstream release on each branch that carries the fix. The table is
482
+ hand-maintained in `scripts/_contract.py` (`FFMPEG_ADVISORIES`), copied from
483
+ <https://ffmpeg.org/security.html> and last checked on 2026-10-22; the document repeats both as `source`
484
+ and `checked`. Nothing is fetched: the check is offline, like the rest of the skill. On that date it holds one row:
485
+
486
+ | CVE | What | CVSS | First fixed upstream |
487
+ |---|---|---|---|
488
+ | CVE-2026-8461 ("PixelSmash") | MagicYUV decoder heap out-of-bounds write | 8.8 | 8.0.3 (8.0 branch), 8.1.2 (8.1), 9.0 (9.0); no 7.1.x, 7.0.x or 6.1.x release is listed with the fix |
489
+
490
+ AISLE's CVE-2026-64830..64835 were not on security.html on that date, so they have no row yet.
491
+
492
+ `{"source": "https://ffmpeg.org/security.html", "checked": "2026-10-22", "ffmpeg": {"version": "...", "status":
493
+ "older_than_fix"|"not_older"|"unknown", "advisories": [{"cve", "name", "component", "kind", "cvss",
494
+ "first_fix", "fix_on_branch", "branch"}]}, "ffprobe": {...}, "warnings": ["..."]}`. The leading upstream
495
+ number is read from the version (`8.0.1-3ubuntu2` is 8.0.1). A version on a branch the table lists is
496
+ compared with that branch's fixing release; a version on an older branch, which has no fixing release, with
497
+ the oldest release that has it (`fix_on_branch: false`). A version with no release number (a git snapshot,
498
+ `N-12345-g…`), or no binary, is `unknown` and never warned about. Each distinct version that is older gets one
499
+ line in `warnings`, also printed to stderr (in plain and `--json` mode). The line says the build is older than
500
+ the upstream fix and that a distribution package may have backported it without changing the version; it does
501
+ not say the build is vulnerable, and it names an update command (apt for an Ubuntu/Debian version, else by
502
+ platform). Like `gpu_encoders` and `fonts`, it never affects `ok`, any tool's `usable` or the exit code.
503
+
474
504
  `doctor`'s `gpu_encoders` field reports GPU-backed encoders (`nvenc`, `videotoolbox`, `qsv`,
475
505
  `vaapi`, `amf`) present in this ffmpeg *build*, read from `-encoders` alone — `{"status":
476
506
  "parsed"|"unparsed"|"failed"|"missing", "present": [...]}`. It proves the build shipped the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.10.7",
3
+ "version": "2.10.9",
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",
@@ -167,9 +167,19 @@ these had ever shown up before.
167
167
  `color_space=bt709` on the trixie/macOS/Windows jobs and the untagged case silently went untested
168
168
  there (#333's colour fixtures until 2.10.3). Relabel the frames `setparams=colorspace=unknown`
169
169
  before the encode, and assert the probe says unknown.
170
- - **The conda-forge 7.1.1 build deadlocks on `tpad` + `adelay`/`apad` (pad.py) and ignores
171
- SIGTERM.** Debian's 7.1.5 in CI does not. Run pad tests against CI, not that build, and note
172
- that the tools have no subprocess timeout to get an agent out of such a hang.
170
+ - **FFmpeg 8.0.1 and conda-forge's 7.1.1 deadlock when one input feeds a `-vf` graph and an `-af` graph whose sound is
171
+ both delayed and padded** (`adelay` + `apad`, the shape pad.py used): no CPU, no progress, until
172
+ `--timeout`. First seen on conda-forge's 7.1.1 and filed as that build's quirk; Ubuntu 26.04's
173
+ 8.0.1 (`ubuntu-latest`) has it too; Debian's 7.1.5 and 8.1.3 do not. pad.py puts both streams in
174
+ one `-filter_complex`, which runs on 5.1.1, 6.1.1, 7.0.2, 8.0.1 and 8.1.3. A tool that delays and
175
+ pads audio should do the same.
176
+ - **FFmpeg 8.0 and 8.0.1's `aselect` passes every frame through** (its `activate` callback was lost
177
+ in the filter conversion; fixed in 8.0.2/8.1). Nothing fails: the picture is cut and the sound is
178
+ not. silence.py tags the kept frames with `ametadata=mode=add:...:enable='<expr>'` and keeps them with
179
+ `ametadata=mode=select`; do not use `aselect` for removal.
180
+ - **FFmpeg 8.0, 8.0.1 and 8.1.0 draw the font's missing-glyph box at each line break of a multi-line
181
+ `drawtext`** (each line is shaped with its newline; fixed in 8.0.2/8.1.1). `ffmpeg_version()` cannot
182
+ tell 8.1.0 from 8.1.1; `drawtext_draws_newline_glyph()` reads the point release.
173
183
  - **Not a 7.1 issue, found while chasing it: `color --correct` desaturates a bt709-*tagged*
174
184
  source by ~8 % at identity settings on 6.1 and 7.1 alike** (113.6 → 103.9 saturation_avg):
175
185
  the RGB stages (exposure/colortemperature/colorbalance) make swscale go yuv→rgb with the
@@ -581,14 +581,15 @@ silence.py INPUT --filler --words transcript.json [--filler-lang auto|en|ja|es|d
581
581
  [--model MODEL] [--filler-list] [--max-cuts 400]
582
582
  ```
583
583
  Runs `silencedetect`, keeps `--margin` seconds of air around speech, drops
584
- gaps shorter than `--min-silence`, and re-encodes once with `select`/`aselect`
585
- (frame accurate). `--list` prints silences, kept ranges and seconds removed
584
+ gaps shorter than `--min-silence`, and re-encodes once: `select` for the picture
585
+ (frame accurate), the same ranges through `ametadata` for the sound (not `aselect`, which passes every frame
586
+ through on FFmpeg 8.0 and 8.0.1). `--list` prints silences, kept ranges and seconds removed
586
587
  without rendering; `--edl` saves the kept ranges in `cut.py --segments` format
587
588
  so the user can edit the list by hand. Quiet rooms need `--threshold -40`
588
589
  to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
589
590
 
590
591
  **`--filler` (1.17)** removes filler words as well, through the same
591
- `keep_ranges()`/`aselect` graph with more, shorter ranges. It is **never**
592
+ `keep_ranges()` graph with more, shorter ranges. It is **never**
592
593
  applied without measured word timings: pass `--words` (a whisper JSON carrying
593
594
  per-word `start`/`end`) or `--transcribe` (the same local engine `caption.py`
594
595
  uses, never required). There is no heuristic fallback — finding an "um" by
@@ -1128,6 +1129,10 @@ and that this machine can draw none of is `kind: input`, because that frame woul
1128
1129
  be blank. `--emoji none` strips the clusters from the drawn text, and a job whose
1129
1130
  emoji would fall to `mode: mono` is routed through libass (which has a font
1130
1131
  fallback chain) instead of drawtext (which does not, and would draw an empty box).
1132
+ A label that is drawn on more than one line (given with newlines, or wrapped to the
1133
+ frame) goes through libass the same way on FFmpeg 8.0, 8.0.1 and 8.1.0, whose drawtext
1134
+ draws the font's missing-glyph box at the end of every line but the last (fixed in
1135
+ 8.0.2 and 8.1.1); `--text-render drawtext` keeps drawtext there and warns.
1131
1136
 
1132
1137
  Every drawn label goes to drawtext as `textfile=<path>:expansion=none`. The file
1133
1138
  is UTF-8, mode 0600, in a private per-run temp directory created with
@@ -55,8 +55,8 @@ from _common.runner import (
55
55
  add_common, apply_common, _check_existing_output, refuse_existing_outputs, _check_no_overwrite_input, _check_output_path, child_args,
56
56
  child_limit, _CHILDREN, _cleanup_partial_output, _cmdline, CODECS, Context, DEFAULT_TIMEOUT,
57
57
  _DRAWTEXT_PENDING, _DRAWTEXT_TMPDIR, _drawtext_tmpdir, dry_run_input_pending, _ENCODERS, _env_timeout,
58
- ERROR_CODE, ERROR_RETRYABLE, EVEN_SCALE, _execute, _fail, ffmpeg_base, ffmpeg_encoders, ffmpeg_exe, _FFMPEG_VERSION,
59
- ffmpeg_version, flush_drawtext_textfiles, INSTALL_HINTS, install_signal_handlers, _is_ffmpeg, _limit_for,
58
+ ERROR_CODE, ERROR_RETRYABLE, EVEN_SCALE, _execute, _fail, ffmpeg_base, ffmpeg_encoders, ffmpeg_exe, _FFMPEG_RELEASE, _FFMPEG_VERSION,
59
+ ffmpeg_release, 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
62
  run_keeping_subtitles, run_tool, _run_with_progress, shell_quote, sibling_temp, _SIGNALS_INSTALLED, _stage_existing_output,
@@ -96,6 +96,7 @@ from _common.color import (
96
96
  )
97
97
  from _common.text import (
98
98
  ADVANCE_EM, BIDI_SCRIPTS, _char_em, char_script, default_font_file, detect_script, drawtext_boxborderw, drawtext_center_align,
99
+ drawtext_draws_newline_glyph, is_multiline, NEWLINE_GLYPH_RELEASES,
99
100
  drawtext_shaping, drawtext_text_opts, emoji_asset_for, EMOJI_ASSET_HINT, emoji_clusters, emoji_codepoint_name,
100
101
  _EMOJI_COLOR_FAMILIES, _emoji_color_font, emoji_filter_chain, _emoji_name_candidates, EMOJI_RANGES,
101
102
  _EMOJI_REGIONAL, emoji_support, _EMOJI_SUPPORT_CACHE, _EMOJI_TAIL, escape_drawtext, _family_rank, FC_LANG,
@@ -145,7 +146,7 @@ class _Facade(_types.ModuleType):
145
146
  # own copy is the import-time binding and would read stale. Reads of these names go to the
146
147
  # defining module (audit 14, P1-1); every other name is a plain re-export of the same object.
147
148
  _LIVE = {
148
- "_FFMPEG_VERSION": "runner", "_SIGNALS_INSTALLED": "runner",
149
+ "_FFMPEG_VERSION": "runner", "_FFMPEG_RELEASE": "runner", "_FFMPEG_RELEASE_READ": "runner", "_SIGNALS_INSTALLED": "runner",
149
150
  "_DRAWTEXT_TMPDIR": "runner", "_ENCODERS": "runner", "_CURRENT_CTX": "emit",
150
151
  }
151
152
 
@@ -189,14 +190,14 @@ __all__ = [
189
190
  "BEAT_REFRACTORY_S", "BEAT_WINDOW_S", "BEAT_SUPPORT_DIVISOR", "BEAT_ALIGN_DIVISOR",
190
191
  "BEAT_Z_FLOOR", "BEAT_Z_SPAN",
191
192
  "_onset_strength", "_pick_onsets", "_autocorrelation_peak", "_grid_score",
192
- "default_font_file", "default_output", "DEFAULT_TIMEOUT", "detect_script", "die", "drawtext_boxborderw", "drawtext_center_align",
193
+ "default_font_file", "default_output", "DEFAULT_TIMEOUT", "detect_script", "die", "drawtext_boxborderw", "drawtext_center_align", "drawtext_draws_newline_glyph", "is_multiline", "NEWLINE_GLYPH_RELEASES",
193
194
  "_DRAWTEXT_PENDING", "drawtext_shaping", "drawtext_text_opts", "_DRAWTEXT_TMPDIR", "_drawtext_tmpdir",
194
195
  "dry_run_input_pending", "emit", "emoji_asset_for", "EMOJI_ASSET_HINT", "emoji_clusters",
195
196
  "emoji_codepoint_name", "_EMOJI_COLOR_FAMILIES", "_emoji_color_font", "emoji_filter_chain",
196
197
  "_emoji_name_candidates", "EMOJI_RANGES", "_EMOJI_REGIONAL", "emoji_support", "_EMOJI_SUPPORT_CACHE",
197
198
  "_EMOJI_TAIL", "encoder_args", "_ENCODERS", "_env_timeout", "ERROR_CODE", "ERROR_RETRYABLE", "escape_drawtext",
198
199
  "escape_filter_path", "EVEN_SCALE", "_execute", "_fail", "_family_rank", "FC_LANG", "_fc_list_fonts",
199
- "FC_UNKNOWN", "ffmpeg_base", "ffmpeg_encoders", "ffmpeg_exe", "_FFMPEG_VERSION", "ffmpeg_version", "fingerprint",
200
+ "FC_UNKNOWN", "ffmpeg_base", "ffmpeg_encoders", "ffmpeg_exe", "_FFMPEG_RELEASE", "_FFMPEG_VERSION", "ffmpeg_release", "ffmpeg_version", "fingerprint",
200
201
  "flush_drawtext_textfiles", "fold_bt709_matrix", "fmt_secs", "fmt_smpte_time", "fmt_srt_time", "font_covers_script",
201
202
  "font_family_for_script", "font_family_of_file", "FONT_FLAG_HINT", "font_for_script", "FONT_INSTALL_HINT",
202
203
  "fonts_dir_covers_script", "_fraction", "has_emoji", "info", "INSTALL_HINTS", "install_signal_handlers",
@@ -10,7 +10,7 @@ import shutil
10
10
  import subprocess
11
11
  from typing import Dict, Optional
12
12
  from _common.decision import escape_filter_path
13
- from _common.runner import STATE, _DRAWTEXT_PENDING, _drawtext_tmpdir, ffmpeg_exe, ffmpeg_version
13
+ from _common.runner import STATE, _DRAWTEXT_PENDING, _drawtext_tmpdir, ffmpeg_exe, ffmpeg_release, ffmpeg_version
14
14
 
15
15
 
16
16
  def drawtext_boxborderw(vertical: int, horizontal: int) -> str:
@@ -97,6 +97,26 @@ def escape_drawtext(text: str) -> str:
97
97
  )
98
98
 
99
99
 
100
+ # FFmpeg 8.0 rewrote drawtext's line splitter to hand harfbuzz `p - start` bytes after GET_UTF8 had
101
+ # already stepped past the line break, so every line but the last is shaped WITH its newline and
102
+ # the font's .notdef box is drawn at its end (and counted in text_w). 8.0.2 and 8.1.1 measure the
103
+ # length before the step; 8.0, 8.0.1 (Ubuntu 26.04) and 8.1 ship the bug.
104
+ NEWLINE_GLYPH_RELEASES = frozenset({(8, 0, 0), (8, 0, 1), (8, 1, 0)})
105
+
106
+
107
+ def drawtext_draws_newline_glyph() -> bool:
108
+ """Whether this build's drawtext draws a box at each line break of a multi-line text (see
109
+ NEWLINE_GLYPH_RELEASES). A git or vendor build without a release number is taken to be fixed:
110
+ the fix is on master, and a guess the other way would reroute every multi-line title."""
111
+ return ffmpeg_release() in NEWLINE_GLYPH_RELEASES
112
+
113
+
114
+ def is_multiline(text: "Optional[str]") -> bool:
115
+ """`text` draws as more than one line (the normalisation drawtext_text_opts() applies)."""
116
+ cleaned = (text or "").replace("\r\n", "\n").replace("\r", "\n").rstrip("\n")
117
+ return "\n" in cleaned
118
+
119
+
100
120
  def drawtext_center_align() -> str:
101
121
  """`:text_align=C` for a centred drawtext, so each line of a multi-line label is centred on
102
122
  its own. The option arrived in FFmpeg 6.1 (5.x and 6.0 reject it); there the block is still
@@ -125,6 +125,30 @@ def ffmpeg_version() -> "Tuple[int, int]":
125
125
  return _FFMPEG_VERSION
126
126
 
127
127
 
128
+ _FFMPEG_RELEASE: "Optional[Tuple[int, int, int]]" = None
129
+ _FFMPEG_RELEASE_READ = False
130
+
131
+
132
+ def ffmpeg_release() -> "Optional[Tuple[int, int, int]]":
133
+ """(major, minor, patch) of a tagged FFmpeg release on PATH ("8.0.1-3ubuntu2", "n8.1" ->
134
+ (8, 1, 0)), or None for a git/vendor build or an unreadable one. ffmpeg_version()'s
135
+ major.minor is the right test for an option spelling; this is for a bug that one point
136
+ release introduced and the next one fixed, which major.minor cannot tell apart. Read from
137
+ ffprobe for the same dry-run reason; cached per process."""
138
+ global _FFMPEG_RELEASE, _FFMPEG_RELEASE_READ
139
+ if not _FFMPEG_RELEASE_READ:
140
+ _FFMPEG_RELEASE_READ = True
141
+ try:
142
+ out = subprocess.run([ffmpeg_exe("ffprobe")[0] or "ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
143
+ timeout=PROBE_TIMEOUT).stdout
144
+ m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)(?:\.(\d+))?", out)
145
+ if m:
146
+ _FFMPEG_RELEASE = (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0))
147
+ except (OSError, subprocess.TimeoutExpired):
148
+ pass
149
+ return _FFMPEG_RELEASE
150
+
151
+
128
152
  def _homebrew_full_build(path: str, name: str) -> str:
129
153
  """On macOS, the keg-only `ffmpeg-full` binary that should replace `path`, or "".
130
154
 
@@ -31,7 +31,8 @@ from _common.emoji import (
31
31
  )
32
32
  from _common.drawtext import (
33
33
  drawtext_boxborderw, SHAPING_SCRIPTS, BIDI_SCRIPTS, _SHAPING_BUILD_CACHE, drawtext_shaping, needs_shaping,
34
- escape_drawtext, drawtext_text_opts, drawtext_center_align,
34
+ escape_drawtext, drawtext_text_opts, drawtext_center_align, NEWLINE_GLYPH_RELEASES,
35
+ drawtext_draws_newline_glyph, is_multiline,
35
36
  )
36
37
  from _common.wrap import (
37
38
  ADVANCE_EM, NO_SPACE_SCRIPTS, NO_BOUNDARY_SCRIPTS, LATIN_EM, LEADING_VOWELS, _is_mark, _char_em,
@@ -444,6 +444,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
444
444
  "sources": {"type": "array", "description": "1.18: [{path, offset_s, confidence, drift_ppm}], one per SOURCE; the only per-source shape once more than one SOURCE is given"}}
445
445
  elif name == "look":
446
446
  extra = {"outputs": {"type": "array", "items": {"type": "string"}}}
447
+ elif name == "overlay":
448
+ extra = {"notes": {"type": "array", "items": {"type": "string"}, "description": "a multi-line --text on FFmpeg 8.0, 8.0.1 or 8.1.0, whose drawtext draws a box at each line break (fixed in 8.0.2 and 8.1.1)"}}
447
449
  elif name == "render":
448
450
  extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"]},
449
451
  "plan": {"type": "string", "description": "when the argument was a plan.json (written by <tool> --plan): its path"},
@@ -722,6 +724,126 @@ def _version_line(binary: str) -> Optional[str]:
722
724
  return m.group(1) if m else (first[0] if first else "unknown")
723
725
 
724
726
 
727
+ # ----------------------------------------------------------------------------- FFmpeg security advisories
728
+ # doctor's `ffmpeg_security`: a small, dated, hand-maintained table of high-severity FFmpeg CVEs and
729
+ # the first upstream point release on each branch that carries the fix. ffmpeg-skill does not ship
730
+ # FFmpeg, so a user stays exposed to a decoder bug until they upgrade; doctor says so. It reads the
731
+ # table and the local `-version` line only -- no network call, ever (execution.network: false).
732
+ #
733
+ # Source: https://ffmpeg.org/security.html (each release section lists the CVEs it fixes), checked
734
+ # on FFMPEG_ADVISORIES_CHECKED. A branch absent from `fixed_in` had no release listed with the fix
735
+ # on that date. Only CVEs that page lists go in: AISLE's batch (CVE-2026-64830..64835) was not on it
736
+ # on 2026-10-22, so it has no row yet -- add one, with its releases, when the page lists them.
737
+ # When editing: update FFMPEG_ADVISORIES_CHECKED, docs/contract.md and the CHANGELOG together.
738
+ FFMPEG_ADVISORY_SOURCE = "https://ffmpeg.org/security.html"
739
+ FFMPEG_ADVISORIES_CHECKED = "2026-10-22"
740
+ FFMPEG_ADVISORIES: List[Dict[str, Any]] = [
741
+ {"cve": "CVE-2026-8461", "name": "PixelSmash", "component": "MagicYUV decoder",
742
+ "kind": "heap out-of-bounds write", "cvss": 8.8,
743
+ # security.html lists the fix under 9.0, 8.1.2 and 8.0.3; no 7.1.x/7.0.x/6.1.x release carries it
744
+ "fixed_in": {"8.0": "8.0.3", "8.1": "8.1.2", "9.0": "9.0"}},
745
+ ]
746
+
747
+
748
+ def _parse_ffmpeg_version(text: Optional[str]) -> Optional[tuple]:
749
+ """(major, minor, patch) from an `ffmpeg -version` token: `6.1.1`, `8.0`, `n7.1.2`, and distro
750
+ builds that append to the upstream number (`8.0.1-3ubuntu2`, `7.1.2-0+deb13u1`,
751
+ `8.1.2-full_build-www.gyan.dev`). None for anything else -- a git snapshot (`N-12345-g...`,
752
+ `2026-10-01-git-...`) has no release number to compare, and a guess would be a false alarm."""
753
+ if not text:
754
+ return None
755
+ m = re.match(r"n?(\d+)\.(\d+)(?:\.(\d+))?(?![\d.])", text.strip())
756
+ if not m:
757
+ return None
758
+ return (int(m.group(1)), int(m.group(2)), int(m.group(3) or 0))
759
+
760
+
761
+ def _vstr(v: tuple) -> str:
762
+ return ".".join(str(x) for x in (v if v[2] else v[:2]))
763
+
764
+
765
+ def _advisory_hits(version: Optional[str]) -> Optional[List[Dict[str, Any]]]:
766
+ """The table's rows this version is older than (None: the version could not be parsed). A
767
+ version on a branch the table lists is compared with that branch's fixing release; one on an
768
+ older branch with no fixing release is compared with the oldest release that has the fix."""
769
+ v = _parse_ffmpeg_version(version)
770
+ if v is None:
771
+ return None
772
+ hits = []
773
+ branch = f"{v[0]}.{v[1]}"
774
+ for adv in FFMPEG_ADVISORIES:
775
+ fixes = {k: _parse_ffmpeg_version(f) for k, f in adv["fixed_in"].items()}
776
+ if branch in fixes:
777
+ first, on_branch = fixes[branch], True
778
+ else:
779
+ first, on_branch = min(fixes.values()), False
780
+ if v < first:
781
+ hits.append({"cve": adv["cve"], "name": adv["name"], "component": adv["component"], "kind": adv["kind"],
782
+ "cvss": adv["cvss"], "first_fix": _vstr(first), "fix_on_branch": on_branch, "branch": branch})
783
+ return hits
784
+
785
+
786
+ def _update_hint(version: str, source: Optional[str]) -> str:
787
+ """How to update the FFmpeg this version string came from, as one short phrase."""
788
+ low = version.lower()
789
+ if "ubuntu" in low or "+deb" in low or "debian" in low:
790
+ return "sudo apt update && sudo apt install --only-upgrade ffmpeg"
791
+ system = platform.system()
792
+ if system == "Darwin":
793
+ return "brew upgrade ffmpeg-full" if source == "homebrew-ffmpeg-full" else "brew upgrade ffmpeg, or the package manager it came from"
794
+ if system == "Windows":
795
+ return "winget upgrade Gyan.FFmpeg, or a newer build from https://ffmpeg.org/download.html"
796
+ if system == "Linux":
797
+ return "your distribution's package update, e.g. sudo apt install --only-upgrade ffmpeg or sudo dnf upgrade ffmpeg"
798
+ return "a newer build from https://ffmpeg.org/download.html"
799
+
800
+
801
+ def _advisory_warning(binary: str, version: str, hits: List[Dict[str, Any]], source: Optional[str]) -> str:
802
+ """One stderr/`warnings` line. It never says the build IS vulnerable: a distro package
803
+ (Ubuntu `8.0.1-3ubuntu2`, Debian `+deb13u1`) backports fixes without changing the upstream
804
+ number, and only the upstream number is visible here."""
805
+ parts = []
806
+ for h in hits:
807
+ where = (f"first fixed on the {h['branch']} branch in {h['first_fix']}" if h["fix_on_branch"]
808
+ else f"no {h['branch']} release is listed with the fix; the oldest that has it is {h['first_fix']}")
809
+ parts.append(f"{h['cve']} ({h['component']} {h['kind']}, CVSS {h['cvss']:g}; {where})")
810
+ return (f"warning: {binary} {version} is older than the first upstream FFmpeg release that fixes "
811
+ f"{'; '.join(parts)}. A distribution package may have backported the fix without changing the "
812
+ f"upstream version, so this is not proof the build is vulnerable: update it "
813
+ f"({_update_hint(version, source)}). Source: {FFMPEG_ADVISORY_SOURCE}, checked {FFMPEG_ADVISORIES_CHECKED}.")
814
+
815
+
816
+ def ffmpeg_security(versions: Dict[str, Optional[str]], sources: Optional[Dict[str, Optional[str]]] = None) -> Dict[str, Any]:
817
+ """doctor's `ffmpeg_security`: per binary {version, status, advisories}, and `warnings`, one line
818
+ per distinct version that is older than a fix. `status`: `older_than_fix`, `not_older`, or
819
+ `unknown` (no binary, or a version this table cannot compare -- which produces no warning).
820
+ Informational like `fonts`/`gpu_encoders`: it never moves `ok`, a tool's `usable` or the exit code."""
821
+ sources = sources or {}
822
+ out: Dict[str, Any] = {"source": FFMPEG_ADVISORY_SOURCE, "checked": FFMPEG_ADVISORIES_CHECKED, "warnings": []}
823
+ warned = set()
824
+ for binary in ("ffmpeg", "ffprobe"):
825
+ version = versions.get(binary)
826
+ hits = _advisory_hits(version)
827
+ status = "unknown" if hits is None else ("older_than_fix" if hits else "not_older")
828
+ out[binary] = {"version": version, "status": status, "advisories": hits or []}
829
+ if hits and version not in warned:
830
+ warned.add(version)
831
+ out["warnings"].append(_advisory_warning(binary, str(version), hits, sources.get(binary)))
832
+ return out
833
+
834
+
835
+ # 2.11.0 deprecates Python 3.9 (docs/contract.md "Deprecation policy"; DEPRECATED below). The same
836
+ # line is printed by bin/install.js (PY39_DEPRECATION there; a test keeps the two equal).
837
+ PY39_DEPRECATION = ("warning: Python 3.9 is deprecated since ffmpeg-skill 2.11.0 (CPython 3.9 reached end of life "
838
+ "in October 2025 and gets no security fixes); it still works in 2.x and support ends in 3.0.0: use Python 3.10+.")
839
+
840
+
841
+ def python_deprecation(version_info: Optional[tuple] = None) -> Optional[str]:
842
+ """The deprecation line for this interpreter, or None when it is not a deprecated one."""
843
+ vi = tuple(version_info if version_info is not None else sys.version_info)[:2]
844
+ return PY39_DEPRECATION if vi == (3, 9) else None
845
+
846
+
725
847
  def _exe_report() -> Dict[str, Any]:
726
848
  """`ffmpeg_path` / `ffmpeg_source` / `ffprobe_path` / `ffprobe_source`: which binary the tools run
727
849
  and why ("path": what PATH resolves; "homebrew-ffmpeg-full": macOS, a lean Homebrew ffmpeg on
@@ -973,12 +1095,16 @@ def doctor() -> Dict[str, Any]:
973
1095
  notes = [f"filter:drawtext: {drawtext_probe['note']}"] if drawtext_probe and drawtext_probe.get("note") else []
974
1096
  notes += [f"{cap}: not in this ffmpeg build; the tools that need it encode with {alt[8:]} (VideoToolbox) instead, so their output is "
975
1097
  "not CRF-equivalent (the result's `encoder`/`hw` say so)" for cap, alt in sorted(covered.items())]
1098
+ versions = {"ffmpeg": _version_line("ffmpeg"), "ffprobe": _version_line("ffprobe")}
1099
+ exes = _exe_report()
976
1100
  return {
977
1101
  "version": skill_version(),
978
1102
  "python": ".".join(str(x) for x in sys.version_info[:3]),
979
- "ffmpeg": _version_line("ffmpeg"),
980
- "ffprobe": _version_line("ffprobe"),
981
- **_exe_report(),
1103
+ "ffmpeg": versions["ffmpeg"],
1104
+ "ffprobe": versions["ffprobe"],
1105
+ **exes,
1106
+ # non-fatal: never part of `ok`, `usable` or the exit code
1107
+ "ffmpeg_security": ffmpeg_security(versions, {b: exes.get(f"{b}_source") for b in versions}),
982
1108
  "available": available,
983
1109
  "missing": missing_required,
984
1110
  "missing_optional": missing_optional,
@@ -1274,7 +1400,13 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
1274
1400
  # step 1 (this list, --help text and the CHANGELOG) in a minor, step 2 keeps it working, step 3
1275
1401
  # removes it in the major. `where` says which surface a caller sees it on. Empty at 2.0.0: all
1276
1402
  # five 1.10.0 deprecations were settled by it (REMOVED below).
1277
- DEPRECATED: List[Dict[str, str]] = []
1403
+ DEPRECATED: List[Dict[str, str]] = [
1404
+ # CPython 3.9 reached end of life in October 2025. Raising the floor is a break, so 2.x keeps
1405
+ # running on it (CI still tests 3.9) and doctor / bin/install.js print PY39_DEPRECATION.
1406
+ {"what": "running on Python 3.9 (the requirements.python floor)",
1407
+ "since": "2.11.0", "removed_in": "3.0.0", "where": "behaviour",
1408
+ "replacement": "Python 3.10 or later; on 3.9, doctor and the npx installer print a one-line warning"},
1409
+ ]
1278
1410
 
1279
1411
  # What 2.0.0 removed or changed, with the version that deprecated each: the "Removed" half of the
1280
1412
  # policy's step 3, kept in the contract so a caller migrating from 1.x can diff it instead of reading
@@ -1465,7 +1597,20 @@ def mcp_tool(spec: Dict[str, Any]) -> Dict[str, Any]:
1465
1597
  descriptions verbatim (the same ~250 characters repeated 42 times in every tools/list dump);
1466
1598
  it now goes once into the server's `initialize` response (`instructions`) instead, so a tool's
1467
1599
  own description stays its own one-line sentence."""
1468
- return {"name": spec["name"], "description": spec["description"], "inputSchema": mcp_input_schema(spec)}
1600
+ return {"name": spec["name"], "description": spec["description"], "inputSchema": mcp_input_schema(spec),
1601
+ "annotations": mcp_annotations(spec)}
1602
+
1603
+
1604
+ def mcp_annotations(spec: Dict[str, Any]) -> Dict[str, Any]:
1605
+ """MCP ToolAnnotations (spec 2025-06-18) derived from the ToolSpec flags. Hints only: clients
1606
+ must not treat them as a security boundary. Every tool runs locally (no open world); none edits
1607
+ its input in place; a tool that writes nothing is read-only."""
1608
+ writes = bool(spec.get("produces_artifact")) or bool(spec.get("mutates_input"))
1609
+ return {"title": spec["name"],
1610
+ "readOnlyHint": not writes,
1611
+ "destructiveHint": bool(spec.get("mutates_input")),
1612
+ "idempotentHint": spec.get("idempotency_hint") in ("bit_exact", "content_equivalent", "cached"),
1613
+ "openWorldHint": False}
1469
1614
 
1470
1615
 
1471
1616
  def mcp_tools(detect: bool = False) -> List[Dict[str, Any]]:
@@ -1499,7 +1644,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
1499
1644
  },
1500
1645
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"],
1501
1646
  },
1502
- "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0", "node": ">=16 (npx installer only)"},
1647
+ "requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.11.0, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0", "node": ">=16 (npx installer only)"},
1503
1648
  "execution": {
1504
1649
  "shell": False,
1505
1650
  "arbitrary_executables": False,
@@ -1563,6 +1708,13 @@ def main() -> int:
1563
1708
  args = ap.parse_args()
1564
1709
  if args.command == "doctor":
1565
1710
  d = doctor()
1711
+ # Advisories, never failures: on stderr in both modes, so `doctor --json`'s stdout stays one
1712
+ # JSON document. The FFmpeg ones are also in the document (`ffmpeg_security.warnings`); the
1713
+ # Python 3.9 deprecation is stderr-only, as every deprecation warning is (docs/contract.md
1714
+ # "Deprecation policy" step 1), and machine-readable in `contract --json`'s `deprecated`.
1715
+ for line in [python_deprecation()] + d["ffmpeg_security"]["warnings"]:
1716
+ if line:
1717
+ print(line, file=sys.stderr)
1566
1718
  if args.json:
1567
1719
  print(json.dumps(d, indent=2, sort_keys=True))
1568
1720
  else:
@@ -39,7 +39,7 @@ from _common import (aac_args, add_common, brand_caption_style, script_font_for_
39
39
  LANGUAGE_NAMES, needs_shaping, detect_script, BIDI_SCRIPTS, font_family_of_file, font_family_for_script, has_emoji,
40
40
  emoji_clusters, emoji_codepoint_name, char_script, emoji_filter_chain, emoji_asset_for, emoji_support, resolve_emoji_assets,
41
41
  EMOJI_ASSET_HINT, text_width_em, drawtext_shaping, wrap_text, WRAP_MODES,
42
- SAFE_WIDTH_FRACTION)
42
+ SAFE_WIDTH_FRACTION, drawtext_draws_newline_glyph, is_multiline, _DRAWTEXT_PENDING)
43
43
  from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
44
44
 
45
45
  # where the template's text chain goes in a filter_complex it builds (graphics.main fills it in)
@@ -63,7 +63,9 @@ def font_opts(brand: dict, font: Optional[str], font_file: Optional[str], script
63
63
  return f"font='{escape_drawtext(font or brand.get('font', 'DejaVu Sans'))}'"
64
64
 
65
65
 
66
- def main() -> int:
66
+ def main(force_route: Optional[str] = None) -> int:
67
+ """`force_route` is the second pass of an auto run whose drawtext labels came out multi-line on a
68
+ build that draws a box at every line break (see the check after the templates)."""
67
69
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
68
70
  ap.add_argument("input")
69
71
  ap.add_argument("-o", "--output", help="output file (default: <name>_gfx.<ext>)")
@@ -104,7 +106,8 @@ def main() -> int:
104
106
  ap.add_argument("--text-render", choices=["auto", "ass", "drawtext"], default="auto",
105
107
  help="which renderer draws the template's text: 'auto' (default) uses libass for "
106
108
  "scripts drawtext cannot shape (Devanagari, Bengali, Tamil, Thai, Lao ...) and for "
107
- "emoji overlays, and drawtext for everything else -- Latin/CJK/Arabic frames are "
109
+ "emoji overlays, and for multi-line text on FFmpeg 8.0, 8.0.1 and 8.1.0 (their drawtext draws "
110
+ "a box at each line break), and drawtext for everything else -- Latin/CJK/Arabic frames are "
108
111
  "pixel-identical to 1.14 (the drawtext command itself changed: the label is "
109
112
  "passed as textfile=, not text=); 'ass' always uses libass; 'drawtext' forces the old renderer and is "
110
113
  "refused for a script it cannot shape")
@@ -244,6 +247,8 @@ def main() -> int:
244
247
  kind="input")
245
248
  route = "ass" if (args.text_render == "ass" or
246
249
  (args.text_render == "auto" and (shaping or emoji_mode == "png"))) else "drawtext"
250
+ if force_route:
251
+ route = force_route
247
252
  # drawtext loads exactly ONE font file and has no fallback chain, so "whatever glyph the text
248
253
  # font has" for an emoji is an empty box on DejaVu Sans and on every script font: reporting
249
254
  # mode "mono" from the drawtext route is a claim the frame does not keep. libass DOES have a
@@ -550,6 +555,20 @@ def main() -> int:
550
555
  outline=float(max(2, fs // 40)), outline_color=bg, start=ks, end=ke,
551
556
  scale_t=(0, seg * 500, 115))
552
557
 
558
+ # FFmpeg 8.0, 8.0.1 and 8.1 draw the font's missing-glyph box at the end of every line but the
559
+ # last of a multi-line drawtext (and widen text_w by it). Every template lays out the same
560
+ # elements for libass, so an auto run whose labels came out multi-line -- given that way or
561
+ # wrapped to the frame above -- is laid out again on that route; a pinned drawtext run says so.
562
+ if route == "drawtext" and drawtext_draws_newline_glyph() and any(is_multiline(t) for t in _DRAWTEXT_PENDING.values()):
563
+ if args.text_render == "auto" and not force_route:
564
+ info("multi-line text: this ffmpeg's drawtext draws a box at each line break "
565
+ "(FFmpeg 8.0-8.0.1 and 8.1.0; fixed in 8.0.2/8.1.1), so it is rendered through libass")
566
+ _DRAWTEXT_PENDING.clear()
567
+ return main(force_route="ass")
568
+ info("warning: this ffmpeg's drawtext draws a box at each line break of a multi-line label "
569
+ "(FFmpeg 8.0-8.0.1 and 8.1.0; fixed in 8.0.2/8.1.1): drop --text-render drawtext to render it "
570
+ "through libass, or upgrade FFmpeg")
571
+
553
572
  output = args.output or default_output(args.input, "gfx")
554
573
  ass_path = None
555
574
  emoji_result = None
@@ -25,7 +25,7 @@ import sys
25
25
  from typing import List, Optional
26
26
 
27
27
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
28
- from _common import STATE, script_font_for_text, drawtext_text_opts, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
28
+ from _common import STATE, script_font_for_text, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
29
29
 
30
30
  # Per-edge margins: a platform's UI does not cover the same fraction of every edge (TikTok's
31
31
  # like column is 14 % of the width, its description block 22 % of the height), so a position
@@ -144,6 +144,7 @@ def main() -> int:
144
144
  add_common(ap)
145
145
  args = ap.parse_args()
146
146
  apply_common(args)
147
+ notes: List[str] = []
147
148
 
148
149
  brand = load_brand(args.brand)
149
150
  if args.logo:
@@ -334,6 +335,12 @@ def main() -> int:
334
335
  x, y = position_exprs(args.position, args.margin, text_mode=True, margins=safe_margins)
335
336
  # 1.15: the text goes in a FILE with expansion off, so `'` and `%` survive verbatim
336
337
  # (they used to be dropped by escape_drawtext) and no character can reach the graph parser.
338
+ if is_multiline(args.text) and drawtext_draws_newline_glyph():
339
+ # overlay has no libass route to send it to (graphics.py's --text-render auto does)
340
+ notes.append("this ffmpeg's drawtext (FFmpeg 8.0, 8.0.1 and 8.1.0; fixed in 8.0.2 and 8.1.1) draws "
341
+ "the font's missing-glyph box at the end of every line but the last of a multi-line "
342
+ "--text: draw multi-line text with graphics.py or caption.py, or upgrade FFmpeg")
343
+ info("warning: " + notes[-1])
337
344
  opts = [drawtext_text_opts(args.text), f"fontsize={args.font_size}", f"x={x}", f"y={y}",
338
345
  f"borderw={args.border}", f"bordercolor={args.border_color}"]
339
346
  if args.font_file:
@@ -360,7 +367,7 @@ def main() -> int:
360
367
  if not STATE.dry_run:
361
368
  result = probe(output, role="output")
362
369
  info(f"wrote {output} ({fmt_secs(result['duration'])})")
363
- emit(output, dropped_non_av_streams=dropped_streams)
370
+ emit(output, dropped_non_av_streams=dropped_streams, **({"notes": notes} if notes else {}))
364
371
  return 0
365
372
 
366
373
 
package/scripts/pad.py CHANGED
@@ -16,7 +16,7 @@ Examples:
16
16
  import argparse
17
17
  import sys
18
18
 
19
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
19
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, EVEN_SCALE, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
20
20
 
21
21
 
22
22
  def main() -> int:
@@ -50,10 +50,18 @@ def main() -> int:
50
50
  # a --color with chroma lands in the BT.709 the output is tagged with (tpad's own YUV is BT.601
51
51
  # before FFmpeg 7.1, and for an untagged picture after it); black is the same under every matrix
52
52
  vf = drawn_chain(tpad + args.color, meta, [args.color], tpad + "black")
53
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
53
+ cmd = ffmpeg_base() + ["-i", args.input]
54
54
  if has_audio:
55
+ # One -filter_complex, not a -vf and an -af: the ffmpeg scheduler of Ubuntu 26.04's 8.0.1 and
56
+ # conda-forge's 7.1.1 (not Debian's 7.1.5 or 8.1) deadlocks with no CPU use when one demuxer feeds two simple
57
+ # graphs and the audio one both delays its input and pads the tail. One graph reading both
58
+ # streams runs on 5.1-8.1 alike. run() only adds an even scale to a -vf, so add it here.
59
+ v = meta["video"]
60
+ even = EVEN_SCALE + "," if (v.get("width") or 0) % 2 or (v.get("height") or 0) % 2 else ""
55
61
  af = f"adelay={int(args.start * 1000)}:all=1,apad=pad_dur={args.end:.3f}"
56
- cmd += ["-map", "0:a:0?", "-af", af]
62
+ cmd += ["-filter_complex", f"[0:v:0]{even}{vf}[v];[0:a:0]{af}[a]", "-map", "[v]", "-map", "[a]"]
63
+ else:
64
+ cmd += ["-vf", vf, "-map", "0:v:0"]
57
65
  cmd += video_args(meta, args.crf, args.preset)
58
66
  cmd += cfr_args(meta)
59
67
  if has_audio:
@@ -3,7 +3,7 @@
3
3
 
4
4
  Detects quiet stretches with ffmpeg's silencedetect, keeps a margin on each
5
5
  side so words are not clipped, drops gaps shorter than --min-silence, and
6
- writes a frame-accurate re-encode in one pass (select/aselect filters).
6
+ writes a frame-accurate re-encode in one pass (select, and ametadata for the sound).
7
7
 
8
8
  Examples:
9
9
  python3 silence.py talk.mp4 # -35 dB, gaps >= 0.6 s, 0.15 s margin
@@ -312,7 +312,7 @@ def main() -> int:
312
312
  silences = detect(args.input, args.threshold, args.min_silence)
313
313
  filler_info, filler_ranges = resolve_filler(args, meta) if args.filler else (None, [])
314
314
  # One sorted, merged removal list through the graph the tool already has: filler removal IS
315
- # time-range removal, so it reuses keep_ranges() and the same aselect/concat chain.
315
+ # time-range removal, so it reuses keep_ranges() and the same select chain.
316
316
  removals = merge_spans(list(silences) + list(filler_ranges))
317
317
  keeps = keep_ranges(removals, duration, args.margin, args.min_keep)
318
318
  kept = sum(e - s for s, e in keeps)
@@ -403,7 +403,13 @@ def main() -> int:
403
403
  output = args.output or default_output(args.input, "tight")
404
404
  expr = "+".join(f"between(t,{s:.3f},{e:.3f})" for s, e in keeps)
405
405
  vf = f"select='{expr}',setpts=N/FRAME_RATE/TB"
406
- af = f"aselect='{expr}',asetpts=N/SR/TB"
406
+ # Not `aselect='{expr}'`: FFmpeg 8.0 and 8.0.1 (Ubuntu 26.04's apt build) ship an aselect that
407
+ # passes every frame through (its activate callback was lost when the filter was converted;
408
+ # fixed in 8.0.2/8.1), which cut the picture and left the sound whole. Tagging the frames the
409
+ # expression keeps (timeline `enable`, the same per-frame t) and selecting the tagged ones keeps
410
+ # exactly the frames aselect keeps, on every release, in a command no longer than before.
411
+ af = (f"ametadata=mode=add:key=ffskill_keep:value=1:enable='{expr}',"
412
+ "ametadata=mode=select:key=ffskill_keep,asetpts=N/SR/TB")
407
413
  cmd = ffmpeg_base() + ["-i", args.input]
408
414
  audio_only = is_audio_output(output) or not meta.get("video")
409
415
  if audio_only: