ffmpeg-skill 3.6.0 → 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 +7 -5
- package/SKILL.md +1 -0
- package/docs/contract.md +37 -9
- package/mcp/server.py +28 -13
- package/package.json +1 -1
- package/references/gotchas.md +2 -1
- package/references/scripts.md +40 -14
- package/scripts/_ass_overlay.py +235 -2
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/asr.py +3 -0
- package/scripts/_common/color.py +65 -3
- package/scripts/_common/decision.py +89 -1
- package/scripts/_common/emit.py +31 -1
- package/scripts/_common/emoji.py +76 -9
- package/scripts/_common/probe.py +2 -1
- package/scripts/_common/runner.py +280 -22
- package/scripts/_common/timeline.py +39 -0
- package/scripts/_common/wrap.py +92 -42
- package/scripts/_contract.py +45 -16
- package/scripts/audio.py +9 -3
- package/scripts/batch.py +158 -18
- package/scripts/broll.py +2 -2
- package/scripts/caption.py +46 -21
- package/scripts/check.py +1 -1
- package/scripts/color.py +2 -42
- package/scripts/cut.py +37 -9
- package/scripts/export.py +2 -2
- package/scripts/fit.py +25 -3
- package/scripts/graphics.py +11 -6
- package/scripts/look.py +31 -12
- package/scripts/multicam.py +6 -1
- package/scripts/overlay.py +20 -7
- package/scripts/redact.py +2 -2
- package/scripts/render.py +101 -25
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +6 -2
- package/scripts/sync.py +28 -4
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
|
|
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)
|
|
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 `--
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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 --
|
|
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
|
|
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
|
|
@@ -897,7 +905,7 @@ here claims otherwise until real exit-code/stderr sniffing exists to back that u
|
|
|
897
905
|
A refusal also carries `error.reason`, a stable snake_case code for *why*
|
|
898
906
|
(`contract --json` → `json_output.error_reasons` lists them: `output_exists`, `output_is_input`,
|
|
899
907
|
`output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`,
|
|
900
|
-
`media_path_is_url
|
|
908
|
+
`media_path_is_url` (`subfile,...:` included), `media_path_is_playlist` (an HLS, DASH, IMF or ffconcat playlist given as media, DASH and IMF told by content whatever the file is called), `media_path_has_line_break` (a path with a line break, which a concat list cannot hold),
|
|
901
909
|
`output_looks_like_option` (an output starting with `-`, other than `-` for stdout), `output_path_taken` (two files one run writes share a path), `media_path_not_a_file` (a FIFO or device as input), `silence.py --timemap`'s `timemap_path_taken`, `join.py --copy`'s `join_copy_mismatch`,
|
|
902
910
|
`render.py`'s `plan_*` reasons, `--codec av1`/`prores`'s `encoder_missing` and
|
|
903
911
|
`encoder_list_unreadable`, and `--transcribe`'s `asr_engine_missing`, `no_speech`, `engine_failed`
|
|
@@ -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`.
|
|
930
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
75
|
+
positionals += [str(v) for v in val]
|
|
75
76
|
else:
|
|
76
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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.
|
|
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": [
|
package/references/gotchas.md
CHANGED
|
@@ -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
|
|
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).
|
package/references/scripts.md
CHANGED
|
@@ -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 --
|
|
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).
|
|
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.
|
|
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
|
|
1728
|
-
otherwise
|
|
1729
|
-
|
|
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),
|
|
1986
|
-
|
|
1987
|
-
|
|
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`
|
|
2057
|
-
ITU
|
|
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).
|