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 +3 -3
- package/SKILL.md +3 -3
- package/docs/contract.md +29 -20
- 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 +59 -12
- package/references/workflow.md +2 -2
- package/scripts/_common/runner.py +33 -3
- package/scripts/_common/timeline.py +45 -12
- package/scripts/_contract.py +88 -8
- package/scripts/batch.py +91 -8
- package/scripts/export.py +1 -1
- package/scripts/graphics.py +14 -1
- package/scripts/metadata.py +4 -0
- package/scripts/render.py +211 -36
- package/scripts/report.py +27 -1
- package/scripts/sequence.py +5 -1
- package/templates/audiogram.json +9 -0
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.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.
|
|
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) |
|
|
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
|
|
@@ -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
|
-
| `
|
|
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.
|
|
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`, `
|
|
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
|
|
|
@@ -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
|
|
981
|
-
|
|
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
|
|
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
|
|
1041
|
-
|
|
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
|
|
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,
|
|
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
|
|
1335
|
-
|
|
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.
|
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()
|
|
@@ -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."""
|