ffmpeg-skill 3.8.6 → 3.8.8

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.6`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.8.8`) | 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.6", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.8", "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
@@ -718,7 +726,8 @@ Per-tool keys added in 1.17, all additive:
718
726
  | `filler`, `removed_seconds_total` | `silence.py --filler` | `{lang, source, engine, words, removed, removed_count, removed_seconds, removed_words, word_timings, list, warnings}`. The existing `removed_seconds` is unchanged in name and meaning — the seconds of *silence* removed, which is what it has always held — and `removed_seconds_total` is the additive sibling covering silence plus filler |
719
727
  | `jobs`, `jobs_requested`, `wall_seconds`, `item_seconds_total`, `timed_out` | `batch.py` | the parallelism actually applied and the number asked for, the batch's wall clock, the sum of the per-item times (so the speed-up can be quoted), and whether the shared timeout budget ran out. A timed-out item carries `"skipped": "timeout"` in its result row |
720
728
  | `timeline` | `render.py --export-timeline FILE` | `{format, rate, duration, frames, clips, transition, transition_frames, music, markers, notes, not_exported}`: the written timeline's own numbers (`rate` is exact, `"30000/1001"` for 29.97) and what the project asked for that an editor timeline cannot carry. Nothing is encoded (`commands: []`); `verified` means the file was written and reads back as its format (`verification` step `parse`) -- not that an editor opened it |
721
- | `cache` | `render.py --cache` | `{dir, ffmpeg, hits, misses, miss_reasons, saved_seconds, entries}`, plus `would_hit` under `--dry-run`. `miss_reasons` (unreleased, additive): `[{stage, reason, ...}]`, `reason` one of `not_cached`, `sidecar_missing`, `artifact_missing`, `sidecar_corrupt`, `artifact_truncated`, `no_output_path`. The ffmpeg build banner, the skill version, the contract version, the forwarded flags (`--fast`, `--codec`, …), the GPU setting (`FFMPEG_SKILL_HW` and whether `--hw`/`--no-hw` made it explicit) and the output's extension are all part of every key, so a cache is never reused across any of them — a `--fast` draft is never served to a run that did not ask for one |
729
+ | `templates` | `render.py --list-templates --json` | one row per shipped template: `{template, destination, frame: {width, height} or null for an audio destination, fit, max_duration, lufs, safe: {top, bottom, left, right}}` (unreleased: before, the table was printed under `--json` too). `render.py --init FILE --json` prints the plain success document with `output` the file it wrote |
730
+ | `cache` | `render.py --cache` | `{dir, ffmpeg, hits, misses, miss_reasons, saved_seconds, entries}`, plus `would_hit` under `--dry-run`. `miss_reasons` (unreleased, additive): `[{stage, reason, ...}]`, `reason` one of `not_cached`, `sidecar_missing`, `artifact_missing`, `sidecar_corrupt`, `artifact_truncated`, `no_output_path`, `destination_not_owned` (a file already at the stage's path in `--work` is not one this render wrote). The ffmpeg build banner, the skill version, the contract version, the forwarded flags (`--fast`, `--codec`, …), the GPU setting (`FFMPEG_SKILL_HW` and whether `--hw`/`--no-hw` made it explicit) and the output's extension are all part of every key, so a cache is never reused across any of them — a `--fast` draft is never served to a run that did not ask for one |
722
731
  | `intermediate_preset` | `render.py` | The x264/x265 preset the stages before an encoding export ran at: `--intermediate-preset`, default `superfast` since 2.16 (`veryfast` in 2.15). `null` when no stage received it: no encoding export was reached, `--fast`, `--stop-after` before the export, a `copy` export, or nothing but the export encoded. Present on every render result, early stops included |
723
732
 
724
733
  Per-tool keys added in 1.17.1, all additive:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.8.6",
3
+ "version": "3.8.8",
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; below 0 or above 500000 is refused); `--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
 
@@ -977,8 +977,9 @@ rows, youtube's spec assumed, as `check.py --content` without `--platform`); und
977
977
 
978
978
  `"export": {"preset": "reels", "normalize": true}` forwards `export.py --normalize`
979
979
  so the rendered file meets the platform's loudness without a separate pass. Since
980
- 1.9.0 it is on by default when the preset is a platform (`youtube|youtube4k|reels|x`)
981
- and the project has no `loudness` stage; `"normalize": false` opts out.
980
+ 1.9.0 it is on by default when the preset is a platform's (`youtube`, `youtube4k`,
981
+ `youtube-hdr`, `youtube-av1`, `reels`, `tiktok`, `shorts`, `x`, `linkedin`, `facebook`:
982
+ `export.PLATFORM_OF`) and the project has no `loudness` stage; `"normalize": false` opts out.
982
983
 
983
984
  `"audio": {"stems": {"dialogue": -2, "music": -18, "effects": -24}}` names one
984
985
  level per element of the mix: `dialogue` is the main track's gain, `music` the
@@ -1015,7 +1016,29 @@ relative to the project file; `--export-timeline` lists the layer under
1015
1016
  before `check`, so the markers are in the file that ships (streams copied). The
1016
1017
  entries and the file path are validated before the first stage runs, and the
1017
1018
  stage plans its `metadata.py` command under `--dry-run`/`--plan` like every
1018
- other stage, so the plan lists `chapters` and the run does the same work.
1019
+ other stage, so the plan lists `chapters` and the run does the same work. An
1020
+ inline title is one line: a line feed, carriage return or NUL in it is refused
1021
+ (`kind: input`), since the chapters file holds one `TIME TITLE` line per chapter
1022
+ and FFmpeg ends a title at NUL. Under `--dry-run` the list is written to a
1023
+ temporary file, not into the work directory.
1024
+
1025
+ **`--stop-after export`** stops once the delivery is written: the chapters and
1026
+ check stages do not run (and their sections are not read). **Without an export
1027
+ stage** the delivery is the last stage's file copied as it is (`.mp4`, or the
1028
+ audio container of an audio-only output), so an output named for another
1029
+ container (`.webm`, `.mkv`) is refused, `kind: input`, before the first stage
1030
+ when the copy is certainly an intermediate and at the copy otherwise (a single
1031
+ uncut clip that no stage touches is its source, copied as it is); `.mov`,
1032
+ `.m4v`, `.m4a` and `.3gp` are the MP4 family and are accepted.
1033
+
1034
+ **The work directory.** Without `--work`, each run makes a new
1035
+ `<output>_work_<pid>_<random>/` beside the output and removes it at exit (kept
1036
+ with `--keep`, or by a `--stop-after` preview whose output is in it). `--work
1037
+ DIR` is used as given and never emptied; a stage file an earlier render wrote
1038
+ there, still unchanged (size, mtime, inode: `DIR/.ffskill-render-stages.json`
1039
+ records them), is replaced without `--overwrite`, while any other existing file
1040
+ is refused as before. A `--dry-run` writes nothing there and removes a `--work`
1041
+ directory it had to create.
1019
1042
 
1020
1043
  **`--cache DIR` (1.17)** reuses the artifact of a stage that already ran with
1021
1044
  exactly the same arguments and the same inputs. Opt-in only: **there is no
@@ -1023,7 +1046,8 @@ default cache directory** — one appearing on someone's disk unasked would
1023
1046
  contradict this tool's "a plan leaves nothing behind" posture. The key is a
1024
1047
  sha1 over the stage name, the tool, its arguments (every existing path replaced
1025
1048
  by its content hash), the input hashes, the **forwarded** flags (`--fast`,
1026
- `--overwrite`, `--timeout`, `--codec`), the output's extension, and **the ffmpeg
1049
+ `--overwrite`, `--timeout`; render.py has no `--codec`), the GPU setting (`--hw`/`--no-hw`,
1050
+ `FFMPEG_SKILL_HW`), the output's extension, and **the ffmpeg
1027
1051
  build banner, the skill version and the contract version**. `--fast` matters as
1028
1052
  much as any of them: it rewrites every child's preset to `veryfast`, so without
1029
1053
  it in the key a `--cache --fast` draft would be served back to a later run that
@@ -1037,8 +1061,10 @@ hardlinks the artifact into the work directory, or copies it where the
1037
1061
  filesystem will not link — never moves it, since the cache has to outlive the
1038
1062
  run's own cleanup. A miss is work to do, not an error, but never a silent one:
1039
1063
  `cache.miss_reasons` says why each stage missed (`not_cached`, `sidecar_missing`,
1040
- `artifact_missing`, `sidecar_corrupt`, `artifact_truncated`, `no_output_path`), and a
1041
- damaged entry (the last two of those that name a file this tool wrote) is also a
1064
+ `artifact_missing`, `sidecar_corrupt`, `artifact_truncated`, `no_output_path`, and
1065
+ `destination_not_owned` when the file already at a stage's path in `--work` is not one this
1066
+ render wrote: the stage runs and refuses it by its own overwrite rule), and a
1067
+ damaged entry (`sidecar_corrupt`, `artifact_truncated`) is also a
1042
1068
  `cache_entry_damaged` warning and a line in `DIR/.ffskill-audit.jsonl`. A sidecar keeps
1043
1069
  the stage's `verified`, `verification`, `notes` and `diagnostics`, and a hit replays
1044
1070
  them, so a cached stage counts toward the render's `verified` as a live one does. A
@@ -1046,7 +1072,14 @@ store that fails is a `cache_store_failed` warning (the run is unaffected).
1046
1072
 
1047
1073
  A stage whose flags changed misses the cache, so editing the captions block (for
1048
1074
  example to `"fit_size": "off"`) re-runs captions and everything after it and
1049
- serves the earlier stages from the cache.
1075
+ serves the earlier stages from the cache. A file is keyed by its name, size,
1076
+ modification time (in nanoseconds; in whole seconds up to 3.8.4) and
1077
+ first and last MiB, the batch cache's key (`batch.file_key()`); a file whose
1078
+ mtime was set back to the old value keeps its key. The chapters file an inline
1079
+ `chapters` list is written to is new every run, so it is keyed by its content
1080
+ instead, and the chapters stage hits when the delivery under it is the cached
1081
+ one (an export served from the cache; a delivery copied without an export stage
1082
+ is a new file, and misses).
1050
1083
 
1051
1084
  `stages_done` is unchanged: a cached stage is still a stage that happened.
1052
1085
  Nothing is written under `--dry-run`, which instead reports `cache.would_hit`.
@@ -1086,7 +1119,10 @@ no overlay stage at all, not an overlay of nothing. The filled project then rend
1086
1119
  normal stages, so `--dry-run --json`, `--stop-after` and the work directory behave as always. An
1087
1120
  unknown name is refused (`kind: input`) with the list. Output defaults to
1088
1121
  `<input>_<template>.mp4` **next to the input** (`.m4a` for an audio-only destination such as
1089
- `podcast`) — the same rule for one template and for a pack, whose `-o` names the directory.
1122
+ `podcast`) — the same rule for one template and for a pack. A pack writes each destination as
1123
+ `<input>_<template>.<ext>` and the table as `<input>_pack.md` in the **directory of** `-o`:
1124
+ `-o packs/talk.mp4` writes into `packs/`, and `-o packs` into the folder that holds `packs`
1125
+ (the file name in `-o` is not used).
1090
1126
  Alias spellings are accepted everywhere one name is: `youtube-shorts`/`yt-shorts` = `shorts`,
1091
1127
  `yt` = `youtube`, `instagram`/`ig` = `reels`, `twitter` = `x`, `fb` = `facebook`
1092
1128
  (`check.py --platform`, `export.py --preset`, `caption.py`/`graphics.py`/`overlay.py
@@ -1137,7 +1173,8 @@ whose destinations did not all pass exits non-zero with the per-destination rows
1137
1173
 
1138
1174
  `audiogram` (1.16) is the one-call form of the podcast-to-video job: an `audiogram` stage runs
1139
1175
  **first** (it makes the picture the rest of the chain works on, through `waveform.py --image`),
1140
- then captions, loudness, export and check. It needs `--image` (a local file) or a `background`
1176
+ then captions, a `--title` lower third (graphics, as the youtube template draws it), loudness,
1177
+ export and check, with `--brand` reaching the audiogram, caption and graphics stages. It needs `--image` (a local file) or a `background`
1141
1178
  colour in the project — this skill fetches nothing and invents no cover art; `--template
1142
1179
  audiogram` without `--image` is refused before anything runs, and a project with an `audiogram`
1143
1180
  and more than one clip is refused (it is drawn from one audio source) — and it is
@@ -1331,8 +1368,18 @@ batch.py FOLDER --recipe batch.json [--force] [--watch SECONDS] [--jobs N|auto]
1331
1368
  ```
1332
1369
  `batch.json` holds either `steps` (a list of script argv with `{in}`/`{out}`
1333
1370
  placeholders, chained) or `project` (a render project applied per file).
1334
- Outputs land in `output_dir` with `suffix`; a content-hash cache skips files
1335
- already done with the same recipe. Use `--dry-run` to preview the plan. The recipe's
1371
+ Outputs land in `output_dir` with `suffix`, named with the source's extension
1372
+ unless the recipe sets `ext` -- except a `.webm` source, whose output is `.mp4`
1373
+ (the steps re-encode to H.264/AAC, which WebM cannot hold; a recipe that keeps
1374
+ WebM sets `"ext": "webm"`). `glob` matches names without regard to case on every
1375
+ platform (`"*.mp4"` finds `GOPR0001.MP4`; the letters inside a `[...]` class keep
1376
+ theirs), and a glob that matches nothing is a note. A cache skips files already
1377
+ done with the same recipe: an item's key is its source's `file_key()` (name,
1378
+ size, mtime in nanoseconds, first and last MiB), the recipe, the GPU setting,
1379
+ the files the steps name and, for a project recipe, the project file and every
1380
+ file it names (music, cues, brand and its logo and font, overlays, chapters,
1381
+ the clips other than the one each item replaces), read again on every
1382
+ `--watch` pass. Use `--dry-run` to preview the plan. The recipe's
1336
1383
  keys are `glob`, `steps`, `project`, `clip_key`, `output_dir`, `suffix` and `ext`; any
1337
1384
  other key is refused before an item runs, with the nearest valid name when one is close, else
1338
1385
  with the list of valid keys.
@@ -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
 
@@ -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
@@ -304,11 +320,12 @@ def require_tool(name: str) -> str:
304
320
  found = ffmpeg_version()
305
321
  if found != (0, 0) and (found[0] < MIN_FFMPEG[0] or (_FFMPEG_VERSION_IS_RELEASE and found < MIN_FFMPEG)):
306
322
  system = platform.system()
307
- 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]}"
308
325
  refuse("missing_tool", "ffmpeg_unsupported",
309
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 "
310
- f"2.14.0): use FFmpeg 6.1+. Install it for {system}:\n{hint}",
311
- 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)
312
329
  if path:
313
330
  return path
314
331
  system = platform.system()
@@ -2675,10 +2692,23 @@ def parse_json(text: str) -> Any:
2675
2692
  if isinstance(value, (dict, list)):
2676
2693
  if depth > JSON_MAX_DEPTH:
2677
2694
  raise JsonTooDeep(f"nested more than {JSON_MAX_DEPTH} levels deep")
2695
+ if isinstance(value, dict):
2696
+ stack.extend((k, depth) for k in value)
2678
2697
  stack.extend((v, depth + 1) for v in (value.values() if isinstance(value, dict) else value))
2698
+ elif isinstance(value, str) and _SURROGATE.search(value):
2699
+ # A264: "\ud83d" alone is valid JSON and no text: json reads it as a lone surrogate,
2700
+ # which no UTF-8 file, argument or stdout can encode -- the run died on the first write
2701
+ # with a UnicodeEncodeError traceback and nothing on stdout
2702
+ bad = _SURROGATE.search(value).group(0)
2703
+ raise ValueError(f"the string \"{value[:60].encode('ascii', 'backslashreplace').decode()}\" holds \\u{ord(bad):04x}, "
2704
+ "half of a UTF-16 surrogate pair with no other half: it is no character, so no file can "
2705
+ "hold it -- write the character itself, or both halves of its \\u escape")
2679
2706
  return data
2680
2707
 
2681
2708
 
2709
+ _SURROGATE = re.compile("[\ud800-\udfff]")
2710
+
2711
+
2682
2712
  def read_json(path: "os.PathLike[str] | str") -> Any:
2683
2713
  """parse_json() of a UTF-8 file. Raises OSError for a file that cannot be read and ValueError
2684
2714
  (UnicodeDecodeError is one) for anything else, so `except (OSError, ValueError)` covers it."""