ffmpeg-skill 2.17.2 → 2.17.4
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 +22 -11
- package/SKILL.md +7 -8
- package/docs/contract.md +6 -4
- package/package.json +2 -2
- package/references/gotchas.md +3 -2
- package/references/scripts.md +2 -3
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/drawtext.py +65 -0
- package/scripts/_common/text.py +1 -1
- package/scripts/_contract.py +3 -1
- package/scripts/graphics.py +2 -1
- package/scripts/grid.py +3 -2
- package/scripts/look.py +8 -4
- package/scripts/overlay.py +3 -2
- package/scripts/scenes.py +3 -2
- package/scripts/verify.py +2 -2
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ npx ffmpeg-skill
|
|
|
47
47
|
</tr>
|
|
48
48
|
</table>
|
|
49
49
|
|
|
50
|
-
Left half is the input, right half is what the command produced. **[
|
|
50
|
+
Left half is the input, right half is what the command produced. **[Every before/after demo, with the exact command under each one →](docs/demos.md)** — all of it generated from synthetic footage by `python3 demos/build.py`, so you can rebuild every frame of it yourself.
|
|
51
51
|
|
|
52
52
|
`ffmpeg-skill` is an [Agent Skill](https://docs.anthropic.com/en/docs/agents-and-tools/agent-skills) for Claude Code, Cursor, Codex and any agent that reads `SKILL.md`. It teaches the agent a fixed workflow (probe → edit losslessly where possible → check → verify) and ships **42 tools** that do the actual work with `ffmpeg` / `ffprobe`: cut, join, silence removal, fit to duration and aspect, captions and karaoke, overlays and motion graphics, HDR → SDR and LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, whole-edit project rendering, batch folders. Every tool is also callable as an MCP tool (`tools/list` advertises a core 12 by default to keep client context small, with `FFMPEG_SKILL_MCP_FULL=1` listing all 42; every tool is reachable by name through `tools/call` either way), and the whole set is described by a machine-readable contract.
|
|
53
53
|
|
|
@@ -93,9 +93,18 @@ An agent that "knows FFmpeg" still guesses: it assumes a frame rate, picks a cod
|
|
|
93
93
|
- **Verification after execution.** The result is probed, checked against the destination's spec and, when the picture changed, looked at as a contact sheet.
|
|
94
94
|
- **Local first.** No cloud, no API keys, no Python dependencies. Optional local transcription is used when a speech engine (Parakeet or a whisper) is installed, never required — by `caption.py --transcribe` and, since 1.17, by `silence.py --filler --transcribe`; both take a transcript you already have instead, and both refuse with the install lines rather than guessing. Both take `--engine` and `--model`; `FFMPEG_SKILL_ASR_ENGINE` and `FFMPEG_SKILL_ASR_MODEL` set this machine's defaults (e.g. `large-v3-turbo` where that whisper.cpp model is installed; without either, auto and `base`). The engine runs on your machine and the audio is never uploaded; parakeet-mlx, faster-whisper and openai-whisper download their model weights the first time they run (parakeet-mlx's default from Hugging Face).
|
|
95
95
|
|
|
96
|
+
## Paper
|
|
97
|
+
|
|
98
|
+
The design discipline behind this skill — per-operation verification for AI agents — is written up as a position paper. The paper scores ffmpeg-skill itself as only partially conformant with the discipline it defines.
|
|
99
|
+
|
|
100
|
+
Kajiyama, S. (2026). *What Is Verification-Driven Coding? — A Discipline for Not Trusting Generation.* Zenodo. https://doi.org/10.5281/zenodo.23720216
|
|
101
|
+
|
|
96
102
|
## Quick start
|
|
97
103
|
|
|
98
104
|
```bash
|
|
105
|
+
# 0. FFmpeg itself, once (Ubuntu/Debian shown; macOS and Windows are in the Install table below)
|
|
106
|
+
sudo apt install ffmpeg
|
|
107
|
+
|
|
99
108
|
# 1. install the skill for Claude Code (Cursor: --cursor, Codex: --codex, all three: --all)
|
|
100
109
|
npx ffmpeg-skill
|
|
101
110
|
|
|
@@ -106,9 +115,11 @@ npx ffmpeg-skill doctor
|
|
|
106
115
|
npx ffmpeg-skill contract --json | head -40
|
|
107
116
|
```
|
|
108
117
|
|
|
118
|
+
Captions from speech (`caption.py --transcribe`) need one local speech engine, installed separately. `pip install faster-whisper` is the simplest; the first run downloads its model, and the audio never leaves the machine. Without an engine, everything else works, `doctor` lists the engine as missing optional, and `--transcribe` refuses with the install line for each engine it supports.
|
|
119
|
+
|
|
109
120
|
Already installed? re-run `npx ffmpeg-skill` to refresh `~/.claude/skills/ffmpeg-skill`. Copies are not updated automatically.
|
|
110
121
|
|
|
111
|
-
`doctor`'s overall `ok` and a single tool's `usable
|
|
122
|
+
`doctor`'s overall `ok` and a single tool's `usable` are different signals: `ok` is false when anything *some* tool requires is missing, so one missing filter (a plain Homebrew `ffmpeg` without `subtitles`, or a build without libvidstab) turns it false even though most tools still run. Check `doctor --json`'s `tools` field for the per-tool answer, not just `ok`.
|
|
112
123
|
|
|
113
124
|
Then talk to your agent:
|
|
114
125
|
|
|
@@ -185,7 +196,7 @@ Names, order and `inputSchema` in `tools/list` are translated from each tool's a
|
|
|
185
196
|
These are the rules the skill file gives the agent and the code enforces.
|
|
186
197
|
|
|
187
198
|
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.
|
|
188
|
-
2. **Lossless when possible.** `cut.py
|
|
199
|
+
2. **Lossless when possible.** `cut.py` and `loudness.py` stream-copy what they do not need to touch (`join.py` always re-encodes, so it can crossfade and conform mismatched clips). Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance.
|
|
189
200
|
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.
|
|
190
201
|
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`.
|
|
191
202
|
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.
|
|
@@ -338,7 +349,7 @@ The contract is generated from the code that runs, not maintained beside it. For
|
|
|
338
349
|
| `mutates_input` | always `false` |
|
|
339
350
|
| `idempotency_hint` | `bit_exact`, `content_equivalent`, `cached` or `environment_dependent` |
|
|
340
351
|
|
|
341
|
-
Next to the tool list the document carries a top-level `deprecated` list (1.10): what
|
|
352
|
+
Next to the tool list the document carries a top-level `deprecated` list (since 1.10): what the next major removes, since when, the replacement and the surface it lives on. Today that is Python 3.9 and FFmpeg 5.x, both removed in 3.0.0. What 2.0 removed moved to `removed`, and `docs/contract.md` "What 2.0 changed" is written from that.
|
|
342
353
|
|
|
343
354
|
`contract_version` (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments → argv), the JSON shapes for success and failure (`{"status": "failed", "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification | interrupted", "message": …}}`; a refusal adds `error.reason`, a stable code such as `output_exists` or `plan_inputs_changed`, and `error.details`, the facts behind it; warnings, fallbacks and skipped steps a run decided on its own are in `diagnostics`), and that no tool runs a shell or executes anything other than the named script, `ffmpeg` and `ffprobe`. Field-by-field reference: [docs/contract.md](docs/contract.md).
|
|
344
355
|
|
|
@@ -538,7 +549,7 @@ claude plugin install ffmpeg-skill@ffmpeg-skill # then in
|
|
|
538
549
|
|
|
539
550
|
The plugin namespaces the skill as `ffmpeg-skill:ffmpeg-skill`. The manifests are [.claude-plugin/plugin.json](.claude-plugin/plugin.json), whose version follows every release automatically, and [.claude-plugin/marketplace.json](.claude-plugin/marketplace.json), which lists the plugin from the repository root. Before 2.17.1 the README's one-line `claude plugin install kajisho5/ffmpeg-skill` could not work, because there was no marketplace file.
|
|
540
551
|
|
|
541
|
-
Without Node: clone this repository and copy `SKILL.md`, `scripts/`, `references/` and `mcp/` into your agent's skills directory.
|
|
552
|
+
Without Node: clone this repository and copy `SKILL.md`, `package.json`, `scripts/`, `templates/`, `references/`, `docs/` and `mcp/` into your agent's skills directory (the same set `npx ffmpeg-skill` installs; without `templates/` no `--template` renders, and without `package.json` the contract reports its version as unknown).
|
|
542
553
|
|
|
543
554
|
After installing:
|
|
544
555
|
|
|
@@ -562,7 +573,7 @@ FFmpeg itself:
|
|
|
562
573
|
|
|
563
574
|
## Requirements
|
|
564
575
|
|
|
565
|
-
- FFmpeg 5.0+ (FFmpeg 5.x is deprecated since 2.14.0 and 3.0 requires 6.1; 6.1+ recommended).
|
|
576
|
+
- FFmpeg 5.0+ (FFmpeg 5.x is deprecated since 2.14.0 and 3.0 requires 6.1; 6.1+ recommended). Required by at least one tool (so `doctor`'s `ok` needs them all): `libx264`, `aac`, and the `drawtext`, `subtitles` (libass), `loudnorm`, `xfade`, `acrossfade`, `amix`, `overlay`, `scdet`, `silencedetect`, `cropdetect`, `tile`, `tpad`, `boxblur`, `hqdn3d`, `yadif`, `reverse`, `rotate`, `showwaves`, `v360`, `xstack` and `vidstabdetect`/`vidstabtransform` (libvidstab, `stabilize.py` only) filters; `doctor --json` lists them as `capabilities.required`. Needed only by the flags that use them: `libx265`, `prores_ks`, `libzimg` / `zscale`, `libmp3lame`, `libopus`, `libvorbis`, the `ass` filter. `doctor` tells you which are present. The apt and gyan.dev builds carry all of them; some Homebrew bottles lack `libass` / `libfreetype` / `libzimg`, which `doctor` reports as missing.
|
|
566
577
|
- Python 3.10+ recommended, standard library only. 3.9 still works but is deprecated (end of life upstream; support ends in 3.0)
|
|
567
578
|
- Node 16+ only for the `npx` installer
|
|
568
579
|
|
|
@@ -575,8 +586,8 @@ FFmpeg itself:
|
|
|
575
586
|
## Development
|
|
576
587
|
|
|
577
588
|
```bash
|
|
578
|
-
npm test # tests/test_all.py
|
|
579
|
-
python3 tests/test_picture.py # one tool group on its own (analysis, editing, audio, picture, delivery, orchestration)
|
|
589
|
+
npm test # tests/run_parallel.py (test_all.py's end-to-end modules, sharded across the cores) + tests/test_contract.py
|
|
590
|
+
python3 tests/test_picture.py # one tool group on its own (analysis, editing, cut_copy, audio, picture, delivery, orchestration, accel, asr)
|
|
580
591
|
npm run release-check # pack, install, contract from the installed copy, MCP == contract, doctor, tests, contract evals
|
|
581
592
|
npm run demo # python3 demos/build.py: synthetic footage -> every before/after demo + docs/demos/*.gif
|
|
582
593
|
npm run demo:pipeline # examples/make_demo.sh: the older single end-to-end run of every script
|
|
@@ -584,11 +595,11 @@ python3 evals/run.py --list # agent eval prompts (see evals/); regex-only grad
|
|
|
584
595
|
node bin/install.js --dir /tmp/skills # try the installer without touching ~/.claude
|
|
585
596
|
```
|
|
586
597
|
|
|
587
|
-
CI (`.github/workflows/ci.yml`) runs on every pull request and on pushes to `main`, on Ubuntu (FFmpeg 6.1
|
|
598
|
+
CI (`.github/workflows/ci.yml`) runs on every pull request and on pushes to `main`, on Ubuntu (FFmpeg 6.1 with Python 3.9 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 two Linux jobs on FFmpeg 5.1.1 (static build) and 7.1 (Debian trixie container), and uploads each runner's FFmpeg listings as an artifact.
|
|
588
599
|
|
|
589
600
|
`tests/test_contract.py` runs on all three OSes, but a handful of its tests build a fake `ffmpeg` as a `#!/bin/sh` script on a PATH shim to force specific FFmpeg 6/7/8/9 fixture layouts through `doctor`'s parser — that technique isn't portable to Windows, so `test_dry_run_never_runs_ffmpeg_and_writes_nothing` and the whole `DoctorDetectionTests` class (fixture-driven layout parsing) are individually `skipIf`'d there and show as `skipped`, not silently absent, in the Windows job's log. Everything else — contract schema, `reencodes_*`, `doctor.tools`, MCP derivation, and every tool exercised through the contract, including `cut.py`'s provenance fields — runs against the real Windows `ffmpeg` on every PR. See [references/ci-platform-pitfalls.md](references/ci-platform-pitfalls.md) for this and other per-OS behaviour differences already diagnosed, before spending a CI cycle re-diagnosing a platform-only failure.
|
|
590
601
|
|
|
591
|
-
**Releasing** is fully automated end to end, including the version number itself — a PR doesn't need to touch `package.json`, `docs/contract.md`, or `CHANGELOG.md` at all. 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` and `docs/
|
|
602
|
+
**Releasing** is fully automated end to end, including the version number itself — a PR doesn't need to touch `package.json`, `docs/contract.md`, or `CHANGELOG.md` at all. 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. Either way — auto-bumped or hand-bumped in the PR — it then creates the `vX.Y.Z` tag, publishes a GitHub Release with notes extracted from `CHANGELOG.md`'s matching section, and publishes the package to npm. A PR that still wants to write its own version bump and `CHANGELOG.md` prose (e.g. to explain the "why" of a release by hand) can — the automation only fills in when nobody made that call already. A push to `main` with nothing new to release is a no-op. npm publishing needs an `NPM_TOKEN` repo secret (an npm access token with publish rights on this package) — without it the tag and GitHub Release still happen, only the npm step is skipped. A repo that depends on this one (an editing skill, an agent) should pin an `ffmpeg-skill` version by tag or npm version, not by tracking `main` — a merged-but-not-yet-released commit on `main` can be ahead of the last published npm version for the few minutes between merge and this workflow completing.
|
|
592
603
|
|
|
593
604
|
Contributing a change: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
594
605
|
|
|
@@ -597,7 +608,7 @@ Contributing a change: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
597
608
|
| | |
|
|
598
609
|
|---|---|
|
|
599
610
|
| [CONTRIBUTING.md](CONTRIBUTING.md) | scope, dev setup, tests, PR expectations |
|
|
600
|
-
| [docs/roadmap.md](docs/roadmap.md) |
|
|
611
|
+
| [docs/roadmap.md](docs/roadmap.md) | the release history from 1.8.0, one theme per minor, each marked shipped + evaluated, shipped with eval pending, or planned; and what 2.0.0 then removes |
|
|
601
612
|
| [docs/design-decisions.md](docs/design-decisions.md) | behaviours that look like bugs but are decisions, with rationale and the pinning test; read before filing a bug |
|
|
602
613
|
| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Contributor Covenant 2.1; reports go through the SECURITY.md channel |
|
|
603
614
|
| [SECURITY.md](SECURITY.md) | how to report a vulnerability privately |
|
package/SKILL.md
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ffmpeg-skill
|
|
3
|
-
description: 'Edit video and audio with local FFmpeg from natural-language requests: cut, trim, join, resize/reframe (9:16, 1:1), speed change, captions and subtitles (SRT/ASS, animated, karaoke), logos and text overlays, lower-thirds and titles, silence removal, multicam and external-mic sync, loudness normalisation, HDR/Dolby Vision to SDR, LUTs, background music with ducking, platform exports (YouTube, Reels, TikTok, X), compliance checks, scene detection and highlight reels, contact sheets to inspect results, and whole-edit project files. Use this skill whenever the user mentions a video or audio file (mp4, mov, mkv, wav, m4a), footage, a clip, captions, subtitles, a reel or short, YouTube/Instagram/TikTok delivery, LUFS, sync, transcoding, ffmpeg, or asks to make something "60 seconds", "vertical", "louder", "captioned" — even when they do not say "edit". Python 3.10+
|
|
3
|
+
description: 'Edit video and audio with local FFmpeg from natural-language requests: cut, trim, join, resize/reframe (9:16, 1:1), speed change, captions and subtitles (SRT/ASS, animated, karaoke), logos and text overlays, lower-thirds and titles, silence removal, multicam and external-mic sync, loudness normalisation, HDR/Dolby Vision to SDR, LUTs, background music with ducking, platform exports (YouTube, Reels, TikTok, X), compliance checks, scene detection and highlight reels, contact sheets to inspect results, and whole-edit project files. Use this skill whenever the user mentions a video or audio file (mp4, mov, mkv, wav, m4a), footage, a clip, captions, subtitles, a reel or short, YouTube/Instagram/TikTok delivery, LUFS, sync, transcoding, ffmpeg, or asks to make something "60 seconds", "vertical", "louder", "captioned" — even when they do not say "edit". FFmpeg 6.1+ and Python 3.10+ (5.x/3.9 deprecated), stdlib only, no cloud, no API keys.'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# ffmpeg-skill
|
|
7
7
|
|
|
8
|
-
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as this one; open one only for a question you have: `references/scripts.md` (
|
|
8
|
+
Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as this one; open one only for a question you have: `references/scripts.md` (most flags; `--help` lists all), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end). MCP: `tools/list` shows only the core 12 by default; the other 30 (per this table) stay callable by name via `tools/call`, described by `contract --json`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
|
|
9
9
|
|
|
10
|
-
Shared flags (every script): `--dry-run`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`;
|
|
10
|
+
Shared flags (every script): `--dry-run`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`; for writing steps); `--fast` (preview quality); `--progress` (%/ETA); `--timeout SECONDS` (`kind: timeout`, default 1800; `0` for hour-long encodes); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses a changed input). Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264 (`cut.py --keep-hevc`: HEVC source stays HEVC), HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
|
|
11
11
|
|
|
12
12
|
Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`; `overlay` for a multi-line `--text` on FFmpeg 8.0-8.1.0) may still run ffmpeg/ffprobe, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`); `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
|
|
13
13
|
|
|
14
14
|
## Workflow (in this order)
|
|
15
15
|
|
|
16
|
-
0. **Environment, only on failure.** Never start a job with `doctor`: a broken machine fails with `kind: missing_tool` or an ffmpeg error naming the filter/encoder. Run `python3 <skill-dir>/scripts/_contract.py doctor` (
|
|
16
|
+
0. **Environment, only on failure.** Never start a job with `doctor`: a broken machine fails with `kind: missing_tool` or an ffmpeg error naming the filter/encoder. Run `python3 <skill-dir>/scripts/_contract.py doctor` (the installed copy) after such a failure, or when asked what the machine can do: read `ok`/`usable`, report the missing capability. `contract --json`'s tool schema is for a *planning* agent, not this workflow.
|
|
17
17
|
1. **Probe what you must plan from.** Run `probe.py` on each input you plan from — duration, fps, resolution, codecs, channels, `variable_frame_rate_suspected` — and whenever the user asks about a file. No separate probe before every edit: every writing tool's `--json` already carries its input and a probe of the output. Plan from real numbers, never assumptions.
|
|
18
18
|
2. **Prefer lossless.** If the request can be met without re-encoding (plain cuts on keyframes, remuxing, audio-only changes), do not re-encode. `cut.py` and `loudness.py` stream-copy video by default; `--accurate` on `cut.py` only for frame-exact cuts.
|
|
19
|
-
3. **Plan with `--dry-run --json`, then execute.** Trust `--json`, not a dry run's summary line, for any number in the plan (a dimension there can be a placeholder). Use before long encodes, to report facts.
|
|
19
|
+
3. **Plan with `--dry-run --json`, then execute.** Trust `--json`, not a dry run's summary line, for any number in the plan (a dimension there can be a placeholder). Use before long encodes, to report facts.
|
|
20
20
|
4. **Chain in a sensible order.** A delivery request with no other editing is one template run (`render.py --template NAME INPUT`), not a hand-built chain. Otherwise: colour (HDR→SDR / LUT) → cut → join → silence → fit → caption/overlay → sync → audio → loudness → export. Frame changes before captions, so text is sized for the final frame. Re-encode as few times as possible: intermediates at CRF 18, `export.py` last. **Three or more steps: `render.py` with a project.json.**
|
|
21
21
|
5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X` for the destination named (a template run already does). Format rows (codec, pixel format, size, true peak, colour tags, VFR) are safe to fix. Judgement rows change content: duration (cut loses material), aspect (crop loses edges), fps (drops motion), loudness (ambience must not be boosted) — fix only when the request implies the answer, else state the choice and its cost. Mention WARNs; do not chase them.
|
|
22
22
|
6. **Verify the output.** Confirm duration, resolution, fps and audio match the request, from the writing tool's `--json`/`--json-brief` probe or `probe.py`, and report those numbers ("final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo"). A step is done only when the script exited 0 and the output probes as expected: a non-zero exit, a missing or empty file, or a probe that contradicts the request is a failure reported with the script's error message.
|
|
23
|
-
7. **Keep the user's originals.** Never overwrite the source; write new files next to input or where asked. An existing output path is refused (`kind: input`); `--overwrite`
|
|
23
|
+
7. **Keep the user's originals.** Never overwrite the source; write new files next to input or where asked. An existing output path is refused (`kind: input`); pass `--overwrite` only for a file this job made or the user asked to replace.
|
|
24
24
|
8. **Look at the picture.** Whenever the picture changed (captions, overlays, graphics, crop/pad, resize, colour, transitions, a `join.py` scaling a clip to the first clip's frame, `color.py --to-sdr`) run `look.py OUTPUT --tiles 3x2` (or `--at T` for one frame) and view the PNG (4x3 for whole-clip layout). Not finished until `Look:` names that PNG — a probe cannot see a caption on a face. Audio-only: `Look: not needed`. What to look for splits like `check.py`'s rows in step 5:
|
|
25
25
|
- **Mechanical (this skill's own job to verify and report):** the specified text/logo is at the specified position, subtitles appear at the specified timestamps, dimensions are even. Letterboxing from `fit.py --fit pad` is the *correct* result of that mode, never a defect to flag.
|
|
26
26
|
- **Judgement (report it, don't silently pass or fail):** whether a subject or face is cut off, text sits over a face, colours look washed out, a transition lands. These need deciding what the subject *is*, the calling agent's call — say what you see in one line; they judge.
|
|
@@ -157,7 +157,6 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
|
|
|
157
157
|
|
|
158
158
|
Audio is a first-class input: `probe`, `cut`, `silence`, `loudness`, `audio`, `sync`, `check --platform podcast`, `render --template podcast` take WAV, FLAC, MP3, M4A/AAC, OGG, Opus; output extension picks the format. Scripts needing a picture (`fit`, `caption`, `overlay`, `graphics`, `color`, `export`, `scenes`, `look`) refuse audio with "input has no video stream" — don't force a video wrapper. Recipes: `references/gotchas.md#audio-only-files`.
|
|
159
159
|
|
|
160
|
-
|
|
161
160
|
## Report format
|
|
162
161
|
|
|
163
162
|
Reply in the language the request is written in — subtitles in another language are still reported in the request's language. Keep the labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English; everything else is the user's language. Never drift because the job was short or failed: even a one-line "file does not exist". A mid-conversation switch follows the user's latest message.
|
|
@@ -182,7 +181,7 @@ Look: final_sheet.png(字幕はセーフエリア内、ロゴは右上)
|
|
|
182
181
|
Notes: 元は VFR だったので 30 fps に揃えた。音声はモノラルだったのでステレオにした
|
|
183
182
|
```
|
|
184
183
|
|
|
185
|
-
Keep it to those five lines plus anything the user must decide. Never
|
|
184
|
+
Keep it to those five lines plus anything the user must decide. Never describe a fix you did not run.
|
|
186
185
|
|
|
187
186
|
When a step fails, replace `Done:` with `Failed:` and keep the rest honest:
|
|
188
187
|
|
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 (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`2.17.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.17.4`) | 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`
|
|
@@ -131,7 +131,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
131
131
|
"deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
|
|
132
132
|
{"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"}],
|
|
133
133
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
134
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.17.
|
|
134
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.17.4", "execution_mode": "local", "kind": "execution",
|
|
135
135
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
136
136
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
137
137
|
"requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
|
|
@@ -172,7 +172,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
|
|
|
172
172
|
| `reencodes_video`, `reencodes_audio` | `"always"` / `"never"` / `"conditional"`, meaning *when that stream is present in the input* — not whether the tool touches the file at all. `"conditional"` tools (`cut`, `export`, `render`, `batch`, `verify`, `caption`, `color`) carry a `reencode_note` explaining what it depends on — for `caption`, `--mode burn` (default) always re-encodes both streams, `--mode mux` copies both untouched; for `color`, `--strip-dovi` and `--retag` are a stream copy of both (retag only re-encodes if the copy attempt fails or ffprobe still reads the old tags on the copy), while `--to-sdr` / `--lut` / `--correct` always re-encode both. Several visual tools (`fit`, `overlay`, `graphics`, `join`, `multicam`, `silence`) are `"always"` on audio too: this codebase never mixes `-c:v` re-encode with `-c:a copy` in one call, so a caller cannot assume the original audio codec survives just because only the picture changed |
|
|
173
173
|
| `audio_only` | accepts an audio-only input (WAV, MP3, M4A, FLAC, OGG, Opus) |
|
|
174
174
|
| `video_required` | refuses an input without a video stream ("input has no video stream") |
|
|
175
|
-
| | `
|
|
175
|
+
| | `render` has `audio_only: true` and `video_required: false` since 2.17.4: `--template podcast` and `--template audiogram` take an audio file. `join` has them since 0.9.1: audio-only inputs are joined as audio (no `look` needed then); mixing audio and video inputs is refused |
|
|
176
176
|
| `deterministic_inputs`, `idempotency_hint` | see Repeatability |
|
|
177
177
|
| `mcp` | the MCP tool name and its positional arguments |
|
|
178
178
|
|
|
@@ -920,7 +920,9 @@ A planning agent (for example video-production-agent's SkillRegistry) can:
|
|
|
920
920
|
3. pick a tool by `role`, `inputs`/`outputs`, `video_required` and `audio_only`;
|
|
921
921
|
4. build the call from `input_schema` and the argument mapping, plan with `--dry-run`;
|
|
922
922
|
5. run, parse `output_schema`, then run `verification.tools`, adding `look` when
|
|
923
|
-
`requires_visual_verification` is true.
|
|
923
|
+
`requires_visual_verification` is true and the output has a video stream. The flag is
|
|
924
|
+
per tool, not per call: an audio-only `join` and `render --template podcast` write no
|
|
925
|
+
picture, and `look` is skipped for them (`Look: not needed`).
|
|
924
926
|
|
|
925
927
|
The measurement documents (`probe`, `check`, `scenes`, `sync`) are ffmpeg-skill's own
|
|
926
928
|
shapes, not another system's Observation model; convert them in the agent's adapter.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.17.
|
|
3
|
+
"version": "2.17.4",
|
|
4
4
|
"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.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"LICENSE"
|
|
51
51
|
],
|
|
52
52
|
"scripts": {
|
|
53
|
-
"test": "python3 tests/
|
|
53
|
+
"test": "python3 tests/run_parallel.py && python3 tests/test_contract.py",
|
|
54
54
|
"test:fast": "python3 tests/test_contract.py",
|
|
55
55
|
"release-check": "bash tests/release_check.sh",
|
|
56
56
|
"demo": "python3 demos/build.py",
|
package/references/gotchas.md
CHANGED
|
@@ -301,8 +301,9 @@ the contact sheet (`--sheet`) before treating the picks as final.
|
|
|
301
301
|
Anything chained by hand through three re-encodes should be one `render.py`
|
|
302
302
|
project instead, so the plan is one file and the user can change one number.
|
|
303
303
|
Re-encodes use x264 `medium`; for long files add `--preset veryfast` to
|
|
304
|
-
intermediates and keep the default for the final export. `render.py`
|
|
305
|
-
2.15 when its project ends in an encoding export
|
|
304
|
+
intermediates and keep the default for the final export. `render.py` encodes its intermediates at
|
|
305
|
+
`superfast` by itself since 2.16 (veryfast in 2.15) when its project ends in an encoding export
|
|
306
|
+
(`--intermediate-preset medium` turns it off). On Apple Silicon,
|
|
306
307
|
`--hw` (or `FFMPEG_SKILL_HW=1` for the machine) moves h264/hevc/prores encodes to
|
|
307
308
|
VideoToolbox: 2–7× faster, 1.1–2.9× the bytes at the same quality (1.9–3.5× on HDR). The variable
|
|
308
309
|
leaves `export.py`'s delivery presets on the CPU, which is the usual split (GPU
|
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`; every re-encoding tool and `export.py` also take `--hw`/`--no-hw` (h264/hevc/prores on Apple VideoToolbox, Apple Silicon only, CRF mapped to `-q:v`; AV1 stays on the CPU; a job VideoToolbox itself refuses -- the encoder cannot open or its compression session fails -- is re-encoded on the CPU and reported in `hw.fallback`/`hw.notes`, any other failure is not retried; a GPU encode adds a `notes` line that its quality is not CRF-equivalent; `FFMPEG_SKILL_HW=1` makes it the default for every tool except `export.py`, whose delivery presets need `--hw` itself; `render.py`/`batch.py --hw`/`--no-hw` apply to every stage, export included; results name the `encoder` that ran); every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, default 18 -- 30 for `proxy.py`; up to 63 for av1; ignored by prores). 2.0 removed `--crf` from these tools (it was `--quality`'s alias); `export.py` keeps its own `--crf`, which is not an alias. Every tool refuses (`kind: input`, `error.reason: output_exists`, before anything runs) to replace an existing output unless `--overwrite` is given. Refusals carry `error.reason` and `error.details` (the facts behind the message; `contract --json` → `json_output.error_reasons`), and what a run decided on its own -- a stale output lock taken over, no lock possible, a plan input nobody fingerprinted, an odd-sized frame scaled to even, subtitle streams dropped on a retry (with the first attempt's error), a font substituted, a measurement skipped under `--dry-run`, an encoder list that could not be read, an invalid `FFMPEG_SKILL_TIMEOUT`, and every `warning:` line -- is in `diagnostics: {warnings, fallbacks, skipped}` as well as on stderr (codes: `contract --json` → `json_output.diagnostic_codes`). `--codec av1`/`prores` with no encoder for it refuses `kind: missing_tool`, `error.reason: encoder_missing`; when the encoder list could not be read at all, `kind: ffmpeg`, `encoder_list_unreadable`. Tools that draw text (`overlay.py --text`, `graphics.py`, `caption.py`, `grid.py --label auto`) report `font_used: {asked, file, family, how, substituted}`. `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither `--codec` nor `--quality`; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize
|
|
3
|
+
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; every re-encoding tool and `export.py` also take `--hw`/`--no-hw` (h264/hevc/prores on Apple VideoToolbox, Apple Silicon only, CRF mapped to `-q:v`; AV1 stays on the CPU; a job VideoToolbox itself refuses -- the encoder cannot open or its compression session fails -- is re-encoded on the CPU and reported in `hw.fallback`/`hw.notes`, any other failure is not retried; a GPU encode adds a `notes` line that its quality is not CRF-equivalent; `FFMPEG_SKILL_HW=1` makes it the default for every tool except `export.py`, whose delivery presets need `--hw` itself; `render.py`/`batch.py --hw`/`--no-hw` apply to every stage, export included; results name the `encoder` that ran); every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, default 18 -- 30 for `proxy.py`; up to 63 for av1; ignored by prores). 2.0 removed `--crf` from these tools (it was `--quality`'s alias); `export.py` keeps its own `--crf`, which is not an alias. Every tool refuses (`kind: input`, `error.reason: output_exists`, before anything runs) to replace an existing output unless `--overwrite` is given. Refusals carry `error.reason` and `error.details` (the facts behind the message; `contract --json` → `json_output.error_reasons`), and what a run decided on its own -- a stale output lock taken over, no lock possible, a plan input nobody fingerprinted, an odd-sized frame scaled to even, subtitle streams dropped on a retry (with the first attempt's error), a font substituted, a measurement skipped under `--dry-run`, an encoder list that could not be read, an invalid `FFMPEG_SKILL_TIMEOUT`, and every `warning:` line -- is in `diagnostics: {warnings, fallbacks, skipped}` as well as on stderr (codes: `contract --json` → `json_output.diagnostic_codes`). `--codec av1`/`prores` with no encoder for it refuses `kind: missing_tool`, `error.reason: encoder_missing`; when the encoder list could not be read at all, `kind: ffmpeg`, `encoder_list_unreadable`. Tools that draw text (`overlay.py --text`, `graphics.py`, `caption.py`, `grid.py --label auto`) report `font_used: {asked, file, family, how, substituted}`. `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither `--codec` nor `--quality`; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), `overlay` measures a multi-line `--text`'s lines on FFmpeg 8.0, 8.0.1 and 8.1.0 (one drawtext on a generated frame), and `verify` accepts the flag but ignores it entirely. 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
|
|
|
@@ -686,8 +686,7 @@ in for its streams (any audio extension the skill reads, `.aiff` and `.caf` incl
|
|
|
686
686
|
picture), so a pending `.mp4` next to measured audio is refused as the mix a real run would
|
|
687
687
|
refuse; `expected_duration` is null while any input is pending, and `notes` names the planned
|
|
688
688
|
frame, rate or xfade offsets that rest on a pending input's unmeasured placeholder.
|
|
689
|
-
Every existing input with an audio stream is also measured (one volumedetect pass
|
|
690
|
-
too): a peak at or below `--silence-threshold` (default -50 dBFS) is silent, the trace of a TTS
|
|
689
|
+
Every existing input with an audio stream is also measured (one volumedetect pass, skipped under `--dry-run`): a peak at or below `--silence-threshold` (default -50 dBFS) is silent, the trace of a TTS
|
|
691
690
|
call that wrote a valid but empty file. `--on-silent warn` (default) joins it and names it under
|
|
692
691
|
`silent: [{index, path, peak_db, at, end}]` (`at`/`end`: where it sits in the output, seconds)
|
|
693
692
|
and in `notes`; `fail` refuses it in the same `kind: input` document (`silent (peak -91.0 dBFS)`);
|
|
@@ -96,7 +96,7 @@ from _common.color import (
|
|
|
96
96
|
is_neutral_color, is_rgb, NON_BT709_MATRICES, rgb_island, rgb_to_bt709_filter, _sdr_bt709, validate_color
|
|
97
97
|
)
|
|
98
98
|
from _common.text import (
|
|
99
|
-
ADVANCE_EM, BIDI_SCRIPTS, _char_em, char_script, default_font_file, detect_script, drawtext_boxborderw, drawtext_center_align,
|
|
99
|
+
ADVANCE_EM, BIDI_SCRIPTS, _char_em, char_script, default_font_file, detect_script, drawtext_boxborderw, drawtext_center_align, drawtext_fontfile,
|
|
100
100
|
drawtext_draws_newline_glyph, is_multiline, drawtext_clean, drawtext_line_metrics, NEWLINE_GLYPH_RELEASES,
|
|
101
101
|
drawtext_shaping, drawtext_text_opts, emoji_asset_for, EMOJI_ASSET_HINT, emoji_clusters, emoji_codepoint_name,
|
|
102
102
|
_EMOJI_COLOR_FAMILIES, _emoji_color_font, emoji_filter_chain, _emoji_name_candidates, EMOJI_RANGES,
|
|
@@ -191,7 +191,7 @@ __all__ = [
|
|
|
191
191
|
"BEAT_REFRACTORY_S", "BEAT_WINDOW_S", "BEAT_SUPPORT_DIVISOR", "BEAT_ALIGN_DIVISOR",
|
|
192
192
|
"BEAT_Z_FLOOR", "BEAT_Z_SPAN",
|
|
193
193
|
"_onset_strength", "_pick_onsets", "_autocorrelation_peak", "_grid_score",
|
|
194
|
-
"default_font_file", "default_output", "DEFAULT_TIMEOUT", "detect_script", "die", "drawtext_boxborderw", "drawtext_center_align", "drawtext_draws_newline_glyph", "is_multiline", "drawtext_clean", "drawtext_line_metrics", "NEWLINE_GLYPH_RELEASES",
|
|
194
|
+
"default_font_file", "default_output", "DEFAULT_TIMEOUT", "detect_script", "die", "drawtext_boxborderw", "drawtext_center_align", "drawtext_fontfile", "drawtext_draws_newline_glyph", "is_multiline", "drawtext_clean", "drawtext_line_metrics", "NEWLINE_GLYPH_RELEASES",
|
|
195
195
|
"_DRAWTEXT_PENDING", "drawtext_shaping", "drawtext_text_opts", "_DRAWTEXT_TMPDIR", "_drawtext_tmpdir",
|
|
196
196
|
"dry_run_input_pending", "emit", "emoji_asset_for", "EMOJI_ASSET_HINT", "emoji_clusters",
|
|
197
197
|
"emoji_codepoint_name", "_EMOJI_COLOR_FAMILIES", "_emoji_color_font", "emoji_filter_chain",
|
|
@@ -13,6 +13,71 @@ from _common.decision import escape_filter_path
|
|
|
13
13
|
from _common.runner import STATE, _DRAWTEXT_PENDING, _drawtext_tmpdir, ffmpeg_exe, ffmpeg_release, ffmpeg_version
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
def _on_windows() -> bool:
|
|
17
|
+
return os.name == "nt"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _windows_short_path(path: str) -> "Optional[str]":
|
|
21
|
+
"""The 8.3 short form of an existing Windows path, or None (not Windows, the call failed, or
|
|
22
|
+
the volume keeps no short names)."""
|
|
23
|
+
if not _on_windows():
|
|
24
|
+
return None
|
|
25
|
+
try:
|
|
26
|
+
import ctypes
|
|
27
|
+
buf = ctypes.create_unicode_buffer(32768)
|
|
28
|
+
n = ctypes.windll.kernel32.GetShortPathNameW(str(path), buf, len(buf))
|
|
29
|
+
except (OSError, AttributeError, ValueError):
|
|
30
|
+
return None
|
|
31
|
+
return buf.value if 0 < n < len(buf) else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def drawtext_fontfile(path: str) -> str:
|
|
35
|
+
"""`fontfile=<path>` for drawtext, with a path FreeType can open.
|
|
36
|
+
|
|
37
|
+
drawtext hands the path to FreeType, which opens it with the C runtime's ANSI fopen on
|
|
38
|
+
Windows: measured on the gyan.dev 9.0.2 build, a font under `compat dir ünïcode` fails to
|
|
39
|
+
load (drawtext falls back to fontconfig and the job dies with "could not parse fontconfig
|
|
40
|
+
pattern") while the same file under an ASCII directory loads, spaces and all. A non-ASCII
|
|
41
|
+
path on Windows is therefore given in its 8.3 short form, or, where the volume keeps no
|
|
42
|
+
short names, copied into this run's private drawtext directory under an ASCII name (recorded,
|
|
43
|
+
`font_path_copied`). Elsewhere the path is passed as it is. The original file is what a plan
|
|
44
|
+
binds, never the copy.
|
|
45
|
+
"""
|
|
46
|
+
path = str(path)
|
|
47
|
+
if not _on_windows() or path.isascii() or not os.path.isfile(path):
|
|
48
|
+
# a missing file is left to ffmpeg's own error, as before: there is nothing to copy
|
|
49
|
+
return f"fontfile={escape_filter_path(path)}"
|
|
50
|
+
if os.path.isfile(path) and path not in STATE.plan_inputs:
|
|
51
|
+
STATE.plan_inputs.append(path)
|
|
52
|
+
short = _windows_short_path(path)
|
|
53
|
+
if short and short.isascii():
|
|
54
|
+
return f"fontfile={escape_filter_path(short, bind=False)}"
|
|
55
|
+
import hashlib
|
|
56
|
+
from _common.emit import record_fallback, warn
|
|
57
|
+
tmpdir = _drawtext_tmpdir(create=not STATE.dry_run)
|
|
58
|
+
if not tmpdir.isascii():
|
|
59
|
+
tmpdir = _windows_short_path(tmpdir) or tmpdir
|
|
60
|
+
st = os.stat(path)
|
|
61
|
+
# the path plus size and mtime: a font replaced in place during the run gets a new copy
|
|
62
|
+
key = f"{path}|{st.st_size}|{st.st_mtime_ns}".encode("utf-8")
|
|
63
|
+
name = "font_" + hashlib.sha1(key).hexdigest()[:16] + os.path.splitext(path)[1].lower()
|
|
64
|
+
copy = os.path.join(tmpdir, name)
|
|
65
|
+
if not copy.isascii():
|
|
66
|
+
warn(f"font: {path} is not an ASCII path and no ASCII copy could be placed; drawtext may "
|
|
67
|
+
"not open it on Windows (move the font to a folder named in ASCII)", "font_path_not_ascii", font=path)
|
|
68
|
+
return f"fontfile={escape_filter_path(path, bind=False)}"
|
|
69
|
+
if not STATE.dry_run and not os.path.exists(copy):
|
|
70
|
+
try:
|
|
71
|
+
shutil.copyfile(path, copy)
|
|
72
|
+
except OSError as e:
|
|
73
|
+
warn(f"font: could not copy {path} to an ASCII path ({e}); drawtext may not open it on "
|
|
74
|
+
"Windows (move the font to a folder named in ASCII)", "font_path_not_ascii", font=path)
|
|
75
|
+
return f"fontfile={escape_filter_path(path, bind=False)}"
|
|
76
|
+
record_fallback(f"font: {path} is not an ASCII path, which drawtext cannot open on Windows; "
|
|
77
|
+
f"drawing with a copy at {copy}", "font_path_copied", font=path, file=copy)
|
|
78
|
+
return f"fontfile={escape_filter_path(copy, bind=False)}"
|
|
79
|
+
|
|
80
|
+
|
|
16
81
|
def drawtext_boxborderw(vertical: int, horizontal: int) -> str:
|
|
17
82
|
"""drawtext's per-side `boxborderw=top|right|bottom|left` (and the two-value `v|h` form)
|
|
18
83
|
arrived in FFmpeg 6.1; 5.x and 6.0 reject the `|` with "Error setting option boxborderw"
|
package/scripts/_common/text.py
CHANGED
|
@@ -30,7 +30,7 @@ from _common.emoji import (
|
|
|
30
30
|
emoji_support, resolve_emoji_assets, emoji_filter_chain,
|
|
31
31
|
)
|
|
32
32
|
from _common.drawtext import (
|
|
33
|
-
drawtext_boxborderw, SHAPING_SCRIPTS, BIDI_SCRIPTS, _SHAPING_BUILD_CACHE, drawtext_shaping, needs_shaping,
|
|
33
|
+
drawtext_boxborderw, drawtext_fontfile, SHAPING_SCRIPTS, BIDI_SCRIPTS, _SHAPING_BUILD_CACHE, drawtext_shaping, needs_shaping,
|
|
34
34
|
escape_drawtext, drawtext_text_opts, drawtext_center_align, NEWLINE_GLYPH_RELEASES,
|
|
35
35
|
drawtext_draws_newline_glyph, is_multiline, drawtext_clean, drawtext_line_metrics,
|
|
36
36
|
)
|
package/scripts/_contract.py
CHANGED
|
@@ -218,7 +218,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
218
218
|
video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
|
|
219
219
|
"render": dict(role="execution", inputs=["project.json (clips, transitions, captions, overlays, audio, loudness, export, check)", "media asset plus --template NAME (a shipped delivery template)"], outputs=["final video artifact", "one artifact per destination plus a pack table (--template all)", "work directory of stage outputs (--keep / --work)", "editor timeline (.fcpxml / .edl / .otio) instead of a render (--export-timeline)"],
|
|
220
220
|
required=FF, optional=[{"capability": "delegated", "when": "each stage (and each destination of --template) runs cut / join / fit / caption / graphics / overlay / audio / loudness / export / check with their capabilities"}],
|
|
221
|
-
video_required=
|
|
221
|
+
video_required=False, audio_only=True, visual=True, verify=["probe", "check", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
222
222
|
"batch": dict(role="execution", inputs=["folder of media", "batch.json recipe (steps or a render project)"], outputs=["one artifact per input file in the recipe's output_dir", "content-hash cache"],
|
|
223
223
|
required=FF, optional=[{"capability": "delegated", "when": "each recipe step runs the named script with its capabilities"}],
|
|
224
224
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="cached", deterministic=True),
|
|
@@ -453,6 +453,8 @@ DIAGNOSTIC_CODES = {
|
|
|
453
453
|
"font_substituted": ("fallbacks", "the asked font family is not installed: fontconfig or the Windows Fonts folder gave another (font_used names it); {asked, file, family, via}"),
|
|
454
454
|
"font_lacks_script": ("warnings", "an explicit --font does not cover the text's script and is kept as asked; {font, script}"),
|
|
455
455
|
"font_coverage_unverified": ("warnings", "whether a font covers the text's script could not be checked (no fontconfig / fc-scan); {script, fonts_dir?}"),
|
|
456
|
+
"font_path_copied": ("fallbacks", "a --font-file under a non-ASCII path was copied to an ASCII one, because drawtext cannot open it on Windows; {font, file}"),
|
|
457
|
+
"font_path_not_ascii": ("warnings", "a --font-file under a non-ASCII path could not be given an ASCII form on Windows; drawtext may not open it; {font}"),
|
|
456
458
|
"fonts_dir_lacks_script": ("fallbacks", "no face in --fonts-dir covers the text's script, so one was picked by script; {fonts_dir, script}"),
|
|
457
459
|
"emoji_monochrome": ("warnings", "emoji drawn by the text font (monochrome at best): no colour path here"),
|
|
458
460
|
"emoji_stripped": ("warnings", "overlay.py --emoji none: emoji removed from the drawn text; {mode}"),
|
package/scripts/graphics.py
CHANGED
|
@@ -43,6 +43,7 @@ from _common import (aac_args, add_common, brand_caption_style, script_font_for_
|
|
|
43
43
|
from _common.emit import warn
|
|
44
44
|
from _common.fonts import font_choice, record_font_choice
|
|
45
45
|
from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
|
|
46
|
+
from _common import drawtext_fontfile # noqa: E402
|
|
46
47
|
|
|
47
48
|
# where the template's text chain goes in a filter_complex it builds (graphics.main fills it in)
|
|
48
49
|
TEXT = "@TEXT@"
|
|
@@ -62,7 +63,7 @@ def font_opts(brand: dict, font: Optional[str], font_file: Optional[str], script
|
|
|
62
63
|
choice = font_choice(font or brand.get("font", "DejaVu Sans"), font_file=font_file or brand.get("font_file"),
|
|
63
64
|
script_file=script_file, script_family=script_family, renderer=renderer)
|
|
64
65
|
if choice["file"]:
|
|
65
|
-
return
|
|
66
|
+
return drawtext_fontfile(choice['file']), choice
|
|
66
67
|
return f"font='{escape_drawtext(font or brand.get('font', 'DejaVu Sans'))}'", choice
|
|
67
68
|
|
|
68
69
|
|
package/scripts/grid.py
CHANGED
|
@@ -26,8 +26,9 @@ import os
|
|
|
26
26
|
import sys
|
|
27
27
|
|
|
28
28
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, X264_PRESETS, fmt_secs, \
|
|
29
|
-
draws_chroma, drawn_chain, escape_drawtext,
|
|
29
|
+
draws_chroma, drawn_chain, escape_drawtext, ffmpeg_base, info, label_chain, matrix_chain, probe, run, validate_color, video_args
|
|
30
30
|
from _common.fonts import font_choice, record_font_choice
|
|
31
|
+
from _common import drawtext_fontfile # noqa: E402
|
|
31
32
|
|
|
32
33
|
LABEL_MARGIN = 10
|
|
33
34
|
|
|
@@ -120,7 +121,7 @@ def main() -> int:
|
|
|
120
121
|
drawn.append((f"trim=duration={target_duration:.3f}",) * 2)
|
|
121
122
|
if args.label == "auto":
|
|
122
123
|
stem = os.path.splitext(os.path.basename(p))[0]
|
|
123
|
-
font_opt =
|
|
124
|
+
font_opt = drawtext_fontfile(font_file) if font_file else f"font='{escape_drawtext(args.font)}'"
|
|
124
125
|
drawn.append((f"drawtext=text='{escape_drawtext(stem)}':{font_opt}:fontsize={args.font_size}:"
|
|
125
126
|
f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
|
|
126
127
|
f"box=1:boxcolor=black@0.5:boxborderw=4", "null"))
|
package/scripts/look.py
CHANGED
|
@@ -17,7 +17,8 @@ from pathlib import Path
|
|
|
17
17
|
from typing import List, Optional
|
|
18
18
|
|
|
19
19
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
|
|
20
|
-
from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext,
|
|
20
|
+
from _common import STATE, require_tool, add_common, apply_common, default_font_file, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_analysis, time_arg
|
|
21
|
+
from _common import drawtext_fontfile # noqa: E402
|
|
21
22
|
|
|
22
23
|
FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
|
|
23
24
|
|
|
@@ -161,9 +162,12 @@ def main() -> int:
|
|
|
161
162
|
# a resolvable font file, given as fontfile=, is the only form confirmed not to crash drawtext's
|
|
162
163
|
# own fontconfig resolution on some real Windows ffmpeg builds (#100); font= is the fallback when
|
|
163
164
|
# nothing can be resolved, unchanged from before this existed.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
165
|
+
if args.no_timecode:
|
|
166
|
+
font_prefix, tc = "", "" # nothing is drawn, so no font is resolved or copied
|
|
167
|
+
else:
|
|
168
|
+
default_font = default_font_file("DejaVu Sans")
|
|
169
|
+
font_prefix = f"{drawtext_fontfile(default_font)}:" if default_font else ""
|
|
170
|
+
tc = "," + timecode_filter(font_prefix)
|
|
167
171
|
# --safe: the platform's occluded zones, drawn as shaded boxes in fractions of the frame so
|
|
168
172
|
# the same filter is right at any scale (a tile of a contact sheet as much as a full frame).
|
|
169
173
|
safe_filter = ""
|
package/scripts/overlay.py
CHANGED
|
@@ -30,9 +30,10 @@ import sys
|
|
|
30
30
|
from typing import List, Optional
|
|
31
31
|
|
|
32
32
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
33
|
-
from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext,
|
|
33
|
+
from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
34
34
|
from _common.emit import warn
|
|
35
35
|
from _common.fonts import font_choice, record_font_choice
|
|
36
|
+
from _common import drawtext_fontfile # noqa: E402
|
|
36
37
|
|
|
37
38
|
# Per-edge margins: a platform's UI does not cover the same fraction of every edge (TikTok's
|
|
38
39
|
# like column is 14 % of the width, its description block 22 % of the height), so a position
|
|
@@ -465,7 +466,7 @@ def main() -> int:
|
|
|
465
466
|
x, y = position_exprs(args.position, args.margin, text_mode=True, margins=safe_margins)
|
|
466
467
|
# 1.15: the text goes in a FILE with expansion off, so `'` and `%` survive verbatim
|
|
467
468
|
# (they used to be dropped by escape_drawtext) and no character can reach the graph parser.
|
|
468
|
-
font_opt = (
|
|
469
|
+
font_opt = (drawtext_fontfile(args.font_file) if args.font_file
|
|
469
470
|
else f"font='{escape_drawtext(args.font)}'")
|
|
470
471
|
alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None), end, args.fade)
|
|
471
472
|
timing = ([f"alpha='{alpha}'"] if alpha != "1" else []) + ([f"enable='{enable}'"] if enable else [])
|
package/scripts/scenes.py
CHANGED
|
@@ -24,8 +24,9 @@ from typing import Dict, List, Optional, Tuple
|
|
|
24
24
|
|
|
25
25
|
# `detect_scenes` moved into _common/probe.py in 1.16.0 (see silence.py); the body is unchanged.
|
|
26
26
|
from _common import (detect_scenes, STATE, add_common, apply_common, beat_grid, default_font_file, die, emit,
|
|
27
|
-
|
|
27
|
+
ffmpeg_base, info, print_json, probe, run, decode_pcm_mono,
|
|
28
28
|
rms_envelope, BEAT_MIN_CONFIDENCE, decode_gray_frames, frame_flow, label_shot_flow)
|
|
29
|
+
from _common import drawtext_fontfile # noqa: E402
|
|
29
30
|
|
|
30
31
|
SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
|
|
31
32
|
SHOT_W, SHOT_H = 48, 27 # decode size for --shots (16:9-ish; enough blocks, still a few KB/shot)
|
|
@@ -286,7 +287,7 @@ def main() -> int:
|
|
|
286
287
|
stamp = ""
|
|
287
288
|
if not args.no_timecode:
|
|
288
289
|
default_font = default_font_file("DejaVu Sans")
|
|
289
|
-
font_prefix = f"
|
|
290
|
+
font_prefix = f"{drawtext_fontfile(default_font)}:" if default_font else ""
|
|
290
291
|
stamp = f",drawtext=text='%{{pts\\:hms}}':{font_prefix}fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6"
|
|
291
292
|
vf = (f"select='{expr}',scale={tile_w}:-2{stamp},"
|
|
292
293
|
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
package/scripts/verify.py
CHANGED
|
@@ -114,7 +114,7 @@ def step(name: str, argv: List[str], timeout: float) -> Dict:
|
|
|
114
114
|
return r
|
|
115
115
|
try:
|
|
116
116
|
proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:] + ["--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
117
|
-
text=True, encoding="utf-8", errors="replace", timeout=timeout)
|
|
117
|
+
text=True, encoding="utf-8", errors="replace", timeout=timeout or None)
|
|
118
118
|
except subprocess.TimeoutExpired:
|
|
119
119
|
msg = f"timeout after {timeout:.0f}s"
|
|
120
120
|
return {"step": name, "ok": False, "seconds": round(time.time() - t0, 1), "error": msg,
|
|
@@ -149,7 +149,7 @@ def main() -> int:
|
|
|
149
149
|
ap.add_argument("--keep", action="store_true", help="keep the outputs")
|
|
150
150
|
ap.add_argument("--quick", action="store_true", help="probe, copy cut, fit, caption, export only")
|
|
151
151
|
ap.add_argument("--seconds", type=float, default=6.0, help="length of the test cut taken from each file (default 6)")
|
|
152
|
-
ap.add_argument("--timeout", type=float, default=600.0, help="per-step timeout in seconds")
|
|
152
|
+
ap.add_argument("--timeout", type=float, default=600.0, help="per-step timeout in seconds (default 600; 0 = no limit, as everywhere else)")
|
|
153
153
|
ap.add_argument("--report", help="write a Markdown report here")
|
|
154
154
|
add_common(ap)
|
|
155
155
|
args = ap.parse_args()
|