ffmpeg-skill 3.6.1 → 3.6.2

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
@@ -90,7 +90,7 @@ An agent that "knows FFmpeg" still guesses: it assumes a frame rate, picks a cod
90
90
  - **Structured tools, not shell strings.** Each operation is a script with typed arguments. Nothing runs through a shell; no filter graph is accepted from the caller.
91
91
  - **A contract the agent can read.** `contract --json` states, for every tool, what it takes, what it writes, which FFmpeg components it needs and how the result is verified. The MCP surface is derived from it.
92
92
  - **Verification after execution.** The result is probed, checked against the destination's spec and, when the picture changed, looked at as a contact sheet.
93
- - **Local first.** No cloud, no API keys, no Python dependencies. Optional local transcription is used when a speech engine (Parakeet or a whisper) is installed, never required — by `caption.py --transcribe` and, since 1.17, by `silence.py --filler --transcribe`; both take a transcript you already have instead, and both refuse with the install lines rather than guessing. Both take `--engine` and `--model`; `FFMPEG_SKILL_ASR_ENGINE` and `FFMPEG_SKILL_ASR_MODEL` set this machine's defaults (e.g. `large-v3-turbo` where that whisper.cpp model is installed; without either, auto and `base`). The engine runs on your machine and the audio is never uploaded; parakeet-mlx, faster-whisper and openai-whisper download their model weights the first time they run (parakeet-mlx's default from Hugging Face).
93
+ - **Local first.** No cloud, no API keys, no Python dependencies. Optional local transcription is used when a speech engine (Parakeet or a whisper) is installed, never required — by `caption.py --transcribe` and, since 1.17, by `silence.py --transcribe` (with `--filler`, or since 3.6 with `--sentence-min-silence` / `--sentence-margin-tail`); both take a transcript you already have instead, and both refuse with the install lines rather than guessing. Both take `--engine` and `--model`; `FFMPEG_SKILL_ASR_ENGINE` and `FFMPEG_SKILL_ASR_MODEL` set this machine's defaults (e.g. `large-v3-turbo` where that whisper.cpp model is installed; without either, auto and `base`). The engine runs on your machine and the audio is never uploaded; parakeet-mlx, faster-whisper and openai-whisper download their model weights the first time they run (parakeet-mlx's default from Hugging Face).
94
94
 
95
95
  ## Paper
96
96
 
@@ -223,7 +223,7 @@ These are the rules the skill file gives the agent and the code enforces.
223
223
  |---|---|
224
224
  | `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact video and sample-exact audio, `--smart` for a frame-exact H.264 cut that re-encodes only the partial GOPs at the cut points (matched to the source, verified against its packets and frames) and copies the rest, an SDR HEVC source re-encodes as HEVC (`--no-keep-hevc` for x264), a single `.mp4`/`.mov` copy keeps its edit list, so the picture starts at `--start` unless a keyframe lands later (`start_snapped` then says so; `--no-edit-list` for 2.x's shifted start), `--vfr-guard` to choose the VFR check (`sampled` by default); reports `precision`; `--snap beats` moves the in/out points onto a measured beat, or refuses when there is no measurable pulse ; `--remove A-B,...` drops ranges and keeps the rest |
225
225
  | `join.py` | Concatenate clips with xfade transitions, normalising size, fps, sample rate and channel layout (the widest clip's, or `--channels`); audio-only inputs are joined as audio; `--list parts.txt` reads generated segments (TTS lines) and checks every one first — missing, empty or unreadable parts are all named in one refusal, or skipped and reported with `--on-missing skip` ; `--copy --transition none` joins clips with matching streams by stream copy, measured afterwards, and refuses a mismatch by name |
226
- | `silence.py` | Detect and remove dead air (jump cuts) with a margin around speech (`--margin`, 0.2 s each side); list or export the cut list, or a source-to-output time map (`--timemap`, JSON or CSV); reports the level at every cut and warns where one may land in a breath or a word's tail; `--filler` also removes filler words, but only where a speech engine timed them; `--speech-aware` keeps a breath inside a sentence and cuts only at sentence-boundary pauses, composing with `--filler` through the same `keep_ranges()` |
226
+ | `silence.py` | Detect and remove dead air (jump cuts) with a margin around speech (`--margin`, 0.2 s each side); list or export the cut list, or a source-to-output time map (`--timemap`, JSON or CSV); reports the level at every cut and warns where one may land in a breath or a word's tail; `--filler` also removes filler words, but only where a speech engine timed them; `--speech-aware` keeps a breath inside a sentence and cuts only at sentence-boundary pauses, composing with `--filler` through the same `keep_ranges()`; `--margin-lead` / `--margin-tail` keep different amounts before and after speech, and with word timings `--sentence-min-silence` / `--sentence-margin-tail` give the pause after a sentence end (the transcript's punctuation) its own floor and tail |
227
227
  | `fit.py` | Fit to a duration (pitch-preserving speed change or trim, smooth slow-mo) and/or aspect ratio (pad, crop or `--fit blur`'s blurred, darkened fill, with `--crop-x`/`--crop-y` to keep an off-centre subject) and/or exact `--width`/`--height`; rotate 90/180/270, flip h/v; force constant fps |
228
228
  | `crop.py` | Crop to an exact pixel rectangle (`--x --y --width --height`) — distinct from `fit.py --fit crop`, which crops to an aspect ratio it computes itself |
229
229
  | `cropdetect.py` | Measure existing black letterbox/pillarbox bars and report the `crop.py`-ready rectangle that removes them — analysis only, writes no file; `--motion-centre` reports the per-second motion centroid instead, for a 9:16 reframe — report only, the calling agent picks the crop, no auto-reframing |
@@ -364,6 +364,8 @@ On Windows, `python3` is only on PATH if Python was installed from the Microsoft
364
364
 
365
365
  No `inputSchema` lists `json` or `progress`: they are transport flags the server sets itself, not tool arguments (2.0; 1.x listed them unless `FFMPEG_SKILL_MCP_LEAN=1`). A client that still sends them is not refused.
366
366
 
367
+ A structured value is never read as an option: one that starts with `-` (overlay text `-50%`) is passed as `--flag=value`, and positionals that start with `-` follow `--`. `probe`, `check` and `cropdetect` are annotated `readOnlyHint` and write no file over MCP (their `plan` is refused, structured or in a raw `argv`); every other tool is `destructiveHint`, because with `overwrite` it replaces an existing file. See `docs/contract.md` § MCP relationship.
368
+
367
369
  The server also advertises a `prompts` capability: five canned workflow recipes an agent can ask for by name instead of composing the individual tool calls itself — `reel`, `podcast`, `multicam`, `delivery_check`, `hdr`. `prompts/list` returns each one's name, description and arguments; `prompts/get {name, arguments}` fills the matching template and returns one text message naming the actual command lines to run and what to check afterward. These are recipes, not new tool calls — every command line a prompt names is one `tools/call` (or the CLI) can already run.
368
370
 
369
371
  ### Capability detection
@@ -458,7 +460,7 @@ They are the most used tools here, and they are tested on every pull request and
458
460
  `color.py --to-sdr` converts PQ (HDR10), HLG and iPhone Dolby Vision 8.4 to SDR BT.709 with real tone mapping (`zscale` and `tonemap`, `hable` by default; `mobius`, `reinhard`, `clip`, `linear` and `gamma` with `--tonemap`). BT.2020 footage on an SDR transfer gets a gamut conversion only. Watch for four things:
459
461
 
460
462
  - It needs `zscale` (libzimg). The plain Homebrew `ffmpeg` lacks it, so use `ffmpeg-full` (keg-only: see [Install](#install) for putting it on `PATH`); `doctor` reports whether it is there.
461
- - `--peak` (default 1000 nits) should match how bright the source was mastered. A wrong value makes highlights dull or clipped.
463
+ - `--peak` (default 1000 nits) is the PQ source's brightest level, the one that becomes SDR white: set it to how bright the source was mastered (its MaxCLL or mastering peak). Too high dims the picture; too low clips the highlights. HLG always peaks at 1000 nits, so `--peak` does not apply to it.
462
464
  - Tone mapping is a judgement, and the result says so (`grade_look` in `unverified`). Compare frames with `look.py`, and try another `--tonemap` if skin or skies look off.
463
465
  - An HDR source that only needs to play everywhere may not need converting at all. `--strip-dovi` removes the Dolby Vision layer from an iPhone clip, keeping its HLG picture, with a stream copy.
464
466
 
@@ -468,7 +470,7 @@ ffmpeg-skill edits footage you already have, on this machine, with FFmpeg tools
468
470
 
469
471
  ### Does it validate the filter graph before running?
470
472
 
471
- No. `--dry-run` prints the ffmpeg command a writing tool would run and does not start ffmpeg, so ffmpeg never parses that filter graph. ffprobe still runs when the tool probes an input that exists. Unless `--overwrite` is passed, an existing output is refused (`kind: input`) before ffmpeg. An output that is the same file as an input is refused either way. No media file is written, and `verified` is false; `--plan FILE` does write the plan JSON. A path that is not on disk yet is not measured: the probe is a stub (0×0, duration 0), so a number taken from it is not a measurement. The measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) still run their measurements; only the artifact is skipped, and `silence` with `--filler --transcribe` also runs the speech engine, which may download a model. `overlay` measures the lines of a multi-line `--text` with one drawtext on a generated frame on FFmpeg 8.0, 8.0.1 and 8.1.0, where it draws them one by one. `verify` accepts `--dry-run` and ignores it: its steps run. `--plan FILE` is that dry run written down — the planned argv, the command lines, and a fingerprint of each existing input (size, plus a sha256 of the first and last 8 MiB). `render.py` and `verify.py` refuse `--plan`. `render.py FILE` runs a plan only when those fingerprints still match (a mismatch is refused with each input's expected and actual fingerprint in `error.details`, and logged to `<plan>.audit.jsonl`), by running the tool again with the planned argv, not by executing the recorded ffmpeg lines; `--dry-run` on that run only prints them. `doctor` reports whether the filters, encoders and bitstream filters the contract names are in this build (`ffmpeg -filters`, `-encoders`, `-bsfs`) and which fonts are installed. A name in that listing is not a check of the graph this command would build, and a listed GPU encoder is not a successful encode.
473
+ No. `--dry-run` prints the ffmpeg command a writing tool would run and does not start ffmpeg, so ffmpeg never parses that filter graph. ffprobe still runs when the tool probes an input that exists. Unless `--overwrite` is passed, an existing output is refused (`kind: input`) before ffmpeg. An output that is the same file as an input is refused either way. No media file is written, and `verified` is false; `--plan FILE` does write the plan JSON. A path that is not on disk yet is not measured: the probe is a stub (0×0, duration 0), so a number taken from it is not a measurement. The measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) still run their measurements; only the artifact is skipped, and `silence` with `--transcribe` (with `--filler` or a sentence flag) also runs the speech engine, which may download a model. `overlay` measures the lines of a multi-line `--text` with one drawtext on a generated frame on FFmpeg 8.0, 8.0.1 and 8.1.0, where it draws them one by one. `verify` accepts `--dry-run` and ignores it: its steps run. `--plan FILE` is that dry run written down — the planned argv, the command lines, and a fingerprint of each existing input (size, plus a sha256 of the first and last 8 MiB). `render.py` and `verify.py` refuse `--plan`. `render.py FILE` runs a plan only when those fingerprints still match (a mismatch is refused with each input's expected and actual fingerprint in `error.details`, and logged to `<plan>.audit.jsonl`), by running the tool again with the planned argv, not by executing the recorded ffmpeg lines; `--dry-run` on that run only prints them. `doctor` reports whether the filters, encoders and bitstream filters the contract names are in this build (`ffmpeg -filters`, `-encoders`, `-bsfs`) and which fonts are installed. A name in that listing is not a check of the graph this command would build, and a listed GPU encoder is not a successful encode.
472
474
 
473
475
  ### Can it remove burned-in subtitles or logos?
474
476
 
@@ -590,7 +592,7 @@ Where it can be installed from today. Each route is spelled out below the table.
590
592
  | MCP Registry | clients that install from the registry | `io.github.kajisho5/ffmpeg-skill` ([entry](https://registry.modelcontextprotocol.io/v0/servers/io.github.kajisho5%2Fffmpeg-skill/versions/latest)); the release workflow publishes a new version there after npm, and a failed registry step leaves the entry on the previous version |
591
593
  | GitHub release | a pinned copy | [releases](https://github.com/kajisho5/ffmpeg-skill/releases) |
592
594
 
593
- Not listed in a directory yet: the Claude plugin directory, the Cursor Marketplace, the Gemini CLI extension gallery and Glama (each is submitted and waiting on its review or crawl). This table gains a row when a listing goes live; until then the routes above are the whole list.
595
+ Not listed in a directory yet. The Claude plugin directory accepts a plugin that ships scripts or programs only from an organization Anthropic has marked Verified, and there is no way to ask for that yet, so the submission is on hold (the tools are Python files; a text-only build would install nothing that runs). The Cursor Marketplace, the Gemini CLI extension gallery and Glama are submitted and waiting on their review or crawl. This table gains a row when a listing goes live; until then the routes above are the whole list.
594
596
 
595
597
  ```bash
596
598
  npx ffmpeg-skill # Claude Code → ~/.claude/skills/ffmpeg-skill
package/SKILL.md CHANGED
@@ -102,6 +102,7 @@ Variants, caveats and extra flags per row: `references/requests.md`.
102
102
  | "cut out the pauses", "jump cuts" | `silence.py in.mp4` |
103
103
  | "cut the ums and uhs" | `silence.py in.mp4 --filler --words words.json` |
104
104
  | "just the real pauses, not inside a sentence" | `silence.py in.mp4 --speech-aware` |
105
+ | "tighter inside a sentence, more room after one" | `silence.py in.mp4 --words w.json --min-silence 0.2 --sentence-min-silence 0.3 --sentence-margin-tail 0.3` |
105
106
  | "a 60 s highlight from this hour" | `scenes.py long.mp4 --highlights 6 --target 60 --edl picks.txt` (then `cut.py --segments` with picks.txt's lines joined by commas) |
106
107
  | "cut on the beat" | `cut.py in.mp4 --segments ... --snap beats --snap-source beats.json` (`scenes.py --beats`; a WAV/MP3 music bed too) |
107
108
  | "speed up here, slow-mo there" | `speedramp.py in.mp4 --segment 0-3:1.0 --segment 3-4:0.25` |
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.6.1`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.6.2`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.6.1", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.6.2", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
@@ -446,7 +446,7 @@ source is refused (`color.py --to-sdr` first). `filter:boxblur` stays required
446
446
  on `redact`, because `--mode blur` is the default.
447
447
 
448
448
  `external:whisper` is **optional** for two tools since 1.17: `caption.py`
449
- (`--transcribe`) and `silence.py` (`--filler --transcribe`). Neither requires
449
+ (`--transcribe`) and `silence.py` (`--transcribe` with `--filler`, or since 3.6 with a sentence flag). Neither requires
450
450
  it — both take a transcript the caller already has (`--srt`/`--words`), and
451
451
  both refuse with the same install lines when asked to make one with no
452
452
  engine present. Whisper is never a dependency of this skill.
@@ -637,6 +637,14 @@ The mapping is stated in `invocation.structured.argument_mapping`: positionals i
637
637
  arrays repeated, `output` → `-o`, `loudness.lufs` → `-I`. `--json` is appended for every
638
638
  tool except `probe` (JSON by default) and `look`.
639
639
 
640
+ A value is always a value. One that starts with `-` is passed as a single `--long-flag=value`
641
+ token (`{"text": "-50%"}` → `--text=-50%`, `{"lufs": -16}` → `--lufs=-16`), because argparse
642
+ reads a separate token that starts with `-` as the next option. When any positional starts with
643
+ `-`, every positional follows `--` after all the options, so `{"inputs": ["--plan=x"]}` names a
644
+ file called `--plan=x` instead of setting `--plan`. `render.py` builds its stage commands the
645
+ same way (`_common.option_args`), so an overlay or a title that starts with `-` works in a
646
+ project too.
647
+
640
648
  The MCP server also accepts `{"argv": [...]}` for CLI compatibility. That path is
641
649
  marked `canonical: false`: it is still bound to the named script and never reaches a
642
650
  shell, but an agent ecosystem should use the structured form. No tool, CLI or MCP,
@@ -723,7 +731,7 @@ Per-tool keys added after 2.2.2, all additive:
723
731
  | `silent` | `waveform.py` (every run) | `true` when the input audio's whole-file peak is at or below `--silence-threshold` (default -50 dBFS), which draws a flat line; `--on-silent warn` (default) renders it anyway with a `notes` line, `fail` refuses (`kind: input`) before ffmpeg runs. `false` when audible, `null` under `--dry-run` (nothing measured). `verified` is unaffected |
724
732
  | `encoder`, `hw`, `notes` | every re-encoding tool, `export.py`, `render.py`, `batch.py` (and its rows), `waveform.py` | `encoder`: the video encoder the last command ran (after any GPU→CPU fallback; under `--dry-run`, the planned one), `copy` for a stream copy; absent when no command encoded video. A tool that runs other tools reports its stages': `render.py` its stages and an executed plan's tool, `waveform.py` the title/caption stage that writes its file (its `commands` include that stage's). `hw` is present when VideoToolbox was asked for (`--hw`, `FFMPEG_SKILL_HW=1`, or a stage or `batch.py` item that reported it), on `export.py` when `FFMPEG_SKILL_HW=1` is set but its delivery preset does not take it, and when an encode ran on VideoToolbox because the build has no `libx264`/`libx265`: `{requested, source: flag\|env\|auto, used, fallback, notes}`. `requested` says whether this run's encodes were asked onto VideoToolbox (`false` for that export, and for `source: "auto"`, where nobody asked: `notes` names the missing CPU encoder and the one that ran, and `--no-hw` is not offered as a remedy since there is no CPU encoder to go back to; a job VideoToolbox refuses there is a plain `kind: ffmpeg` failure, not retried), `used` whether the encoder that ran last is VideoToolbox (`null` under `--dry-run`, which ran nothing, and when the tool encoded nothing itself: `batch.py`, whose top-level `hw` gathers its items' `requested`/`source`/`fallback`/`notes` while each `results` row carries the item's own `encoder`, and `hw`/`notes` when VideoToolbox was asked for), `fallback` whether a job VideoToolbox refused was re-encoded on the CPU, and `notes` why. Only a VideoToolbox failure (the encoder could not open, or its compression session failed) is retried on the CPU; any other failure is reported as it happened, once. Every VideoToolbox encode adds a top-level `notes` line: its quality is not CRF-equivalent (`--quality` / a preset's CRF maps to `-q:v` approximately), and `verified` stays what it is for every tool, the measured properties of the output. A `kind: ffmpeg` failure under `--hw` / `FFMPEG_SKILL_HW` carries `encoder` and `hw` for the command that ran last (a stage's, when `render.py`/`waveform.py` re-raise it); when the command itself failed inside the run (not a tool's own check afterwards, such as `cut.py`'s), its message also names that encoder |
725
733
  | `transcription` | `caption.py --transcribe` (every `--mode`); inside `filler` for `silence.py --filler --transcribe` | `{routing, detected_language?, engine, model, language}`: the engine and model that made the transcript; `language` is the language the engine was told -- the one named (`--language`, `--filler-lang`), else the one auto's detector named -- and, when nothing was named, the language the engine decoded where it reports one (whisper.cpp's word-timing JSON, faster-whisper, openai-whisper's word-timing JSON), else `null`; `detected_language` is present when auto's whisper.cpp detector named one; `routing` says why `--engine auto` chose the engine (`"requested"` for a named engine), ending `, no Parakeet engine transcribed it` when auto chose Parakeet and a Whisper engine made the transcript after every Parakeet engine failed |
726
- | `notes` | `silence.py --filler --transcribe` (new for `silence.py`; `caption.py` already had it) | present when `--engine auto` ran the English-only Parakeet model on English *assumed* (no language given or detectable, and no Whisper engine installed or none that succeeded): the transcript is wrong if the speech is not English. `caption.py --transcribe` puts the same line in its `notes` |
734
+ | `notes` | `silence.py --transcribe` with `--filler` or a sentence flag (new for `silence.py`; `caption.py` already had it) | present when `--engine auto` ran the English-only Parakeet model on English *assumed* (no language given or detectable, and no Whisper engine installed or none that succeeded): the transcript is wrong if the speech is not English. `caption.py --transcribe` puts the same line in its `notes` |
727
735
  | `reencode_reason`, `segment_precision`, `least_exact_precision`, `start_snapped` | `cut.py` (every run) | `reencode_reason`: why anything was re-encoded, distinct values in first-seen order, `[]` for a clean copy: `requested` (`--accurate`), `codec` (`--codec`), `vfr`, `vfr_inconclusive`, `pcm_container`, `copy_failed`, `stream_lost` (the copy wrote no video or no audio: an MPEG-TS copy whose window holds no video keyframe, #367), `tolerance`, `concat_fallback` (a `--segments` join that could not stay a stream copy, which now also sets `reencoded: true` and `mode: hybrid`; it reported `copy` before). `segment_precision`: each `--segments` segment's precision (`null` for one segment); `least_exact_precision`: the least exact of them, on every run. Since 3.0.0 `precision` is the least exact segment's, equal to `least_exact_precision`, and `keyframe_snapped` equals `start_snapped` (2.x: `precision` was the whole run's, `packet` only when nothing re-encoded, and `keyframe_snapped` was `precision == "packet"`, a stream copy end to end; see "What 3.0 changed"). `start_snapped`: measured, `true` when a copied segment's picture starts more than one frame from its requested start (or its start could not be measured: an unknown start is never claimed exact), `false` when every start is where asked or everything re-encoded, `null` under `--dry-run`. `requested_segments`/`requested_duration` keep their meaning, the request clamped to the media's duration: a segment ended with the video (less than a frame of sound trimmed) shows in `duration_delta_seconds` and a `notes` line |
728
736
  | `edit_list`, `stored_preroll_seconds`, `av_start_skew_seconds`, `notes` | `cut.py` (every run) | `edit_list`: `true` only for a single-segment `.mp4`/`.m4v`/`.mov` stream copy, which by default since 3.0.0 keeps the MP4 edit list the copy writes (`--no-edit-list` restores 2.x's `-avoid_negative_ts make_zero`): the keyframe's pre-roll is stored but hidden, so the picture starts at `--start`. `stored_preroll_seconds`: with `edit_list`, the seconds decoded from the keyframe the copy's seek lands on (measured with the seek ffmpeg makes) up to the first presented frame; `null` otherwise. With an edit list a copy is judged by its video's length (a `notes` line says so when the container is longer), and one that starts where asked but ends past `--tolerance` re-encodes (`tolerance`) without a `lossless_alternative`. `av_start_skew_seconds`: the output's audio start minus its video start (`null` when either is missing or under `--dry-run`); on a stream copy, when it is more than max(2 frames, 0.1 s) away from the source's own audio/video offset where the copy's picture starts (the keyframe it began from; `--start` with an edit list), a `notes` line names `--accurate`, and the edit list for a single `.mp4`/`.mov` copy whose start snapped under `--no-edit-list`. A re-encode carries no such note. The skew is reported, never repaired. `notes`: what the run did that a caller should know, in plain sentences (`[]` when nothing) |
729
737
  | `video.start_time`, `audio.start_time` | `probe.py` (and every `probe` block a tool returns) | where each stream starts, in seconds on the file's own clock (ffprobe's stream `start_time`; `null` when unknown). A video stream that starts after its audio, or the reverse, is how a lossless cut's A/V skew shows; `cut.py` reads them for `av_start_skew_seconds` |
@@ -817,7 +825,7 @@ Per-tool keys added after 2.10.11 ("nothing happens silently"), all additive:
817
825
  | `ass_shifted`, `ass_unshifted`, `ass_malformed` | `caption.py --ass` (inside `caption`) | with `--offset`: the Dialogue lines moved, and those whose timestamps could not be read (copied as they were); every run: Dialogue lines that could not be read (too few fields, an unreadable start/end) and are not counted as cues. A count above 0 is a warning |
818
826
  | `matched_untimed`, `matched_too_long` | `silence.py --filler` (inside `filler`) | listed words that were not cut: no usable timing in the transcript (also a `filler_words_untimed` warning), or longer than a filler (`FILLER_MAX_WORD`, a held vowel) |
819
827
 
820
- `caption.py --transcribe` and `silence.py --filler --transcribe`: when a local speech engine ran
828
+ `caption.py --transcribe` and `silence.py --transcribe` (with `--filler` or a sentence flag): when a local speech engine ran
821
829
  and produced no cue (for `--filler`: a Whisper transcript with no text at all; text without word
822
830
  timings is a build without them, refused as such), the refusal is `"<engine> found no speech in
823
831
  <input>"` with `kind: input`, `reason: "no_speech"` and `engine` in the failure document (the
@@ -926,8 +934,13 @@ says it is kept without the step that was stopped, and the document carries it a
926
934
  besides the picture and the sound with the file it wrote, and says so in `source_carry:
927
935
  {kept: [...], dropped: [...]}`. The items are `subtitles`, `chapters`, `cover_art`,
928
936
  `audio_channels` (a surround source only: more than two), `creation_time`, `audio_language` and
929
- `timecode`. The key is present only when the source had at least one of them, so a result for a
930
- plain source is unchanged. It is absent under `--dry-run`, for a still or GIF output, and when two
937
+ `timecode`. A timecode is compared by its value: it is kept when the written start timecode
938
+ labels the frame the output starts on, which is the source's own label or, where the tool moved
939
+ the picture's start (`cut` from a later frame, `fit --method trim`, `sync --trim-second`, a
940
+ `render.py` clip with an `in`), the label of the source frame the output now starts on. A join of
941
+ several ranges (`cut --segments`, `--remove` leaving two or more) writes no start timecode, since
942
+ no single start labels the frames after the first join. The key is present only when the source
943
+ had at least one of them, so a result for a plain source is unchanged. It is absent under `--dry-run`, for a still or GIF output, and when two
931
944
  or more inputs have a picture or sound (which one's items the output should carry is the tool's
932
945
  choice). An item bound to a stream the output does not have at all is not compared (a sound-only
933
946
  extract has no subtitle track to lose). It is a report, not a check: `verified` does not depend on
@@ -977,7 +990,8 @@ failed row's `error: {step, tool, kind, message, code, reason, details, hint, ex
977
990
  failing step's own document, and `cache_miss` when a cache entry was not served because its output
978
991
  changed or could not be checked; an interrupted batch has a row for every input
979
992
  (`interrupted: true`, or `skipped: "interrupted"` when it had not started) and `total` counts them
980
- all. `verify.py`'s steps keep the tool's `kind`, `reason`, `message` and `exit_code`, and the steps
993
+ all; under `--watch` a source that has not settled since the previous poll is `skipped: "settling"`
994
+ (`ok: false`, not cached) and processed on a later pass. `verify.py`'s steps keep the tool's `kind`, `reason`, `message` and `exit_code`, and the steps
981
995
  that do not apply are listed under each file's `skipped` with a reason.
982
996
 
983
997
  **Audit log.** Events a later reader must be able to reconstruct are appended as JSON lines,
@@ -1040,6 +1054,20 @@ caller chooses. 1.10.0-1.26 listed them unless `FFMPEG_SKILL_MCP_LEAN=1`; 2.0 le
1040
1054
  good (see "What 2.0 changed"). A client that still sends either is not refused: the server maps
1041
1055
  it like any other key.
1042
1056
 
1057
+ ### Annotations: read-only means it writes no file
1058
+
1059
+ Each `tools/list` entry carries MCP `annotations` (`_contract.mcp_annotations`). `readOnlyHint` is
1060
+ true for the three tools that write no artifact: `probe`, `check` and `cropdetect`. On the CLI
1061
+ they still take `--plan FILE` and `--overwrite` like every tool. Over MCP the two property names
1062
+ stay in their `inputSchema` (3.x removes no property) with descriptions that say they are refused
1063
+ or have no effect (`_contract.MCP_READ_ONLY_REFUSED`), and the server runs these three tools
1064
+ with `FFMPEG_SKILL_READ_ONLY=1`. With that variable set, `--plan` is refused after argparse,
1065
+ however a raw `argv` spells it (`kind: input`, `error.reason: read_only_plan_refused`), and no
1066
+ file is written. Every other tool is annotated `destructiveHint: true`: none edits its input,
1067
+ but with `overwrite` it replaces an existing file at its output or plan path, which MCP does not
1068
+ count as an additive update. The hints are hints: a client must not treat them as a security
1069
+ boundary.
1070
+
1043
1071
  ### Default `tools/list` surface: core 12, opt-in for all 42
1044
1072
 
1045
1073
  By default `tools/list` returns only the core dozen in `_contract.MCP_CORE_TOOLS` (`render`,
package/mcp/server.py CHANGED
@@ -64,30 +64,39 @@ def build_argv(name: str, args: Dict[str, Any]) -> List[str]:
64
64
  return argv
65
65
  spec = specs().get(name)
66
66
  exceptions = spec["mcp"]["argument_exceptions"] if spec else {}
67
- argv: List[str] = []
67
+ props = spec["input_schema"]["properties"] if spec else {}
68
+ positionals: List[str] = []
68
69
  args = dict(args)
69
70
  for key in POSITIONAL[name]:
70
71
  val = args.pop(key, None)
71
72
  if val is None:
72
73
  continue
73
74
  if isinstance(val, list):
74
- argv += [str(v) for v in val]
75
+ positionals += [str(v) for v in val]
75
76
  else:
76
- argv.append(str(val))
77
+ positionals.append(str(val))
78
+ options: List[str] = []
77
79
  for key, val in args.items():
78
80
  if val is None or val is False:
79
81
  continue
80
82
  flag = exceptions.get(key, "--" + key.replace("_", "-"))
81
83
  if val is True:
82
- argv.append(flag)
83
- elif isinstance(val, list):
84
- for v in val:
85
- argv += [flag, str(v)]
86
- else:
87
- argv += [flag, str(val)]
88
- if name not in _contract.MCP_JSON_EXEMPT and "--json" not in argv and "--help" not in argv:
89
- argv.append("--json")
90
- return argv
84
+ options.append(flag)
85
+ continue
86
+ # A value is always a value: one starting with '-' goes as `--flag=value` (option_args),
87
+ # which needs the long spelling of a short exception (-o -> --output, -I -> --lufs).
88
+ cli = props.get(key, {}).get("cli")
89
+ longs = [f for f in cli if f.startswith("--")] if isinstance(cli, list) else []
90
+ long_flag = flag if flag.startswith("--") or not longs else longs[0]
91
+ for v in (val if isinstance(val, list) else [val]):
92
+ options += _common.option_args(long_flag, v) if str(v).startswith("-") else [flag, str(v)]
93
+ if name not in _contract.MCP_JSON_EXEMPT and "--json" not in options and "--help" not in options:
94
+ options.append("--json")
95
+ # A positional starting with '-' would be read as an option (`--plan=x` given as an input path
96
+ # wrote a plan through a read-only tool): such positionals go after `--`, after every option.
97
+ if any(p.startswith("-") for p in positionals):
98
+ return options + ["--"] + positionals
99
+ return positionals + options
91
100
 
92
101
 
93
102
  def _timeout_in_argv(argv: List[str]) -> Optional[float]:
@@ -150,7 +159,13 @@ def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
150
159
  # `--timeout` inside a raw argv list sets both (0 = no limit); otherwise
151
160
  # FFMPEG_SKILL_TIMEOUT / the 1800 s default.
152
161
  per_call = timeout_from_call(args or {})
153
- proc = _common.run_tool([str(script)] + argv, per_call=per_call)
162
+ # A tool annotated readOnlyHint must write nothing: its schema has no `plan`, and this makes
163
+ # the tool itself refuse --plan however a raw argv spells it. Every other tool runs without
164
+ # the variable, even when the server's own environment has it.
165
+ env = {k: v for k, v in os.environ.items() if k != _contract.READ_ONLY_ENV}
166
+ if _contract.mcp_read_only(specs()[name]):
167
+ env[_contract.READ_ONLY_ENV] = "1"
168
+ proc = _common.run_tool([str(script)] + argv, per_call=per_call, env=env)
154
169
  stdout = proc.stdout.strip()
155
170
  text = stdout
156
171
  structured = None
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.6.1",
3
+ "version": "3.6.2",
4
4
  "mcpName": "io.github.kajisho5/ffmpeg-skill",
5
5
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
6
6
  "keywords": [
@@ -56,7 +56,8 @@ at source colour: `export.py --preset prores` does not retag.
56
56
 
57
57
  What else the source carried is compared on every write: `source_carry: {kept, dropped}` lists
58
58
  subtitles, chapters, a cover picture, surround channels, `creation_time`, the audio language tag
59
- and the start timecode, and a lost subtitle track, chapter list, cover picture or surround layout
59
+ and the start timecode (kept only when it labels the output's first frame: a cut from a later
60
+ frame moves it there, a join of several ranges writes none), and a lost subtitle track, chapter list, cover picture or surround layout
60
61
  is a `source_items_dropped` warning. Read it before telling the user the file is complete; to
61
62
  carry the items over, use the tool that keeps them (`export.py` keeps subtitle tracks,
62
63
  `metadata.py` writes chapters and tags).
@@ -1376,7 +1376,18 @@ above the cap is clamped with a note, not refused, and both `jobs` and
1376
1376
  row has `written: false` (`output` keeps its path string), a note says so, and `ok` follows the steps' exit codes.
1377
1377
  Final names that differ only in case are a
1378
1378
  collision (they are one file on macOS and Windows).
1379
- - `--watch` composes: each pass is parallel, passes are sequential.
1379
+ - `--watch` composes: each pass is parallel, passes are sequential. A pass processes a
1380
+ source only once it has settled: the same size and mtime as at the previous poll, or, the
1381
+ first time it is seen, last modified at least one interval ago. Until then its row is
1382
+ `"skipped": "settling"` (`ok: false`, not cached), so a file still being copied, downloaded
1383
+ or recorded into the folder is not delivered as a fraction of itself; a writer that pauses
1384
+ longer than the interval can still be read early, and the pass after it changes again
1385
+ processes it again. An output an earlier pass of the same watch delivered, unchanged since,
1386
+ is that pass's own and is replaced without `--overwrite`; one someone else replaced or edited,
1387
+ or one an earlier run left, is still refused (`output_exists`).
1388
+ - A `project` recipe's relative paths (`clips[].src`, `audio.music`, `brand`, `captions.srt`,
1389
+ `overlays[].image`, `chapters`, ...) are read against the project file's directory, as
1390
+ `render.py` reads them: each item's copy of the project carries them absolute.
1380
1391
 
1381
1392
  ### caption.py --transcribe — optional local speech-to-text
1382
1393
  If `parakeet-mlx`, `parakeet-cli` (parakeet.cpp, with a `.gguf`), `whisper-cli` (whisper.cpp),
@@ -1400,8 +1411,8 @@ English `tdt-0.6b-v2`. A Parakeet model in `FFMPEG_SKILL_ASR_MODEL` goes to the
1400
1411
  only (the Whisper engines keep `base`); one given with `--model` goes to whichever engine runs.
1401
1412
  A non-English `--language` on an English-only Parakeet model is refused; a
1402
1413
  multilingual (v3) model assumes no language, so it gets no English-assumed note. The result's
1403
- `transcription` names the engine, model and routing. `silence.py --filler --transcribe` takes
1404
- the same `--engine` and `--model`.
1414
+ `transcription` names the engine, model and routing. `silence.py --transcribe` (with `--filler`
1415
+ or a sentence flag) takes the same `--engine` and `--model`.
1405
1416
  Nothing is required: without an engine it prints install hints and the user can supply `--text`
1406
1417
  cues instead. When engines are installed but none transcribed, the refusal names each and why
1407
1418
  instead (`reason: engine_failed`, or `english_only` for a Parakeet engine auto passed over for
@@ -1705,7 +1716,11 @@ Lines without a time run for `--auto-seconds` (3 s) after the previous cue. `|`
1705
1716
  SRT/text cues: `pop` is the short-form "bouncy" entrance, `--karaoke` fills each
1706
1717
  word from `--color` to `--highlight-color` across the cue; `--karaoke-timing
1707
1718
  energy` (default) follows the speech loudness in the audio, `even` splits the
1708
- cue equally (word timing is derived, not transcribed). The ASS is kept next to the
1719
+ cue equally (word timing is derived, not transcribed). SRT markup in the cues is styled the
1720
+ way ffmpeg's own SRT route styles it: `<i>` `<b>` `<u>` `<s>` and `<font color>` become
1721
+ ASS overrides and a `{\anN}` keeps its position, measured as no width; any other tag or `{...}`
1722
+ stays text (escaped), and under `--karaoke` `<font color>` is dropped for the highlight's
1723
+ colours. The ASS is kept next to the
1709
1724
  user can hand-tune timings and re-run with `--ass`.
1710
1725
  Emoji (1.15): `--emoji-assets DIR` is a directory of PNGs named by code point
1711
1726
  (`1f389.png`, `1f1ef-1f1f5.png`, `1f469-200d-1f4bb.png` — the Twemoji/Noto
@@ -1715,7 +1730,9 @@ with an invisible placeholder reserving the emoji's box and each PNG is
1715
1730
  composited on top (`--emoji-scale` sizes the box, `--emoji-max` caps the count).
1716
1731
  Without one the run still succeeds and says `emoji: {"mode": "mono"}` plus a
1717
1732
  warning; `--emoji none` strips them; `--emoji color` insists on a colour-capable
1718
- libass and refuses otherwise. Nothing is ever downloaded. What this machine can
1733
+ libass and refuses otherwise. An emoji is what Unicode draws as one by default: a text
1734
+ symbol such as `♪ ✓ ★` is never one, and `♥ ✔ ☺` only with VS16 (`♥️`) or a skin tone
1735
+ after them; the font draws the rest. Nothing is ever downloaded. What this machine can
1719
1736
  do: `doctor --json` → `.fonts.emoji`. Details: `references/gotchas.md#emoji`.
1720
1737
 
1721
1738
  Readable by default (1.12, rebalanced in 1.15): every cue is wrapped to the safe area — the
@@ -1724,9 +1741,10 @@ frame width minus the two side margins below, 90 % of it without a `--platform`
1724
1741
  full em per character, Latin per character from a table read off DejaVu Sans (so
1725
1742
  an all-caps line measures as wide as it draws), Cyrillic/Greek about 0.55,
1726
1743
  Arabic/Hebrew 0.6, Devanagari 0.7, and a combining mark nothing at all —
1727
- breaking between characters for CJK (a katakana word stays whole) and at spaces
1728
- otherwise, but never between a character and the combining marks that belong
1729
- to it (Devanagari matras, Arabic and Hebrew points). Thai (1.16.1) is never
1744
+ breaking between characters for Chinese and Japanese (a katakana word stays whole) and at
1745
+ spaces otherwise (Korean included: it is written with spaces between words, and a space
1746
+ that stood at a break is kept when a later pass moves the break), but never between a
1747
+ character and the combining marks that belong to it (Devanagari matras, Arabic and Hebrew points). Thai (1.16.1) is never
1730
1748
  broken inside a run: it writes no space inside a phrase and the wrapper has no
1731
1749
  dictionary, so the break goes where you put a space or a `|`, and a run with
1732
1750
  none stays long on its own line — `caption.py` counts such lines as `overlong`
@@ -1898,7 +1916,10 @@ Cross-correlates loudness envelopes: coarse FFT search (20 ms), then a direct
1898
1916
  video with the source file's audio aligned (video stream copied); the output
1899
1917
  keeps the reference's full length -- a shorter or head-trimmed source file is
1900
1918
  padded with silence, never allowed to cut the picture.
1901
- `--trim-second` writes the source file shifted to the reference timeline.
1919
+ `--trim-second` writes the source file shifted to the reference timeline. A video source file is
1920
+ re-encoded from the frame at the trim point (a stream copy would start at the keyframe before it);
1921
+ a sound-only one is stream-copied (to the packet: within an AAC frame). Its start timecode moves
1922
+ with the trim or the padded head.
1902
1923
  `--fix-drift` measures the offset again near the end of the overlap, reports
1903
1924
  the clock difference in ppm, and resamples the source file so a 60-minute
1904
1925
  take stays in sync (typical consumer devices drift 20-500 ppm = up to 1.8 s/h).
@@ -1982,9 +2003,11 @@ iPhone "HDR" video is Dolby Vision profile 8.4 on an HLG base layer:
1982
2003
  `probe.py` reports `hdr_format: Dolby Vision profile 8` and `--to-sdr`
1983
2004
  tone-maps it from the HLG base layer. When the user wants to keep HDR but
1984
2005
  players mis-render the DV layer, `--strip-dovi` removes it losslessly.
1985
- `--to-sdr` does a real conversion: linearise (zscale, PQ or HLG), tone-map
1986
- (default `hable`, `mobius` keeps more highlight detail; FFmpeg's CPU
1987
- `tonemap` filter has no BT.2390 curve), then BT.709 gamma + matrix. Refuses when probe says the
2006
+ `--to-sdr` does a real conversion: linearise (zscale, PQ or HLG, 1.0 = 100 nits),
2007
+ tone-map the source peak to SDR white (`--peak` nits for PQ, default 1000; HLG
2008
+ always peaks at 1000; default `hable`, `mobius` keeps more highlight detail; FFmpeg's CPU
2009
+ `tonemap` filter has no BT.2390 curve), then BT.709 gamma + matrix. `look.py` shows an
2010
+ HDR source through the same chain at its defaults. Refuses when probe says the
1988
2011
  input is not HDR unless `--force`. `--lut` applies a 3D .cube with
1989
2012
  tetrahedral interpolation (Log→709 conversions, creative looks); blend with
1990
2013
  `--lut-strength`. Everything else in the skill assumes SDR BT.709, so run this
@@ -2053,8 +2076,11 @@ needs a real stereo source: it scales the side signal (L−R), so a mono track
2053
2076
  duplicated to two channels has nothing to scale. A 1-channel input is refused
2054
2077
  (`kind: input`) — `--stereo` duplicates it but does not widen it — and more than
2055
2078
  two channels are refused unless `--downmix` is given too, in which case the
2056
- widening runs on the stereo fold-down. `--downmix` uses the
2057
- ITU centre/LFE weights for 5.1/7.1 → stereo. `--mono` averages a stereo pair,
2079
+ widening runs on the stereo fold-down. `--downmix` folds 5.1/7.1 to stereo with
2080
+ the ITU-R BS.775 weights (centre and surrounds at -3 dB, LFE dropped), scaled so
2081
+ each side's gains sum to 1: in-phase channels cannot clip, and a front channel
2082
+ alone comes out 7.7 dB lower than it went in (9.9 dB on 7.1; follow with `loudness.py`).
2083
+ `--mono` averages a stereo pair,
2058
2084
  leaves a 1-channel input untouched and downmixes >2 channels through
2059
2085
  swresample. Video is always stream-copied, and so is a subtitle/data track
2060
2086
  when the container can hold it (`dropped_non_av_streams` says when it could not).