ffmpeg-skill 1.10.1 → 1.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -153,7 +153,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
153
153
 
154
154
  ## Tools
155
155
 
156
- 42 public tools, all Python 3.9 standard library, all with `--help`, `--dry-run`, `--json`, `--plan FILE` (a dry run written as a plan `render.py` executes later), non-zero exit and a reason on stderr on failure. Every re-encoding tool takes `--codec h264|hevc|av1|prores` and `--quality N` (1.8), and every time flag takes seconds, `mm:ss`, `hh:mm:ss.fff` or SMPTE `hh:mm:ss:ff` with an optional `@fps` suffix (1.9).
156
+ 42 public tools, all Python 3.9 standard library, all with `--help`, `--dry-run`, `--json`, `--plan FILE` (a dry run written as a plan `render.py` executes later), non-zero exit and a reason on stderr on failure. `--json-brief` (1.11.0) prints the same result document trimmed to what a caller acts on — status, output, `verified`, a compact `summary` of the output probe, the tool's own keys and the command count instead of the command lines — for roughly a third of the bytes; `--json` itself is unchanged. Every re-encoding tool takes `--codec h264|hevc|av1|prores` and `--quality N` (1.8), and every time flag takes seconds, `mm:ss`, `hh:mm:ss.fff` or SMPTE `hh:mm:ss:ff` with an optional `@fps` suffix (1.9).
157
157
 
158
158
  **Analysis and inspection**
159
159
 
@@ -259,6 +259,9 @@ built straight from that object. (The rest of a `ToolSpec` — `role`, `capabili
259
259
  aren't things a parser can express; only `input_schema` is parser-derived.)
260
260
 
261
261
  - **The contract**'s `input_schema` for every tool is generated from the live parser directly.
262
+ - **SKILL.md is two-tier** (1.11.0): the file the agent loads every session keeps the workflow, the
263
+ request→script table and one line per gotcha; the long-form detail lives in `references/gotchas.md`
264
+ and the other `references/` files, read only when a job needs it.
262
265
  - **The MCP server** (`mcp/server.py`) carries no schema of its own; `tools/list` is translated
263
266
  straight from the contract, `input_schema` included.
264
267
  - **The docs** (`docs/contract.md`'s field reference, this README's tool table) describe the same
@@ -286,7 +289,7 @@ The contract is generated from the code that runs, not maintained beside it. For
286
289
  | `output_schema` | what `--json` prints: `status`, `output`, `commands`, `probe`, plus tool-specific fields (`precision`, `checks`, `offset_seconds`, …) |
287
290
  | `role` | `analysis`, `analysis_and_execution`, `execution` or `verification` |
288
291
  | `capabilities` | the FFmpeg encoders, filters and bitstream filters the tool always needs, and the ones needed only for a flag or input |
289
- | `supports_dry_run`, `supports_json` | measured by the tests, not declared |
292
+ | `supports_dry_run`, `supports_json`, `supports_json_brief` | measured by the tests, not declared |
290
293
  | `verification` | which tools to run on the output afterwards (`probe`, `check`, `look`) |
291
294
  | `requires_visual_verification` | the picture changed; inspect the contact sheet |
292
295
  | `audio_only`, `video_required` | whether an audio-only input is accepted or refused |
@@ -360,6 +363,7 @@ FFmpeg 8 shortened the flag column of `ffmpeg -filters`. A parser anchored on th
360
363
  | **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
361
364
  | **exact to the sample** | `cut.py --accurate` on WAV, FLAC (44.1 kHz) and AAC → WAV; WAV stream copy within 2 ms; AAC output +21 ms of encoder priming, reported as `codec_frame` (0.9.1) |
362
365
  | **72 / 72** | agent runs of 24 prompts (12 English edits, 8 Japanese, 4 that must be declined), three repeats, graded by an independent model: routing, honest refusals and user's language 72/72, report format 71/72, visual check whenever the picture changed 24/24 (0.8.4) |
366
+ | **36 / 36** | 1.11.0 re-run (2026-09-13, one pass per prompt, Sonnet agent, regex grader + focused Opus grader): routing 36/36, honest refusals and failures 36/36 with 0 false successes and 0 raw ffmpeg calls, report format 36/36, user's language 36/36, visual check 8/8, trigger set 22/22. First iteration to measure tokens per run: mean 72.2k against 68.7k at 1.10.0, because the two-tier SKILL.md sent agents to `references/` and `doctor` before every job; 1.11.1 rewords it and iteration 12 re-measures. Details in `evals/results/iteration-11.json` |
363
367
  | **108 / 108** | 1.10.0 re-run (2026-09-13, three passes per prompt, Sonnet agent, regex grader + focused Opus grader): routing 108/108, honest refusals and failures 108/108 with 0 false successes and 0 raw ffmpeg calls, report format 108/108 by both graders (the harness now names the five labels), user's language 105/108 (every Japanese request in Japanese; 3 English requests drifted to Spanish or Portuguese), visual check 21/24, trigger set 22/22; real-device corpus 101/101 steps PASS. Details in `evals/results/iteration-10.json` |
364
368
  | **108 / 108** | 1.9.0 re-run (2026-09-13, three passes per prompt, Sonnet agent, regex grader + independent Opus grader): routing 108/108, honest refusals and failures 108/108 with 0 false successes and 0 raw ffmpeg calls, visual check 23/26, user's language 105/108 (every Japanese request answered in Japanese; 3 English requests drifted to Spanish), report format 108/108 by regex (65/108 by the stricter grader, which now counts any missing label), trigger set 22/22; 12 of 15 platform jobs were one encode and `render.py` rendered once in 3/3 (was 1/3). The 1.9.0 time grammar was not used by any agent. Details in `evals/results/iteration-9.json` |
365
369
  | **108 / 108** | 1.8.0 re-run (2026-09-12, three passes per prompt, Sonnet agent, regex grader + independent Opus grader that re-probed 22 outputs): routing 108/108, honest refusals and failures 108/108 with 0 false successes and 0 raw ffmpeg calls, visual check 22/24, report format 108/108 by regex (91/108 by the stricter grader: 'What/how:' in place of Steps:), user's language 98/108 by the stricter grader (Japanese labels-only reports counted), trigger set 22/22; 13 of 14 platform exports used `--normalize` and platform jobs went from three encodes to one; r04/f01 now answered in the request's language 5/6 (was 0/6). Details in `evals/results/iteration-8.json` |
@@ -449,7 +453,7 @@ Contributing a change: see [CONTRIBUTING.md](CONTRIBUTING.md).
449
453
  | | |
450
454
  |---|---|
451
455
  | [CONTRIBUTING.md](CONTRIBUTING.md) | scope, dev setup, tests, PR expectations |
452
- | [docs/roadmap.md](docs/roadmap.md) | 1.8.0 to 1.20.0 one theme per minor (1.8 to 1.10 pre-ship 2.0), and what 2.0.0 then removes |
456
+ | [docs/roadmap.md](docs/roadmap.md) | 1.8.0 to 1.21.0 one theme per minor (1.8 to 1.10 pre-ship 2.0, 1.11 the token diet), and what 2.0.0 then removes |
453
457
  | [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 |
454
458
  | [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Contributor Covenant 2.1; reports go through the SECURITY.md channel |
455
459
  | [SECURITY.md](SECURITY.md) | how to report a vulnerability privately |
package/SKILL.md CHANGED
@@ -5,126 +5,51 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
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`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress`, `--timeout SECONDS` (a single ffmpeg run is killed past this and reported as `kind: timeout`; default 1800) and `--overwrite` (consent to replace an output that already exists; without it the tool warns today and refuses from 2.0) and `--plan FILE` (a dry run written as a plan: fingerprinted inputs, commands, expected output, verify steps; `render.py FILE` executes it later and refuses if an input changed, so "plan → user confirms → execute" is one round trip). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact (nor side files such as `--edl`, `--sheet` or a generated `.ass`); `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Every tool that re-encodes also takes `--codec h264|hevc|av1|prores` and `--quality N` (CRF scale; overrides `--crf`): without them SDR is x264 and HDR is x265 Main10, as before; `--codec prores` needs an explicit `-o NAME.mov` (or `.mkv`), `--codec h264` refuses an HDR source (run `color.py --to-sdr` first). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
8
+ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. This file is enough to do a job: the table below routes the request, the recipes give the flags, and `--help` on the one script you are about to run is the cheapest full flag list. The reference files cost as much to read as this file does, so open one only when it answers a question you actually have: `references/scripts.md` (every flag of all 42 scripts, for comparing tools), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end of this file).
9
9
 
10
- ## Workflow (always follow this order)
10
+ Shared flags, on every script: `--dry-run`; `--json` (output path, a probe of the output, the commands run); `--json-brief` (that document trimmed to status/output/verified, a compact `summary` and the command count — enough to confirm and report, so prefer it on every writing step and keep `--json` for when you need the full probe); `--fast` (preview quality); `--progress`; `--timeout SECONDS` (`kind: timeout`, default 1800); `--overwrite` (consent to replace an existing output — without it the tool warns today, refuses from 2.0); `--plan FILE` (the dry run as a plan document `render.py FILE` executes later, refusing if an input changed: "plan → user confirms → execute" in one round trip). Every re-encoding tool also takes `--codec h264|hevc|av1|prores` and `--quality N` (CRF scale, replaces the deprecated `--crf`): unset, SDR is x264 and HDR is x265 Main10; `prores` needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (run `color.py --to-sdr` first).
11
+
12
+ Writing tools run nothing under `--dry-run`; `probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness` and `stabilize` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact (nor side files such as `--edl`, `--sheet` or a generated `.ass`); `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
11
13
 
12
- 0. **Check the environment once per session, if unfamiliar.** On a machine
13
- you haven't confirmed capability on this session, run
14
- `python3 <skill-dir>/scripts/_contract.py doctor --json` (the npm install
15
- also exposes it as `npx ffmpeg-skill doctor --json`; there is no doctor.py)
16
- once: check `ok` and the target tool's `usable` before relying on it. If
17
- `usable` isn't `yes`, don't run that tool — report the missing capability
18
- instead of discovering it via a runtime failure (a missing `libass`,
19
- `zscale`, or encoder is the common case, e.g. `caption.py`). Don't re-run
20
- `doctor` per job — it queries `ffmpeg -filters`/`-encoders`, not free, and
21
- once per session/unfamiliar machine is enough. `contract --json`'s full
22
- tool schema is for a *planning* agent deciding which tool/params to use
23
- from an abstract goal — not part of this per-job workflow.
24
- 1. **Probe first.** Run `probe.py` on every input before touching it. Read the
25
- duration, fps, resolution, codecs, audio channels and the
26
- `variable_frame_rate_suspected` flag. Plan the edit from real numbers, never
27
- from assumptions about the file.
28
- 2. **Prefer lossless.** If the request can be satisfied without re-encoding
29
- (plain cuts on keyframes, remuxing, audio-only changes), do not re-encode.
30
- `cut.py` and `loudness.py` stream-copy video by default; only pass
31
- `--accurate` to `cut.py` when the user needs frame-exact cuts.
32
- 3. **Plan with `--dry-run --json`, then execute.** Every script accepts
33
- `--dry-run` (prints the ffmpeg commands that would run; writing tools write
34
- nothing, analysis tools still measure — the per-tool list is in the
35
- opening paragraph above and in `contract --json`'s `dry_run` field) and
36
- `--json` (structured result: output path, probe of the output, commands
37
- run). Trust `--json`, not a dry-run's human-readable summary line, for any
38
- number after the plan (dimensions in that line can be a placeholder, not a
39
- computed preview — see `docs/contract.md`). Use them to confirm a plan
40
- before long encodes and to report exact facts. `--fast` gives a quick
41
- preview-quality render (x264 veryfast), `--progress` prints percent and
42
- ETA on stderr for long encodes. Never point `-o` at a file
43
- you did not create in this job unless the user asked for it to be replaced;
44
- pass `--overwrite` only then.
45
- 4. **Chain operations in a sensible order.** Colour (HDR→SDR / LUT) → cut →
46
- join → silence → fit → caption/overlay → sync → audio → loudness → export.
47
- Do frame changes (fit/crop) before captions and overlays so text is sized
48
- for the final frame. Re-encode as few times as possible: keep intermediates
49
- at CRF 18 (the default) and only use `export.py` for the last step; for
50
- anything with more than two steps use `render.py` with a project.json.
51
- 5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X`
52
- for the destination the user named. Each row is marked `format` or
53
- `judgement`. Format rows (codec, pixel format, size, true peak, colour
54
- tags, VFR) are safe to fix mechanically. Judgement rows change the content:
55
- duration (cut loses material, speed changes motion), aspect (crop loses
56
- edges), fps (drops motion), loudness (ambience must not be boosted). Fix
57
- those only when the user's request already implies the answer, otherwise
58
- state the choice and its cost in one line. Mention WARNs; do not chase them.
59
- 6. **Verify the output.** Run `probe.py` on each result and confirm duration,
60
- resolution, fps and audio match what was requested. Report those numbers to
61
- the user (e.g. "final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo").
62
- A step is done only when the script exited 0 and the output probes as
63
- expected. Writing the command is not doing the job; a non-zero exit, a
64
- missing or empty file, or a probe that contradicts the request is a
65
- failure, and the report says so with the script's error message.
66
- 7. **Keep the user's originals.** Never overwrite the source file. Write new
67
- files next to the input or where the user asked.
68
- Set `FFMPEG_SKILL_NO_OVERWRITE=1` in the environment you run these scripts
69
- in: an output path that already exists is then refused (`kind: input`)
70
- instead of warned about, and `--overwrite` stays the one way to say "yes,
71
- replace it". It is the recommended agent setting because an agent picking
72
- output names cannot see which files the user already cares about — and it
73
- is what 2.0 does by default.
74
- 8. **Look at the picture.** Whenever the picture changed (captions, overlays,
75
- graphics, crop/pad, resize, colour, transitions, a `join.py` that scaled or
76
- padded a clip to the first clip's frame, a `color.py --to-sdr` that tone-maps
77
- an HDR source) run `look.py OUTPUT`
78
- (contact sheet) or `look.py OUTPUT --at T`, view the PNG. The job is not
79
- finished until the report's `Look:` line names that PNG; a probe alone
80
- cannot see a caption sitting on someone's face. Audio-only jobs (sync,
81
- loudness, silence, or any job whose input is an audio file) write
82
- `Look: not needed`; there is no picture to inspect. What to look for
83
- splits the same way `check.py`'s rows do in step 5:
84
- - **Mechanical (verify and report as this skill's own job):** the
85
- specified text/logo is present at the specified position, subtitles/text
86
- appear at the specified timestamps, resolution has even dimensions.
87
- Letterboxing/pillarboxing from `fit.py --fit pad` is the *correct*
88
- result of that mode, not a defect — never flag it.
89
- - **Judgement (report to the calling agent/user, don't silently pass or
90
- fail):** whether a subject or face is cut off, whether text sits over a
91
- face, whether colours look washed out, whether a transition "lands"
92
- well or the edit feels cinematic. These require deciding what the
93
- subject *is*, which belongs to the calling agent (see "What this skill
94
- does and does not decide") — state what you see in one line and let the
95
- calling agent or user judge it, don't decide it here.
96
- If the execution environment cannot actually view images (no vision
97
- capability), write `Look: PATH (pixels not inspected; agent has no image
98
- view)` — never claim a picture was inspected when it wasn't, and don't
99
- stall indefinitely waiting for a capability that isn't there.
14
+ ## Workflow (always follow this order)
100
15
 
16
+ 0. **Environment, only on failure.** Do not start a job with `doctor`: on a working machine it is a call that tells you nothing the job needs, and on a broken one the script fails on its own with `kind: missing_tool` (no ffmpeg) or an ffmpeg error naming the filter or encoder (`No such filter: 'subtitles'`). Run `python3 <skill-dir>/scripts/_contract.py doctor` (also `npx ffmpeg-skill doctor`; there is no doctor.py) after such a failure, or when the user asks what the machine can do. It queries `ffmpeg -filters`/`-encoders`, so it is not a per-job step. Read `ok` and the tool's `usable`; if `usable` isn't `yes`, report the missing capability (a missing `libass`, `zscale` or encoder is the common case, e.g. `caption.py`) instead of discovering it through a runtime failure. `contract --json`'s full tool schema is for a *planning* agent choosing a tool from an abstract goal, not for this per-job workflow.
17
+ 1. **Probe what you must plan from.** Run `probe.py` on each input you plan the edit from — duration, fps, resolution, codecs, channels, `variable_frame_rate_suspected` — and whenever the user asks a question about a file. You do not need a separate probe before every edit: every writing tool's `--json` already carries its input and a probe of the output it wrote. Plan from real numbers, never assumptions.
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; pass `--accurate` to `cut.py` only for frame-exact cuts.
19
+ 3. **Plan with `--dry-run --json`, then execute.** Trust `--json`, not a dry run's human-readable summary line, for any number after the plan (dimensions there can be a placeholder, not a computed preview — `docs/contract.md`). Use it to confirm a plan before long encodes and to report exact facts. `--fast` is preview quality (x264 veryfast), `--progress` prints percent/ETA on stderr. Never point `-o` at a file you did not create in this job unless the user asked for it to be replaced; pass `--overwrite` only then.
20
+ 4. **Chain in a sensible order.** Colour (HDR→SDR / LUT) → cut → join → silence → fit → caption/overlay → sync → audio → loudness → export. Frame changes (fit/crop) before captions and overlays, so text is sized for the final frame. Re-encode as few times as possible: intermediates at CRF 18 (the default), `export.py` only for the last step. **Three or more steps: use `render.py` with a project.json** — one call, one JSON, one place for the user to change a number — rather than hand-chaining tools.
21
+ 5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X` for the destination the user named. Each row is `format` or `judgement`. Format rows (codec, pixel format, size, true peak, colour tags, VFR) are safe to fix mechanically. Judgement rows change the content: duration (cut loses material, speed changes motion), aspect (crop loses edges), fps (drops motion), loudness (ambience must not be boosted) — fix those only when the request already implies the answer, otherwise state the choice and its cost in one line. Mention WARNs; do not chase them.
22
+ 6. **Verify the output.** Confirm duration, resolution, fps and audio match the request — from the writing tool's own `--json`/`--json-brief` probe, or `probe.py` when you have none — 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. Writing the command is not doing the job: a non-zero exit, a missing or empty file, or a probe that contradicts the request is a failure, and the report says so with the script's error message.
23
+ 7. **Keep the user's originals.** Never overwrite the source; write new files next to the input or where the user asked. Set `FFMPEG_SKILL_NO_OVERWRITE=1` in the environment you run these scripts in: an existing output path is then refused (`kind: input`) instead of warned about, and `--overwrite` stays the one way to say "yes, replace it". It is the recommended agent setting — an agent picking output names cannot see which files the user already cares about — and it is what 2.0 does by default.
24
+ 8. **Look at the picture.** Whenever the picture changed (captions, overlays, graphics, crop/pad, resize, colour, transitions, a `join.py` that scaled or padded a clip to the first clip's frame, a `color.py --to-sdr` that tone-maps an HDR source) run `look.py OUTPUT --tiles 3x2` (or `--at T` for one frame) and view the PNG; the full 4x3 sheet is for a job about layout across the whole clip. The job is not finished until the report's `Look:` line names that PNG — a probe cannot see a caption sitting on someone's face. Audio-only jobs (sync, loudness, silence, or any job whose input is an audio file) write `Look: not needed`; there is no picture. What to look for splits like `check.py`'s rows in step 5:
25
+ - **Mechanical (this skill's own job to verify and report):** the specified text/logo is present at the specified position, subtitles/text appear at the specified timestamps, dimensions are even. Letterboxing/pillarboxing from `fit.py --fit pad` is the *correct* result of that mode, never a defect to flag.
26
+ - **Judgement (report it, don't silently pass or fail):** whether a subject or face is cut off, whether text sits over a face, whether colours look washed out, whether a transition lands. These need deciding what the subject *is*, which belongs to the calling agent (see "What this skill does and does not decide") — say what you see in one line and let them judge it.
27
+ With no vision capability, write `Look: PATH (pixels not inspected; agent has no image view)` — never claim a picture was inspected when it wasn't, and don't stall waiting for a capability that isn't there.
101
28
 
102
29
  ## Before you run anything: what to ask, what to assume
103
30
 
104
- Ask one short question only when the answer changes the output materially and the request does not imply it. When several things are open at once (a vague "make it for social media" leaves destination, aspect method, length and captions unresolved), do not ask them one per turn: propose one bundle with your defaults and let the user change any part ("Reels: 9:16 with padding, trimmed to 60 s, -14 LUFS, no captions — OK, or change something?"). One question, one answer, then the run.
31
+ Ask one short question only when the answer changes the output materially and the request does not imply it. When several things are open at once (a vague "make it for social media" leaves destination, aspect method, length and captions unresolved), don't ask them one per turn: propose one bundle with your defaults and let the user change any part ("Reels: 9:16 with padding, trimmed to 60 s, -14 LUFS, no captions — OK, or change something?"). One question, one answer, then the run. Never ask for what `probe.py` can tell you.
105
32
 
106
- - **Destination** decides aspect, length limit, loudness and codec. "For Reels" answers all four. If no destination is named and the edit is a plain cut/caption, keep the source format and say so; if the user asks to "export", "post" or "deliver", ask where.
107
- - **Duration** ("make it 60 s") without a method: speed up for ≤1.5× changes, trim otherwise, and state which you chose. Ask if the content is a talk (trimming loses words) and the change is large.
108
- - **Captions** without a text source: use `--transcribe` if a local whisper exists, otherwise ask for the text or a timed file; never invent dialogue.
33
+ - **Destination** decides aspect, length limit, loudness and codec; "for Reels" answers all four. No destination named and a plain cut/caption: keep the source format and say so. If the user says "export", "post" or "deliver", ask where.
34
+ - **Duration** ("make it 60 s") without a method: speed up for ≤1.5× changes, trim otherwise, and say which you chose. Ask when the content is a talk (trimming loses words) and the change is large.
35
+ - **Captions** without a text source: `--transcribe` if a local whisper exists, otherwise ask for the text or a timed file; never invent dialogue.
109
36
  - **Fonts and brand**: if the user mentions a brand, colours or "our font", ask for or create `brand.json` once and reuse it.
110
- - **CJK / non-Latin text**: check that a font exists before rendering (`fc-list :lang=ja file` / `:lang=ko` / `:lang=zh`); pass it with `--font "Name"` or `--font-file /path.ttf`. Tofu boxes are a failed job, not a style.
111
- - **Crop position** for `--fit crop`: default to centre, but if the request or the source names an off-centre subject ("keep the product on the right", "don't cut off my hands", a logo/person visibly off-centre in `look.py`'s sheet) use `--crop-x`/`--crop-y` (0=left/top, 1=right/bottom) instead of the silent centre guess. Ask which edge to keep when the sheet shows the subject near an edge and the request doesn't say.
112
- - Anything else (transition type, caption style): pick the conventional default, say what you picked, and offer the alternative in one line.
113
-
114
- Do not ask for things `probe.py` can tell you.
37
+ - **CJK / non-Latin text**: check a font exists before rendering (`fc-list :lang=ja file` / `:lang=ko` / `:lang=zh`) and pass it with `--font "Name"` or `--font-file /path.ttf`. Tofu boxes are a failed job, not a style.
38
+ - **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", "don't cut off my hands", someone visibly off-centre in the sheet) use `--crop-x`/`--crop-y` (0=left/top, 1=right/bottom) instead of a silent centre guess. Ask which edge to keep when the sheet shows the subject near an edge and the request doesn't say.
39
+ - Anything else (transition type, caption style): pick the conventional default, say what you picked, offer the alternative in one line.
115
40
 
116
41
  ## What this skill does and does not decide
117
42
 
118
- This skill cuts, joins, measures, syncs, exports and checks files — it executes an edit, it does not decide one. Some things that sound like part of the job but belong to the human, the calling agent, or another skill instead:
43
+ This skill cuts, joins, measures, syncs, exports and checks files — it executes an edit, it does not decide one. What belongs to the human, the calling agent or another skill:
119
44
 
120
- - **Which cut is the right one, or whether a deliverable is approvable for release** — this skill measures and reports (`check.py`'s PASS/WARN/FAIL, `cut.py`'s measured duration error); a production agent or the user decides whether that's good enough to ship.
121
- - **What makes a highlight interesting** — `scenes.py --highlights` ranks by a measured proxy (audio energy or scene duration, see its own docs), never by understanding the content; treat its output as candidates, not a verdict.
122
- - **Thumbnail or cover-image composition** — that's a design decision, not a measurement; a thumbnail-generation skill or the user makes it.
123
- - **Understanding what a video is *about*** — this skill has no transcription or vision beyond `look.py`'s contact sheets, which exist for the calling agent's own eyes, not for this skill to interpret on its own.
124
- - **Judging what looks good** — "apply this LUT" or "correct exposure by +0.3 stops" (`color.py`) is mechanical, parameter-determined execution and belongs here; "grade this scene to look cinematic" is a subjective judgement about what looks right and belongs in a colour-grading skill ([`color-grading-skill`](https://github.com/kajisho5/color-grading-skill), see README's "Standalone, and in an ecosystem") that decides the parameters and then calls `color.py` to apply them.
125
- - **Picking a subject or region without being told one** — "crop to this exact box" or "crop to 9:16 keeping x=200,y=0" (`crop.py`/`fit.py --fit crop --crop-x/-y`) is mechanical once the box is known; "crop to keep the speaker in frame" requires deciding *what* the speaker is, which is a vision/composition judgement for the calling agent (from a `look.py` contact sheet) or a motion-graphics skill, not this one.
45
+ - **Which cut is right, or whether a deliverable is approvable** — this skill measures and reports (`check.py`'s PASS/WARN/FAIL, `cut.py`'s measured duration error); the user or a production agent decides whether that ships.
46
+ - **What makes a highlight interesting** — `scenes.py --highlights` ranks by a measured proxy (audio energy, scene duration), never by understanding content: candidates, not a verdict.
47
+ - **Thumbnail or cover composition** — a design decision, not a measurement.
48
+ - **Understanding what a video is *about*** — there is no transcription or vision here beyond `look.py`'s contact sheets, which exist for the calling agent's eyes, not for this skill to interpret.
49
+ - **Judging what looks good** — "apply this LUT", "correct exposure by +0.3 stops" (`color.py`) is mechanical and belongs here; "grade this scene to look cinematic" belongs to a colour-grading skill ([`color-grading-skill`](https://github.com/kajisho5/color-grading-skill)) that decides the parameters and then calls `color.py`.
50
+ - **Picking a subject or region you were not given** — "crop to x=200,y=0" (`crop.py`/`fit.py --crop-x/-y`) is mechanical once the box is known; "crop to keep the speaker in frame" needs deciding *what* the speaker is a judgement for the calling agent (from a `look.py` sheet) or a motion-graphics skill.
126
51
 
127
- The line in general: if the same input and the same explicit parameters always produce the same, verifiable output, it belongs here. If the "right" answer depends on taste, content understanding, or what looks or sounds good, it belongs to whichever skill or agent makes that judgement this skill only ever executes parameters it's given, never infers them from what something looks or sounds like.
52
+ The line: same input + same explicit parameters always producing the same verifiable output belongs here; anything that depends on taste, content understanding or what looks or sounds good belongs to whoever makes that judgement. This skill executes parameters it is given, never infers them from what something looks or sounds like.
128
53
 
129
54
  If a request needs an FFmpeg feature none of the 42 scripts expose, say so and name the closest built-in option (`--dry-run` to show what would run, or a documented limitation) — never fall back to guessing a raw `ffmpeg`/`ffprobe` invocation or a hand-built filter graph outside `scripts/*.py`. A raw command bypasses every guarantee this skill makes (no shell, typed arguments, verification afterwards); it is exactly the failure mode this skill exists to prevent, so it is never the fallback when a script's flag doesn't cover something.
130
55
 
@@ -132,131 +57,94 @@ If a request needs an FFmpeg feature none of the 42 scripts expose, say so and n
132
57
 
133
58
  This table and `doctor --json`'s `tools` list are the source of truth for what exists: name only a script you have seen in one of them, never a plausible-sounding one (there is no `doctor.py`, no `trim.py`, no `subtitle.py`).
134
59
 
135
- Timestamp flags -- `--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`, and the times in cue and chapter files -- take seconds, `mm:ss(.fff)`, `hh:mm:ss(.fff)` or four-part SMPTE `hh:mm:ss:ff`, with `@fps` naming the rate (`00:01:02:15@29.97`); tolerance-style flags that are a length rather than a point in time (`--min-silence`, `--margin`, `--min-keep`, `--fade`) are plain seconds. Use the timecode forms when the user pastes an editor's timecode list or an NLE cue sheet, so nothing is converted by hand on the way in.
60
+ Timestamp flags `--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`, and the times in cue and chapter files take seconds, `mm:ss(.fff)`, `hh:mm:ss(.fff)` or four-part SMPTE `hh:mm:ss:ff`, with `@fps` naming the rate (`00:01:02:15@29.97`); tolerance-style flags that are a length rather than a point in time (`--min-silence`, `--margin`, `--min-keep`, `--fade`) are plain seconds. Use the timecode forms when the user pastes an editor's timecode list or an NLE cue sheet, so nothing is converted by hand on the way in.
136
61
 
137
62
  | User says | Do |
138
63
  |-----------|----|
139
- | "what's in this file", "how long is it", "is it 4K" | `probe.py input.mp4` |
140
- | "cut from 1:20 to 2:05", "trim the first 10 seconds" | `cut.py input.mp4 --start 1:20 --end 2:05` |
64
+ | "what's in this file", "how long is it" | `probe.py input.mp4` |
65
+ | "cut from 1:20 to 2:05", "trim the first 10 s" | `cut.py input.mp4 --start 1:20 --end 2:05` |
141
66
  | "keep only these parts", "remove the middle" | `cut.py input.mp4 --segments 0-1:00,1:30-2:00` |
142
- | "make it exactly 60 seconds", "fit it in 30s" | `fit.py input.mp4 --duration 60` (speed) or `--method trim` |
143
- | "make it vertical / for TikTok / 9:16", "square for Instagram" | `fit.py input.mp4 --aspect 9:16 --fit pad` (or `--fit crop`); add `--pad-fill blur` for the blurred-background bars phone editors produce |
144
- | "resize to a specific height, width follows" | `fit.py input.mp4 --height 1080` (or `--width`, or both for an exact frame) |
145
- | "crop to this exact box/rectangle" (known x/y/width/height, not an aspect ratio) | `crop.py input.mp4 --x 100 --y 0 --width 1080 --height 1920` |
146
- | "are there black bars on this?", "what's the crop rectangle to remove the letterboxing" | `cropdetect.py input.mp4` |
147
- | "this old footage is interlaced / combed on motion" | `deinterlace.py input.mp4` |
148
- | "this footage is grainy/noisy, clean it up" | `denoise.py input.mp4 --strength medium` |
149
- | "blur/pixelate this face/plate/region" (known x/y/width/height) | `redact.py input.mp4 --x 820 --y 140 --width 240 --height 240 --mode pixelate` |
150
- | "pull a flat clip out of this 360/spherical video, looking this way" (known yaw/pitch/fov, not "find the interesting part") | `sphere.py insta360.mp4 --yaw 90 --pitch 0 --h-fov 100 --v-fov 70` |
151
- | "the horizon is tilted, straighten this out" (known degrees, not "auto-level") | `straighten.py tilted.mp4 --degrees -2.5` |
152
- | "turn this image into a N-second clip", "title card / end slate" | `insert.py title.png --duration 3` |
153
- | "slow zoom on a photo", "Ken Burns effect" | `insert.py photo.jpg --duration 6 --zoom in --pan right --width 1920 --height 1080` |
154
- | "rotate this 90 degrees", "mirror it horizontally" | `fit.py input.mp4 --rotate 90` / `fit.py input.mp4 --flip h` |
155
- | "reverse this clip", "play it backwards" | `reverse.py input.mp4` |
67
+ | "make it exactly 60 seconds" | `fit.py input.mp4 --duration 60` (speed) or `--method trim` |
68
+ | "make it vertical / 9:16 / square" | `fit.py input.mp4 --aspect 9:16 --fit pad` (or `--fit crop`; `--pad-fill blur` for blurred bars) |
69
+ | "resize to a height/width" | `fit.py input.mp4 --height 1080` (or `--width`, or both for an exact frame) |
70
+ | "crop to this exact box" (known x/y/w/h) | `crop.py input.mp4 --x 100 --y 0 --width 1080 --height 1920` |
71
+ | "are there black bars on this?" | `cropdetect.py input.mp4` |
72
+ | "this old footage is interlaced" | `deinterlace.py input.mp4` |
73
+ | "it's grainy/noisy, clean it up" | `denoise.py input.mp4 --strength medium` |
74
+ | "blur/pixelate this face/plate" (known box) | `redact.py input.mp4 --x 820 --y 140 --width 240 --height 240 --mode pixelate` |
75
+ | "flat view out of this 360 video" (known yaw/pitch/fov) | `sphere.py insta360.mp4 --yaw 90 --pitch 0 --h-fov 100 --v-fov 70` |
76
+ | "the horizon is tilted" (known degrees) | `straighten.py tilted.mp4 --degrees -2.5` |
77
+ | "turn this image into a clip", "title card" | `insert.py title.png --duration 3` |
78
+ | "slow zoom on a photo", "Ken Burns" | `insert.py photo.jpg --duration 6 --zoom in --pan right --width 1920 --height 1080` |
79
+ | "rotate 90 degrees", "mirror it" | `fit.py input.mp4 --rotate 90` / `fit.py input.mp4 --flip h` |
80
+ | "reverse this clip" | `reverse.py input.mp4` |
156
81
  | "stabilize this shaky footage" | `stabilize.py input.mp4` |
157
82
  | "make a blank/colour background clip" | `background.py -o bg.mp4 --duration 3 --width 1920 --height 1080 --color 0x101010` |
158
83
  | "turn these numbered frames into a video" | `sequence.py --dir frames --pattern "frame_%04d.png" --fps 24` |
159
- | "make a waveform/spectrum video for this podcast/track" | `waveform.py podcast.wav -o waveform.mp4` |
160
- | "hold on this frame for a couple seconds", "freeze the last frame" | `freeze.py clip.mp4 --hold 2` |
161
- | "add some black at the start before the title card" | `pad.py clip.mp4 --start 1.5` |
162
- | "speed up here, slam into slow-mo there, then speed back up" (known segments) | `speedramp.py action.mp4 --segment 0-3:1.0 --segment 3-4:0.25 --segment 4-8:2.0` |
163
- | "loop this background clip to fill 30 seconds" | `loop.py bg_loop.mp4 --duration 30` |
164
- | "cut to the product shot from 0:12 to 0:16, keep my voice underneath", "B-roll over this bit" | `broll.py talk.mp4 --insert product.mp4 --at 12 --end 16` (repeat `--insert/--at` per cutaway; `--audio b|mix` to hear B) |
165
- | "add chapters at 0:00 Intro, 2:15 Setup, …", "chapter markers for YouTube" | `metadata.py episode.mp4 --chapters chapters.txt` (one `TIME TITLE` per line; streams are copied, nothing re-encodes) |
166
- | "set the title / artist / comment on the file" | `metadata.py episode.mp4 --title "Episode 12" --artist "Studio"` |
167
- | "put these videos in a 4x2 grid with the filename on each" | `grid.py t1.mp4 t2.mp4 t3.mp4 t4.mp4 t5.mp4 t6.mp4 t7.mp4 t8.mp4 --cols 4 --rows 2` |
84
+ | "waveform/spectrum video for this track" | `waveform.py podcast.wav -o waveform.mp4` |
85
+ | "hold on this frame", "freeze the last frame" | `freeze.py clip.mp4 --hold 2` |
86
+ | "add black at the start" | `pad.py clip.mp4 --start 1.5` |
87
+ | "speed up here, slow-mo there" (known segments) | `speedramp.py action.mp4 --segment 0-3:1.0 --segment 3-4:0.25 --segment 4-8:2.0` |
88
+ | "loop this clip to fill 30 seconds" | `loop.py bg_loop.mp4 --duration 30` |
89
+ | "cut to the product shot 0:12-0:16", "B-roll over this bit" | `broll.py talk.mp4 --insert product.mp4 --at 12 --end 16` (repeat `--insert/--at`; `--audio b\|mix`) |
90
+ | "add chapters", "chapter markers for YouTube" | `metadata.py episode.mp4 --chapters chapters.txt` (`TIME TITLE` per line; streams copied) |
91
+ | "set the title / artist / comment" | `metadata.py episode.mp4 --title "Episode 12" --artist "Studio"` |
92
+ | "put these videos in a 4x2 grid" | `grid.py t1.mp4 ... t8.mp4 --cols 4 --rows 2` |
168
93
  | "add subtitles from this SRT", "burn in captions" | `caption.py input.mp4 --srt subs.srt` |
169
- | "caption it with these lines" (plain text with times) | `caption.py input.mp4 --text cues.txt` |
170
- | "add subtitles but keep them toggleable / editable", "mux in an SRT, don't burn it" | `caption.py input.mp4 --srt subs.srt --mode mux` |
171
- | "put our logo top-right", "add a watermark" | `overlay.py input.mp4 --image logo.png --position top-right --scale 200` |
172
- | "add a title for the first 4 seconds" | `overlay.py input.mp4 --text "Title" --position top --start 0 --end 4 --fade 0.4` |
173
- | "put this webcam clip in the corner", "picture-in-picture" | `overlay.py input.mp4 --video webcam.mp4 --position bottom-right --scale 480` |
174
- | "remove the green screen", "chroma key this" | `overlay.py bg.mp4 --video greenscreen.mp4 --chromakey 0x00ff00` |
175
- | "sync the lav mic to the camera", "line up the two cameras" | `sync.py camera.mp4 mic.wav --replace-audio` / `sync.py camA.mp4 camB.mp4 --trim-second` |
176
- | "fix the audio levels", "normalise to -14 LUFS" | `loudness.py input.mp4` (`-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast) |
177
- | "cut this and make it HEVC / AV1", "a ProRes intermediate of the trimmed clip" (an edit whose *output codec* the user named) | `cut.py input.mp4 --start 0:10 --end 0:40 --codec hevc` (`--codec h264\|hevc\|av1\|prores` and `--quality N` on every editing tool that re-encodes; ProRes needs `-o NAME.mov`; without a named codec leave the default) |
178
- | "export for YouTube / Reels / X", "give me a ProRes master", "make it HEVC" | `export.py input.mp4 --preset youtube|reels|x|prores|h265` (`--normalize` meets the platform's loudness spec in the same call, no separate `loudness.py` pass) |
94
+ | "caption it with these lines" (text with times) | `caption.py input.mp4 --text cues.txt` |
95
+ | "keep the subtitles toggleable", "mux in an SRT" | `caption.py input.mp4 --srt subs.srt --mode mux` |
96
+ | "our logo top-right", "a watermark" | `overlay.py input.mp4 --image logo.png --position top-right --scale 200` |
97
+ | "a title for the first 4 seconds" | `overlay.py input.mp4 --text "Title" --position top --start 0 --end 4 --fade 0.4` |
98
+ | "webcam clip in the corner", "picture-in-picture" | `overlay.py input.mp4 --video webcam.mp4 --position bottom-right --scale 480` |
99
+ | "remove the green screen" | `overlay.py bg.mp4 --video greenscreen.mp4 --chromakey 0x00ff00` |
100
+ | "sync the lav mic", "line up two cameras" | `sync.py camera.mp4 mic.wav --replace-audio` / `sync.py camA.mp4 camB.mp4 --trim-second` |
101
+ | "fix the audio levels", "normalise to -14 LUFS" | `loudness.py input.mp4` (`-I -16 --tp -1.5` podcast, `-I -23` broadcast) |
102
+ | "cut this and make it HEVC / AV1 / ProRes" (output codec named) | `cut.py input.mp4 --start 0:10 --end 0:40 --codec hevc` (`--codec`/`--quality` on any re-encoding tool; ProRes needs `-o NAME.mov`) |
103
+ | "export for YouTube / Reels / X", "a ProRes master" | `export.py input.mp4 --preset youtube\|reels\|x\|prores\|h265` (`--normalize` hits the loudness spec in the same call) |
179
104
  | "make a GIF preview" | `export.py input.mp4 --preset gif` |
180
- | "make a small/low-res proxy for an analysis pass", "a cheap preview file" | `proxy.py input.mp4 [--width 640 --no-audio]` — not a delivery preset, see `export.py` for those |
181
- | "cut out the pauses / dead air", "tighten it up", "jump cuts" | `silence.py input.mp4 [--threshold -40 --min-silence 0.8]` |
182
- | "stitch these clips together", "add a crossfade between them" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` |
183
- | "show me what it looks like", "check the captions are readable" | `look.py output.mp4` then view the PNG |
105
+ | "a small proxy / cheap preview file" | `proxy.py input.mp4 [--width 640 --no-audio]` — not a delivery preset (those are `export.py`) |
106
+ | "cut out the pauses", "jump cuts" | `silence.py input.mp4 [--threshold -40 --min-silence 0.8]` |
107
+ | "stitch these clips", "add a crossfade" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` |
108
+ | "show me what it looks like", "are the captions readable" | `look.py output.mp4 --tiles 3x2` then view the PNG |
184
109
  | "what would you run?", "don't render yet" | any script with `--dry-run` |
185
- | "make a 60 s highlight from this hour", "find the good bits" | `scenes.py long.mp4 --highlights 6 --target 60 --edl picks.txt` → `cut.py --segments` |
186
- | "is this OK to upload?", "check it meets the Reels spec" | `check.py final.mp4 --platform reels` |
187
- | "set it up so I can tweak and re-render", "several changes to the same edit" | `render.py --init project.json`, edit, `render.py project.json` |
188
- | "add a lower third with my name", "title card", "countdown intro", "progress bar" | `graphics.py input.mp4 --template lower-third --name "..." --title "..." --start 2 --end 8` |
189
- | "use our brand fonts/colours/logo" | pass `--brand brand.json` to caption/overlay/graphics, or `"brand"` in project.json |
110
+ | "a 60 s highlight from this hour" | `scenes.py long.mp4 --highlights 6 --target 60 --edl picks.txt` → `cut.py --segments` |
111
+ | "is this OK to upload?" | `check.py final.mp4 --platform reels` |
112
+ | "several changes to the same edit", 3+ steps | `render.py --init project.json`, edit, `render.py project.json` |
113
+ | "a lower third with my name", "countdown intro", "progress bar" | `graphics.py input.mp4 --template lower-third --name "..." --title "..." --start 2 --end 8` |
114
+ | "use our brand fonts/colours/logo" | `--brand brand.json` on caption/overlay/graphics, or `"brand"` in project.json |
190
115
  | "send me a summary of what you did" | `report.py --before raw.mov --after final.mp4 --platform youtube -o report.html` |
191
- | "do this to every file in the folder", "process the whole shoot" | `batch.py FOLDER --recipe batch.json` (steps or a render project; cached) |
192
- | "transcribe it and caption it" | `caption.py input.mp4 --transcribe --animate pop --karaoke` (needs a local whisper; otherwise `--text`) |
116
+ | "do this to every file in the folder" | `batch.py FOLDER --recipe batch.json` (steps or a render project; cached) |
117
+ | "transcribe it and caption it" | `caption.py input.mp4 --transcribe --animate pop --karaoke` (needs a local whisper; else `--text`) |
193
118
  | "three cameras, cut between them" | `multicam.py camA.mp4 camB.mp4 camC.mp4 --switch "0-20:0,20-40:1,40-60:2"` |
194
- | "it's an iPhone Dolby Vision clip and players show it wrong" | `color.py clip.mov --to-sdr` or `color.py clip.mov --strip-dovi` (keep HDR, drop the DV layer) |
195
- | "does it look like Log / S-Log / flat footage?" | `probe.py clip.mp4 --analyze` (`looks_like_log`) then `color.py --lut` |
119
+ | "iPhone Dolby Vision clip looks wrong" | `color.py clip.mov --to-sdr` or `--strip-dovi` (keep HDR, drop the DV layer) |
120
+ | "does it look like Log / S-Log?" | `probe.py clip.mp4 --analyze` (`looks_like_log`) then `color.py --lut` |
196
121
  | "test the tool on my real files" | `verify.py ~/Footage --report verify.md` |
197
122
  | "show me progress", "quick preview first" | any encoding script with `--progress` and/or `--fast` |
198
- | "the colours look washed out / it's an iPhone HDR video" | `color.py input.mov --to-sdr` (probe shows `hdr: true`) |
199
- | "apply this LUT", "convert the S-Log / V-Log footage" | `color.py input.mp4 --lut grade.cube [--lut-strength 0.7]` |
200
- | "the colours are tagged wrong" | `color.py input.mp4 --retag bt709` (stream copy; re-encodes only if the copy can't carry the retagged colour info check `reencoded` in `--json`) |
201
- | "brighten it a touch / punch up the contrast and saturation / fix the white balance" | `color.py input.mp4 --correct --exposure 0.3 --contrast 1.1 --saturation 1.05 --temperature 5600 --tint -0.05` (typed, no filter string) |
202
- | "clean up the audio", "remove the hiss / room noise" | `audio.py input.mp4 --voice` (speech) or `--denoise` |
123
+ | "the colours look washed out / iPhone HDR" | `color.py input.mov --to-sdr` (probe shows `hdr: true`) |
124
+ | "apply this LUT", "convert the S-Log footage" | `color.py input.mp4 --lut grade.cube [--lut-strength 0.7]` |
125
+ | "the colours are tagged wrong" | `color.py input.mp4 --retag bt709` (stream copy; re-encodes only if the copy can't carry itsee `reencoded`) |
126
+ | "brighten it / punch up contrast / fix white balance" | `color.py input.mp4 --correct --exposure 0.3 --contrast 1.1 --saturation 1.05 --temperature 5600 --tint -0.05` |
127
+ | "clean up the audio", "remove the hiss" | `audio.py input.mp4 --voice` (speech) or `--denoise` |
203
128
  | "add background music under the talking" | `audio.py input.mp4 --music bed.mp3 --duck --fade-out 3` |
204
129
  | "convert the 5.1 to stereo" | `audio.py input.mov --downmix` |
205
130
  | "swap in the narration track" | `audio.py input.mp4 --replace narration.wav` |
206
- | "pull the audio out of this video", "give me the sound as WAV" | `audio.py input.mp4 -o input.wav` (any audio extension drops the picture; `--audio-stream 1` picks another track) |
207
- | "compress the voice", "limit the peaks to -1 dB", "gate the room noise" | `audio.py input.mp4 --compress --comp-threshold -20 --comp-ratio 4` / `--limit --limit-ceiling -1` / `--gate --gate-threshold -45` (typed acompressor / alimiter / agate options, range-checked) |
131
+ | "pull the audio out", "give me the sound as WAV" | `audio.py input.mp4 -o input.wav` (an audio extension drops the picture; `--audio-stream 1` picks a track) |
132
+ | "compress the voice", "limit the peaks", "gate the room noise" | `audio.py input.mp4 --compress --comp-threshold -20 --comp-ratio 4` / `--limit --limit-ceiling -1` / `--gate --gate-threshold -45` |
208
133
  | "the audio drifts out of sync over the hour" | `sync.py camera.mp4 recorder.wav --fix-drift --replace-audio` |
209
134
  | "smooth slow motion", "half speed but fluid" | `fit.py input.mp4 --duration 2x --smooth interpolate` (slow) or `--smooth blend` |
210
- | "TikTok-style captions with the words popping / highlighted" | `caption.py input.mp4 --text cues.txt --animate pop --karaoke` |
211
- | "it's a phone video with variable frame rate" | nothing extra: every re-encoding script conforms VFR to constant fps automatically; `fit.py --fps 30` to pick the rate |
212
-
135
+ | "TikTok-style captions with the words popping" | `caption.py input.mp4 --text cues.txt --animate pop --karaoke` |
136
+ | "it's a phone video with variable frame rate" | nothing extra: re-encodes conform VFR to constant fps; `fit.py --fps 30` picks the rate |
213
137
 
214
138
  ## Audio-only files
215
139
 
216
- Audio files are a first-class input, not a special case. `probe.py`, `cut.py`,
217
- `silence.py`, `loudness.py`, `audio.py`, `sync.py` and `check.py --platform
218
- podcast` all accept WAV, FLAC, MP3, M4A/AAC, OGG and Opus (any container ffmpeg
219
- can read) and write the codec that fits the output extension, so the same
220
- commands work with `talk.wav` in place of `talk.mp4`. What changes:
221
-
222
- - The output extension picks the format: `-o out.mp3` converts, `-o out.wav`
223
- keeps PCM, `-o out.m4a` writes AAC. `audio.py in.wav -o out.mp3` with no
224
- other flag is a plain conversion.
225
- - `cut.py` stream-copies audio too, so trims land on a packet boundary
226
- (`precision: packet`, a few ms; the JSON reports `duration_error_ms`). Pass
227
- `--accurate` for a sample-exact trim: `precision: sample` when the output is
228
- PCM or FLAC, `codec_frame` when a lossy codec (AAC, MP3, Opus) frames it
229
- again. A `.wav` output is always PCM, never AAC packets inside a WAV.
230
- - `join.py` joins audio-only clips as audio (`acrossfade` or a butt join) at
231
- one sample rate and channel layout; the output must have an audio extension.
232
- Video and audio clips cannot be mixed in one join.
233
- - An audio extension on a video input (`audio.py talk.mp4 -o talk.wav`,
234
- `cut.py talk.mp4 --start 1:00 --end 2:00 -o part.wav`) extracts the audio; the
235
- output has no video stream. `audio.py --audio-stream N` picks a track when
236
- `probe` lists several under `audio_streams`.
237
- - `Look: not needed` in the report; `Check:` still applies for loudness
238
- (`check.py file.wav --platform podcast` measures LUFS and true peak).
239
- - Scripts that need a picture (`fit`, `caption`, `overlay`, `graphics`,
240
- `color`, `export`, `scenes`, `look`) refuse an audio file with
241
- "input has no video stream". Say so instead of forcing a video wrapper.
242
-
243
- | User says (audio file) | Do |
244
- |-----------|----|
245
- | "normalise this WAV to -14 LUFS", "podcast levels" | `loudness.py talk.wav -I -14 --tp -1 -o talk_norm.wav` (`-I -16 --tp -1.5` for podcasts) |
246
- | "remove the silence from this recording" | `silence.py talk.wav -o talk_tight.wav` |
247
- | "clean up the noise in this M4A" | `audio.py talk.m4a --voice -o talk_clean.m4a` (speech) or `--denoise` |
248
- | "convert this WAV to MP3" | `audio.py talk.wav -o talk.mp3` |
249
- | "trim this audio from 00:30 to 02:00" | `cut.py talk.wav --start 0:30 --end 2:00 -o talk_cut.wav` (`--accurate` for sample-exact) |
250
- | "join these recordings", "intro + episode + outro" | `join.py intro.wav episode.m4a outro.wav -o full.flac` (`--transition none` for a butt join) |
251
- | "extract the audio from the video", "mp4 to wav" | `audio.py talk.mp4 -o talk.wav` (`--voice -o talk.m4a` to clean it on the way) |
252
- | "compress / limit / gate the voice" | `audio.py talk.wav --compress --comp-threshold -20 --comp-ratio 4 --limit --limit-ceiling -1 -o talk_dyn.wav` |
253
- | "is this loud enough for Apple Podcasts?" | `check.py talk.m4a --platform podcast` |
140
+ Audio is a first-class input: `probe.py`, `cut.py`, `silence.py`, `loudness.py`, `audio.py`, `sync.py` and `check.py --platform podcast` take WAV, FLAC, MP3, M4A/AAC, OGG and Opus, and the output extension picks the format. `Look: not needed` in the report; `Check:` still applies (`check.py file.wav --platform podcast`). Scripts that need a picture (`fit`, `caption`, `overlay`, `graphics`, `color`, `export`, `scenes`, `look`) refuse an audio file with "input has no video stream" — say so instead of forcing a video wrapper. The same commands work with `talk.wav` in place of `talk.mp4` (see the table above); the audio-specific recipes, packet vs sample precision, joining and extracting one track: `references/gotchas.md#audio-only-files`.
141
+
254
142
 
255
143
  ## Report format
256
144
 
257
- Reply in the language the request itself is written in: the language of the user's own sentences, not a language the request talks about (a request asking for subtitles in some other language is still answered in the language it was written in) and not the language of a tool's error text or of the file names. Keep the shape below and the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English (they read like log fields, not prose, and stay recognisable across languages); the sentences around them, any question asked, and any explanation of a judgement call are in the user's language. Never default to English because the tool names and flags happen to be English, and never drift into another language because the job is short or the report is a failure: a one-line "file does not exist" is written in the request's language too. A mid-conversation language switch follows the user's latest message, not the first one. This holds for a one-command job too: a three-second audio trim answered with English labels, numbers and one Japanese word in `Notes:` is an English report; the `Done:` line's own description (what was cut, from where) and `Steps:` are written in the user's language even when the values are technical.
145
+ Reply in the language the request itself is written in the language of the user's own sentences, not a language the request talks about (a request for subtitles in another language is still answered in the language it was written in) and not the language of a tool's error text or file names. Keep the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English: they read like log fields and stay recognisable across languages. Everything around them — the sentences, any question, any explanation of a judgement call is in the user's language. Never default to English because the tool names and flags are English, and never drift into another language because the job was short or the report is a failure: a one-line "file does not exist" is written in the request's language too. A mid-conversation switch follows the user's latest message, not the first one. This holds for a one-command job: a three-second audio trim answered with English labels, numbers and one Japanese word in `Notes:` is an English report the `Done:` description (what was cut, from where) and `Steps:` are in the user's language even when the values are technical.
258
146
 
259
- Finish every job with this shape (numbers from `probe.py`/`check.py`, not memory):
147
+ Finish every job with this shape (numbers from a tool's `--json` or `probe.py`/`check.py`, not memory):
260
148
 
261
149
  ```
262
150
  Done: final.mp4 — 59.98 s, 1080x1920, 30 fps, H.264, AAC stereo, -14.1 LUFS
@@ -266,9 +154,7 @@ Look: final_sheet.png (captions inside the safe area, logo top-right)
266
154
  Notes: source was VFR, conformed to 30 fps; audio was mono, made stereo
267
155
  ```
268
156
 
269
- The same five lines for a Japanese request, prose in Japanese around the English labels
270
- (this is the shape a short job keeps too; English `Done:`/`Steps:` sentences with one Japanese
271
- word in `Notes:` is not a Japanese report):
157
+ The same five lines for a Japanese request, prose in Japanese around the English labels (the shape a short job keeps too; English `Done:`/`Steps:` sentences with one Japanese word in `Notes:` is not a Japanese report):
272
158
 
273
159
  ```
274
160
  Done: final.mp4 — 59.98 秒、1080x1920、30 fps、H.264、AAC ステレオ、-14.1 LUFS
@@ -290,73 +176,23 @@ Look: not needed (nothing written)
290
176
  Notes: send a valid .cube, or say if you want the clip left as is
291
177
  ```
292
178
 
293
- A refusal (the request asks for a judgement this skill does not make, or for something outside its scope) uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run (usually only probe), `Look: not needed`. Both keep the five labels so a reader can scan a failed report the way they scan a successful one. That includes the shortest failure: a missing input or an invalid LUT still gets `Failed:`, `Steps:`, `Check:`, `Look:` and `Notes:` lines, never prose headings in their place. When a tool's failure JSON carries `error.hint`, quote it in `Notes:` it is the flag change that would make the retry meaningful.
179
+ A refusal (a judgement this skill does not make, or something outside its scope) uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run (usually only probe), `Look: not needed`. Both keep the five labels so a failed report scans like a successful one including the shortest failure: a missing input or an invalid LUT still gets all five lines, never prose headings in their place. When a failure JSON carries `error.hint`, quote it in `Notes:`: it is the flag change that would make the retry meaningful.
294
180
 
295
181
  Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | output | missing_tool | timeout | verification | interrupted, "message": ...}}` with `--json` and exits non-zero; quote the message, do not paraphrase it into a success.
296
182
 
297
183
  ## Things that look right but are wrong
298
184
 
299
- - Re-encoding an HDR (iPhone, HDR10) source through the SDR path: colours go flat. The scripts keep HDR; if you hand-write ffmpeg, do not tag BT.709 on BT.2020 pixels.
300
- - Lossless `-c copy` cuts on VFR or non-keyframe boundaries: the file "works" but starts on a frozen or wrong frame. `cut.py` re-encodes automatically when the snap exceeds 0.5 s; respect that.
301
- - A sync or multicam alignment with `confidence` under 0.3, or an offset larger than 60 % of the analysis window: probably wrong; enlarge `--analyze-seconds` or find a clap. `multicam.py` reports one `confidence` per camera check all of them, not just that the command succeeded, before trusting the cut.
302
- - `sync.py`/`multicam.py` align audio tracks to each other, never lip sync (mouth movement vs. audio) — there is no face or mouth detection anywhere in this skill. A high confidence means the audio matched well, not that the picture looks right; if the user asks whether lip sync is correct, that needs a look at the actual video, not just the reported offset.
303
- - "Normalised" audio that still clips: check true peak, not just LUFS (`check.py` does both).
304
- - Normalising ambience or near-silence to a speech target: a clip measured at
305
- -40 LUFS or below is room tone, wind or nothing; raising it 25 dB raises the
306
- noise, not the content. Leave the level, say so, and offer music or narration.
307
- - Captions burned before a crop/resize: text lands off-frame. Frame changes first, then text.
308
- - Captions burned at an intermediate size and then upscaled by `export.py` come out soft (a 1280x720 source fit to 9:16 is 406x720 until export scales it to 1080x1920). Fit to the delivery size first (`fit.py --width 1080 --height 1920`), then caption, then export.
309
- - Anything chained by hand through three re-encodes: use `render.py` so the plan is one file and the user can change one number.
310
- - `--fit crop` to reach 9:16 from 16:9 throws away 70 % of the width: a wide shot loses people at the edges. Check the sheet; pad (bars), `--crop-x`/`--crop-y` toward the subject, or a reframe is often the honest answer a silent centre crop is a guess, not a decision.
311
- - Conforming 60 fps to 30 halves the motion samples: fine for a talking head, visibly choppy for sports, gaming, drone pans. Keep 60 when the platform allows it.
312
- - "Make it 60 seconds" on a 3-minute talk by speed change is unwatchable (); by trim it drops two thirds of the words. Ask which, or propose a highlight cut with `scenes.py`.
313
- - `scenes.py --highlights` defaults to the loudest scenes (`--rank-by audio`): a quiet but important moment (a confession, a punchline landing in silence) is skipped, and pure crowd noise or a mic bump can outrank it. `--rank-by duration` picks the longest unbroken scenes instead. Neither is "the best parts" — check the contact sheet (`--sheet`) before treating the picks as final.
314
-
315
- ## Gotchas
316
-
317
- - **Variable frame rate (phone/screen recordings).** `probe.py` sets
318
- `variable_frame_rate_suspected` when `r_frame_rate` and `avg_frame_rate`
319
- disagree. Every re-encoding script then adds `-fps_mode cfr` at the source's
320
- average rate, and `cut.py` switches itself to `--accurate` (copy-cuts on VFR
321
- are unreliable). Pick the rate explicitly with `fit.py --fps 30|60` when the
322
- average is odd (e.g. 23.4 fps from dropped frames).
323
- - **Audio drift / sync.** Don't mix files with different frame rates or sample
324
- rates in one `cut.py --segments` join without re-encoding (`--accurate`).
325
- After `sync.py`, verify by running it again on the output: offset (and drift
326
- ppm with `--fix-drift`) should be ~0. Recordings longer than ~10 minutes from
327
- separate devices: always use `--fix-drift`.
328
- - **Colour.** SDR outputs are H.264 tagged BT.709 `yuv420p`. When `probe.py`
329
- reports `hdr: true` (HDR10/PQ, HLG, Dolby Vision, BT.2020), every editing
330
- script keeps the output HDR (HEVC Main10, source colour tags) so nothing is
331
- silently flattened. Decide with the user: keep HDR (fine for YouTube/phones)
332
- or run `color.py --to-sdr` first for SDR-only destinations, LUT work or
333
- H.264 deliverables. `hdr: true` counts BT.2020 primaries too, so it is also
334
- true for a wide-gamut SDR file; `hdr_signal: true` is the narrower fact —
335
- a real PQ / HLG / Dolby Vision transfer — and `hdr_format` names the
336
- in-between case (`BT.2020 SDR`). `export.py` platform presets are SDR and warn on HDR
337
- input. iPhone `.mov` files also carry timecode/metadata tracks; scripts map
338
- only the first audio track, so extra tracks are dropped on re-encode.
339
- For Log footage (S-Log, V-Log, C-Log: looks grey and low-contrast but is
340
- tagged SDR) run `probe.py --analyze`; `looks_like_log: true` means apply the
341
- manufacturer's `.cube` with `color.py --lut` before anything else. Keep
342
- ProRes masters at source colour: `export.py --preset prores` does not retag.
343
- - **CJK and other non-Latin text.** libass and drawtext need a font that has
344
- the glyphs. Check with `fc-list | grep -i cjk`. Then either name it
345
- (`caption.py --font "Noto Sans CJK JP"`) or point at the file
346
- (`overlay.py --font-file /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc`,
347
- `caption.py --fonts-dir ./fonts --font "Noto Sans CJK JP"`). Without a
348
- matching font you get boxes, not an error. Install: `apt install fonts-noto-cjk`,
349
- `brew install --cask font-noto-sans-cjk`.
350
- - **Windows drawtext crashes on some real builds** (#100): the drawtext tools
351
- resolve a concrete `--font-file` by default, which avoids it; if one still
352
- crashes, pass `--font-file` explicitly. Details: `references/ci-platform-pitfalls.md`.
353
- - **Keyframe cuts.** A lossless `cut.py` result may start up to one GOP (often
354
- 1–10 s) earlier than requested; the script re-encodes automatically when the
355
- deviation exceeds 0.5 s. If the user insists on lossless output, pass
356
- `--tolerance -1` and tell them the cut lands on the nearest earlier keyframe.
357
- - **Rotation metadata.** Phone footage often has a `rotation` tag; `probe.py`
358
- reports it and `fit.py` accounts for it when computing the output frame.
359
- - **Odd dimensions.** `yuv420p` needs even width/height; `fit.py` and
360
- `export.py` round to even values automatically.
361
- - **Speed.** Re-encodes use x264 `medium`. For long files add `--preset veryfast`
362
- to intermediates and keep the default for the final export.
185
+ One line each, and each line is enough to act on; open the linked `references/gotchas.md` section only when the job is in that area and the line leaves you with a question.
186
+
187
+ - HDR (iPhone, HDR10) re-encoded through an SDR path goes flat; the scripts keep HDR, and `hdr: true` is wider than `hdr_signal: true` (a real PQ/HLG/Dolby Vision transfer). Details: [#hdr-and-colour](references/gotchas.md#hdr-and-colour)
188
+ - Log footage (S-Log/V-Log/C-Log) is tagged SDR and looks grey: `probe.py --analyze`, then `color.py --lut` before anything else. Details: [#log-footage](references/gotchas.md#log-footage)
189
+ - A `-c copy` cut can start on a wrong or frozen frame; `cut.py` re-encodes past a 0.5 s snap — respect it. Details: [#keyframe-cuts](references/gotchas.md#keyframe-cuts)
190
+ - VFR phone/screen recordings: re-encodes conform to CFR, `cut.py` switches to `--accurate`; pick the rate with `fit.py --fps` when the average is odd. Details: [#variable-frame-rate](references/gotchas.md#variable-frame-rate)
191
+ - Sync/multicam `confidence` under 0.3 (or a huge offset) is probably wrong — check every camera, and remember these align audio, never lip sync. Details: [#sync-multicam-and-drift](references/gotchas.md#sync-multicam-and-drift)
192
+ - "Normalised" audio can still clip (check true peak), and ambience at -40 LUFS or below must never be raised to a speech target. Details: [#loudness-and-ambience](references/gotchas.md#loudness-and-ambience)
193
+ - Captions burned before a crop/resize land off-frame; burned small then upscaled by `export.py` they come out soft — fit to the delivery size first. Non-Latin text without a real font renders boxes, not an error. Details: [#captions-fonts-and-text-order](references/gotchas.md#captions-fonts-and-text-order)
194
+ - `--fit crop` 16:9 9:16 throws away 70 % of the width, 60→30 fps halves the motion, and "60 seconds" by speed or by trim are different answers say which and why. Details: [#reframing-fps-and-duration](references/gotchas.md#reframing-fps-and-duration)
195
+ - `yuv420p` needs even dimensions and phone rotation tags are honoured, both automatically. Details: [#dimensions-and-rotation](references/gotchas.md#dimensions-and-rotation)
196
+ - `scenes.py --highlights` ranks by loudness (or duration), never by meaning: check the sheet before treating picks as final. Details: [#highlights](references/gotchas.md#highlights)
197
+ - Three hand-chained re-encodes should be one `render.py` project; re-encodes use x264 `medium`. Details: [#chaining-and-speed](references/gotchas.md#chaining-and-speed)
198
+ - Windows drawtext crashes on some builds (#100): pass `--font-file` explicitly if one does. Details: `references/ci-platform-pitfalls.md`
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 (`1.10.1`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.11.1`) | 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`
@@ -88,19 +88,19 @@ spelling keeps working until 2.0.
88
88
 
89
89
  | What 2.0 removes | Since | Replacement | To be ready today |
90
90
  |---|---|---|---|
91
- | The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.10.1 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
92
- | `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.10.1 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
93
- | `json` and `progress` in the MCP `inputSchema` | 1.10.1 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
94
- | `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.10.1 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
95
- | Overwriting an existing output with only a warning | 1.10.1 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
91
+ | The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.11.1 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
92
+ | `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.11.1 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
93
+ | `json` and `progress` in the MCP `inputSchema` | 1.11.1 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
94
+ | `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.11.1 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
95
+ | Overwriting an existing output with only a warning | 1.11.1 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
96
96
 
97
97
  ## Skill
98
98
 
99
99
  ```json
100
100
  {
101
101
  "contract_version": "1.0",
102
- "deprecated": [{"what": "...", "since": "1.10.1", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
- "skill": {"id": "ffmpeg-skill", "version": "1.10.1", "execution_mode": "local", "kind": "execution",
102
+ "deprecated": [{"what": "...", "since": "1.11.1", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
+ "skill": {"id": "ffmpeg-skill", "version": "1.11.1", "execution_mode": "local", "kind": "execution",
104
104
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
105
105
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
106
106
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -128,6 +128,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
128
128
  | `output_schema` | what `--json` prints on stdout |
129
129
  | `supports_dry_run`, `dry_run` | whether `--dry-run` plans without running ffmpeg or writing files |
130
130
  | `supports_json` | whether `--json` exists |
131
+ | `supports_json_brief` | whether `--json-brief` exists (1.11.1): the same success document with `probe` replaced by a compact `summary` (`duration_s`, `width`, `height`, `fps`, `vcodec`, `acodec`, `channels`, and `lufs` when the tool measured one), `commands` replaced by the number of commands run, and the per-step `verification` list dropped (its verdict stays in `verified`). Tool-specific keys are unchanged, `--json`'s own output is unchanged, and a failure prints the same failure document either way |
131
132
  | `mutates_input` | always `false`: no tool overwrites its input |
132
133
  | `produces_artifact` | writes a file (media, PNG, HTML, EDL) |
133
134
  | `verification` | `{required, tools}`: which tools to run on the output afterwards |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.10.1",
3
+ "version": "1.11.1",
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",
@@ -33,6 +33,7 @@
33
33
  "references/scripts.md",
34
34
  "references/devices.md",
35
35
  "references/ci-platform-pitfalls.md",
36
+ "references/gotchas.md",
36
37
  "docs/contract.md",
37
38
  "SKILL.md",
38
39
  "README.md",
@@ -0,0 +1,172 @@
1
+ # Gotchas and things that look right but are wrong
2
+
3
+ The long form of SKILL.md's "Things that look right but are wrong" and "Gotchas"
4
+ bullets. SKILL.md keeps one line per item and points here with the anchors below;
5
+ nothing here is optional reading when the job touches that area — it is the
6
+ detail, not an aside.
7
+
8
+ ## Colour, HDR and Log
9
+
10
+ ### HDR and colour
11
+ SDR outputs are H.264 tagged BT.709 `yuv420p`. When `probe.py` reports
12
+ `hdr: true` (HDR10/PQ, HLG, Dolby Vision, BT.2020), every editing script keeps
13
+ the output HDR (HEVC Main10, source colour tags) so nothing is silently
14
+ flattened. Decide with the user: keep HDR (fine for YouTube/phones) or run
15
+ `color.py --to-sdr` first for SDR-only destinations, LUT work or H.264
16
+ deliverables. `hdr: true` counts BT.2020 primaries too, so it is also true for a
17
+ wide-gamut SDR file; `hdr_signal: true` is the narrower fact — a real PQ / HLG /
18
+ Dolby Vision transfer — and `hdr_format` names the in-between case
19
+ (`BT.2020 SDR`). `export.py` platform presets are SDR and warn on HDR input.
20
+ iPhone `.mov` files also carry timecode/metadata tracks; scripts map only the
21
+ first audio track, so extra tracks are dropped on re-encode. Keep ProRes masters
22
+ at source colour: `export.py --preset prores` does not retag.
23
+
24
+ Re-encoding an HDR (iPhone, HDR10) source through an SDR path flattens the
25
+ colours. The scripts keep HDR; if you hand-write ffmpeg (never do — see SKILL.md),
26
+ do not tag BT.709 on BT.2020 pixels.
27
+
28
+ ### Log footage
29
+ S-Log, V-Log and C-Log look grey and low-contrast but are tagged SDR. Run
30
+ `probe.py --analyze`; `looks_like_log: true` means apply the manufacturer's
31
+ `.cube` with `color.py --lut` before anything else.
32
+
33
+ ## Cutting
34
+
35
+ ### Keyframe cuts
36
+ A lossless `cut.py` result may start up to one GOP (often 1–10 s) earlier than
37
+ requested; the script re-encodes automatically when the deviation exceeds 0.5 s.
38
+ If the user insists on lossless output, pass `--tolerance -1` and tell them the
39
+ cut lands on the nearest earlier keyframe. A `-c copy` cut on VFR or a
40
+ non-keyframe boundary produces a file that "works" but starts on a frozen or
41
+ wrong frame — respect the automatic re-encode rather than forcing the copy.
42
+
43
+ ### Variable frame rate
44
+ `probe.py` sets `variable_frame_rate_suspected` when `r_frame_rate` and
45
+ `avg_frame_rate` disagree (phone and screen recordings). Every re-encoding
46
+ script then adds `-fps_mode cfr` at the source's average rate, and `cut.py`
47
+ switches itself to `--accurate` (copy-cuts on VFR are unreliable). Pick the rate
48
+ explicitly with `fit.py --fps 30|60` when the average is odd (e.g. 23.4 fps from
49
+ dropped frames).
50
+
51
+ ## Audio
52
+
53
+ ### Sync, multicam and drift
54
+ A sync or multicam alignment with `confidence` under 0.3, or an offset larger
55
+ than 60 % of the analysis window, is probably wrong: enlarge `--analyze-seconds`
56
+ or find a clap. `multicam.py` reports one `confidence` per camera — check all of
57
+ them, not just that the command succeeded, before trusting the cut.
58
+
59
+ `sync.py`/`multicam.py` align audio tracks to each other, never lip sync (mouth
60
+ movement vs. audio) — there is no face or mouth detection anywhere in this skill.
61
+ High confidence means the audio matched well, not that the picture looks right;
62
+ whether lip sync is correct needs a look at the video, not the reported offset.
63
+
64
+ Don't mix files with different frame rates or sample rates in one
65
+ `cut.py --segments` join without re-encoding (`--accurate`). After `sync.py`,
66
+ verify by running it again on the output: offset (and drift ppm with
67
+ `--fix-drift`) should be ~0. Recordings longer than ~10 minutes from separate
68
+ devices: always use `--fix-drift`.
69
+
70
+ ### Loudness and ambience
71
+ "Normalised" audio can still clip: check true peak, not just LUFS (`check.py`
72
+ does both). And do not normalise ambience or near-silence to a speech target — a
73
+ clip measured at -40 LUFS or below is room tone, wind or nothing; raising it
74
+ 25 dB raises the noise, not the content. Leave the level, say so, and offer music
75
+ or narration.
76
+
77
+ ## Text and framing
78
+
79
+ ### Captions, fonts and text order
80
+ Captions burned before a crop/resize land off-frame: frame changes first, then
81
+ text. Captions burned at an intermediate size and then upscaled by `export.py`
82
+ come out soft (a 1280x720 source fit to 9:16 is 406x720 until export scales it to
83
+ 1080x1920) — fit to the delivery size first
84
+ (`fit.py --width 1080 --height 1920`), then caption, then export.
85
+
86
+ CJK and other non-Latin text: libass and drawtext need a font that has the
87
+ glyphs. Check with `fc-list | grep -i cjk`. Then either name it
88
+ (`caption.py --font "Noto Sans CJK JP"`) or point at the file
89
+ (`overlay.py --font-file /usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc`,
90
+ `caption.py --fonts-dir ./fonts --font "Noto Sans CJK JP"`). Without a matching
91
+ font you get boxes, not an error. Install: `apt install fonts-noto-cjk`,
92
+ `brew install --cask font-noto-sans-cjk`.
93
+
94
+ Windows drawtext crashes on some real builds (#100): the drawtext tools resolve a
95
+ concrete `--font-file` by default, which avoids it; if one still crashes, pass
96
+ `--font-file` explicitly. Details: `references/ci-platform-pitfalls.md`.
97
+
98
+ ### Reframing, fps and duration
99
+ `--fit crop` to reach 9:16 from 16:9 throws away 70 % of the width: a wide shot
100
+ loses people at the edges. Check the sheet; pad (bars), `--crop-x`/`--crop-y`
101
+ toward the subject, or a reframe is often the honest answer — a silent centre
102
+ crop is a guess, not a decision.
103
+
104
+ Conforming 60 fps to 30 halves the motion samples: fine for a talking head,
105
+ visibly choppy for sports, gaming, drone pans. Keep 60 when the platform allows.
106
+
107
+ "Make it 60 seconds" on a 3-minute talk by speed change is unwatchable (3×); by
108
+ trim it drops two thirds of the words. Ask which, or propose a highlight cut with
109
+ `scenes.py`.
110
+
111
+ ### Dimensions and rotation
112
+ `yuv420p` needs even width/height; `fit.py` and `export.py` round to even values
113
+ automatically. Phone footage often carries a `rotation` tag; `probe.py` reports
114
+ it and `fit.py` accounts for it when computing the output frame.
115
+
116
+ ## Planning
117
+
118
+ ### Highlights
119
+ `scenes.py --highlights` defaults to the loudest scenes (`--rank-by audio`): a
120
+ quiet but important moment (a confession, a punchline landing in silence) is
121
+ skipped, and pure crowd noise or a mic bump can outrank it. `--rank-by duration`
122
+ picks the longest unbroken scenes instead. Neither is "the best parts" — check
123
+ the contact sheet (`--sheet`) before treating the picks as final.
124
+
125
+ ### Chaining and speed
126
+ Anything chained by hand through three re-encodes should be one `render.py`
127
+ project instead, so the plan is one file and the user can change one number.
128
+ Re-encodes use x264 `medium`; for long files add `--preset veryfast` to
129
+ intermediates and keep the default for the final export.
130
+
131
+ ## Audio-only files
132
+
133
+ Audio files are a first-class input, not a special case. `probe.py`, `cut.py`,
134
+ `silence.py`, `loudness.py`, `audio.py`, `sync.py` and `check.py --platform
135
+ podcast` all accept WAV, FLAC, MP3, M4A/AAC, OGG and Opus (any container ffmpeg
136
+ can read) and write the codec that fits the output extension, so the same
137
+ commands work with `talk.wav` in place of `talk.mp4`. What changes:
138
+
139
+ - The output extension picks the format: `-o out.mp3` converts, `-o out.wav`
140
+ keeps PCM, `-o out.m4a` writes AAC. `audio.py in.wav -o out.mp3` with no
141
+ other flag is a plain conversion.
142
+ - `cut.py` stream-copies audio too, so trims land on a packet boundary
143
+ (`precision: packet`, a few ms; the JSON reports `duration_error_ms`). Pass
144
+ `--accurate` for a sample-exact trim: `precision: sample` when the output is
145
+ PCM or FLAC, `codec_frame` when a lossy codec (AAC, MP3, Opus) frames it
146
+ again. A `.wav` output is always PCM, never AAC packets inside a WAV.
147
+ - `join.py` joins audio-only clips as audio (`acrossfade` or a butt join) at
148
+ one sample rate and channel layout; the output must have an audio extension.
149
+ Video and audio clips cannot be mixed in one join.
150
+ - An audio extension on a video input (`audio.py talk.mp4 -o talk.wav`,
151
+ `cut.py talk.mp4 --start 1:00 --end 2:00 -o part.wav`) extracts the audio; the
152
+ output has no video stream. `audio.py --audio-stream N` picks a track when
153
+ `probe` lists several under `audio_streams`.
154
+ - `Look: not needed` in the report; `Check:` still applies for loudness
155
+ (`check.py file.wav --platform podcast` measures LUFS and true peak).
156
+ - Scripts that need a picture (`fit`, `caption`, `overlay`, `graphics`,
157
+ `color`, `export`, `scenes`, `look`) refuse an audio file with
158
+ "input has no video stream". Say so instead of forcing a video wrapper.
159
+
160
+ ### Audio-only recipes
161
+
162
+ | User says (audio file) | Do |
163
+ |-----------|----|
164
+ | "normalise this WAV to -14 LUFS", "podcast levels" | `loudness.py talk.wav -I -14 --tp -1 -o talk_norm.wav` (`-I -16 --tp -1.5` podcast) |
165
+ | "remove the silence from this recording" | `silence.py talk.wav -o talk_tight.wav` |
166
+ | "clean up the noise in this M4A" | `audio.py talk.m4a --voice -o talk_clean.m4a` (speech) or `--denoise` |
167
+ | "convert this WAV to MP3" | `audio.py talk.wav -o talk.mp3` |
168
+ | "trim this audio from 00:30 to 02:00" | `cut.py talk.wav --start 0:30 --end 2:00 -o talk_cut.wav` (`--accurate` for sample-exact) |
169
+ | "join these recordings", "intro + episode + outro" | `join.py intro.wav episode.m4a outro.wav -o full.flac` (`--transition none` for a butt join) |
170
+ | "extract the audio from the video" | `audio.py talk.mp4 -o talk.wav` (`--voice -o talk.m4a` to clean it on the way) |
171
+ | "compress / limit / gate the voice" | `audio.py talk.wav --compress --comp-threshold -20 --comp-ratio 4 --limit --limit-ceiling -1 -o talk_dyn.wav` |
172
+ | "is this loud enough for Apple Podcasts?" | `check.py talk.m4a --platform podcast` |
@@ -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`, `--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 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, overrides `--crf`; up to 63 for av1; ignored by prores). `--crf` is deprecated since 1.10.0 (it warns on stderr and is removed in 2.0): use `--quality`, except on `export.py`, whose `--crf` is not an alias and stays. With `FFMPEG_SKILL_NO_OVERWRITE=1` in the environment, any tool refuses (`kind: input`) to replace an existing output unless `--overwrite` is given. `--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 flag; 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), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
3
+ Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; 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, overrides `--crf`; up to 63 for av1; ignored by prores). `--crf` is deprecated since 1.10.0 (it warns on stderr and is removed in 2.0): use `--quality`, except on `export.py`, whose `--crf` is not an alias and stays. With `FFMPEG_SKILL_NO_OVERWRITE=1` in the environment, any tool refuses (`kind: input`) to replace an existing output unless `--overwrite` is given. `--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 flag; 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), 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
 
@@ -7,6 +7,7 @@ error reporting, and provides a compact media probe used by every script.
7
7
  from __future__ import annotations
8
8
 
9
9
  import json
10
+ import math
10
11
  import os
11
12
  import platform
12
13
  import argparse
@@ -236,7 +237,7 @@ class Context:
236
237
  makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
237
238
  """
238
239
 
239
- __slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec")
240
+ __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec")
240
241
 
241
242
  def __init__(self) -> None:
242
243
  self.reset()
@@ -244,6 +245,7 @@ class Context:
244
245
  def reset(self) -> None:
245
246
  self.dry_run = False # print ffmpeg commands, run nothing (ffprobe still runs)
246
247
  self.json = False # emit() prints a JSON document instead of the output path
248
+ self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
247
249
  self.progress = False # run() streams percent / ETA to stderr for ffmpeg
248
250
  self.fast = False # x264 preset forced to veryfast
249
251
  self.duration_hint: Optional[float] = None # expected output length, for the progress percent
@@ -277,6 +279,8 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
277
279
  g = ap.add_argument_group("agent options")
278
280
  g.add_argument("--dry-run", action="store_true", help="print the ffmpeg commands that would run, run nothing")
279
281
  g.add_argument("--json", action="store_true", help="print a JSON result (output, probe, commands) on stdout instead of the path")
282
+ g.add_argument("--json-brief", action="store_true",
283
+ help="like --json but trimmed: status, output, dry_run, verified, a compact summary of the output probe, this tool's own keys, and the command count instead of the command lines (failures print the full failure document, unchanged)")
280
284
  g.add_argument("--progress", action="store_true", help="show percent / ETA on stderr while ffmpeg encodes")
281
285
  g.add_argument("--fast", action="store_true", help="preview quality: x264 preset veryfast (overrides --preset) for quick iterations")
282
286
  if "--timeout" not in ap._option_string_actions: # verify.py defines its own per-step --timeout; apply_common reads either
@@ -329,7 +333,10 @@ def apply_common(args: "argparse.Namespace") -> None:
329
333
  # tools without --json) still get their plan written, at exit, unless die() ran (review 6)
330
334
  import atexit
331
335
  atexit.register(_plan_at_exit)
332
- STATE.json = bool(getattr(args, "json", False))
336
+ STATE.json_brief = bool(getattr(args, "json_brief", False))
337
+ # --json-brief is a shorter --json, not a second output mode: it implies it, so a caller that
338
+ # passes only --json-brief still gets a JSON document (and --json --json-brief is the brief one).
339
+ STATE.json = bool(getattr(args, "json", False)) or STATE.json_brief
333
340
  STATE.progress = bool(getattr(args, "progress", False))
334
341
  STATE.fast = bool(getattr(args, "fast", False))
335
342
  STATE.overwrite = bool(getattr(args, "overwrite", False))
@@ -455,13 +462,64 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
455
462
  doc["result_v2"] = _result_v2(output, meta, dict(extra, verified=doc["verified"], verification=steps))
456
463
  if ctx.plan:
457
464
  doc["plan"] = write_plan(ctx.plan, output, extra, ctx=ctx)
458
- print_json(doc)
465
+ print_json(_brief(doc, meta) if ctx.json_brief else doc)
459
466
  elif ctx.plan:
460
467
  print(write_plan(ctx.plan, output, extra, ctx=ctx))
461
468
  elif output:
462
469
  print(output)
463
470
 
464
471
 
472
+ # Keys the brief document replaces or drops: the full probe (summarised), the command lines
473
+ # (counted), the per-step verification list (its verdict stays as `verified`) and the 2.0 preview.
474
+ _BRIEF_DROP = ("probe", "commands", "verification", "result_v2")
475
+
476
+
477
+ def _brief_summary(meta: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
478
+ """The handful of output facts a caller reports or branches on, from the probe this tool
479
+ already ran -- plus the measured loudness when the tool measured one. Keys whose value is
480
+ unknown are left out rather than emitted as null."""
481
+ video = (meta or {}).get("video") or {}
482
+ audio = (meta or {}).get("audio") or {}
483
+ summary: Dict[str, Any] = {}
484
+ duration = (meta or {}).get("duration")
485
+ if duration is not None:
486
+ summary["duration_s"] = round(float(duration), 3)
487
+ for key, value in (("width", video.get("width")), ("height", video.get("height")), ("fps", video.get("fps")),
488
+ ("vcodec", video.get("codec")), ("acodec", audio.get("codec")), ("channels", audio.get("channels"))):
489
+ if value is not None:
490
+ summary[key] = value
491
+ lufs = None
492
+ for source, key in ((extra.get("result"), "input_i"), (extra.get("measured"), "input_i")):
493
+ if lufs is None and isinstance(source, dict):
494
+ lufs = _to_float(source.get(key))
495
+ for step in extra.get("verification") or []:
496
+ if lufs is None and isinstance(step, dict):
497
+ lufs = _to_float(step.get("lufs"))
498
+ # a silent file measures -inf, which json.dumps writes as the non-standard -Infinity: the
499
+ # brief document stays valid JSON by leaving the key out instead (the full document's own
500
+ # `measured`/`result` still carries whatever the tool reported).
501
+ if lufs is not None and math.isfinite(lufs):
502
+ summary["lufs"] = round(lufs, 2)
503
+ return summary
504
+
505
+
506
+ def _brief(doc: Dict[str, Any], meta: Dict[str, Any]) -> Dict[str, Any]:
507
+ """--json-brief: the same success document with the bulky parts replaced by what a caller
508
+ acts on. Same keys, same meanings -- `commands` becomes the count of the command lines,
509
+ `probe` becomes `summary` -- plus every tool-specific key the tool itself passed to emit().
510
+ Failures are untouched: die() prints the full failure document either way."""
511
+ brief: Dict[str, Any] = {"status": doc["status"], "output": doc["output"], "dry_run": doc["dry_run"],
512
+ "verified": doc.get("verified", False)}
513
+ summary = _brief_summary(meta, doc)
514
+ if summary:
515
+ brief["summary"] = summary
516
+ brief["commands"] = len(doc.get("commands") or [])
517
+ for key, value in doc.items():
518
+ if key not in brief and key not in _BRIEF_DROP:
519
+ brief[key] = value
520
+ return brief
521
+
522
+
465
523
  PLAN_VERSION = 1
466
524
  _PLAN_STRIP = ("--plan", "--dry-run", "--json")
467
525
 
@@ -1047,7 +1105,6 @@ def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool
1047
1105
  """RMS per block of `step` samples. full_blocks_only drops a short tail block (sync.py: every
1048
1106
  block must be the same length for the correlation); remove_mean subtracts the envelope's mean
1049
1107
  (sync.py: so silence does not correlate). scenes.py keeps the tail and the absolute level."""
1050
- import math
1051
1108
  step = max(1, int(step))
1052
1109
  n = len(samples)
1053
1110
  stop = n - step + 1 if full_blocks_only else n
@@ -930,6 +930,8 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
930
930
  "semantics": "prints the ffmpeg command lines that would run; no output file is written",
931
931
  **({"note": DRY_RUN_ANALYSIS.get(name) or DRY_RUN_NOTES[name]} if name in DRY_RUN_ANALYSIS or name in DRY_RUN_NOTES else {})},
932
932
  "supports_json": "json" in schema["properties"],
933
+ # additive mirror of supports_json (1.11.0): --json-brief is the same document trimmed
934
+ "supports_json_brief": "json_brief" in schema["properties"],
933
935
  "mutates_input": False,
934
936
  "produces_artifact": meta["produces_artifact"],
935
937
  "verification": {"required": bool(meta["verify"]), "tools": [f"{SKILL_ID}/{t}" for t in meta["verify"]]},
@@ -1132,7 +1134,14 @@ def main() -> int:
1132
1134
  else:
1133
1135
  print(f"ffmpeg-skill {d['version']} (this installed copy; re-run `npx ffmpeg-skill` to refresh it -- copies are not updated automatically)")
1134
1136
  print(f"python {d['python']}; ffmpeg {d['ffmpeg'] or 'MISSING'}; ffprobe {d['ffprobe'] or 'MISSING'}")
1135
- print(f"available: {', '.join(d['available'])}")
1137
+ # counts, not the full capability list: the names of the ~60 available capabilities
1138
+ # answer no question a caller has (they are in `doctor --json .available` when one
1139
+ # does), while what is MISSING is the whole reason to run doctor (1.11.0 token diet).
1140
+ head = (f"{'ok' if d['ok'] else 'NOT ok'}: {len(d['available'])} capabilities available, "
1141
+ f"{len(d['missing'])} required missing, {len(d['missing_optional'])} optional missing")
1142
+ if d["unknown"]:
1143
+ head += f", {len(d['unknown'])} unknown"
1144
+ print(head)
1136
1145
  print(f"missing required: {', '.join(d['missing']) or 'none'}")
1137
1146
  print(f"missing optional: {', '.join(d['missing_optional']) or 'none'}")
1138
1147
  if d["unknown"]:
@@ -1142,9 +1151,11 @@ def main() -> int:
1142
1151
  print(f"note: overall 'ok' means nothing REQUIRED BY EVERY TOOL is missing -- {len(not_usable)} tool(s) still can't run today: {', '.join(not_usable)} (see doctor --json .tools for why)")
1143
1152
  gpu = d["gpu_encoders"]
1144
1153
  if gpu["status"] == "parsed":
1145
- print(f"GPU-backed encoders in this build: {', '.join(gpu['present']) or 'none'} (no tool here uses one yet; this build-presence check does not prove the GPU/driver will accept a job)")
1154
+ print(f"GPU-backed encoders in this build: {len(gpu['present'])} (no tool here uses one; names in doctor --json)")
1146
1155
  fonts = d["fonts"]
1147
- print(f"default drawtext font '{fonts['default_font']}': {fonts['status']} ({fonts['detail']})")
1156
+ print(f"default drawtext font '{fonts['default_font']}': {fonts['status']}"
1157
+ + (f" ({fonts['detail']})" if fonts["status"] != "available" else ""))
1158
+ print("full detail: doctor --json (capability lists, per-tool `usable`, fix hints)")
1148
1159
  for err in d["errors"]:
1149
1160
  print(f"detection error: {err}", file=sys.stderr)
1150
1161
  if d["ok"]: