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 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 a handful of its tests build a fake `ffmpeg` as a `#!/bin/sh` script on a PATH shim to force specific FFmpeg 6/7/8/9 fixture layouts through `doctor`'s parser — that technique isn't portable to Windows, so `test_dry_run_never_runs_ffmpeg_and_writes_nothing` and the whole `DoctorDetectionTests` class (fixture-driven layout parsing) are individually `skipIf`'d there and show as `skipped`, not silently absent, in the Windows job's log. Everything else — contract schema, `reencodes_*`, `doctor.tools`, MCP derivation, and every tool 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.
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 no-op. 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.
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 2x --smooth interpolate` |
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.5`) | any release |
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.5", "execution_mode": "local", "kind": "execution",
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) | silence (`--list`), loudness (`--measure-only`), sync (offset JSON without `-o`) |
204
- | `execution` (writes a new artifact) | cut, fit, caption, overlay, graphics, multicam, audio, join, color, export, render, batch |
205
- | `verification` (checks or shows an artifact) | check, look, verify, report |
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, silence, audio, sync, batch | probe |
219
- | loudness | probe, check |
220
- | export | probe, check |
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
- | probe, check, look, scenes, verify, report | nothing (they are the verification) |
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 that change the picture
226
- (fit, caption, overlay, graphics, color, join, multicam, render). Audio-only tools and
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. Under `--dry-run` a tool prints the command lines
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); `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`,
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 every tool's dry run behind an ffmpeg that logs
265
- each decode and asserts that every tool that decoded is in `DRY_RUN_ANALYSIS`. `SKILL.md` and
266
- `references/scripts.md` repeat the same list; the contract is the authority.
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 negative);
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, matching
140
- apply_common (0 means no limit)."""
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.5",
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`, `overlay.py --text`
207
- and `graphics.py` all resolve a concrete `--font-file` by default when one is
208
- available (`fontfile=` skips fontconfig entirely and is the form confirmed
209
- not to crash), so this should already be handled automatically. If a
210
- drawtext tool still crashes, pass `--font-file` explicitly rather than
211
- relying on `--font`/`font=` resolution; `doctor` also runs a real one-frame
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.
@@ -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`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`); `--fast` (preview quality); `--progress` (%/ETA); `--timeout SECONDS` (`kind: timeout`, default 1800; `0` for hour-long encodes); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses a changed input). 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).
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 nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`; `overlay` for a multi-line `--text` on FFmpeg 8.0-8.1.0) may still run ffmpeg/ffprobe, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`); `verify` ignores the flag. Per tool: `contract --json` `dry_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`.
@@ -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` is the same flag), or `"lang"` in brand.json.
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`, `scenes`, `look`) refuse an audio file with
404
- "input has no video stream". Say so instead of forcing a video wrapper.
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
 
@@ -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`; 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, `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`).
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 — and it is
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]] [--edl picks.txt] [--sheet scenes.png] [--json]
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`. `--track-title` names a track (repeated in `--srt` order;
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] | --denoise [--denoise-strength 25]] [--gain dB]
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`
@@ -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`, `scenes`, `look`) refuse audio with "input has no video stream" — don't force a video wrapper. Recipes: `references/gotchas.md#audio-only-files`.
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
- return Fraction(int(parts[0]), int(parts[1]))
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 = INSTALL_HINTS.get(system, " See https://ffmpeg.org/download.html")
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): use FFmpeg 6.1+. Install it for {system}:\n{hint}",
298
- code=127, version=f"{found[0]}.{found[1]}", required="6.1")
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
- STATE.timeout = max(0.0, float(args.timeout))
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
- return (limit * 4 + 60) if limit else None
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,