ffmpeg-skill 3.8.5 → 3.8.7
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 +3 -3
- package/SKILL.md +3 -3
- package/docs/contract.md +29 -20
- package/mcp/server.py +3 -2
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +9 -6
- package/references/flags.md +2 -2
- package/references/gotchas.md +10 -4
- package/references/scripts.md +26 -8
- package/references/workflow.md +2 -2
- package/scripts/_common/decision.py +6 -1
- package/scripts/_common/runner.py +46 -5
- package/scripts/_contract.py +84 -7
- package/scripts/audio.py +38 -5
- package/scripts/batch.py +10 -0
- package/scripts/caption.py +51 -1
- package/scripts/color.py +6 -0
- package/scripts/cropdetect.py +6 -1
- package/scripts/cut.py +5 -0
- package/scripts/export.py +1 -1
- package/scripts/fit.py +5 -0
- package/scripts/graphics.py +20 -1
- package/scripts/look.py +7 -0
- package/scripts/loudness.py +8 -0
- package/scripts/metadata.py +3 -0
- package/scripts/overlay.py +18 -1
- package/scripts/render.py +50 -5
- package/scripts/scenes.py +14 -0
- package/scripts/sequence.py +4 -0
- package/scripts/silence.py +3 -0
- package/scripts/sync.py +6 -0
- package/scripts/verify.py +4 -0
- package/scripts/waveform.py +13 -4
package/README.md
CHANGED
|
@@ -196,7 +196,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
196
196
|
|
|
197
197
|
1. **Probe first.** No tool decides from the file name. `probe.py` measures duration, fps (with variable-frame-rate detection), resolution, rotation, bit depth, HDR format including Dolby Vision, colour tags and every audio stream before anything is cut.
|
|
198
198
|
2. **Lossless when possible.** `cut.py` and `loudness.py` stream-copy what they do not need to touch (`join.py` re-encodes so it can crossfade and conform mismatched clips; `--copy --transition none` joins clips that already match by stream copy). Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance.
|
|
199
|
-
3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--plan FILE` (the dry run saved as a plan with fingerprinted inputs that `render.py FILE` executes later, refusing if an input changed), `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress` (percent and ETA), `--timeout` (a hung ffmpeg is killed and reported, never waited on forever; Ctrl-C or SIGTERM likewise stops the running ffmpeg, removes its partial output and reports `kind: interrupted`) and `--overwrite` (explicit consent before an existing output is replaced). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared
|
|
199
|
+
3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing; `verify` ignores it), `--plan FILE` (the dry run saved as a plan with fingerprinted inputs that `render.py FILE` executes later, refusing if an input changed; `render` and `verify` refuse it), `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress` (percent and ETA), `--timeout` (a hung ffmpeg is killed and reported, never waited on forever; Ctrl-C or SIGTERM likewise stops the running ffmpeg, removes its partial output and reports `kind: interrupted`) and `--overwrite` (explicit consent before an existing output is replaced). A test runs every tool whose contract says its dry run runs no ffmpeg (`ffmpeg_execution: none`, plus `cut` and `metadata` without their measuring flags) under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared; another runs every tool the contract says supports `--dry-run` (all but `verify`) behind an ffmpeg that logs each decode, and asserts that no file appeared and that every tool that decoded is one the contract lists as `analysis_only`.
|
|
200
200
|
4. **Machine-readable contract.** `contract --json` describes all 42 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required, `mutates_input: false`. `provides` lists all 42 by a cross-repository Capability id (`ffmpeg-skill.cut`, `ffmpeg-skill.loudness`, ...) for [`kajisho5/AI-video-production-OS`](https://github.com/kajisho5/AI-video-production-OS)'s `CapabilityContract.provides` — see `docs/contract.md`.
|
|
201
201
|
5. **Contract-derived MCP.** `mcp/server.py` builds its `tools/list` from the contract. Tool names, order and `inputSchema` cannot drift from the scripts; a test keeps the two byte-identical.
|
|
202
202
|
6. **Capability detection.** `doctor` reads `ffmpeg -encoders / -decoders / -filters / -bsfs` and reports which of the components the tools need are present on this build (libx264, libass, zscale, loudnorm, xfade, …), before a job fails inside ffmpeg.
|
|
@@ -674,9 +674,9 @@ node bin/install.js --dir /tmp/skills # try the installer without touching ~/.
|
|
|
674
674
|
|
|
675
675
|
CI (`.github/workflows/ci.yml`) runs on every pull request and on pushes to `main`, on Ubuntu (FFmpeg 6.1 with Python 3.10 on 24.04, FFmpeg 8.0.1 with Python 3.13 on 26.04), macOS (Homebrew FFmpeg 8.x) and Windows (gyan.dev FFmpeg 9.x), plus a Linux job on FFmpeg 7.1 (Debian trixie container), and uploads each runner's FFmpeg listings as an artifact.
|
|
676
676
|
|
|
677
|
-
`tests/test_contract.py` runs on all three OSes, but
|
|
677
|
+
`tests/test_contract.py` runs on all three OSes, but some of its tests put a fake `ffmpeg` or `ffprobe` (a `#!/bin/sh` script on a PATH shim) in front of the real one — to record every call under `--dry-run`, to force specific FFmpeg 6/7/8/9 fixture layouts through `doctor`'s parser, to report an old version, to hang or fail on cue — or need what Windows does not have or does differently (SIGTERM, unprivileged symlinks, a line break or a backslash in a file name). Those tests, among them `test_dry_run_never_runs_ffmpeg_and_writes_nothing` and the whole `DoctorDetectionTests` class, skip there (`skipIf`, or `skipTest` inside the test) and show as `skipped`, not silently absent, in the Windows job's log; `grep -n 'os.name == "nt"\|platform.system() == "Windows"' tests/test_contract.py` finds their conditions, among the suite's other platform branches. The rest — contract schema, `reencodes_*`, `doctor.tools`, MCP derivation, and the tools exercised through the contract, including `cut.py`'s provenance fields — runs against the real Windows `ffmpeg` on every PR. See [references/ci-platform-pitfalls.md](references/ci-platform-pitfalls.md) for this and other per-OS behaviour differences already diagnosed, before spending a CI cycle re-diagnosing a platform-only failure.
|
|
678
678
|
|
|
679
|
-
**Releasing** is fully automated end to end, including the version number itself — a PR never needs to touch `package.json` or the version in `docs/contract.md`. `CHANGELOG.md` is different: the release writes the version heading and one line per merged PR, but a `feat` PR writes its own notes under `## Unreleased` (CONTRIBUTING.md), and a PR that closes an issue must name that issue in `CHANGELOG.md` or `test_changelog_mentions_every_closed_issue_since_last_tag` fails. Once a PR merges to `main`, `.github/workflows/release.yml` takes it from there: if nobody bumped the version by hand, it resolves the next version ([.github/scripts/resolve_version.py](.github/scripts/resolve_version.py)) from the labels on every PR merged since the last tag (`minor`/`feature`/`enhancement` → minor, `fix`/`bug`/`patch` → patch, an unlabeled PR defaults to `patch`; a PR whose only labels are `chore`, `ci`, `docs` or `dependencies` is not releasable, so a merge that changes no shipped file releases nothing). Most PRs don't need a label added by hand: `release-drafter.yml`'s `autolabel` job applies one automatically from the PR's title/changed files (`Fix ...` → `fix`, `Add ...`/`feat ...` → `feature`, `docs`/`.md`/`.github/`/`build(deps)` changes → `chore`) as soon as it's opened — add a label yourself only to override that. **A major version is never chosen automatically**: no label rule produces `major`, and the workflow refuses to auto-bump across a major boundary even if someone applies that label — a real major release is a deliberate `package.json` bump in a PR, which the manual path below already handles. It bumps `package.json`, `.claude-plugin/plugin.json`, `docs/contract.md` and `docs/roadmap.md`'s released-version sentence, writes a `CHANGELOG.md` section listing those PRs (and any issues they closed), and pushes that commit to `main` itself. If another PR merged while the bump was being prepared, the push is refused and that run releases nothing: the newer merge's own run, queued behind it, starts from `main`'s tip and releases both (the bump is never rebased onto a merge it did not read). After the tag it also asks `pages.yml` to rebuild the site, because the bump commit carries `[skip ci]` and would otherwise leave the site on the previous version. Either way — auto-bumped or hand-bumped in the PR — it then creates the `vX.Y.Z` tag, publishes a GitHub Release with notes extracted from `CHANGELOG.md`'s matching section, and publishes the package to npm. A PR that still wants to write its own version bump and `CHANGELOG.md` prose (e.g. to explain the "why" of a release by hand) can — the automation only fills in when nobody made that call already. A push to `main` with nothing new to release is a
|
|
679
|
+
**Releasing** is fully automated end to end, including the version number itself — a PR never needs to touch `package.json` or the version in `docs/contract.md`. `CHANGELOG.md` is different: the release writes the version heading and one line per merged PR, but a `feat` PR writes its own notes under `## Unreleased` (CONTRIBUTING.md), and a PR that closes an issue must name that issue in `CHANGELOG.md` or `test_changelog_mentions_every_closed_issue_since_last_tag` fails. Once a PR merges to `main`, `.github/workflows/release.yml` takes it from there: if nobody bumped the version by hand, it resolves the next version ([.github/scripts/resolve_version.py](.github/scripts/resolve_version.py)) from the labels on every PR merged since the last tag (`minor`/`feature`/`enhancement` → minor, `fix`/`bug`/`patch` → patch, an unlabeled PR defaults to `patch`; a PR whose only labels are `chore`, `ci`, `docs` or `dependencies` is not releasable, so a merge that changes no shipped file releases nothing). Most PRs don't need a label added by hand: `release-drafter.yml`'s `autolabel` job applies one automatically from the PR's title/changed files (`Fix ...` → `fix`, `Add ...`/`feat ...` → `feature`, `docs`/`.md`/`.github/`/`build(deps)` changes → `chore`) as soon as it's opened — add a label yourself only to override that. **A major version is never chosen automatically**: no label rule produces `major`, and the workflow refuses to auto-bump across a major boundary even if someone applies that label — a real major release is a deliberate `package.json` bump in a PR, which the manual path below already handles. It bumps `package.json`, `.claude-plugin/plugin.json`, `docs/contract.md` and `docs/roadmap.md`'s released-version sentence, writes a `CHANGELOG.md` section listing those PRs (and any issues they closed), and pushes that commit to `main` itself. If another PR merged while the bump was being prepared, the push is refused and that run releases nothing: the newer merge's own run, queued behind it, starts from `main`'s tip and releases both (the bump is never rebased onto a merge it did not read). After the tag it also asks `pages.yml` to rebuild the site, because the bump commit carries `[skip ci]` and would otherwise leave the site on the previous version. Either way — auto-bumped or hand-bumped in the PR — it then creates the `vX.Y.Z` tag, publishes a GitHub Release with notes extracted from `CHANGELOG.md`'s matching section, and publishes the package to npm. A PR that still wants to write its own version bump and `CHANGELOG.md` prose (e.g. to explain the "why" of a release by hand) can — the automation only fills in when nobody made that call already. A push to `main` with nothing new to release creates no tag, Release or npm publish; its MCP Registry step still asks npm and the registry about `server.json`'s version and publishes the entry only when npm has that version and the registry does not, which is how a later run finishes an entry an earlier one failed to publish. npm publishing needs an `NPM_TOKEN` repo secret (an npm access token with publish rights on this package) — without it the tag and GitHub Release still happen, only the npm step is skipped. A repo that depends on this one (an editing skill, an agent) should pin an `ffmpeg-skill` version by tag or npm version, not by tracking `main` — a merged-but-not-yet-released commit on `main` can be ahead of the last published npm version for the few minutes between merge and this workflow completing.
|
|
680
680
|
|
|
681
681
|
**A version that was tagged but never reached npm** (the npm step failed; a re-run of that release sees the tag and skips the publish) is finished by dispatching the same workflow on its tag: `gh workflow run release.yml --ref vX.Y.Z -f version=X.Y.Z`. That runs only the workflow's `recover` job. It refuses a version that is not `X.Y.Z`, has no tag, or that npm already has, and a run whose commit is not the tagged one: the npm provenance attestation names the run's commit, so it must be the commit the package is built from. It publishes with `--provenance` under the `latest` dist-tag when the version is above every stable (non-prerelease) version npm has, and under `backport` otherwise, so `latest` never moves back. Then it publishes the `plugin` branch when no commit there is for this version and the branch's tip is older, and the MCP Registry entry when the registry does not list the version (never for a `backport`). The GitHub Release is left as it is. A tag cut before the job was added (#448; every tag up to v3.6.3) has no `recover` job at its commit and cannot be recovered this way.
|
|
682
682
|
|
package/SKILL.md
CHANGED
|
@@ -12,7 +12,7 @@ MCP `tools/list` shows the core 12; the other 30 of the 42 tools stay callable b
|
|
|
12
12
|
|
|
13
13
|
Shared flags (the rest, codec defaults and `--hw`: `references/flags.md`):
|
|
14
14
|
`--dry-run` (plan, write nothing), `--json` (output, probe, commands), `--json-brief` (status + `summary`),
|
|
15
|
-
`--overwrite` (step 7), `--timeout SECONDS` (default 1800, `0` = none; `kind: timeout`).
|
|
15
|
+
`--overwrite` (step 7), `--timeout SECONDS` (default 1800, `verify` 600 per step, `0` = none; `kind: timeout`).
|
|
16
16
|
|
|
17
17
|
Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`,
|
|
18
18
|
`scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`, `overlay`, `metadata`, `cut`, `render`,
|
|
@@ -108,7 +108,7 @@ Variants, caveats and extra flags per row: `references/requests.md`.
|
|
|
108
108
|
| "cut on the beat" | `cut.py in.mp4 --segments ... --snap beats --snap-source beats.json` (`scenes.py --beats`; a WAV/MP3 music bed too) |
|
|
109
109
|
| "speed up here, slow-mo there" | `speedramp.py in.mp4 --segment 0-3:1.0 --segment 3-4:0.25` |
|
|
110
110
|
| "freeze the last frame" | `freeze.py clip.mp4 --hold 2` |
|
|
111
|
-
| "smooth slow motion" | `fit.py in.mp4 --duration
|
|
111
|
+
| "smooth slow motion" | `fit.py in.mp4 --duration 20 --smooth interpolate` (a 10 s clip at half speed; `--duration` is the target length) |
|
|
112
112
|
| "reverse this clip" | `reverse.py in.mp4` |
|
|
113
113
|
| "loop it to fill 30 seconds", "seamless loop" | `loop.py clip.mp4 --duration 30` (`--crossfade 0.5` hides the seam) |
|
|
114
114
|
| "make it vertical / 9:16 / square" | `fit.py in.mp4 --aspect 9:16 --fit pad` (or `crop`, `blur`) |
|
|
@@ -163,7 +163,7 @@ Variants, caveats and extra flags per row: `references/requests.md`.
|
|
|
163
163
|
| "is it broadcast legal?" | `check.py final.mp4 --levels ebu-r103` (clamp: `color.py in.mp4 --correct --legalize ebu-r103`) |
|
|
164
164
|
| "send me a summary of what you did" | `report.py --before raw.mov --after final.mp4 -o report.html` |
|
|
165
165
|
| "title card", "Ken Burns on a photo" | `insert.py title.png --duration 3` (`--zoom in --pan right`) |
|
|
166
|
-
| "a blank colour clip" | `background.py -o bg.mp4 --duration 3 --color 0x101010` |
|
|
166
|
+
| "a blank colour clip" | `background.py -o bg.mp4 --duration 3 --width 1920 --height 1080 --color 0x101010` |
|
|
167
167
|
| "grade it but keep the alpha" | `color.py logo.mov --correct --exposure 0.3 --keep-alpha -o graded.mov` |
|
|
168
168
|
| "lower third as a transparent layer" | `background.py --transparent -o canvas.mov --duration 5 --width 1920 --height 1080`, then `graphics.py canvas.mov --template lower-third --name "Ada" --keep-alpha -o lt.mov` |
|
|
169
169
|
| "numbered frames into a video" | `sequence.py --dir frames --pattern "frame_%04d.png" --fps 24` |
|
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 (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.8.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.8.7`) | 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`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.8.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.7", "execution_mode": "local", "kind": "execution",
|
|
153
153
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
154
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
155
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
|
@@ -197,12 +197,16 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
|
|
|
197
197
|
|
|
198
198
|
### Roles
|
|
199
199
|
|
|
200
|
+
Every tool is in exactly one row; `tests/test_contract.py`'s
|
|
201
|
+
`test_contract_md_role_and_verification_tables_match_the_contract` compares both tables below with
|
|
202
|
+
`contract --json`. A parenthesis after a name is a note, not part of it.
|
|
203
|
+
|
|
200
204
|
| Role | Tools |
|
|
201
205
|
|---|---|
|
|
202
|
-
| `analysis` (measures, writes no media) | probe, scenes |
|
|
203
|
-
| `analysis_and_execution` (measures by default or with a flag, can also write) |
|
|
204
|
-
| `execution` (writes a new artifact) |
|
|
205
|
-
| `verification` (checks or shows an artifact) | check, look,
|
|
206
|
+
| `analysis` (measures, writes no media) | cropdetect, probe, scenes (`--sheet` draws a contact sheet PNG, `--edl` writes a text list) |
|
|
207
|
+
| `analysis_and_execution` (measures by default or with a flag, can also write) | loudness (`--measure-only`), silence (`--list`), sync (offset JSON without `-o`) |
|
|
208
|
+
| `execution` (writes a new artifact) | audio, background, batch, broll, caption, color, crop, cut, deinterlace, denoise, export, fit, freeze, graphics, grid, insert, join, loop, metadata, multicam, overlay, pad, proxy, redact, render, reverse, sequence, speedramp, sphere, stabilize, straighten, waveform |
|
|
209
|
+
| `verification` (checks or shows an artifact) | check, look, report, verify |
|
|
206
210
|
|
|
207
211
|
`batch` writes one artifact per input only when a recipe step names `{out}` (or the recipe is a
|
|
208
212
|
render project): a recipe in which no step does (a QC pass of `check.py`/`probe.py`) writes none,
|
|
@@ -215,15 +219,15 @@ The workflow in `SKILL.md` is "probe first, verify last". The contract states it
|
|
|
215
219
|
|
|
216
220
|
| Tool | After it wrote an artifact, run |
|
|
217
221
|
|---|---|
|
|
218
|
-
| cut,
|
|
219
|
-
| loudness | probe, check |
|
|
220
|
-
|
|
|
221
|
-
| fit, caption, overlay, graphics, color, join, multicam | probe, look |
|
|
222
|
+
| audio, batch, cut, metadata, silence, sync | probe |
|
|
223
|
+
| export, loudness | probe, check |
|
|
224
|
+
| background, broll, caption, color, crop, deinterlace, denoise, fit, freeze, graphics, grid, insert, join, loop, multicam, overlay, pad, proxy, redact, reverse, sequence, speedramp, sphere, stabilize, straighten, waveform | probe, look |
|
|
222
225
|
| render | probe, check, look |
|
|
223
|
-
|
|
|
226
|
+
| check, cropdetect, look, probe, report, scenes, verify | nothing (they measure or are the verification) |
|
|
224
227
|
|
|
225
|
-
`requires_visual_verification` is `true` exactly for the tools
|
|
226
|
-
|
|
228
|
+
`requires_visual_verification` is `true` exactly for the tools whose row above names `look`:
|
|
229
|
+
the tools that make or change a picture. `join` of audio-only inputs and `render`'s audio
|
|
230
|
+
templates write no picture, but the flag is per tool, not per run. Audio-only tools and
|
|
227
231
|
audio-only inputs never need `look`; the report line is `Look: not needed`. `check`
|
|
228
232
|
rows carry `kind: format` (fix it) or `kind: judgement` (decide with the user), and `basis`:
|
|
229
233
|
`requirement` (the row can FAIL) or `guidance`. The `duration` and `file size` rows are `basis: guidance`
|
|
@@ -248,12 +252,15 @@ document. Thresholds: `docs/design-decisions.md` ("check.py --content", "broadca
|
|
|
248
252
|
|
|
249
253
|
### Dry run
|
|
250
254
|
|
|
251
|
-
`supports_dry_run` is measured, not declared: `tests/test_contract.py` runs every tool
|
|
255
|
+
`supports_dry_run` is measured, not declared: `tests/test_contract.py` runs every tool whose
|
|
256
|
+
`dry_run.ffmpeg_execution` is `none` (and `cut` and `metadata` without their measuring flags)
|
|
252
257
|
with `--dry-run` behind a fake `ffmpeg` that records any call, and asserts that no
|
|
253
|
-
call happened and no file appeared
|
|
258
|
+
call happened and no file appeared; the `analysis_only` tools in that test run with the real
|
|
259
|
+
ffmpeg and are checked for files only. Under `--dry-run` a tool prints the command lines
|
|
254
260
|
it would run, reports `dry_run: true`, and never reports an output probe. The
|
|
255
261
|
exceptions are stated per tool in the contract's `dry_run` field: `probe` and `check` are
|
|
256
|
-
read-only (ffprobe still runs
|
|
262
|
+
read-only (ffprobe still runs; `probe` decodes with ffmpeg only with `--analyze`, to sample
|
|
263
|
+
picture levels); `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`,
|
|
257
264
|
`loudness` and `stabilize` still run their ffmpeg/ffprobe measurements (the analysis is the
|
|
258
265
|
tool's job; only the artifact is skipped, including side files such as `--edl`, `--sheet` or a
|
|
259
266
|
generated `.ass`), `overlay` measures the lines of a multi-line `--text` with one drawtext on a
|
|
@@ -261,9 +268,10 @@ generated frame on FFmpeg 8.0, 8.0.1 and 8.1.0 (it draws them one by one there),
|
|
|
261
268
|
with `--auto-chapters` and `cut` with `--snap beats` decode the input for their measurement,
|
|
262
269
|
`render` and `batch` run each stage or step as its own dry run (a measuring stage measures an
|
|
263
270
|
input that exists), and `verify` does not support dry-run (its steps run, and its document says
|
|
264
|
-
`dry_run: false`). `tests/test_contract.py` runs
|
|
265
|
-
each decode and asserts that every tool that decoded is in
|
|
266
|
-
`references/scripts.md`
|
|
271
|
+
`dry_run: false`). `tests/test_contract.py` runs the dry run of every tool that supports one
|
|
272
|
+
behind an ffmpeg that logs each decode and asserts that every tool that decoded is in
|
|
273
|
+
`DRY_RUN_ANALYSIS`. `SKILL.md`, `README.md`, `references/scripts.md` and `references/flags.md`
|
|
274
|
+
repeat the same list (a test checks each names every tool in it); the contract is the authority.
|
|
267
275
|
|
|
268
276
|
`--codec h264|hevc|av1|prores` and `--quality N` (1.8) are on every tool that re-encodes,
|
|
269
277
|
marked `common`. They are resolved in one place
|
|
@@ -984,7 +992,8 @@ row or column), `non_av_streams_dropped` (with the
|
|
|
984
992
|
first attempt's error beside `dropped_non_av_streams`), `measurement_input_pending` (skipped: a
|
|
985
993
|
dry-run measurement of an earlier stage's output), `ffmpeg_list_unreadable` (`ffmpeg -encoders` or
|
|
986
994
|
`-bsfs` could not be read; what needed it names the read failure, not a missing encoder) and
|
|
987
|
-
`timeout_env_invalid` (an `FFMPEG_SKILL_TIMEOUT` that is not a number, or is
|
|
995
|
+
`timeout_env_invalid` (an `FFMPEG_SKILL_TIMEOUT` that is not a number, read as the 1800 s default, or is
|
|
996
|
+
negative or above 500000 s, read as 0, no limit; a negative or too-long `--timeout` is refused instead, `kind: input`);
|
|
988
997
|
`av1_encoder_libaom` and `crf_clamped`; `font_substituted` (with `font_used`); `drift_correction_skipped`
|
|
989
998
|
(with `drift_skipped_reason`); `cropdetect_windows_failed` (with `windows_failed`);
|
|
990
999
|
`ass_lines_unshifted` / `ass_lines_malformed`; `timeline_parse_failed`; and every `warning:` line a
|
package/mcp/server.py
CHANGED
|
@@ -136,8 +136,9 @@ def timeout_from_call(args: Dict[str, Any]) -> float:
|
|
|
136
136
|
|
|
137
137
|
Structured `timeout` is the canonical key. A raw `argv` list ignores every other key, so
|
|
138
138
|
`--timeout` has to be read from that list: `--timeout 0` otherwise left the ceiling at 4x
|
|
139
|
-
the 1800 s default while the child itself ran with no limit. A negative value is 0
|
|
140
|
-
|
|
139
|
+
the 1800 s default while the child itself ran with no limit. A negative value is 0 here (no
|
|
140
|
+
outer ceiling): the child refuses a negative --timeout itself, at once, as it refuses one too
|
|
141
|
+
long to wait for, which child_limit() gives no ceiling either."""
|
|
141
142
|
if isinstance(args.get("argv"), list):
|
|
142
143
|
parsed = _timeout_in_argv([str(a) for a in args["argv"]])
|
|
143
144
|
if parsed is None:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.7",
|
|
4
4
|
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
5
5
|
"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.",
|
|
6
6
|
"keywords": [
|
|
@@ -203,12 +203,15 @@ these had ever shown up before.
|
|
|
203
203
|
On certain Windows ffmpeg
|
|
204
204
|
builds (e.g. winget's gyan.dev), `drawtext` crashes with an access violation
|
|
205
205
|
whenever it resolves a font by family name through fontconfig, even with a
|
|
206
|
-
valid `fonts.conf` (#100). `look.py`, `scenes.py --sheet`, `
|
|
207
|
-
and `graphics.py` all resolve a concrete
|
|
208
|
-
available (`fontfile=` skips fontconfig entirely and is the form
|
|
209
|
-
not to crash), so this should already be handled automatically. If a
|
|
210
|
-
drawtext tool still crashes
|
|
211
|
-
relying on `--font`/`font=` resolution;
|
|
206
|
+
valid `fonts.conf` (#100). `look.py`, `scenes.py --sheet`, `grid.py`'s labels,
|
|
207
|
+
`overlay.py --text` and `graphics.py` all resolve a concrete font file by default
|
|
208
|
+
when one is available (`fontfile=` skips fontconfig entirely and is the form
|
|
209
|
+
confirmed not to crash), so this should already be handled automatically. If a
|
|
210
|
+
drawtext tool still crashes: `overlay.py` and `graphics.py` take `--font-file`,
|
|
211
|
+
pass one explicitly rather than relying on `--font`/`font=` resolution;
|
|
212
|
+
`look.py` and `scenes.py` have no font flag, and `--no-timecode` draws no text
|
|
213
|
+
at all; `grid.py` has only `--font` (a family name, the form that crashes), and
|
|
214
|
+
`--label none` draws no labels. `doctor` also runs a real one-frame
|
|
212
215
|
drawtext probe and reports `filter:drawtext` missing (with the crash detail
|
|
213
216
|
in `errors[]`) rather than a false "available" from the `-filters` listing
|
|
214
217
|
alone.
|
package/references/flags.md
CHANGED
|
@@ -9,8 +9,8 @@ Scripts live in `scripts/` next to SKILL.md; run them with `python3 <skill-dir>/
|
|
|
9
9
|
|
|
10
10
|
## Shared flags
|
|
11
11
|
|
|
12
|
-
Shared flags (every script): `--dry-run
|
|
12
|
+
Shared flags (every script accepts them; the exceptions are named): `--dry-run` (`verify.py` ignores it); `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`); `--fast` (preview quality); `--progress` (%/ETA); `--timeout SECONDS` (`kind: timeout`; per ffmpeg run, default 1800, `verify.py` per step, default 600, `batch.py` one budget for the whole batch when `--timeout` is given or `--jobs` is above 1; `0` for hour-long encodes; below 0 or above 500000 is refused); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses a changed input; `render.py` and `verify.py` refuse `--plan`). `-o` is not shared: `probe`, `check`, `cropdetect`, `scenes`, `batch` and `verify` have none. Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264 (`cut.py`: HEVC source stays HEVC, `--no-keep-hevc` x264), HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
|
|
13
13
|
|
|
14
14
|
## What `--dry-run` runs
|
|
15
15
|
|
|
16
|
-
Writing tools run
|
|
16
|
+
Writing tools run no ffmpeg under `--dry-run` (ffprobe still reads an input that exists); the measuring tools (`probe` with `--analyze`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`; `overlay` for a multi-line `--text` on FFmpeg 8.0-8.1.0; `metadata` with `--auto-chapters`; `cut` with `--snap beats`) still run their ffmpeg measurements, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`), and `render` and `batch` run each stage or step as its own dry run; `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
|
package/references/gotchas.md
CHANGED
|
@@ -211,8 +211,11 @@ printing one line — `font: /usr/share/fonts/.../wqy-zenhei.ttc (covers ko)`.
|
|
|
211
211
|
already resolves a file for you on Windows, so this bites only a hand-built
|
|
212
212
|
drawtext call. More: `references/ci-platform-pitfalls.md`.
|
|
213
213
|
- Han characters alone (no kana, no hangul) are read as Chinese. Japanese or
|
|
214
|
-
Korean hanja text with no kana needs `--lang ja` / `--lang ko`
|
|
215
|
-
(`caption.py --language
|
|
214
|
+
Korean hanja text with no kana needs `--lang ja` / `--lang ko` on `graphics.py`
|
|
215
|
+
(`caption.py --language`, alias `--lang`, is the same hint), or `"lang"` in the
|
|
216
|
+
brand.json those two read. `overlay.py` has no such flag and its brand.json
|
|
217
|
+
`"lang"` is not read: name the face (`--font "Noto Sans CJK JP"` or
|
|
218
|
+
`--font-file`).
|
|
216
219
|
- An explicit `--font`, an explicit `--font-file`, or a font your brand file
|
|
217
220
|
itself names is always kept, even when fontconfig says it does not cover the script: you get one info
|
|
218
221
|
line saying so, not a silent substitution. A brand file that never names a
|
|
@@ -400,8 +403,11 @@ commands work with `talk.wav` in place of `talk.mp4`. What changes:
|
|
|
400
403
|
- `Look: not needed` in the report; `Check:` still applies for loudness
|
|
401
404
|
(`check.py file.wav --platform podcast` measures LUFS and true peak).
|
|
402
405
|
- Scripts that need a picture (`fit`, `caption`, `overlay`, `graphics`,
|
|
403
|
-
`color`, `export`, `
|
|
404
|
-
"input has no video stream". Say so instead of
|
|
406
|
+
`color`, `export`, `look`; the contract's `video_required` lists the rest)
|
|
407
|
+
refuse an audio file with "input has no video stream". Say so instead of
|
|
408
|
+
forcing a video wrapper. `scenes` takes one as a single scene (`--beats`,
|
|
409
|
+
`--audio-peaks`, `--speech`, `--highlights`); its `--sheet` and `--shots`
|
|
410
|
+
refuse it.
|
|
405
411
|
|
|
406
412
|
### Audio-only recipes
|
|
407
413
|
|
package/references/scripts.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Script reference
|
|
2
2
|
|
|
3
|
-
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT
|
|
3
|
+
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py; `render.py` and `verify.py` refuse it), `-o OUT` (except `probe`, `check`, `cropdetect`, `scenes`, `batch` and `verify`, which take none); every re-encoding tool and `export.py` also take `--hw`/`--no-hw` (h264/hevc/prores on Apple VideoToolbox, Apple Silicon only, CRF mapped to `-q:v`; AV1 stays on the CPU; a job VideoToolbox itself refuses -- the encoder cannot open or its compression session fails -- is re-encoded on the CPU and reported in `hw.fallback`/`hw.notes`, any other failure is not retried; a GPU encode adds a `notes` line that its quality is not CRF-equivalent; `FFMPEG_SKILL_HW=1` makes it the default for every tool except `export.py`, whose delivery presets need `--hw` itself; `render.py`/`batch.py --hw`/`--no-hw` apply to every stage, export included; results name the `encoder` that ran); every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, default 18 -- 30 for `proxy.py`; up to 63 for av1; ignored by prores). 2.0 removed `--crf` from these tools (it was `--quality`'s alias); `export.py` keeps its own `--crf`, which is not an alias. Every tool refuses (`kind: input`, `error.reason: output_exists`, before anything runs) to replace an existing output unless `--overwrite` is given. Refusals carry `error.reason` and `error.details` (the facts behind the message; `contract --json` → `json_output.error_reasons`), and what a run decided on its own -- a stale output lock taken over, no lock possible, a plan input nobody fingerprinted, an odd-sized frame scaled to even, subtitle streams dropped on a retry (with the first attempt's error), a font substituted, a measurement skipped under `--dry-run`, an encoder list that could not be read, an invalid `FFMPEG_SKILL_TIMEOUT`, and every `warning:` line -- is in `diagnostics: {warnings, fallbacks, skipped}` as well as on stderr (codes: `contract --json` → `json_output.diagnostic_codes`). `--codec av1`/`prores` with no encoder for it refuses `kind: missing_tool`, `error.reason: encoder_missing`; when the encoder list could not be read at all, `kind: ffmpeg`, `encoder_list_unreadable`. Tools that draw text (`overlay.py --text`, `graphics.py`, `caption.py`, `grid.py --label auto`) report `font_used: {asked, file, family, how, substituted}`. `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither `--codec` nor `--quality`; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, and with `--analyze` ffmpeg to sample picture levels, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), `overlay` measures a multi-line `--text`'s lines on FFmpeg 8.0, 8.0.1 and 8.1.0 (one drawtext on a generated frame), `metadata` with `--auto-chapters` and `cut` with `--snap beats` decode the input for their measurement, `render` and `batch` run each stage or step as its own dry run (so a measuring stage measures an input that exists), and `verify` accepts the flag but ignores it entirely (its document says `dry_run: false` and a note says so). Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
|
|
4
4
|
|
|
5
5
|
## Time grammar (every time-taking flag, 1.9)
|
|
6
6
|
|
|
@@ -359,7 +359,9 @@ scope-ratio film in a 16:9 frame) "detects" the same way as accidental
|
|
|
359
359
|
bars; look at the frame before cropping it away. `windows` / `windows_failed`
|
|
360
360
|
say how many windows were sampled and how many ffmpeg could not decode (those
|
|
361
361
|
are skipped, with a `cropdetect_windows_failed` warning; all of them failing is
|
|
362
|
-
a `kind: ffmpeg` failure).
|
|
362
|
+
a `kind: ffmpeg` failure). `--limit` is a fraction of the top pixel code, 0..1;
|
|
363
|
+
`--limit 1` is written to FFmpeg as that top code (255 for an 8-bit source), since
|
|
364
|
+
FFmpeg reads a bare `1` as code 1.
|
|
363
365
|
|
|
364
366
|
`--motion-centre` (1.18) reports the motion centroid once per second, sampled
|
|
365
367
|
across the same windows: `motion_centre: [{time, x, y, x_frac, y_frac, motion}]`,
|
|
@@ -1103,7 +1105,9 @@ render result carries the caption stage's own block as `caption`.
|
|
|
1103
1105
|
|
|
1104
1106
|
Under `--dry-run` a pack prints every child's planned commands and its table reads `planned`
|
|
1105
1107
|
with no size or duration: nothing was encoded, so nothing is reported as verified. `--chapters`
|
|
1106
|
-
reaches a pack's audio destination like it does the single-template form.
|
|
1108
|
+
reaches a pack's audio destination like it does the single-template form. `--stop-after`,
|
|
1109
|
+
`--cache`, `--from`, `--work`, `--keep` and `--export-timeline` apply to one render and are
|
|
1110
|
+
refused with a pack; `--audit-log` is handed to every destination.
|
|
1107
1111
|
|
|
1108
1112
|
Each template's frame, duration limit, loudness target and safe zones come from the one delivery
|
|
1109
1113
|
table (`scripts/_platforms.py`). Safe zones are the fraction of the frame the app's own UI covers;
|
|
@@ -1134,13 +1138,15 @@ whose destinations did not all pass exits non-zero with the per-destination rows
|
|
|
1134
1138
|
`audiogram` (1.16) is the one-call form of the podcast-to-video job: an `audiogram` stage runs
|
|
1135
1139
|
**first** (it makes the picture the rest of the chain works on, through `waveform.py --image`),
|
|
1136
1140
|
then captions, loudness, export and check. It needs `--image` (a local file) or a `background`
|
|
1137
|
-
colour in the project — this skill fetches nothing and invents no cover art
|
|
1141
|
+
colour in the project — this skill fetches nothing and invents no cover art; `--template
|
|
1142
|
+
audiogram` without `--image` is refused before anything runs, and a project with an `audiogram`
|
|
1143
|
+
and more than one clip is refused (it is drawn from one audio source) — and it is
|
|
1138
1144
|
deliberately **not** part of `--template all`, whose destinations all assume a source that
|
|
1139
1145
|
already has a picture.
|
|
1140
1146
|
|
|
1141
1147
|
### scenes.py — scene changes and highlight candidates
|
|
1142
1148
|
```
|
|
1143
|
-
scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15]
|
|
1149
|
+
scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15] [--edl picks.txt]] [--sheet scenes.png] [--json]
|
|
1144
1150
|
scenes.py INPUT --beats [--beat-step 0.01] [--beat-range 60-200] [--min-confidence 0.5] [--json] # INPUT may be audio-only
|
|
1145
1151
|
scenes.py INPUT [--shots] [--audio-peaks] [--speech] [--json]
|
|
1146
1152
|
```
|
|
@@ -1326,7 +1332,10 @@ batch.py FOLDER --recipe batch.json [--force] [--watch SECONDS] [--jobs N|auto]
|
|
|
1326
1332
|
`batch.json` holds either `steps` (a list of script argv with `{in}`/`{out}`
|
|
1327
1333
|
placeholders, chained) or `project` (a render project applied per file).
|
|
1328
1334
|
Outputs land in `output_dir` with `suffix`; a content-hash cache skips files
|
|
1329
|
-
already done with the same recipe. Use `--dry-run` to preview the plan.
|
|
1335
|
+
already done with the same recipe. Use `--dry-run` to preview the plan. The recipe's
|
|
1336
|
+
keys are `glob`, `steps`, `project`, `clip_key`, `output_dir`, `suffix` and `ext`; any
|
|
1337
|
+
other key is refused before an item runs, with the nearest valid name when one is close, else
|
|
1338
|
+
with the list of valid keys.
|
|
1330
1339
|
A recipe that runs `cut.py` rolls its calls up: each of those `results` rows carries
|
|
1331
1340
|
`cut_reencoded` (each call's `reencoded`) and `cut_reencode_reasons` (each
|
|
1332
1341
|
`reencode_reason` its calls named, once per call),
|
|
@@ -1807,7 +1816,9 @@ toggleable streams and copies the video and audio bit for bit. The suffix splits
|
|
|
1807
1816
|
on the last colon, and only when the tail is a BCP-47-shaped code *and* the whole
|
|
1808
1817
|
token is not itself a file on disk, so `C:\subs\en.srt` and a file named
|
|
1809
1818
|
`a:b.srt` are never mangled; a single `--srt` with no suffix still takes
|
|
1810
|
-
`--language`. `--
|
|
1819
|
+
`--language`. `--offset` shifts every added track (each later one as a
|
|
1820
|
+
`<output>_trackN.srt` copy); `--max-lines` and `--min-duration` lay out the first
|
|
1821
|
+
`--srt` only, and a note says so when they changed it. `--track-title` names a track (repeated in `--srt` order;
|
|
1811
1822
|
otherwise a frozen display-name table, and a code the table does not know gets
|
|
1812
1823
|
the code itself — never a guessed or translated name), `--default-track LANG`
|
|
1813
1824
|
marks one for auto-selection. Every other new track is explicitly marked *not*
|
|
@@ -2049,7 +2060,7 @@ completing with the old tags.
|
|
|
2049
2060
|
|
|
2050
2061
|
### audio.py — clean-up, music, ducking, layout
|
|
2051
2062
|
```
|
|
2052
|
-
audio.py INPUT [--voice [light|medium|strong]
|
|
2063
|
+
audio.py INPUT [--voice [light|medium|strong]] [--denoise [--denoise-strength 25]] [--gain dB]
|
|
2053
2064
|
[--music FILE [--music-volume -14] [--duck [--duck-amount 12] [--duck-threshold -26.02]
|
|
2054
2065
|
[--duck-attack 20] [--duck-release 400]] [--music-loop]]
|
|
2055
2066
|
[--effects FILE [--effects-volume -14]] [--stereo-widen 0..1]
|
|
@@ -2076,6 +2087,13 @@ alone), `medium` for a normal talking head, `strong` for phone/laptop audio.
|
|
|
2076
2087
|
stop at −1 dBFS: `medium` (the default, and the chain a bare `--voice` gets) can
|
|
2077
2088
|
clip a hot source, since its make-up gain has nothing above it — normalise
|
|
2078
2089
|
afterwards with `loudness.py`, or use `strong`, which measures quieter and safer.
|
|
2090
|
+
`--voice` with `--denoise` runs the level's chain with `afftdn=nf=-N:tn=1` at
|
|
2091
|
+
`--denoise-strength`: `light`, which has no denoise of its own, gets it after the
|
|
2092
|
+
highpass, and `medium`/`strong` run their own denoise stage at that strength
|
|
2093
|
+
(25 by default, the chain they have without `--denoise`). A parameter of a switch
|
|
2094
|
+
that is off is refused, as the `--duck-*` flags are: `--denoise-strength` without
|
|
2095
|
+
`--denoise`, `--music-volume`, `--music-fade-out` or `--music-loop` without `--music`,
|
|
2096
|
+
`--effects-volume` without `--effects` (a value equal to the default is not seen as given).
|
|
2079
2097
|
`--duck` uses a sidechain compressor keyed by the speech so music dips under
|
|
2080
2098
|
dialogue and swells in pauses:
|
|
2081
2099
|
`sidechaincompress=threshold=0.05:ratio=<amount/3, min 2>:attack=20:release=400:makeup=1`
|
package/references/workflow.md
CHANGED
|
@@ -26,13 +26,13 @@ Ask a short question only when the answer changes the output materially and the
|
|
|
26
26
|
- **Duration** ("make it 60 s") without a method: speed up for ≤1.5× changes, trim otherwise, say which. Ask when the content is a talk (trimming loses words) and the change is large.
|
|
27
27
|
- **Captions** without a text source: `--transcribe` if a local engine exists, else ask for the text or a timed file; never invent dialogue.
|
|
28
28
|
- **Fonts and brand**: if the user mentions a brand, colours or "our font", ask for or create `brand.json` once, reuse it.
|
|
29
|
-
- **CJK / non-Latin text**: let the tool pick the font by script (`--font` turns it off); `--lang ja|ko|zh-TW` for Han-only text. `doctor --json` `.fonts.scripts` says what renders here. Tofu is a failed job.
|
|
29
|
+
- **CJK / non-Latin text**: let the tool pick the font by script (`--font` turns it off); `--lang ja|ko|zh-TW` for Han-only text on `graphics`/`caption` (`overlay` has none: name the face with `--font`/`--font-file`). `doctor --json` `.fonts.scripts` says what renders here. Tofu is a failed job.
|
|
30
30
|
- **Crop position** for `--fit crop`: centre by default, but when the request or the source names an off-centre subject ("keep the product on the right", someone visibly off-centre in the sheet) use `--crop-x`/`--crop-y` (0=left/top, 1=right/bottom) instead of guessing centre, or ask which edge to keep.
|
|
31
31
|
- Anything else (transition type, caption style): pick the conventional default, say what you picked, offer the alternative in one line.
|
|
32
32
|
|
|
33
33
|
## Audio-only files
|
|
34
34
|
|
|
35
|
-
Audio is a first-class input: `probe`, `cut`, `silence`, `loudness`, `audio`, `sync`, `check --platform podcast`, `render --template podcast` take WAV, FLAC, MP3, M4A/AAC, OGG, Opus; output extension picks the format. Scripts needing a picture (`fit`, `caption`, `overlay`, `graphics`, `color`, `export`, `
|
|
35
|
+
Audio is a first-class input: `probe`, `cut`, `silence`, `loudness`, `audio`, `sync`, `check --platform podcast`, `render --template podcast` take WAV, FLAC, MP3, M4A/AAC, OGG, Opus; output extension picks the format. Scripts needing a picture (`fit`, `caption`, `overlay`, `graphics`, `color`, `export`, `look`; the contract's `video_required` lists the rest) refuse audio with "input has no video stream" — don't force a video wrapper. `scenes` takes an audio file as one scene (`--beats`, `--audio-peaks`, `--speech`, `--highlights`); its `--sheet` and `--shots` refuse one. Recipes: `references/gotchas.md#audio-only-files`.
|
|
36
36
|
|
|
37
37
|
## What this skill does and does not decide
|
|
38
38
|
|
|
@@ -70,9 +70,14 @@ def aspect_ratio(value: Any) -> Optional[Fraction]:
|
|
|
70
70
|
if len(parts) != 2:
|
|
71
71
|
return None
|
|
72
72
|
try:
|
|
73
|
-
|
|
73
|
+
w, h = int(parts[0]), int(parts[1])
|
|
74
|
+
ratio = Fraction(w, h)
|
|
74
75
|
except (ValueError, ZeroDivisionError):
|
|
75
76
|
return None
|
|
77
|
+
# a negative side (`-16:9`, `16:-9`) is no aspect anyone means: frame_size() made a negative
|
|
78
|
+
# frame of it, which fit.py's pad refused inside ffmpeg (kind ffmpeg) and the timeline wrote.
|
|
79
|
+
# Each side is looked at, not the ratio: `-16:-9` is a positive fraction
|
|
80
|
+
return None if w < 0 or h < 0 else ratio
|
|
76
81
|
|
|
77
82
|
|
|
78
83
|
def _even(n: float) -> int:
|
|
@@ -34,6 +34,22 @@ INSTALL_HINTS = {
|
|
|
34
34
|
),
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
# For an FFmpeg that is on PATH but older than MIN_FFMPEG (#467 A226): reinstalling the
|
|
38
|
+
# distribution's package gives the same old version back (Ubuntu 22.04's is 4.4, Debian 12's 5.1),
|
|
39
|
+
# so the hint names a newer build instead.
|
|
40
|
+
UPGRADE_HINTS = {
|
|
41
|
+
"Darwin": " brew upgrade ffmpeg-full # or ffmpeg, whichever formula is installed",
|
|
42
|
+
"Linux": (
|
|
43
|
+
" Your distribution's own package may be older than that (Ubuntu 22.04 ships 4.4, Debian 12 ships 5.1).\n"
|
|
44
|
+
" Use a release whose package is new enough (Ubuntu 24.04, Debian 13), or a static build\n"
|
|
45
|
+
" from https://ffmpeg.org/download.html put ahead of the old one on PATH"
|
|
46
|
+
),
|
|
47
|
+
"Windows": (
|
|
48
|
+
" winget upgrade Gyan.FFmpeg\n"
|
|
49
|
+
" or download a newer build from https://ffmpeg.org/download.html and put it ahead of the old one on PATH"
|
|
50
|
+
),
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
|
|
38
54
|
# `kind` (below) is the machine-readable failure axis: input / missing_tool / ffmpeg / output
|
|
39
55
|
# since 0.1, plus timeout (1.3), verification (1.4.3) and interrupted (1.4.10). `ERROR_CODE` is an
|
|
@@ -70,6 +86,14 @@ DEFAULT_TIMEOUT = 1800.0
|
|
|
70
86
|
|
|
71
87
|
PROBE_TIMEOUT = 120.0
|
|
72
88
|
|
|
89
|
+
# The largest per-call limit a run accepts (A8). subprocess's communicate() waits in poll(), whose
|
|
90
|
+
# timeout is a C int of milliseconds: past 2147483.647 s (24.8 days) it raised OverflowError after
|
|
91
|
+
# the child had started, so the tool died with a traceback while its ffmpeg ran on (measured on
|
|
92
|
+
# Linux with Python 3.11: `--timeout 1e8` and `1e300` both did). The outer ceiling a run_tool()
|
|
93
|
+
# child gets is 4x the per-call limit plus 60 s (child_limit()), so the per-call limit stops well
|
|
94
|
+
# below a quarter of that: 500000 s, about 5.8 days. 0 is "no limit" and is what a longer job passes.
|
|
95
|
+
MAX_TIMEOUT = 500000.0
|
|
96
|
+
|
|
73
97
|
|
|
74
98
|
def _env_timeout() -> float:
|
|
75
99
|
return _read_env_timeout()[0]
|
|
@@ -91,6 +115,11 @@ def _read_env_timeout() -> "Tuple[float, Optional[str]]":
|
|
|
91
115
|
return DEFAULT_TIMEOUT, f"FFMPEG_SKILL_TIMEOUT={raw!r} is not a number of seconds; using the default {DEFAULT_TIMEOUT:g} s"
|
|
92
116
|
if value < 0:
|
|
93
117
|
return 0.0, f"FFMPEG_SKILL_TIMEOUT={raw!r} is negative; read as 0, which means no time limit"
|
|
118
|
+
if value > MAX_TIMEOUT:
|
|
119
|
+
# inf, 1e300: longer than a wait can be on this platform, which was an OverflowError
|
|
120
|
+
# traceback once the first child had started
|
|
121
|
+
return 0.0, (f"FFMPEG_SKILL_TIMEOUT={raw!r} is longer than a run can wait for here ({MAX_TIMEOUT:.0f} s); "
|
|
122
|
+
"read as 0, which means no time limit")
|
|
94
123
|
return value, None
|
|
95
124
|
|
|
96
125
|
|
|
@@ -291,11 +320,12 @@ def require_tool(name: str) -> str:
|
|
|
291
320
|
found = ffmpeg_version()
|
|
292
321
|
if found != (0, 0) and (found[0] < MIN_FFMPEG[0] or (_FFMPEG_VERSION_IS_RELEASE and found < MIN_FFMPEG)):
|
|
293
322
|
system = platform.system()
|
|
294
|
-
hint =
|
|
323
|
+
hint = UPGRADE_HINTS.get(system, " See https://ffmpeg.org/download.html")
|
|
324
|
+
floor = f"{MIN_FFMPEG[0]}.{MIN_FFMPEG[1]}"
|
|
295
325
|
refuse("missing_tool", "ffmpeg_unsupported",
|
|
296
326
|
f"FFmpeg {found[0]}.{found[1]} (read from ffprobe -version) is not supported since ffmpeg-skill 3.0.0 (5.x was deprecated in "
|
|
297
|
-
f"2.14.0):
|
|
298
|
-
code=127, version=f"{found[0]}.{found[1]}", required=
|
|
327
|
+
f"2.14.0): a newer build is needed, FFmpeg {floor} or later. On {system}:\n{hint}",
|
|
328
|
+
code=127, version=f"{found[0]}.{found[1]}", required=floor)
|
|
299
329
|
if path:
|
|
300
330
|
return path
|
|
301
331
|
system = platform.system()
|
|
@@ -500,7 +530,13 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
500
530
|
out = getattr(args, "output", None)
|
|
501
531
|
guard_sidecar(STATE.plan, *inputs, siblings=(out,) if isinstance(out, str) else ())
|
|
502
532
|
if getattr(args, "timeout", None) is not None:
|
|
503
|
-
|
|
533
|
+
# a negative --timeout used to be read as 0 without a word, which removed every limit the
|
|
534
|
+
# caller thought it had tightened, and one past MAX_TIMEOUT was an OverflowError traceback
|
|
535
|
+
# after ffmpeg had started (the MCP call reported a failure while the tool wrote its output)
|
|
536
|
+
if not 0 <= float(args.timeout) <= MAX_TIMEOUT:
|
|
537
|
+
die(f"--timeout must be 0 (no limit) or a number of seconds up to {MAX_TIMEOUT:.0f}, got {float(args.timeout):g}",
|
|
538
|
+
hint="--timeout 0 runs without a time limit")
|
|
539
|
+
STATE.timeout = float(args.timeout)
|
|
504
540
|
else:
|
|
505
541
|
env_limit, env_problem = _read_env_timeout()
|
|
506
542
|
if env_problem:
|
|
@@ -2012,7 +2048,12 @@ def child_limit(per_call: Optional[float] = None) -> Optional[float]:
|
|
|
2012
2048
|
fires first on a healthy run, and it is the only thing that ends a child hung for a reason
|
|
2013
2049
|
that is not ffmpeg (a stuck import, a wedged pipe). None when the per-call limit is 0."""
|
|
2014
2050
|
limit = STATE.timeout if per_call is None else per_call
|
|
2015
|
-
|
|
2051
|
+
if not limit or limit != limit or limit > MAX_TIMEOUT:
|
|
2052
|
+
# 0 is "no limit". A per-call value no run can wait for (nan, inf, 1e300 from an MCP
|
|
2053
|
+
# `timeout`) has no ceiling either: the child refuses that --timeout itself, at once, and
|
|
2054
|
+
# communicate(timeout=4e300) would raise OverflowError with the child already running
|
|
2055
|
+
return None
|
|
2056
|
+
return limit * 4 + 60
|
|
2016
2057
|
|
|
2017
2058
|
|
|
2018
2059
|
def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None,
|