ffmpeg-skill 3.8.6 → 3.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/SKILL.md +3 -3
- package/docs/contract.md +27 -19
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +9 -6
- package/references/flags.md +2 -2
- package/references/gotchas.md +10 -4
- package/references/scripts.md +1 -1
- package/references/workflow.md +2 -2
- package/scripts/_common/runner.py +20 -3
- package/scripts/_contract.py +83 -6
- package/scripts/export.py +1 -1
- package/scripts/graphics.py +14 -1
package/README.md
CHANGED
|
@@ -196,7 +196,7 @@ These are the rules the skill file gives the agent and the code enforces.
|
|
|
196
196
|
|
|
197
197
|
1. **Probe first.** No tool decides from the file name. `probe.py` measures duration, fps (with variable-frame-rate detection), resolution, rotation, bit depth, HDR format including Dolby Vision, colour tags and every audio stream before anything is cut.
|
|
198
198
|
2. **Lossless when possible.** `cut.py` and `loudness.py` stream-copy what they do not need to touch (`join.py` re-encodes so it can crossfade and conform mismatched clips; `--copy --transition none` joins clips that already match by stream copy). Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance.
|
|
199
|
-
3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--plan FILE` (the dry run saved as a plan with fingerprinted inputs that `render.py FILE` executes later, refusing if an input changed), `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress` (percent and ETA), `--timeout` (a hung ffmpeg is killed and reported, never waited on forever; Ctrl-C or SIGTERM likewise stops the running ffmpeg, removes its partial output and reports `kind: interrupted`) and `--overwrite` (explicit consent before an existing output is replaced). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared
|
|
199
|
+
3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing; `verify` ignores it), `--plan FILE` (the dry run saved as a plan with fingerprinted inputs that `render.py FILE` executes later, refusing if an input changed; `render` and `verify` refuse it), `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress` (percent and ETA), `--timeout` (a hung ffmpeg is killed and reported, never waited on forever; Ctrl-C or SIGTERM likewise stops the running ffmpeg, removes its partial output and reports `kind: interrupted`) and `--overwrite` (explicit consent before an existing output is replaced). A test runs every tool whose contract says its dry run runs no ffmpeg (`ffmpeg_execution: none`, plus `cut` and `metadata` without their measuring flags) under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared; another runs every tool the contract says supports `--dry-run` (all but `verify`) behind an ffmpeg that logs each decode, and asserts that no file appeared and that every tool that decoded is one the contract lists as `analysis_only`.
|
|
200
200
|
4. **Machine-readable contract.** `contract --json` describes all 42 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required, `mutates_input: false`. `provides` lists all 42 by a cross-repository Capability id (`ffmpeg-skill.cut`, `ffmpeg-skill.loudness`, ...) for [`kajisho5/AI-video-production-OS`](https://github.com/kajisho5/AI-video-production-OS)'s `CapabilityContract.provides` — see `docs/contract.md`.
|
|
201
201
|
5. **Contract-derived MCP.** `mcp/server.py` builds its `tools/list` from the contract. Tool names, order and `inputSchema` cannot drift from the scripts; a test keeps the two byte-identical.
|
|
202
202
|
6. **Capability detection.** `doctor` reads `ffmpeg -encoders / -decoders / -filters / -bsfs` and reports which of the components the tools need are present on this build (libx264, libass, zscale, loudnorm, xfade, …), before a job fails inside ffmpeg.
|
|
@@ -674,9 +674,9 @@ node bin/install.js --dir /tmp/skills # try the installer without touching ~/.
|
|
|
674
674
|
|
|
675
675
|
CI (`.github/workflows/ci.yml`) runs on every pull request and on pushes to `main`, on Ubuntu (FFmpeg 6.1 with Python 3.10 on 24.04, FFmpeg 8.0.1 with Python 3.13 on 26.04), macOS (Homebrew FFmpeg 8.x) and Windows (gyan.dev FFmpeg 9.x), plus a Linux job on FFmpeg 7.1 (Debian trixie container), and uploads each runner's FFmpeg listings as an artifact.
|
|
676
676
|
|
|
677
|
-
`tests/test_contract.py` runs on all three OSes, but
|
|
677
|
+
`tests/test_contract.py` runs on all three OSes, but some of its tests put a fake `ffmpeg` or `ffprobe` (a `#!/bin/sh` script on a PATH shim) in front of the real one — to record every call under `--dry-run`, to force specific FFmpeg 6/7/8/9 fixture layouts through `doctor`'s parser, to report an old version, to hang or fail on cue — or need what Windows does not have or does differently (SIGTERM, unprivileged symlinks, a line break or a backslash in a file name). Those tests, among them `test_dry_run_never_runs_ffmpeg_and_writes_nothing` and the whole `DoctorDetectionTests` class, skip there (`skipIf`, or `skipTest` inside the test) and show as `skipped`, not silently absent, in the Windows job's log; `grep -n 'os.name == "nt"\|platform.system() == "Windows"' tests/test_contract.py` finds their conditions, among the suite's other platform branches. The rest — contract schema, `reencodes_*`, `doctor.tools`, MCP derivation, and the tools exercised through the contract, including `cut.py`'s provenance fields — runs against the real Windows `ffmpeg` on every PR. See [references/ci-platform-pitfalls.md](references/ci-platform-pitfalls.md) for this and other per-OS behaviour differences already diagnosed, before spending a CI cycle re-diagnosing a platform-only failure.
|
|
678
678
|
|
|
679
|
-
**Releasing** is fully automated end to end, including the version number itself — a PR never needs to touch `package.json` or the version in `docs/contract.md`. `CHANGELOG.md` is different: the release writes the version heading and one line per merged PR, but a `feat` PR writes its own notes under `## Unreleased` (CONTRIBUTING.md), and a PR that closes an issue must name that issue in `CHANGELOG.md` or `test_changelog_mentions_every_closed_issue_since_last_tag` fails. Once a PR merges to `main`, `.github/workflows/release.yml` takes it from there: if nobody bumped the version by hand, it resolves the next version ([.github/scripts/resolve_version.py](.github/scripts/resolve_version.py)) from the labels on every PR merged since the last tag (`minor`/`feature`/`enhancement` → minor, `fix`/`bug`/`patch` → patch, an unlabeled PR defaults to `patch`; a PR whose only labels are `chore`, `ci`, `docs` or `dependencies` is not releasable, so a merge that changes no shipped file releases nothing). Most PRs don't need a label added by hand: `release-drafter.yml`'s `autolabel` job applies one automatically from the PR's title/changed files (`Fix ...` → `fix`, `Add ...`/`feat ...` → `feature`, `docs`/`.md`/`.github/`/`build(deps)` changes → `chore`) as soon as it's opened — add a label yourself only to override that. **A major version is never chosen automatically**: no label rule produces `major`, and the workflow refuses to auto-bump across a major boundary even if someone applies that label — a real major release is a deliberate `package.json` bump in a PR, which the manual path below already handles. It bumps `package.json`, `.claude-plugin/plugin.json`, `docs/contract.md` and `docs/roadmap.md`'s released-version sentence, writes a `CHANGELOG.md` section listing those PRs (and any issues they closed), and pushes that commit to `main` itself. If another PR merged while the bump was being prepared, the push is refused and that run releases nothing: the newer merge's own run, queued behind it, starts from `main`'s tip and releases both (the bump is never rebased onto a merge it did not read). After the tag it also asks `pages.yml` to rebuild the site, because the bump commit carries `[skip ci]` and would otherwise leave the site on the previous version. Either way — auto-bumped or hand-bumped in the PR — it then creates the `vX.Y.Z` tag, publishes a GitHub Release with notes extracted from `CHANGELOG.md`'s matching section, and publishes the package to npm. A PR that still wants to write its own version bump and `CHANGELOG.md` prose (e.g. to explain the "why" of a release by hand) can — the automation only fills in when nobody made that call already. A push to `main` with nothing new to release is a
|
|
679
|
+
**Releasing** is fully automated end to end, including the version number itself — a PR never needs to touch `package.json` or the version in `docs/contract.md`. `CHANGELOG.md` is different: the release writes the version heading and one line per merged PR, but a `feat` PR writes its own notes under `## Unreleased` (CONTRIBUTING.md), and a PR that closes an issue must name that issue in `CHANGELOG.md` or `test_changelog_mentions_every_closed_issue_since_last_tag` fails. Once a PR merges to `main`, `.github/workflows/release.yml` takes it from there: if nobody bumped the version by hand, it resolves the next version ([.github/scripts/resolve_version.py](.github/scripts/resolve_version.py)) from the labels on every PR merged since the last tag (`minor`/`feature`/`enhancement` → minor, `fix`/`bug`/`patch` → patch, an unlabeled PR defaults to `patch`; a PR whose only labels are `chore`, `ci`, `docs` or `dependencies` is not releasable, so a merge that changes no shipped file releases nothing). Most PRs don't need a label added by hand: `release-drafter.yml`'s `autolabel` job applies one automatically from the PR's title/changed files (`Fix ...` → `fix`, `Add ...`/`feat ...` → `feature`, `docs`/`.md`/`.github/`/`build(deps)` changes → `chore`) as soon as it's opened — add a label yourself only to override that. **A major version is never chosen automatically**: no label rule produces `major`, and the workflow refuses to auto-bump across a major boundary even if someone applies that label — a real major release is a deliberate `package.json` bump in a PR, which the manual path below already handles. It bumps `package.json`, `.claude-plugin/plugin.json`, `docs/contract.md` and `docs/roadmap.md`'s released-version sentence, writes a `CHANGELOG.md` section listing those PRs (and any issues they closed), and pushes that commit to `main` itself. If another PR merged while the bump was being prepared, the push is refused and that run releases nothing: the newer merge's own run, queued behind it, starts from `main`'s tip and releases both (the bump is never rebased onto a merge it did not read). After the tag it also asks `pages.yml` to rebuild the site, because the bump commit carries `[skip ci]` and would otherwise leave the site on the previous version. Either way — auto-bumped or hand-bumped in the PR — it then creates the `vX.Y.Z` tag, publishes a GitHub Release with notes extracted from `CHANGELOG.md`'s matching section, and publishes the package to npm. A PR that still wants to write its own version bump and `CHANGELOG.md` prose (e.g. to explain the "why" of a release by hand) can — the automation only fills in when nobody made that call already. A push to `main` with nothing new to release creates no tag, Release or npm publish; its MCP Registry step still asks npm and the registry about `server.json`'s version and publishes the entry only when npm has that version and the registry does not, which is how a later run finishes an entry an earlier one failed to publish. npm publishing needs an `NPM_TOKEN` repo secret (an npm access token with publish rights on this package) — without it the tag and GitHub Release still happen, only the npm step is skipped. A repo that depends on this one (an editing skill, an agent) should pin an `ffmpeg-skill` version by tag or npm version, not by tracking `main` — a merged-but-not-yet-released commit on `main` can be ahead of the last published npm version for the few minutes between merge and this workflow completing.
|
|
680
680
|
|
|
681
681
|
**A version that was tagged but never reached npm** (the npm step failed; a re-run of that release sees the tag and skips the publish) is finished by dispatching the same workflow on its tag: `gh workflow run release.yml --ref vX.Y.Z -f version=X.Y.Z`. That runs only the workflow's `recover` job. It refuses a version that is not `X.Y.Z`, has no tag, or that npm already has, and a run whose commit is not the tagged one: the npm provenance attestation names the run's commit, so it must be the commit the package is built from. It publishes with `--provenance` under the `latest` dist-tag when the version is above every stable (non-prerelease) version npm has, and under `backport` otherwise, so `latest` never moves back. Then it publishes the `plugin` branch when no commit there is for this version and the branch's tip is older, and the MCP Registry entry when the registry does not list the version (never for a `backport`). The GitHub Release is left as it is. A tag cut before the job was added (#448; every tag up to v3.6.3) has no `recover` job at its commit and cannot be recovered this way.
|
|
682
682
|
|
package/SKILL.md
CHANGED
|
@@ -12,7 +12,7 @@ MCP `tools/list` shows the core 12; the other 30 of the 42 tools stay callable b
|
|
|
12
12
|
|
|
13
13
|
Shared flags (the rest, codec defaults and `--hw`: `references/flags.md`):
|
|
14
14
|
`--dry-run` (plan, write nothing), `--json` (output, probe, commands), `--json-brief` (status + `summary`),
|
|
15
|
-
`--overwrite` (step 7), `--timeout SECONDS` (default 1800, `0` = none; `kind: timeout`).
|
|
15
|
+
`--overwrite` (step 7), `--timeout SECONDS` (default 1800, `verify` 600 per step, `0` = none; `kind: timeout`).
|
|
16
16
|
|
|
17
17
|
Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`,
|
|
18
18
|
`scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`, `overlay`, `metadata`, `cut`, `render`,
|
|
@@ -108,7 +108,7 @@ Variants, caveats and extra flags per row: `references/requests.md`.
|
|
|
108
108
|
| "cut on the beat" | `cut.py in.mp4 --segments ... --snap beats --snap-source beats.json` (`scenes.py --beats`; a WAV/MP3 music bed too) |
|
|
109
109
|
| "speed up here, slow-mo there" | `speedramp.py in.mp4 --segment 0-3:1.0 --segment 3-4:0.25` |
|
|
110
110
|
| "freeze the last frame" | `freeze.py clip.mp4 --hold 2` |
|
|
111
|
-
| "smooth slow motion" | `fit.py in.mp4 --duration
|
|
111
|
+
| "smooth slow motion" | `fit.py in.mp4 --duration 20 --smooth interpolate` (a 10 s clip at half speed; `--duration` is the target length) |
|
|
112
112
|
| "reverse this clip" | `reverse.py in.mp4` |
|
|
113
113
|
| "loop it to fill 30 seconds", "seamless loop" | `loop.py clip.mp4 --duration 30` (`--crossfade 0.5` hides the seam) |
|
|
114
114
|
| "make it vertical / 9:16 / square" | `fit.py in.mp4 --aspect 9:16 --fit pad` (or `crop`, `blur`) |
|
|
@@ -163,7 +163,7 @@ Variants, caveats and extra flags per row: `references/requests.md`.
|
|
|
163
163
|
| "is it broadcast legal?" | `check.py final.mp4 --levels ebu-r103` (clamp: `color.py in.mp4 --correct --legalize ebu-r103`) |
|
|
164
164
|
| "send me a summary of what you did" | `report.py --before raw.mov --after final.mp4 -o report.html` |
|
|
165
165
|
| "title card", "Ken Burns on a photo" | `insert.py title.png --duration 3` (`--zoom in --pan right`) |
|
|
166
|
-
| "a blank colour clip" | `background.py -o bg.mp4 --duration 3 --color 0x101010` |
|
|
166
|
+
| "a blank colour clip" | `background.py -o bg.mp4 --duration 3 --width 1920 --height 1080 --color 0x101010` |
|
|
167
167
|
| "grade it but keep the alpha" | `color.py logo.mov --correct --exposure 0.3 --keep-alpha -o graded.mov` |
|
|
168
168
|
| "lower third as a transparent layer" | `background.py --transparent -o canvas.mov --duration 5 --width 1920 --height 1080`, then `graphics.py canvas.mov --template lower-third --name "Ada" --keep-alpha -o lt.mov` |
|
|
169
169
|
| "numbered frames into a video" | `sequence.py --dir frames --pattern "frame_%04d.png" --fps 24` |
|
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.8.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.8.7`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.8.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.8.7", "execution_mode": "local", "kind": "execution",
|
|
153
153
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
154
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
155
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
|
@@ -197,12 +197,16 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
|
|
|
197
197
|
|
|
198
198
|
### Roles
|
|
199
199
|
|
|
200
|
+
Every tool is in exactly one row; `tests/test_contract.py`'s
|
|
201
|
+
`test_contract_md_role_and_verification_tables_match_the_contract` compares both tables below with
|
|
202
|
+
`contract --json`. A parenthesis after a name is a note, not part of it.
|
|
203
|
+
|
|
200
204
|
| Role | Tools |
|
|
201
205
|
|---|---|
|
|
202
|
-
| `analysis` (measures, writes no media) | probe, scenes |
|
|
203
|
-
| `analysis_and_execution` (measures by default or with a flag, can also write) |
|
|
204
|
-
| `execution` (writes a new artifact) |
|
|
205
|
-
| `verification` (checks or shows an artifact) | check, look,
|
|
206
|
+
| `analysis` (measures, writes no media) | cropdetect, probe, scenes (`--sheet` draws a contact sheet PNG, `--edl` writes a text list) |
|
|
207
|
+
| `analysis_and_execution` (measures by default or with a flag, can also write) | loudness (`--measure-only`), silence (`--list`), sync (offset JSON without `-o`) |
|
|
208
|
+
| `execution` (writes a new artifact) | audio, background, batch, broll, caption, color, crop, cut, deinterlace, denoise, export, fit, freeze, graphics, grid, insert, join, loop, metadata, multicam, overlay, pad, proxy, redact, render, reverse, sequence, speedramp, sphere, stabilize, straighten, waveform |
|
|
209
|
+
| `verification` (checks or shows an artifact) | check, look, report, verify |
|
|
206
210
|
|
|
207
211
|
`batch` writes one artifact per input only when a recipe step names `{out}` (or the recipe is a
|
|
208
212
|
render project): a recipe in which no step does (a QC pass of `check.py`/`probe.py`) writes none,
|
|
@@ -215,15 +219,15 @@ The workflow in `SKILL.md` is "probe first, verify last". The contract states it
|
|
|
215
219
|
|
|
216
220
|
| Tool | After it wrote an artifact, run |
|
|
217
221
|
|---|---|
|
|
218
|
-
| cut,
|
|
219
|
-
| loudness | probe, check |
|
|
220
|
-
|
|
|
221
|
-
| fit, caption, overlay, graphics, color, join, multicam | probe, look |
|
|
222
|
+
| audio, batch, cut, metadata, silence, sync | probe |
|
|
223
|
+
| export, loudness | probe, check |
|
|
224
|
+
| background, broll, caption, color, crop, deinterlace, denoise, fit, freeze, graphics, grid, insert, join, loop, multicam, overlay, pad, proxy, redact, reverse, sequence, speedramp, sphere, stabilize, straighten, waveform | probe, look |
|
|
222
225
|
| render | probe, check, look |
|
|
223
|
-
|
|
|
226
|
+
| check, cropdetect, look, probe, report, scenes, verify | nothing (they measure or are the verification) |
|
|
224
227
|
|
|
225
|
-
`requires_visual_verification` is `true` exactly for the tools
|
|
226
|
-
|
|
228
|
+
`requires_visual_verification` is `true` exactly for the tools whose row above names `look`:
|
|
229
|
+
the tools that make or change a picture. `join` of audio-only inputs and `render`'s audio
|
|
230
|
+
templates write no picture, but the flag is per tool, not per run. Audio-only tools and
|
|
227
231
|
audio-only inputs never need `look`; the report line is `Look: not needed`. `check`
|
|
228
232
|
rows carry `kind: format` (fix it) or `kind: judgement` (decide with the user), and `basis`:
|
|
229
233
|
`requirement` (the row can FAIL) or `guidance`. The `duration` and `file size` rows are `basis: guidance`
|
|
@@ -248,12 +252,15 @@ document. Thresholds: `docs/design-decisions.md` ("check.py --content", "broadca
|
|
|
248
252
|
|
|
249
253
|
### Dry run
|
|
250
254
|
|
|
251
|
-
`supports_dry_run` is measured, not declared: `tests/test_contract.py` runs every tool
|
|
255
|
+
`supports_dry_run` is measured, not declared: `tests/test_contract.py` runs every tool whose
|
|
256
|
+
`dry_run.ffmpeg_execution` is `none` (and `cut` and `metadata` without their measuring flags)
|
|
252
257
|
with `--dry-run` behind a fake `ffmpeg` that records any call, and asserts that no
|
|
253
|
-
call happened and no file appeared
|
|
258
|
+
call happened and no file appeared; the `analysis_only` tools in that test run with the real
|
|
259
|
+
ffmpeg and are checked for files only. Under `--dry-run` a tool prints the command lines
|
|
254
260
|
it would run, reports `dry_run: true`, and never reports an output probe. The
|
|
255
261
|
exceptions are stated per tool in the contract's `dry_run` field: `probe` and `check` are
|
|
256
|
-
read-only (ffprobe still runs
|
|
262
|
+
read-only (ffprobe still runs; `probe` decodes with ffmpeg only with `--analyze`, to sample
|
|
263
|
+
picture levels); `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`,
|
|
257
264
|
`loudness` and `stabilize` still run their ffmpeg/ffprobe measurements (the analysis is the
|
|
258
265
|
tool's job; only the artifact is skipped, including side files such as `--edl`, `--sheet` or a
|
|
259
266
|
generated `.ass`), `overlay` measures the lines of a multi-line `--text` with one drawtext on a
|
|
@@ -261,9 +268,10 @@ generated frame on FFmpeg 8.0, 8.0.1 and 8.1.0 (it draws them one by one there),
|
|
|
261
268
|
with `--auto-chapters` and `cut` with `--snap beats` decode the input for their measurement,
|
|
262
269
|
`render` and `batch` run each stage or step as its own dry run (a measuring stage measures an
|
|
263
270
|
input that exists), and `verify` does not support dry-run (its steps run, and its document says
|
|
264
|
-
`dry_run: false`). `tests/test_contract.py` runs
|
|
265
|
-
each decode and asserts that every tool that decoded is in
|
|
266
|
-
`references/scripts.md`
|
|
271
|
+
`dry_run: false`). `tests/test_contract.py` runs the dry run of every tool that supports one
|
|
272
|
+
behind an ffmpeg that logs each decode and asserts that every tool that decoded is in
|
|
273
|
+
`DRY_RUN_ANALYSIS`. `SKILL.md`, `README.md`, `references/scripts.md` and `references/flags.md`
|
|
274
|
+
repeat the same list (a test checks each names every tool in it); the contract is the authority.
|
|
267
275
|
|
|
268
276
|
`--codec h264|hevc|av1|prores` and `--quality N` (1.8) are on every tool that re-encodes,
|
|
269
277
|
marked `common`. They are resolved in one place
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.7",
|
|
4
4
|
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
5
5
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
6
6
|
"keywords": [
|
|
@@ -203,12 +203,15 @@ these had ever shown up before.
|
|
|
203
203
|
On certain Windows ffmpeg
|
|
204
204
|
builds (e.g. winget's gyan.dev), `drawtext` crashes with an access violation
|
|
205
205
|
whenever it resolves a font by family name through fontconfig, even with a
|
|
206
|
-
valid `fonts.conf` (#100). `look.py`, `scenes.py --sheet`, `
|
|
207
|
-
and `graphics.py` all resolve a concrete
|
|
208
|
-
available (`fontfile=` skips fontconfig entirely and is the form
|
|
209
|
-
not to crash), so this should already be handled automatically. If a
|
|
210
|
-
drawtext tool still crashes
|
|
211
|
-
relying on `--font`/`font=` resolution;
|
|
206
|
+
valid `fonts.conf` (#100). `look.py`, `scenes.py --sheet`, `grid.py`'s labels,
|
|
207
|
+
`overlay.py --text` and `graphics.py` all resolve a concrete font file by default
|
|
208
|
+
when one is available (`fontfile=` skips fontconfig entirely and is the form
|
|
209
|
+
confirmed not to crash), so this should already be handled automatically. If a
|
|
210
|
+
drawtext tool still crashes: `overlay.py` and `graphics.py` take `--font-file`,
|
|
211
|
+
pass one explicitly rather than relying on `--font`/`font=` resolution;
|
|
212
|
+
`look.py` and `scenes.py` have no font flag, and `--no-timecode` draws no text
|
|
213
|
+
at all; `grid.py` has only `--font` (a family name, the form that crashes), and
|
|
214
|
+
`--label none` draws no labels. `doctor` also runs a real one-frame
|
|
212
215
|
drawtext probe and reports `filter:drawtext` missing (with the crash detail
|
|
213
216
|
in `errors[]`) rather than a false "available" from the `-filters` listing
|
|
214
217
|
alone.
|
package/references/flags.md
CHANGED
|
@@ -9,8 +9,8 @@ Scripts live in `scripts/` next to SKILL.md; run them with `python3 <skill-dir>/
|
|
|
9
9
|
|
|
10
10
|
## Shared flags
|
|
11
11
|
|
|
12
|
-
Shared flags (every script): `--dry-run
|
|
12
|
+
Shared flags (every script accepts them; the exceptions are named): `--dry-run` (`verify.py` ignores it); `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`); `--fast` (preview quality); `--progress` (%/ETA); `--timeout SECONDS` (`kind: timeout`; per ffmpeg run, default 1800, `verify.py` per step, default 600, `batch.py` one budget for the whole batch when `--timeout` is given or `--jobs` is above 1; `0` for hour-long encodes; below 0 or above 500000 is refused); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses a changed input; `render.py` and `verify.py` refuse `--plan`). `-o` is not shared: `probe`, `check`, `cropdetect`, `scenes`, `batch` and `verify` have none. Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264 (`cut.py`: HEVC source stays HEVC, `--no-keep-hevc` x264), HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
|
|
13
13
|
|
|
14
14
|
## What `--dry-run` runs
|
|
15
15
|
|
|
16
|
-
Writing tools run
|
|
16
|
+
Writing tools run no ffmpeg under `--dry-run` (ffprobe still reads an input that exists); the measuring tools (`probe` with `--analyze`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`; `overlay` for a multi-line `--text` on FFmpeg 8.0-8.1.0; `metadata` with `--auto-chapters`; `cut` with `--snap beats`) still run their ffmpeg measurements, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`), and `render` and `batch` run each stage or step as its own dry run; `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
|
package/references/gotchas.md
CHANGED
|
@@ -211,8 +211,11 @@ printing one line — `font: /usr/share/fonts/.../wqy-zenhei.ttc (covers ko)`.
|
|
|
211
211
|
already resolves a file for you on Windows, so this bites only a hand-built
|
|
212
212
|
drawtext call. More: `references/ci-platform-pitfalls.md`.
|
|
213
213
|
- Han characters alone (no kana, no hangul) are read as Chinese. Japanese or
|
|
214
|
-
Korean hanja text with no kana needs `--lang ja` / `--lang ko`
|
|
215
|
-
(`caption.py --language
|
|
214
|
+
Korean hanja text with no kana needs `--lang ja` / `--lang ko` on `graphics.py`
|
|
215
|
+
(`caption.py --language`, alias `--lang`, is the same hint), or `"lang"` in the
|
|
216
|
+
brand.json those two read. `overlay.py` has no such flag and its brand.json
|
|
217
|
+
`"lang"` is not read: name the face (`--font "Noto Sans CJK JP"` or
|
|
218
|
+
`--font-file`).
|
|
216
219
|
- An explicit `--font`, an explicit `--font-file`, or a font your brand file
|
|
217
220
|
itself names is always kept, even when fontconfig says it does not cover the script: you get one info
|
|
218
221
|
line saying so, not a silent substitution. A brand file that never names a
|
|
@@ -400,8 +403,11 @@ commands work with `talk.wav` in place of `talk.mp4`. What changes:
|
|
|
400
403
|
- `Look: not needed` in the report; `Check:` still applies for loudness
|
|
401
404
|
(`check.py file.wav --platform podcast` measures LUFS and true peak).
|
|
402
405
|
- Scripts that need a picture (`fit`, `caption`, `overlay`, `graphics`,
|
|
403
|
-
`color`, `export`, `
|
|
404
|
-
"input has no video stream". Say so instead of
|
|
406
|
+
`color`, `export`, `look`; the contract's `video_required` lists the rest)
|
|
407
|
+
refuse an audio file with "input has no video stream". Say so instead of
|
|
408
|
+
forcing a video wrapper. `scenes` takes one as a single scene (`--beats`,
|
|
409
|
+
`--audio-peaks`, `--speech`, `--highlights`); its `--sheet` and `--shots`
|
|
410
|
+
refuse it.
|
|
405
411
|
|
|
406
412
|
### Audio-only recipes
|
|
407
413
|
|
package/references/scripts.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Script reference
|
|
2
2
|
|
|
3
|
-
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT
|
|
3
|
+
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py; `render.py` and `verify.py` refuse it), `-o OUT` (except `probe`, `check`, `cropdetect`, `scenes`, `batch` and `verify`, which take none); every re-encoding tool and `export.py` also take `--hw`/`--no-hw` (h264/hevc/prores on Apple VideoToolbox, Apple Silicon only, CRF mapped to `-q:v`; AV1 stays on the CPU; a job VideoToolbox itself refuses -- the encoder cannot open or its compression session fails -- is re-encoded on the CPU and reported in `hw.fallback`/`hw.notes`, any other failure is not retried; a GPU encode adds a `notes` line that its quality is not CRF-equivalent; `FFMPEG_SKILL_HW=1` makes it the default for every tool except `export.py`, whose delivery presets need `--hw` itself; `render.py`/`batch.py --hw`/`--no-hw` apply to every stage, export included; results name the `encoder` that ran); every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, default 18 -- 30 for `proxy.py`; up to 63 for av1; ignored by prores). 2.0 removed `--crf` from these tools (it was `--quality`'s alias); `export.py` keeps its own `--crf`, which is not an alias. Every tool refuses (`kind: input`, `error.reason: output_exists`, before anything runs) to replace an existing output unless `--overwrite` is given. Refusals carry `error.reason` and `error.details` (the facts behind the message; `contract --json` → `json_output.error_reasons`), and what a run decided on its own -- a stale output lock taken over, no lock possible, a plan input nobody fingerprinted, an odd-sized frame scaled to even, subtitle streams dropped on a retry (with the first attempt's error), a font substituted, a measurement skipped under `--dry-run`, an encoder list that could not be read, an invalid `FFMPEG_SKILL_TIMEOUT`, and every `warning:` line -- is in `diagnostics: {warnings, fallbacks, skipped}` as well as on stderr (codes: `contract --json` → `json_output.diagnostic_codes`). `--codec av1`/`prores` with no encoder for it refuses `kind: missing_tool`, `error.reason: encoder_missing`; when the encoder list could not be read at all, `kind: ffmpeg`, `encoder_list_unreadable`. Tools that draw text (`overlay.py --text`, `graphics.py`, `caption.py`, `grid.py --label auto`) report `font_used: {asked, file, family, how, substituted}`. `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither `--codec` nor `--quality`; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, and with `--analyze` ffmpeg to sample picture levels, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), `overlay` measures a multi-line `--text`'s lines on FFmpeg 8.0, 8.0.1 and 8.1.0 (one drawtext on a generated frame), `metadata` with `--auto-chapters` and `cut` with `--snap beats` decode the input for their measurement, `render` and `batch` run each stage or step as its own dry run (so a measuring stage measures an input that exists), and `verify` accepts the flag but ignores it entirely (its document says `dry_run: false` and a note says so). Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
|
|
4
4
|
|
|
5
5
|
## Time grammar (every time-taking flag, 1.9)
|
|
6
6
|
|
package/references/workflow.md
CHANGED
|
@@ -26,13 +26,13 @@ Ask a short question only when the answer changes the output materially and the
|
|
|
26
26
|
- **Duration** ("make it 60 s") without a method: speed up for ≤1.5× changes, trim otherwise, say which. Ask when the content is a talk (trimming loses words) and the change is large.
|
|
27
27
|
- **Captions** without a text source: `--transcribe` if a local engine exists, else ask for the text or a timed file; never invent dialogue.
|
|
28
28
|
- **Fonts and brand**: if the user mentions a brand, colours or "our font", ask for or create `brand.json` once, reuse it.
|
|
29
|
-
- **CJK / non-Latin text**: let the tool pick the font by script (`--font` turns it off); `--lang ja|ko|zh-TW` for Han-only text. `doctor --json` `.fonts.scripts` says what renders here. Tofu is a failed job.
|
|
29
|
+
- **CJK / non-Latin text**: let the tool pick the font by script (`--font` turns it off); `--lang ja|ko|zh-TW` for Han-only text on `graphics`/`caption` (`overlay` has none: name the face with `--font`/`--font-file`). `doctor --json` `.fonts.scripts` says what renders here. Tofu is a failed job.
|
|
30
30
|
- **Crop position** for `--fit crop`: centre by default, but when the request or the source names an off-centre subject ("keep the product on the right", someone visibly off-centre in the sheet) use `--crop-x`/`--crop-y` (0=left/top, 1=right/bottom) instead of guessing centre, or ask which edge to keep.
|
|
31
31
|
- Anything else (transition type, caption style): pick the conventional default, say what you picked, offer the alternative in one line.
|
|
32
32
|
|
|
33
33
|
## Audio-only files
|
|
34
34
|
|
|
35
|
-
Audio is a first-class input: `probe`, `cut`, `silence`, `loudness`, `audio`, `sync`, `check --platform podcast`, `render --template podcast` take WAV, FLAC, MP3, M4A/AAC, OGG, Opus; output extension picks the format. Scripts needing a picture (`fit`, `caption`, `overlay`, `graphics`, `color`, `export`, `
|
|
35
|
+
Audio is a first-class input: `probe`, `cut`, `silence`, `loudness`, `audio`, `sync`, `check --platform podcast`, `render --template podcast` take WAV, FLAC, MP3, M4A/AAC, OGG, Opus; output extension picks the format. Scripts needing a picture (`fit`, `caption`, `overlay`, `graphics`, `color`, `export`, `look`; the contract's `video_required` lists the rest) refuse audio with "input has no video stream" — don't force a video wrapper. `scenes` takes an audio file as one scene (`--beats`, `--audio-peaks`, `--speech`, `--highlights`); its `--sheet` and `--shots` refuse one. Recipes: `references/gotchas.md#audio-only-files`.
|
|
36
36
|
|
|
37
37
|
## What this skill does and does not decide
|
|
38
38
|
|
|
@@ -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 =
|
|
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):
|
|
311
|
-
code=127, version=f"{found[0]}.{found[1]}", required=
|
|
327
|
+
f"2.14.0): a newer build is needed, FFmpeg {floor} or later. On {system}:\n{hint}",
|
|
328
|
+
code=127, version=f"{found[0]}.{found[1]}", required=floor)
|
|
312
329
|
if path:
|
|
313
330
|
return path
|
|
314
331
|
system = platform.system()
|
package/scripts/_contract.py
CHANGED
|
@@ -239,6 +239,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
239
239
|
# on PATH). "analysis_only": ffmpeg still decodes/measures the input under --dry-run, but nothing
|
|
240
240
|
# is encoded and no file is written. Every other tool with the flag runs no ffmpeg at all.
|
|
241
241
|
DRY_RUN_ANALYSIS = {
|
|
242
|
+
# #466 A193: probe was "none" with an ffprobe note while --analyze decoded with ffmpeg
|
|
243
|
+
"probe": "read-only tool; --dry-run changes nothing: ffprobe still runs, and only with --analyze ffmpeg decodes the first 20 s of each video input to sample picture levels",
|
|
242
244
|
"sync": "audio is decoded to find the offset; the aligned output is not written",
|
|
243
245
|
"multicam": "audio is decoded to align the cameras; the switched output is not written",
|
|
244
246
|
"scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
|
|
@@ -255,7 +257,6 @@ DRY_RUN_ANALYSIS = {
|
|
|
255
257
|
"overlay": "only for a multi-line --text on FFmpeg 8.0, 8.0.1 or 8.1.0: one drawtext measures each line on a generated one-frame picture (the input is not read), so the planned per-line positions are the ones a run uses; otherwise no ffmpeg runs and the composited output is never written",
|
|
256
258
|
}
|
|
257
259
|
DRY_RUN_NOTES = {
|
|
258
|
-
"probe": "read-only tool; --dry-run changes nothing (ffprobe still runs)",
|
|
259
260
|
"verify": "not supported: the flag is accepted but the steps run and outputs are written",
|
|
260
261
|
}
|
|
261
262
|
|
|
@@ -581,7 +582,7 @@ def alpha_result_schema(name: str) -> Dict[str, Any]:
|
|
|
581
582
|
out["alpha_codec"]["description"] += " (an RGB source that is neither HDR nor BT.2020, on a prores4444/vp9 line has no matrix to keep: it is converted to BT.709 and tagged so; an HDR or BT.2020 one is not: FFmpeg's own conversion makes its YUV, as before)"
|
|
582
583
|
out["alpha_dropped"] = {"type": "boolean", "description": "true when the source's alpha is used (a frame below opaque, or a stored alpha plane that could not be read) and the output does not keep it (color.py --retag too, when its copy fell back to an H.264 re-encode): without --keep-alpha the `alpha` verification step fails (reason alpha_dropped, attribution artifact, with an alpha_dropped warning); with --drop-alpha there is no such step and a note says the alpha was dropped on purpose. Absent on a source without alpha"}
|
|
583
584
|
if name == "color":
|
|
584
|
-
out["alpha_codec"]["description"] += ". --correct / --lut only: the grade works on the colour (at 4:4:4) and the source's alpha is merged back unchanged; the `alpha` step compares the written alpha with the source's frame by frame, tolerance 2 of 255 (comparison {ok, paired, worst_delta, worst_frame}; frames {expected, measured}; written {codec, pix_fmt, alpha_plane})"
|
|
585
|
+
out["alpha_codec"]["description"] += ". --correct / --lut only: the grade works on the colour (at 4:4:4) and the source's alpha is merged back onto that 8-bit picture (an 8-bit alpha unchanged; a 10- or 12-bit ProRes 4444 alpha keeps 8 bits of precision); the `alpha` step compares the written alpha with the source's frame by frame, tolerance 2 of 255 (comparison {ok, paired, worst_delta, worst_frame}; frames {expected, measured}; written {codec, pix_fmt, alpha_plane})"
|
|
585
586
|
else:
|
|
586
587
|
out["alpha_codec"]["description"] += ". Drawn on the input's alpha at 4:4:4 (layers composited with their alpha, text drawn into it); the `alpha` step (property alpha_at_least_source) passes when the written stream stores an alpha plane, every frame's alpha is at or above the source's (tolerance 2 of 255) and the output did not turn opaque in every frame the source was transparent in (reason: opaque_everywhere, alpha_below_source or no_alpha_plane when it fails)"
|
|
587
588
|
out["alpha_codec"]["description"] += ". A source that stores an alpha plane whose alpha could not be read is never taken as opaque: its step fails with reason source_alpha_unmeasured"
|
|
@@ -608,6 +609,60 @@ NON_AV_STREAM_TOOLS = frozenset({"audio", "broll", "color", "cut", "deinterlace"
|
|
|
608
609
|
"sphere", "straighten"})
|
|
609
610
|
|
|
610
611
|
|
|
612
|
+
# Top-level keys a tool emits that its own branch in output_schema() does not list (#466 A194: the
|
|
613
|
+
# schemas were written by hand and left these out). tests/test_contract.py's
|
|
614
|
+
# test_every_emitted_top_level_key_is_in_the_output_schema runs each tool and fails on a key that
|
|
615
|
+
# is in neither.
|
|
616
|
+
EMITTED_KEYS: Dict[str, Dict[str, Any]] = {
|
|
617
|
+
"cropdetect": {
|
|
618
|
+
"file": {"type": "string", "description": "the input path, as given"},
|
|
619
|
+
"source_width": {"type": "integer", "description": "the displayed frame's width (a 90/270 degree rotation swaps the stored size; a non-square-pixel source is squared at its display aspect): the frame crop and motion_centre are in"},
|
|
620
|
+
"source_height": {"type": "integer", "description": "the displayed frame's height, as source_width"},
|
|
621
|
+
"crop": {"type": ["object", "null"], "description": "{width, height, x, y}: the rectangle cropdetect read most often over the sampled windows, in source pixels (the full frame when there are no bars); null when it produced no reading"},
|
|
622
|
+
"confidence": {"type": "number", "description": "present with a crop: the share of readings (0..1) that gave that rectangle"},
|
|
623
|
+
"motion_centre": {"type": "array", "description": "--motion-centre only: [{time, x, y, x_frac, y_frac, motion}] per second over the same windows; x/y null for a window with no measured motion. Report only"}},
|
|
624
|
+
"graphics": {
|
|
625
|
+
"template": {"type": "string", "description": "the --template drawn"},
|
|
626
|
+
"text_renderer": {"enum": ["ass", "drawtext"], "description": "what drew the text: libass (a generated .ass) or drawtext"},
|
|
627
|
+
"script": {"type": "string", "description": "the writing system detected in the text, which picks the font (latin, han, ...)"},
|
|
628
|
+
"ass": {"type": "string", "description": "text_renderer ass only: the generated .ass path (--write-ass, else <output>_gfx.ass); under --dry-run the path that would be written"},
|
|
629
|
+
"emoji": {"type": "object", "description": "text with emoji only: {mode, count, clusters, assets, missing, overlays}"},
|
|
630
|
+
"broken_inside_word": {"type": "integer", "description": "present when above 0: words hard-sliced because they did not fit a line alone"}},
|
|
631
|
+
"loudness": {
|
|
632
|
+
"loudnorm_mode": {"enum": ["linear", "dynamic"], "description": "--dry-run only (a real run reports it in result): the mode loudnorm's planned pass 2 runs in for the measured input"}},
|
|
633
|
+
"join": {
|
|
634
|
+
"reencoded": {"type": "boolean", "description": "--copy --transition none only: false, the clips were joined by stream copy"},
|
|
635
|
+
"audio_substituted": {"type": "array", "items": {"type": "string"}, "description": "a re-encoded video join: the clips with no audio stream, whose span of the join is generated silence (notes says the same); [] when every clip has sound"},
|
|
636
|
+
"inputs_duration": {"type": ["number", "null"], "description": "the clips' own total length, seconds"},
|
|
637
|
+
"transition_shortening": {"type": ["number", "null"], "description": "how much the transitions took out of inputs_duration, seconds"}},
|
|
638
|
+
"multicam": {
|
|
639
|
+
"inputs": {"type": "array", "items": {"type": "string"}, "description": "the input paths as given, reference first; a camera number in cuts indexes this list"}},
|
|
640
|
+
"broll": {
|
|
641
|
+
"cutaways": {"type": "array", "description": "[{insert, at, end, from}] per cutaway: the B clip, where it starts and ends on A's timeline (seconds), and where in B it starts"},
|
|
642
|
+
"audio": {"enum": ["a", "b", "mix"], "description": "--audio: what plays under a cutaway"}},
|
|
643
|
+
"grid": {
|
|
644
|
+
"cols": {"type": "integer"}, "rows": {"type": "integer"},
|
|
645
|
+
"clips": {"type": "integer", "description": "how many input clips the grid holds"}},
|
|
646
|
+
"scenes": {
|
|
647
|
+
"highlights": {"type": "array", "description": "--highlights only: [{start, end, duration}] the proposed ranges"},
|
|
648
|
+
"highlights_total": {"type": "number", "description": "--highlights only: the ranges' total seconds"},
|
|
649
|
+
"highlights_rank_by": {"enum": ["audio", "duration"], "description": "--highlights only: --rank-by"},
|
|
650
|
+
"sheet": {"type": "string", "description": "--sheet only: the contact sheet path (under --dry-run the path that would be written; nothing is drawn)"}},
|
|
651
|
+
"batch": {
|
|
652
|
+
"jobs": {"type": "integer", "description": "the parallelism actually applied"},
|
|
653
|
+
"jobs_requested": {"type": ["integer", "null"], "description": "the --jobs asked for (auto resolved to a number)"},
|
|
654
|
+
"wall_seconds": {"type": "number", "description": "the batch's wall clock"},
|
|
655
|
+
"item_seconds_total": {"type": "number", "description": "the sum of the per-item times"},
|
|
656
|
+
"timed_out": {"type": "boolean", "description": "whether the shared --timeout budget ran out (a timed-out item's row carries skipped: timeout)"}},
|
|
657
|
+
"render": {
|
|
658
|
+
"template": {"type": "string", "description": "--write-project only: the template the written project was filled from"},
|
|
659
|
+
"pack": {"type": "array", "description": "--template naming several destinations (a social pack) only: one row per destination {platform, file, path, size_bytes, duration, check, guidance, ok}; output is then the <stem>_pack.md table"},
|
|
660
|
+
"outputs": {"type": "array", "items": {"type": "string"}, "description": "a social pack only: each destination's output path"}},
|
|
661
|
+
"report": {
|
|
662
|
+
"pack": {"type": "array", "description": "--pack only: the pack table's rows as {column: cell} objects, as read from the file"}},
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
|
|
611
666
|
def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str, Any]:
|
|
612
667
|
"""What the tool prints on stdout with --json (keys observed in the implementation). `hw`: the
|
|
613
668
|
tool takes --hw, so its `notes` can also carry a VideoToolbox encode's quality line."""
|
|
@@ -618,7 +673,15 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
618
673
|
"timecode": {"type": "object", "description": "3.0.0: the source's start timecode {value, drop_frame, source}: the first HH:MM:SS[:;.]FF timecode tag from the video stream, then a tmcd data stream, then the container; all three null when none carries one"},
|
|
619
674
|
"chapters": {"type": "array", "description": "the container's chapter markers [{index, start, end, title}]"},
|
|
620
675
|
"subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title, default (the stream's default disposition)}]"},
|
|
621
|
-
"keyframes": {"type": "array", "description": "--keyframes-near only: one entry per time [{at, window, keyframes (the video keyframes within window seconds of it), before (the nearest of those at or before it, or null when none is within the window), after (at or after it, likewise)}], seconds from the file's start; a failed keyframe read fails the run (kind ffmpeg)"}
|
|
676
|
+
"keyframes": {"type": "array", "description": "--keyframes-near only: one entry per time [{at, window, keyframes (the video keyframes within window seconds of it), before (the nearest of those at or before it, or null when none is within the window), after (at or after it, likewise)}], seconds from the file's start; a failed keyframe read fails the run (kind ffmpeg)"},
|
|
677
|
+
"start_time": {"type": ["number", "null"], "description": "where the container's timeline starts, seconds (an MPEG-TS file often starts above 0)"},
|
|
678
|
+
"bitrate": {"type": ["integer", "null"], "description": "the container's overall bit rate, bits per second, as ffprobe reads it; null when it states none"},
|
|
679
|
+
"subtitle_streams": {"type": "integer", "description": "how many subtitle streams the file carries"},
|
|
680
|
+
"data_streams": {"type": "integer", "description": "how many data streams (a tmcd timecode track, telemetry) the file carries"},
|
|
681
|
+
"stream_counts": {"type": "object", "description": "{video, audio}: picture streams (a cover picture is not one) and sound streams"},
|
|
682
|
+
"tags": {"type": "object", "description": "the container's title, artist, album, comment, date and genre tags that are set, keys lower-cased"},
|
|
683
|
+
"audio_streams": {"type": "array", "description": "every audio stream in file order [{index (the n of -map 0:a:n), codec, channels, channel_layout, sample_rate, language, title}]"},
|
|
684
|
+
"levels": {"type": "object", "description": "--analyze only, on an input with a video stream: picture levels sampled at 2 fps over the first 20 s, on an 8-bit scale inferred from the values measured (a 10-bit or deeper picture whose luma never passes code 255 is read as 8-bit and left unscaled) {scale, y_min, y_max, y_avg, y_low_p5, y_high_p95, saturation_avg, looks_like_log, note}; {error} when no frame was analysed. Measured under --dry-run too"}},
|
|
622
685
|
"additionalProperties": True}
|
|
623
686
|
base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
|
|
624
687
|
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "the ffmpeg command lines this run planned or ran to write its output (render.py and waveform.py: their stages' too); a measurement pass is listed only where the tool records it (e.g. loudness.py pass 1, check.py, stabilize.py pass 1), and batch.py lists none of its steps' (each results row's step documents carry their own)"},
|
|
@@ -781,7 +844,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
781
844
|
"drift_ppm": {"type": "array", "items": {"type": ["number", "null"]}, "description": "--fix-drift: per input, the drift measured against the reference (the reference itself 0, the clock); null for a camera whose drift was not measured, with the reason in drift_skipped_reason (also a diagnostics.skipped entry, drift_correction_skipped)"},
|
|
782
845
|
"drift_skipped_reason": {"type": "array", "items": {"enum": ["too_short", "low_confidence", None]}, "description": "--fix-drift: per input, why its drift was not measured (too_short: no tail window far enough from the head; low_confidence: the tail did not correlate), null when it was and for the reference"}}
|
|
783
846
|
elif name == "join":
|
|
784
|
-
extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
|
|
847
|
+
extra = {"mode": {"enum": ["video", "audio", "copy"], "description": "copy: --copy --transition none wrote a stream copy"}, "clips": {"type": "integer"}, "transition": {"type": "string"},
|
|
785
848
|
"expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
|
|
786
849
|
"frame_rate": {"type": "string", "description": "video mode only: the join's frame rate as an exact fraction (\"30000/1001\"), the rate every clip length and xfade offset is counted in"},
|
|
787
850
|
"clip_frames": {"type": "array", "items": {"type": "integer"}, "description": "video mode only: per clip, the whole frames of frame_rate it holds in the join (its picture's, or the frames that cover a sound running more than a frame past it); its sound is cut to the samples those frames last. A pending --dry-run input counts 0"},
|
|
@@ -802,6 +865,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
802
865
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "present with `silent`: which tracks were silent and how to refuse them"},
|
|
803
866
|
"timing": {"type": "object", "description": "#486: {tempo, atempo, length_samples, sample_rate}: the --tempo factor (null without it), the atempo stages (each 0.5..2.0, [] at tempo 1) whose product it is, --length-samples (null without it) and the sample rate that count is in (null when not measured yet); with --length-samples a length_samples verification step counts the written track's decoded samples (exact for PCM/FLAC/ALAC, within tolerance_samples, one codec frame, for a lossy codec)"},
|
|
804
867
|
"audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, channel (--channel N, the 0-based channel kept, else null), sample_rate (--sample-rate, else null; both are read back from the output as the channels / sample_rate verification steps), effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db actually used)"}}
|
|
868
|
+
for key, prop in EMITTED_KEYS.get(name, {}).items():
|
|
869
|
+
extra.setdefault(key, prop)
|
|
805
870
|
if name in ("overlay", "graphics", "caption", "grid"):
|
|
806
871
|
extra["font_used"] = FONT_USED_SCHEMA
|
|
807
872
|
if name in ALPHA_LINE_TOOLS:
|
|
@@ -1672,6 +1737,18 @@ def _merge_optional(base: List[Dict[str, Any]], extra: List[Dict[str, Any]]) ->
|
|
|
1672
1737
|
return out
|
|
1673
1738
|
|
|
1674
1739
|
|
|
1740
|
+
_SENTENCE_END = re.compile(r"(?<!\be\.g)(?<!\bi\.e)(?<!\betc)[.!?](?=\s|$)")
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
def summary_sentence(docstring: str) -> str:
|
|
1744
|
+
"""The docstring's first sentence, its wrapped lines joined (#466 A195: the first physical line
|
|
1745
|
+
cut 16 descriptions mid-phrase). A first paragraph with no full stop is taken whole."""
|
|
1746
|
+
para = re.split(r"\n\s*\n", docstring.strip(), maxsplit=1)[0]
|
|
1747
|
+
joined = " ".join(line.strip() for line in para.splitlines())
|
|
1748
|
+
end = _SENTENCE_END.search(joined)
|
|
1749
|
+
return joined[:end.end()] if end else joined
|
|
1750
|
+
|
|
1751
|
+
|
|
1675
1752
|
def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
1676
1753
|
if name not in TOOL_META:
|
|
1677
1754
|
# a public script without metadata is drift: fail loudly instead of guessing its role or capabilities
|
|
@@ -1696,7 +1773,7 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
|
1696
1773
|
"id": f"{SKILL_ID}/{name}",
|
|
1697
1774
|
"name": name,
|
|
1698
1775
|
"version": version,
|
|
1699
|
-
"description": (parser.description or "")
|
|
1776
|
+
"description": summary_sentence(parser.description or ""),
|
|
1700
1777
|
"executable": f"scripts/{name}.py",
|
|
1701
1778
|
# 1.20.0: SKILL.md's own request-table rows for this tool, machine-readable
|
|
1702
1779
|
"examples": skill_examples().get(name, []),
|
|
@@ -2079,7 +2156,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
2079
2156
|
"hidden .<output>.ffskill-audit.jsonl beside the output, and a cache event's .ffskill-audit.jsonl in the "
|
|
2080
2157
|
"cache directory (render.py --cache DIR) or the batch's output_dir. Never written under --dry-run; a write that "
|
|
2081
2158
|
"fails is a diagnostics.warnings entry (audit_log_unwritable), never a failure"},
|
|
2082
|
-
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH, or older than the supported 6.1 (error.reason ffmpeg_unsupported)
|
|
2159
|
+
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "something the run needs is not installed: ffmpeg or ffprobe not on PATH, or older than the supported 6.1 (error.reason ffmpeg_unsupported) (both exit 127); an encoder this build lacks for --codec, an export.py preset or an alpha line (encoder_missing); the libvpx-vp9 decoder for a VP9 source with an alpha layer (decoder_missing); no local speech-to-text engine for --transcribe (asr_engine_missing), or a named --engine that is not installed (these exit 1)","timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT; verify.py's --timeout is per step, default 600 s) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), a render.py --template social pack with a destination that failed (pack rows attached; a destination that timed out or was interrupted gives that kind instead), batch.py items (results attached), verify.py steps (files attached), color.py --retag (output written, but ffprobe does not read the requested colour tags on it), loudness.py (output written, its true peak still above --tp after the re-encodes; result attached), join.py --copy (the stream-copied join does not hold the clips it was given; it is not moved to the output path, verification attached); exit 1", "interrupted": "the tool received SIGINT or SIGTERM: the running ffmpeg (or sibling script) was stopped and its partial output removed; exit 130 or 143"},
|
|
2083
2160
|
"success_criterion": "exit 0 AND the output exists AND is non-empty AND ffprobe reads a stream from it; only then is status completed printed and the output probe attached",
|
|
2084
2161
|
},
|
|
2085
2162
|
"capabilities": caps,
|
package/scripts/export.py
CHANGED
|
@@ -484,7 +484,7 @@ def main() -> int:
|
|
|
484
484
|
ap.add_argument("--max-mb", type=float, metavar="MB",
|
|
485
485
|
help="fit the file under MB megabytes (MiB): a two-pass x264 encode at the video bitrate the "
|
|
486
486
|
"running time leaves after the audio, measured afterwards (H.264 presets; not with --crf or --hw)")
|
|
487
|
-
ap.add_argument("--normalize", action="store_true", help="
|
|
487
|
+
ap.add_argument("--normalize", action="store_true", help=f"the platform presets ({', '.join(sorted(PLATFORM_OF))}): when the written file misses the platform's loudness spec, run loudness.py on it (audio re-encoded, video copied) so one export delivers")
|
|
488
488
|
ap.add_argument("--alpha-mode", choices=["straight", "premultiplied"],
|
|
489
489
|
help="alpha presets (prores4444, prores4444xq, qtrle, vp9-alpha, hap-alpha): write the colour straight "
|
|
490
490
|
"(default, as decoded) or premultiplied by the alpha; written to the result as alpha_mode")
|
package/scripts/graphics.py
CHANGED
|
@@ -55,6 +55,9 @@ from _common.emit import input_length # noqa: E402
|
|
|
55
55
|
TEXT = "@TEXT@"
|
|
56
56
|
BOX = "@BOX@" # a box's place in the filter chain, followed by its index (graphics.main fill_box)
|
|
57
57
|
TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug", "sticker", "hook", "meme"]
|
|
58
|
+
# --dry-run on an input that does not exist yet, with no --end: how long after --start the planned
|
|
59
|
+
# graphic ends (its length is unknown; the plan says so in a note)
|
|
60
|
+
PENDING_PLACEHOLDER_SECONDS = 10.0
|
|
58
61
|
|
|
59
62
|
|
|
60
63
|
def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
|
|
@@ -208,7 +211,17 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
208
211
|
info(f"--platform {args.platform}: safe margins top {m_top} / bottom {m_bottom} / left {m_left} / right {m_right} px")
|
|
209
212
|
fps = meta["video"].get("fps")
|
|
210
213
|
s = time_arg(args.start, "--start", fps) if args.start else 0.0
|
|
211
|
-
|
|
214
|
+
if args.end:
|
|
215
|
+
e = time_arg(args.end, "--end", fps)
|
|
216
|
+
elif meta.get("dry_run"):
|
|
217
|
+
# #466 A202: an input an earlier --dry-run stage would write is probe()'s stub, whose
|
|
218
|
+
# length is unknown, not 0 s: planning to "the clip's end" refused with "--end must be
|
|
219
|
+
# after --start". The plan ends the graphic at a stated placeholder instead
|
|
220
|
+
e = s + PENDING_PLACEHOLDER_SECONDS
|
|
221
|
+
note(f"{args.input} does not exist yet, so its length is unknown: the planned graphic ends at a placeholder "
|
|
222
|
+
f"{e:g} s; a real run ends it at the clip's end (or pass --end)")
|
|
223
|
+
else:
|
|
224
|
+
e = input_length(meta) # no --end is the clip's end (#491)
|
|
212
225
|
# the picture's own length first: sound that runs on keeps the container open past the last frame
|
|
213
226
|
picture_len = meta["video"].get("duration") or dur
|
|
214
227
|
if picture_len and s >= picture_len:
|