ffmpeg-skill 2.10.8 → 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 +5 -3
- package/SKILL.md +1 -1
- package/bin/install.js +33 -5
- package/docs/contract.md +36 -6
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +13 -3
- package/references/scripts.md +8 -3
- package/scripts/_common/__init__.py +6 -5
- package/scripts/_common/drawtext.py +21 -1
- package/scripts/_common/runner.py +24 -0
- package/scripts/_common/text.py +2 -1
- package/scripts/_contract.py +144 -5
- package/scripts/graphics.py +22 -3
- package/scripts/overlay.py +9 -2
- package/scripts/pad.py +11 -3
- package/scripts/silence.py +9 -3
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).
|
|
@@ -417,7 +419,7 @@ The encode path a tool will actually take is Apple VideoToolbox, for h264, hevc
|
|
|
417
419
|
|
|
418
420
|
## FFmpeg compatibility
|
|
419
421
|
|
|
420
|
-
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:
|
|
421
423
|
|
|
422
424
|
| FFmpeg | `-filters` row layout | Source |
|
|
423
425
|
|---|---|---|
|
|
@@ -525,7 +527,7 @@ FFmpeg itself:
|
|
|
525
527
|
## Requirements
|
|
526
528
|
|
|
527
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.
|
|
528
|
-
- Python 3.
|
|
530
|
+
- Python 3.10+ recommended, standard library only. 3.9 still works but is deprecated (end of life upstream; support ends in 3.0)
|
|
529
531
|
- Node 16+ only for the `npx` installer
|
|
530
532
|
|
|
531
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
85
|
-
|
|
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.
|
|
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.
|
|
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
|
-
- **
|
|
171
|
-
|
|
172
|
-
|
|
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
|
package/references/scripts.md
CHANGED
|
@@ -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
|
|
585
|
-
(frame accurate)
|
|
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()
|
|
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
|
|
package/scripts/_common/text.py
CHANGED
|
@@ -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,
|
package/scripts/_contract.py
CHANGED
|
@@ -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":
|
|
980
|
-
"ffprobe":
|
|
981
|
-
**
|
|
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
|
|
@@ -1512,7 +1644,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1512
1644
|
},
|
|
1513
1645
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"],
|
|
1514
1646
|
},
|
|
1515
|
-
"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)"},
|
|
1516
1648
|
"execution": {
|
|
1517
1649
|
"shell": False,
|
|
1518
1650
|
"arbitrary_executables": False,
|
|
@@ -1576,6 +1708,13 @@ def main() -> int:
|
|
|
1576
1708
|
args = ap.parse_args()
|
|
1577
1709
|
if args.command == "doctor":
|
|
1578
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)
|
|
1579
1718
|
if args.json:
|
|
1580
1719
|
print(json.dumps(d, indent=2, sort_keys=True))
|
|
1581
1720
|
else:
|
package/scripts/graphics.py
CHANGED
|
@@ -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
|
|
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
|
package/scripts/overlay.py
CHANGED
|
@@ -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
|
|
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 += ["-
|
|
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:
|
package/scripts/silence.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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:
|