ffmpeg-skill 2.24.1 → 3.1.0

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
@@ -27,7 +27,7 @@ No cloud · No API keys · Python standard library</p>
27
27
  <p align="center">
28
28
  <a href="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml"><img src="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml/badge.svg" alt="tests"></a>
29
29
  <a href="https://www.npmjs.com/package/ffmpeg-skill"><img src="https://img.shields.io/npm/v/ffmpeg-skill" alt="npm"></a>
30
- <a href="#ffmpeg-compatibility"><img src="https://img.shields.io/badge/ffmpeg-5.1%20%7C%206.1%20%7C%207.1%20%7C%208%20%7C%209%20tested-orange" alt="FFmpeg 5.1, 6.1, 7.1, 8 and 9 tested in CI"></a>
30
+ <a href="#ffmpeg-compatibility"><img src="https://img.shields.io/badge/ffmpeg-6.1%20%7C%207.1%20%7C%208%20%7C%209%20tested-orange" alt="FFmpeg 6.1, 7.1, 8 and 9 tested in CI"></a>
31
31
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></a>
32
32
  <a href="https://github.com/sponsors/kajisho5"><img src="https://img.shields.io/badge/sponsor-%E2%9D%A4-ea4aaa?logo=githubsponsors" alt="Sponsor"></a>
33
33
  </p>
@@ -208,7 +208,7 @@ These are the rules the skill file gives the agent and the code enforces.
208
208
 
209
209
  ## Tools
210
210
 
211
- 42 public tools, all Python 3.9 standard library, all with `--help`, `--dry-run`, `--json`, `--plan FILE` (a dry run written as a plan `render.py` executes later), non-zero exit and a reason on stderr on failure. `--json-brief` (1.11.0) prints the same result document trimmed to what a caller acts on — status, output, `verified`, a compact `summary` of the output probe, the tool's own keys and the command count instead of the command lines — for roughly a third of the bytes; `--json` itself is unchanged. Every re-encoding tool except `export.py`, `render.py` and `batch.py` (a preset or the project decides there) takes `--codec h264|hevc|av1|prores` and `--quality N` (1.8) and, with `export.py`, `--hw`/`--no-hw` (Apple VideoToolbox on Apple Silicon, opt-in; see `doctor` below), and every time flag takes seconds, `mm:ss`, `hh:mm:ss.fff` or SMPTE `hh:mm:ss:ff` with an optional `@fps` suffix (1.9).
211
+ 42 public tools, all Python 3.10+ standard library, all with `--help`, `--dry-run`, `--json`, `--plan FILE` (a dry run written as a plan `render.py` executes later), non-zero exit and a reason on stderr on failure. `--json-brief` (1.11.0) prints the same result document trimmed to what a caller acts on — status, output, `verified`, a compact `summary` of the output probe, the tool's own keys and the command count instead of the command lines — for roughly a third of the bytes; `--json` itself is unchanged. Every re-encoding tool except `export.py`, `render.py` and `batch.py` (a preset or the project decides there) takes `--codec h264|hevc|av1|prores` and `--quality N` (1.8) and, with `export.py`, `--hw`/`--no-hw` (Apple VideoToolbox on Apple Silicon, opt-in; see `doctor` below), and every time flag takes seconds, `mm:ss`, `hh:mm:ss.fff` or SMPTE `hh:mm:ss:ff` with an optional `@fps` suffix (1.9).
212
212
 
213
213
  **Analysis and inspection**
214
214
 
@@ -222,7 +222,7 @@ These are the rules the skill file gives the agent and the code enforces.
222
222
 
223
223
  | Tool | What it does |
224
224
  |---|---|
225
- | `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact video and sample-exact audio, `--keep-hevc` to re-encode an HEVC source as HEVC, `--edit-list` to keep an `.mp4` copy's start exact, `--vfr-guard` to choose the VFR check; 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
+ | `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact video and sample-exact audio, 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 |
226
226
  | `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 |
227
227
  | `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()` |
228
228
  | `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 |
@@ -349,7 +349,7 @@ The contract is generated from the code that runs, not maintained beside it. For
349
349
  | `mutates_input` | always `false` |
350
350
  | `idempotency_hint` | `bit_exact`, `content_equivalent`, `cached` or `environment_dependent` |
351
351
 
352
- Next to the tool list the document carries a top-level `deprecated` list (since 1.10): what the next major removes, since when, the replacement and the surface it lives on. Today that is Python 3.9 and FFmpeg 5.x, both removed in 3.0.0. What 2.0 removed moved to `removed`, and `docs/contract.md` "What 2.0 changed" is written from that.
352
+ Next to the tool list the document carries a top-level `deprecated` list (since 1.10): what the next major removes, since when, the replacement and the surface it lives on. It is empty in 3.0.0: Python 3.9 and FFmpeg 5.x, the two 2.x entries, moved to `removed` with what 2.0 removed, and `docs/contract.md` "What 2.0 changed" and "What 3.0 changed" are written from that.
353
353
 
354
354
  `contract_version` (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments → argv), the JSON shapes for success and failure (`{"status": "failed", "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification | interrupted", "message": …}}`; a refusal adds `error.reason`, a stable code such as `output_exists` or `plan_inputs_changed`, and `error.details`, the facts behind it; warnings, fallbacks and skipped steps a run decided on its own are in `diagnostics`), and that no tool runs a shell or executes anything other than the named script, `ffmpeg` and `ffprobe`. Field-by-field reference: [docs/contract.md](docs/contract.md).
355
355
 
@@ -384,8 +384,8 @@ npx ffmpeg-skill doctor --json # available / missing / missing_optional / unkn
384
384
 
385
385
  The short list for humans. The agent-facing version, with the reasoning, is the "Things that look right but are wrong" and "Gotchas" sections of [SKILL.md](SKILL.md).
386
386
 
387
- - **Variable frame rate (phone and screen recordings).** `probe.py` flags it; every re-encoding tool conforms to a constant rate automatically. `cut.py` switches to frame-accurate mode on it, because copy-cuts on VFR land on the wrong frame, and samples the frame timing to say whether that was needed (`--vfr-guard sampled` decides on the sample instead; `--vfr-guard off` keeps the copy). Choose the rate yourself with `fit.py input.mp4 --fps 30` when the measured average is odd.
388
- - **Lossless cuts snap to keyframes.** A stream-copy cut can start up to one GOP earlier than asked. `cut.py` re-encodes when the snap exceeds 0.5 s (`--tolerance` changes the limit). For a strictly lossless file pass `--tolerance -1` (plus `--vfr-guard off` on a variable-frame-rate source), and expect the cut to land on the nearest earlier keyframe; the JSON result lists them under `nearest_keyframes`. A `--segments` join can still be re-cut: open-GOP video, a later segment of B-frame `.mp4`/`.mov` video that starts between keyframes (start it on a keyframe, or write `.mkv`), or a join that fails its check. For a single `.mp4`/`.mov` cut, `--edit-list` keeps the copy and starts the picture where asked: the keyframe's pre-roll is stored but hidden by an MP4 edit list, which a player that ignores edit lists shows.
387
+ - **Variable frame rate (phone and screen recordings).** `probe.py` flags it; every re-encoding tool conforms to a constant rate automatically. `cut.py` samples the frame timing before a copy and switches to frame-accurate mode when it is variable or too short to judge, because copy-cuts on VFR land on the wrong frame (`--vfr-guard average` restores 2.x's rule, re-encoding whenever the nominal and average rates differ; `--vfr-guard off` keeps the copy). Choose the rate yourself with `fit.py input.mp4 --fps 30` when the measured average is odd.
388
+ - **Lossless cuts snap to keyframes.** A stream-copy cut can start up to one GOP earlier than asked. `cut.py` re-encodes when the snap exceeds 0.5 s (`--tolerance` changes the limit). For a strictly lossless file pass `--tolerance -1` (plus `--vfr-guard off` on a variable-frame-rate source), and expect the cut to land on the nearest earlier keyframe; the JSON result lists them under `nearest_keyframes`. A `--segments` join can still be re-cut: open-GOP video, a later segment of B-frame `.mp4`/`.mov` video that starts between keyframes (start it on a keyframe, or write `.mkv`), or a join that fails its check. A single `.mp4`/`.mov` copy keeps its edit list by default (since 3.0.0), so the picture starts where asked: the keyframe's pre-roll is stored but hidden by an MP4 edit list, which a player that ignores edit lists shows (`--no-edit-list` starts the file at the keyframe instead).
389
389
  - **HDR stays HDR.** When the probe reports HDR (HDR10, HLG, Dolby Vision, BT.2020), the tools keep it rather than flatten it. Convert deliberately with `color.py --to-sdr` before H.264 deliverables or LUT work. `export.py` platform presets are SDR and warn on HDR input.
390
390
  - **Loudness targets.** −14 LUFS / −1 dBTP for YouTube and social platforms (the `loudness.py` default), `-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast. A clip measured at −40 LUFS or below is room tone, not content; raising it raises the noise. Check true peak as well as LUFS: `check.py file --platform podcast` measures both.
391
391
  - **Frame changes first, text second.** Captions and overlays burned before a crop or resize end up off-frame. Reframe, then caption.
@@ -447,7 +447,7 @@ It cannot tell you whether the result looks or sounds right, and the result says
447
447
 
448
448
  ### Are silence removal and captions stable?
449
449
 
450
- They are the most used tools here, and they are tested on every pull request and every push to `main` on Linux, macOS and Windows, with FFmpeg 5.1 to 9. On 20 test files with known gaps, `silence.py` missed none and left at most 1 ms of silence (see [Tested on real footage](#tested-on-real-footage)). A cut keeps `--margin` seconds of air on each side, 0.2 s by default; raise it to 0.3 s for a speaker who trails off. `--speech-aware` keeps the short breaths inside a sentence. Filler words are removed only from measured word timings (`--words` or a local `--transcribe`), never guessed. `--timemap` writes where each kept and removed range sits in the source and the output, for re-timing subtitles. If `--threshold` is set high enough to cut into a breath or a weak word ending, the result warns at that cut.
450
+ They are the most used tools here, and they are tested on every pull request and every push to `main` on Linux, macOS and Windows, with FFmpeg 6.1 to 9. On 20 test files with known gaps, `silence.py` missed none and left at most 1 ms of silence (see [Tested on real footage](#tested-on-real-footage)). A cut keeps `--margin` seconds of air on each side, 0.2 s by default; raise it to 0.3 s for a speaker who trails off. `--speech-aware` keeps the short breaths inside a sentence. Filler words are removed only from measured word timings (`--words` or a local `--transcribe`), never guessed. `--timemap` writes where each kept and removed range sits in the source and the output, for re-timing subtitles. If `--threshold` is set high enough to cut into a breath or a weak word ending, the result warns at that cut.
451
451
 
452
452
  `caption.py` burns SRT/ASS or muxes them as soft tracks, fits the size to the platform's safe area, and picks a font that covers the script (Japanese, Chinese, Korean, Arabic, Thai, Hindi and others). It needs FFmpeg's `subtitles` filter (libass); `doctor` reports whether this build has it. Placement and legibility are listed as `unverified`, so look at a contact sheet before delivering. Speech-to-text runs on a local engine you install; its first run may download a model.
453
453
 
@@ -482,11 +482,10 @@ The encode path a tool will actually take is Apple VideoToolbox, for h264, hevc
482
482
 
483
483
  ## FFmpeg compatibility
484
484
 
485
- The tools need FFmpeg 5.0 or later and Python 3.9 or later (standard library only). FFmpeg 6.1+ is recommended: FFmpeg 5.x still works but is deprecated since 2.14.0, `doctor` and the `npx` installer warn on it, and 3.0 requires 6.1. Python 3.10+ is recommended too: 3.9 still works but is deprecated since 2.10.9 (CPython 3.9 is end of life), `doctor` and the `npx` installer warn on it, and 3.0 drops it. What CI actually exercises on every pull request is FFmpeg 5.1.1 (static build), 6.1 (Ubuntu 24.04 apt), 8.0.1 (Ubuntu 26.04 apt), 7.1 (Debian trixie apt), 8.x (macOS Homebrew) and 9.x (Windows gyan.dev), on Python 3.9 and 3.13 (the two ends of the supported range). The capability parser has been run against the listings of these builds:
485
+ The tools need FFmpeg 6.1 or later and Python 3.10 or later (standard library only). 3.0.0 removed FFmpeg 5.x and Python 3.9: every tool refuses an `ffmpeg`/`ffprobe` whose version reads as older than 6.1 (`kind: missing_tool`, `error.reason: ffmpeg_unsupported`; a git build whose version is not a release number is not refused), and `doctor` lists an unsupported FFmpeg or Python under `unsupported` with `ok: false`. What CI actually exercises on every pull request is FFmpeg 6.1 (Ubuntu 24.04 apt), 7.1 (Debian trixie apt), 8.x (macOS Homebrew), 9.x (Windows gyan.dev) and 8.0.1 (Ubuntu 26.04 apt), on Python 3.10 and 3.13 (the two ends of the supported range). The capability parser has been run against the listings of these builds:
486
486
 
487
487
  | FFmpeg | `-filters` row layout | Source |
488
488
  |---|---|---|
489
- | 5.1.1 | three flag characters, same as 6.x | johnvansickle.com static build on the Linux CI runner |
490
489
  | 6.1.1 | three flag characters: `..C acompressor A->A` | Ubuntu 24.04 apt, captured |
491
490
  | 7.1.x | same as 6.x | Debian trixie apt in a CI container (plus a constructed fixture in tests/) |
492
491
  | 8.1.2 | two flag characters: `TS aap AA->A`, three-character legend, `------` separator | Homebrew on the macOS CI runner, captured |
@@ -621,15 +620,15 @@ FFmpeg itself:
621
620
 
622
621
  ## Requirements
623
622
 
624
- - FFmpeg 5.0+ (FFmpeg 5.x is deprecated since 2.14.0 and 3.0 requires 6.1; 6.1+ recommended). Required by at least one tool (so `doctor`'s `ok` needs them all): `libx264`, `aac`, and the `drawtext`, `subtitles` (libass), `loudnorm`, `xfade`, `acrossfade`, `amix`, `overlay`, `scdet`, `silencedetect`, `cropdetect`, `tile`, `tpad`, `boxblur`, `hqdn3d`, `yadif`, `reverse`, `rotate`, `showwaves`, `v360`, `xstack` and `vidstabdetect`/`vidstabtransform` (libvidstab, `stabilize.py` only) filters; `doctor --json` lists them as `capabilities.required`. Needed only by the flags that use them: `libx265`, `prores_ks`, `libzimg` / `zscale`, `libmp3lame`, `libopus`, `libvorbis`, the `ass` filter. `doctor` tells you which are present. The apt and gyan.dev builds carry all of them; some Homebrew bottles lack `libass` / `libfreetype` / `libzimg`, which `doctor` reports as missing.
625
- - Python 3.10+ recommended, standard library only. 3.9 still works but is deprecated (end of life upstream; support ends in 3.0)
623
+ - FFmpeg 6.1+ (3.0.0 removed 5.x; an older build is refused). Required by at least one tool (so `doctor`'s `ok` needs them all): `libx264`, `aac`, and the `drawtext`, `subtitles` (libass), `loudnorm`, `xfade`, `acrossfade`, `amix`, `overlay`, `scdet`, `silencedetect`, `cropdetect`, `tile`, `tpad`, `boxblur`, `hqdn3d`, `yadif`, `reverse`, `rotate`, `showwaves`, `v360`, `xstack` and `vidstabdetect`/`vidstabtransform` (libvidstab, `stabilize.py` only) filters; `doctor --json` lists them as `capabilities.required`. Needed only by the flags that use them: `libx265`, `prores_ks`, `libzimg` / `zscale`, `libmp3lame`, `libopus`, `libvorbis`, the `ass` filter. `doctor` tells you which are present. The apt and gyan.dev builds carry all of them; some Homebrew bottles lack `libass` / `libfreetype` / `libzimg`, which `doctor` reports as missing.
624
+ - Python 3.10+, standard library only (3.0.0 removed 3.9)
626
625
  - Node 16+ only for the `npx` installer
627
626
 
628
627
  `doctor`'s own introspection calls (`ffmpeg -filters`/`-encoders`/`-bsfs`/`-version`) time out after 10s and report `failed` rather than hanging forever — those are meant to be fast. Every tool's actual media-processing `ffmpeg` invocation (cut, fit, caption, ...) runs under `--timeout` (default 1800 s, `FFMPEG_SKILL_TIMEOUT`, `0` = none): past the limit the process is killed, its partial output removed, and the failure reported as `kind: timeout` (exit 124) — a legitimately long `--accurate` re-encode should raise the limit rather than run unbounded. `-nostdin` is always passed, so a hung ffmpeg process waiting on stdin cannot happen.
629
628
 
630
629
  ## Stability
631
630
 
632
- 2.x keeps every tool name, CLI argument, JSON output key and exit code working: nothing is removed or renamed, and nothing optional becomes required, until 3.0. The full list of what is promised and what is not, and the three-step deprecation policy, is in [docs/contract.md](docs/contract.md#stability-guarantee-2x). It is enforced by a test that pins every tool's argument names against a snapshot, so a breaking change fails CI instead of slipping into a patch. 2.0.0 made the five changes announced since 1.10.0 -- `--crf` removed in favour of `--quality` (except on `export.py`, whose preset chooses the encoder), an existing output refused without `--overwrite`, `probe`'s `hdr` narrowed to a real HDR signal, `json`/`progress` out of the MCP schema, the `result_v2` preview withdrawn -- listed with what to change in [docs/contract.md](docs/contract.md#what-20-changed) and under `removed` in `contract --json`.
631
+ 3.x keeps every tool name, CLI argument, JSON output key and exit code working: nothing is removed or renamed, and nothing optional becomes required, until 4.0. The full list of what is promised and what is not, and the three-step deprecation policy, is in [docs/contract.md](docs/contract.md#stability-guarantee-3x). It is enforced by a test that pins every tool's argument names against a snapshot, so a breaking change fails CI instead of slipping into a patch. 2.0.0 made the five changes announced since 1.10.0 -- `--crf` removed in favour of `--quality` (except on `export.py`, whose preset chooses the encoder), an existing output refused without `--overwrite`, `probe`'s `hdr` narrowed to a real HDR signal, `json`/`progress` out of the MCP schema, the `result_v2` preview withdrawn -- listed with what to change in [docs/contract.md](docs/contract.md#what-20-changed) and under `removed` in `contract --json`. 3.0.0 is the second break: it removed FFmpeg 5.x and Python 3.9 (announced in 2.14.0 and 2.10.9), made `cut.py` keep an `.mp4`/`.mov` copy's edit list, keep an SDR HEVC source HEVC and use `--vfr-guard sampled` by default (`--no-edit-list`, `--no-keep-hevc` and `--vfr-guard average` restore 2.x), and changed what `cut.py`'s `precision` (now the least exact segment's) and `keyframe_snapped` (now equal to `start_snapped`) mean (`contract_version` 2.0) -- see [docs/contract.md](docs/contract.md#what-30-changed).
633
632
 
634
633
  ## Development
635
634
 
@@ -643,7 +642,7 @@ python3 evals/run.py --list # agent eval prompts (see evals/); regex-only grad
643
642
  node bin/install.js --dir /tmp/skills # try the installer without touching ~/.claude
644
643
  ```
645
644
 
646
- CI (`.github/workflows/ci.yml`) runs on every pull request and on pushes to `main`, on Ubuntu (FFmpeg 6.1 with Python 3.9 on 24.04, FFmpeg 8.0.1 with Python 3.13 on 26.04), macOS (Homebrew FFmpeg 8.x) and Windows (gyan.dev FFmpeg 9.x), plus two Linux jobs on FFmpeg 5.1.1 (static build) and 7.1 (Debian trixie container), and uploads each runner's FFmpeg listings as an artifact.
645
+ CI (`.github/workflows/ci.yml`) runs on every pull request and on pushes to `main`, on Ubuntu (FFmpeg 6.1 with Python 3.10 on 24.04, FFmpeg 8.0.1 with Python 3.13 on 26.04), macOS (Homebrew FFmpeg 8.x) and Windows (gyan.dev FFmpeg 9.x), plus a Linux job on FFmpeg 7.1 (Debian trixie container), and uploads each runner's FFmpeg listings as an artifact.
647
646
 
648
647
  `tests/test_contract.py` runs on all three OSes, but a handful of its tests build a fake `ffmpeg` as a `#!/bin/sh` script on a PATH shim to force specific FFmpeg 6/7/8/9 fixture layouts through `doctor`'s parser — that technique isn't portable to Windows, so `test_dry_run_never_runs_ffmpeg_and_writes_nothing` and the whole `DoctorDetectionTests` class (fixture-driven layout parsing) are individually `skipIf`'d there and show as `skipped`, not silently absent, in the Windows job's log. Everything else — contract schema, `reencodes_*`, `doctor.tools`, MCP derivation, and every tool exercised through the contract, including `cut.py`'s provenance fields — runs against the real Windows `ffmpeg` on every PR. See [references/ci-platform-pitfalls.md](references/ci-platform-pitfalls.md) for this and other per-OS behaviour differences already diagnosed, before spending a CI cycle re-diagnosing a platform-only failure.
649
648
 
package/SKILL.md CHANGED
@@ -1,13 +1,13 @@
1
1
  ---
2
2
  name: ffmpeg-skill
3
- description: 'Edit video and audio with local FFmpeg from natural-language requests: cut, trim, join, resize/reframe (9:16, 1:1), speed change, captions and subtitles (SRT/ASS, animated, karaoke), logos and text overlays, lower-thirds and titles, silence removal, multicam and external-mic sync, loudness normalisation, HDR/Dolby Vision to SDR, LUTs, background music with ducking, platform exports (YouTube, Reels, TikTok, X), compliance checks, scene detection and highlight reels, contact sheets to inspect results, and whole-edit project files. Use this skill whenever the user mentions a video or audio file (mp4, mov, mkv, wav, m4a), footage, a clip, captions, subtitles, a reel or short, YouTube/Instagram/TikTok delivery, LUFS, sync, transcoding, ffmpeg, or asks to make something "60 seconds", "vertical", "louder", "captioned" — even when they do not say "edit". FFmpeg 6.1+ and Python 3.10+ (5.x/3.9 deprecated), stdlib only, no cloud, no API keys.'
3
+ description: 'Edit video and audio with local FFmpeg from natural-language requests: cut, trim, join, resize/reframe (9:16, 1:1), speed change, captions and subtitles (SRT/ASS, animated, karaoke), logos and text overlays, lower-thirds and titles, silence removal, multicam and external-mic sync, loudness normalisation, HDR/Dolby Vision to SDR, LUTs, background music with ducking, platform exports (YouTube, Reels, TikTok, X), compliance checks, scene detection and highlight reels, contact sheets to inspect results, and whole-edit project files. Use this skill whenever the user mentions a video or audio file (mp4, mov, mkv, wav, m4a), footage, a clip, captions, subtitles, a reel or short, YouTube/Instagram/TikTok delivery, LUFS, sync, transcoding, ffmpeg, or asks to make something "60 seconds", "vertical", "louder", "captioned" — even when they do not say "edit". FFmpeg 6.1+ and Python 3.10+, stdlib only, no cloud, no API keys.'
4
4
  ---
5
5
 
6
6
  # ffmpeg-skill
7
7
 
8
8
  Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as this one; open one only for a question you have: `references/scripts.md` (most flags; `--help` lists all), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end). MCP: `tools/list` shows only the core 12 by default; the other 30 (per this table) stay callable by name via `tools/call`, described by `contract --json`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
9
9
 
10
- Shared flags (every script): `--dry-run`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`); `--fast` (preview quality); `--progress` (%/ETA); `--timeout SECONDS` (`kind: timeout`, default 1800; `0` for hour-long encodes); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses a changed input). Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264 (`cut.py --keep-hevc`: HEVC source stays HEVC), HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
10
+ Shared flags (every script): `--dry-run`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`); `--fast` (preview quality); `--progress` (%/ETA); `--timeout SECONDS` (`kind: timeout`, default 1800; `0` for hour-long encodes); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses a changed input). Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264 (`cut.py`: HEVC source stays HEVC, `--no-keep-hevc` x264), HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
11
11
 
12
12
  Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`; `overlay` for a multi-line `--text` on FFmpeg 8.0-8.1.0) may still run ffmpeg/ffprobe, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`); `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
13
13
 
@@ -203,8 +203,8 @@ One line each; open the linked `references/gotchas.md` section when the job is i
203
203
 
204
204
  - HDR (iPhone, HDR10) re-encoded through an SDR path goes flat; the scripts keep HDR; `hdr: true` is a real PQ/HLG/DV signal, `bt2020_or_hdr: true` also BT.2020 SDR. -> [#hdr-and-colour](references/gotchas.md#hdr-and-colour)
205
205
  - Log footage (S-Log/V-Log/C-Log) is tagged SDR and looks grey: `probe.py --analyze`, then `color.py --lut` first. -> [#log-footage](references/gotchas.md#log-footage)
206
- - A `-c copy` cut can start on a wrong/frozen frame; `cut.py` re-encodes past a 0.5 s snap, respect it; `--edit-list` hides an .mp4's pre-roll. -> [#keyframe-cuts](references/gotchas.md#keyframe-cuts)
207
- - VFR phone/screen clips: re-encodes go CFR; `cut.py` re-encodes (`--vfr-guard sampled`: only measured VFR); odd rate: `fit.py --fps`. -> [#variable-frame-rate](references/gotchas.md#variable-frame-rate)
206
+ - A `-c copy` cut can start on a wrong/frozen frame; `cut.py` re-encodes past a 0.5 s snap, respect it; an .mp4/.mov copy's edit list hides the pre-roll (`--no-edit-list` off). -> [#keyframe-cuts](references/gotchas.md#keyframe-cuts)
207
+ - VFR phone/screen clips: re-encodes go CFR; `cut.py` re-encodes measured VFR (`--vfr-guard average`: 2.x rule); odd rate: `fit.py --fps`. -> [#variable-frame-rate](references/gotchas.md#variable-frame-rate)
208
208
  - Sync/multicam `confidence` under 0.3 (or a huge offset) is suspect — check every camera; these align audio, never lip sync. -> [#sync-multicam-and-drift](references/gotchas.md#sync-multicam-and-drift)
209
209
  - "Normalised" audio can still clip (check true peak); ambience at -40 LUFS or below must never be raised to a speech target. -> [#loudness-and-ambience](references/gotchas.md#loudness-and-ambience)
210
210
  - Captions burned before a crop/resize land off-frame; burned small then upscaled by `export.py` come out soft. -> [#captions-fonts-and-text-order](references/gotchas.md#captions-fonts-and-text-order)
package/bin/install.js CHANGED
@@ -44,32 +44,27 @@ if (has('--help') || has('-h')) {
44
44
  // Windows Python installers commonly expose `python`/`py`, not `python3`. The Microsoft Store
45
45
  // app-execution alias is a real python3.exe that spawns (no ENOENT) and exits non-zero, so a
46
46
  // missing `error` is not proof an interpreter ran. Probe with a version check, then fall through.
47
- // The probe also prints major.minor, so the installer can warn about a deprecated interpreter.
48
- const PYTHON_PROBE = ['-c', "import sys; sys.stdout.write('%d.%d' % sys.version_info[:2]); raise SystemExit(0 if sys.version_info >= (3, 9) else 1)"];
49
-
50
- // 2.10.9 deprecates Python 3.9 (docs/contract.md "Deprecation policy"; `contract --json`'s
51
- // `deprecated`). The same line as scripts/_contract.py's PY39_DEPRECATION (a test keeps them equal).
52
- const PY39_DEPRECATION = 'warning: Python 3.9 is deprecated since ffmpeg-skill 2.10.9 (CPython 3.9 reached end of life in October 2025 and gets no security fixes); it still works in 2.x and support ends in 3.0.0: use Python 3.10+.';
53
-
54
- function pythonDeprecation(version) {
55
- return version === '3.9' ? PY39_DEPRECATION : null;
56
- }
57
-
58
- // 2.14.0 deprecates FFmpeg 5.x the same way. The same line as scripts/_contract.py's
59
- // FFMPEG5_DEPRECATION (a test keeps them equal).
60
- const FFMPEG5_DEPRECATION = 'warning: FFmpeg 5.x is deprecated since ffmpeg-skill 2.14.0 (the 5.x branches date from 2022; Ubuntu 24.04 ships 6.1); it still works in 2.x and support ends in 3.0.0: use FFmpeg 6.1+.';
61
-
62
- // The line for `ffmpeg -version`'s first line, or null (not 5.x, or not a release number).
63
- function ffmpegDeprecation(versionLine) {
64
- const m = /^ffmpeg version n?(\d+)\.\d+/.exec(versionLine || '');
65
- return m && m[1] === '5' ? FFMPEG5_DEPRECATION : null;
47
+ // The probe also prints major.minor. 3.0.0 removed Python 3.9 (deprecated in 2.10.9): an older
48
+ // interpreter fails the probe, so it is skipped like a missing one.
49
+ const PYTHON_PROBE = ['-c', "import sys; sys.stdout.write('%d.%d' % sys.version_info[:2]); raise SystemExit(0 if sys.version_info >= (3, 10) else 1)"];
50
+
51
+ // 3.0.0 removed FFmpeg 5.x (deprecated in 2.14.0). The same line as scripts/_contract.py's
52
+ // FFMPEG_UNSUPPORTED (a test keeps them equal); every tool refuses such a build at run time.
53
+ const FFMPEG_UNSUPPORTED = 'error: FFmpeg {v} is not supported since ffmpeg-skill 3.0.0 (5.x was deprecated in 2.14.0): use FFmpeg 6.1+.';
54
+
55
+ // The line for `ffmpeg -version`'s first line, or null (6.1 or later, or not a release number).
56
+ function ffmpegUnsupported(versionLine) {
57
+ const m = /^ffmpeg version n?(\d+)\.(\d+)/.exec(versionLine || '');
58
+ if (!m) return null;
59
+ const major = Number(m[1]), minor = Number(m[2]);
60
+ return major < 6 || (major === 6 && minor < 1) ? FFMPEG_UNSUPPORTED.replace('{v}', `${major}.${minor}`) : null;
66
61
  }
67
62
 
68
63
  function pythonCandidates(platform) {
69
64
  return platform === 'win32' ? ['python3', 'python', 'py'] : ['python3'];
70
65
  }
71
66
 
72
- // The first candidate that runs and is 3.9+: { cmd, version } (version "3.9", or null when the
67
+ // The first candidate that runs and is 3.10+: { cmd, version } (version "3.12", or null when the
73
68
  // probe printed nothing), or { missing: true, ... } when none is.
74
69
  function findPython(platform, spawn) {
75
70
  const candidates = pythonCandidates(platform);
@@ -93,12 +88,12 @@ function runWithPython(scriptArgs, platform, spawn) {
93
88
  return (spawn || spawnSync)(py.cmd, scriptArgs, { stdio: 'inherit' });
94
89
  }
95
90
 
96
- // After an install: the one-line deprecation warning when the Python the skill will run on is 3.9.
97
- // `doctor` prints the same line itself (from Python), so it is not repeated for that subcommand.
98
- function warnDeprecatedPython(platform, spawn, warn) {
91
+ // After an install: say so when no Python 3.10+ is on PATH, since every script needs one.
92
+ function warnMissingPython(platform, spawn, warn) {
99
93
  const py = findPython(platform, spawn);
100
- const line = py.missing ? null : pythonDeprecation(py.version);
101
- if (line) (warn || console.warn)(`\n ${line}`);
94
+ if (!py.missing) return null;
95
+ const line = `warning: no Python 3.10+ interpreter was found (tried: ${py.candidates.join(', ')}); the skill's scripts need one (3.0.0 removed Python 3.9).`;
96
+ (warn || console.warn)(`\n ${line}`);
102
97
  return line;
103
98
  }
104
99
 
@@ -107,7 +102,7 @@ function main() {
107
102
  if (args[0] === 'contract' || args[0] === 'doctor') {
108
103
  const py = runWithPython([path.join(ROOT, 'scripts', '_contract.py'), ...args], process.platform, spawnSync);
109
104
  if (py.missing) {
110
- console.error(`error: could not find a Python 3.9+ interpreter (tried: ${py.candidates.join(', ')}). Install Python 3.9+ and ensure it is on PATH.`);
105
+ console.error(`error: could not find a Python 3.10+ interpreter (tried: ${py.candidates.join(', ')}). Install Python 3.10+ and ensure it is on PATH.`);
111
106
  process.exit(127);
112
107
  }
113
108
  process.exit(py.error ? 127 : (py.status == null ? 1 : py.status));
@@ -158,7 +153,7 @@ function checkFfmpeg() {
158
153
  }
159
154
  const first = r.stdout.split('\n')[0];
160
155
  console.log(` found ${first}`);
161
- const line = ffmpegDeprecation(first);
156
+ const line = ffmpegUnsupported(first);
162
157
  if (line) console.warn(`\n ${line}`);
163
158
  return true;
164
159
  }
@@ -214,7 +209,7 @@ for (const t of targets) {
214
209
 
215
210
  if (!has('--uninstall')) {
216
211
  checkFfmpeg();
217
- warnDeprecatedPython(process.platform, spawnSync);
212
+ warnMissingPython(process.platform, spawnSync);
218
213
  console.log('\nDone. Ask your agent things like "make this video 60 seconds and add captions".');
219
214
  }
220
215
  process.exit(failed ? 1 : 0);
@@ -224,4 +219,4 @@ if (require.main === module) {
224
219
  main();
225
220
  }
226
221
 
227
- module.exports = { pythonCandidates, runWithPython, findPython, pythonDeprecation, warnDeprecatedPython, PY39_DEPRECATION, ffmpegDeprecation, FFMPEG5_DEPRECATION };
222
+ module.exports = { pythonCandidates, runWithPython, findPython, warnMissingPython, ffmpegUnsupported, FFMPEG_UNSUPPORTED, PYTHON_PROBE };
package/docs/contract.md CHANGED
@@ -20,8 +20,8 @@ The contract is derived from the code that runs, not maintained beside it:
20
20
 
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
- | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`2.24.1`) | any release |
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.1.0`) | 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`
@@ -35,12 +35,12 @@ from `scripts/` on every CI run instead — a stale count fails a test rather th
35
35
  silently. (`SKILL.md` was added to that check after its "the 28 scripts" sat stale through
36
36
  twelve tool additions while the other three files were correct.)
37
37
 
38
- ## Stability guarantee (2.x)
38
+ ## Stability guarantee (3.x)
39
39
 
40
40
  1.0.0 was published on 2026-09-11 (by accident: see CHANGELOG.md's 1.0.0 entry; the number
41
41
  is kept rather than burned). From 1.0.3 on, the number is treated as the promise it implies;
42
- 2.0.0 (2026-09-22) is the first deliberate break, listed under "What 2.0 changed" below.
43
- The same promise holds for the whole of 2.x:
42
+ 2.0.0 (2026-09-22) is the first deliberate break, listed under "What 2.0 changed" below, and
43
+ 3.0.0 is the second, listed under "What 3.0 changed". The same promise holds for the whole of 3.x:
44
44
 
45
45
  | Surface | Promise |
46
46
  |---|---|
@@ -48,7 +48,7 @@ The same promise holds for the whole of 2.x:
48
48
  | CLI arguments (`argparse` dests, flags, positionals) | never removed, renamed, or made newly required; new optional arguments may be added |
49
49
  | `--json` output keys, and the keys of `contract --json` / `doctor --json` | never removed or given a different type; new keys may be added |
50
50
  | Exit codes (0 success, 1 failure incl. ffmpeg failures, 2 unknown/undecidable in `doctor`, 124 timeout, 127 missing tool, 128+signal interrupted) | unchanged |
51
- | `contract_version` (`1.0`) | unchanged; a ToolSpec shape change is a major |
51
+ | `contract_version` (`2.0`) | unchanged; a ToolSpec shape change is a major |
52
52
  | MCP `tools/list` names and `inputSchema` property names | derived from the above, so covered by the same promise |
53
53
  | Behaviour of a tool for the same input and arguments | may change only to fix a defect or to track an FFmpeg change, and every such change gets a CHANGELOG line |
54
54
 
@@ -81,20 +81,17 @@ on, the line says so.
81
81
 
82
82
  `contract --json` carries a top-level `deprecated` list, next to `contract_version`: one entry per
83
83
  thing the next major removes, `{"what", "since", "replacement", "removed_in", "where"}` with `where`
84
- naming the surface (`cli`, `json`, `mcp`, `behaviour`). It was empty in 2.0.0. Since 2.10.9 it lists one
85
- entry: **running on Python 3.9** (`where: behaviour`, `removed_in: 3.0.0`). CPython 3.9 reached end of life
86
- in October 2025 and gets no security fixes, but raising the floor is a break, so 2.x keeps running on 3.9
87
- (`requirements.python` stays `>=3.9`, and CI keeps testing it). It is deprecated, and Python 3.10+ is
88
- recommended: on 3.9, `doctor` and the `npx` installer print one stderr line,
89
- `warning: Python 3.9 is deprecated since ffmpeg-skill 2.10.9 (...); it still works in 2.x and support ends in 3.0.0: use Python 3.10+.`
90
- Like every deprecation warning it is stderr only: `doctor --json`'s document has no key for it.
91
- Since 2.14.0 there is a second entry, **running on FFmpeg 5.x** (`where: behaviour`, `removed_in: 3.0.0`),
92
- handled the same way: `requirements.ffmpeg` stays `>=5.0` and CI keeps its 5.1.1 job until 3.0, which
93
- requires 6.1. When `ffmpeg -version` reads 5.x, `doctor` and the `npx` installer print one stderr line,
94
- `warning: FFmpeg 5.x is deprecated since ffmpeg-skill 2.14.0 (...); it still works in 2.x and support ends in 3.0.0: use FFmpeg 6.1+.`
95
- A version that is not a release number (a git snapshot) gets no line.
84
+ naming the surface (`cli`, `json`, `mcp`, `behaviour`). It was empty in 2.0.0, and it is empty again
85
+ in 3.0.0: its two 2.x entries, **running on Python 3.9** (`since: 2.10.9`) and **running on
86
+ FFmpeg 5.x** (`since: 2.14.0`), were settled by 3.0.0 and moved to `removed`
87
+ (`removed_in: 3.0.0`). The 2.x warning lines are gone with them. 3.0.0 requires Python 3.10+ and
88
+ FFmpeg 6.1+: every tool refuses an `ffmpeg`/`ffprobe` whose version it can read as older than 6.1
89
+ (`kind: missing_tool`, `error.reason: ffmpeg_unsupported`; a git or vendor build whose version is
90
+ unreadable is not refused), `doctor` names an unsupported Python or FFmpeg in `unsupported` and
91
+ reports `ok: false`, and the `npx` installer skips a Python older than 3.10 and prints the same
92
+ "not supported" line for an older FFmpeg.
96
93
  `tests/test_contract.py` refuses a package.json major bump that still lists an entry that major should remove, or that
97
- comes before step 2's window has passed -- unless the waiver is written here, as 2.0.0's is.
94
+ comes before step 2's window has passed -- unless the waiver is written here, as 2.0.0's and 3.0.0's are.
98
95
 
99
96
  **2.0.0 waived the 90-day half of step 2.** Its deprecations date from 1.10.0 (2026-09-13), so
100
97
  the policy's earliest 2.0.0 was 2026-12-12; on 2026-09-22 the maintainer decided not to wait.
@@ -104,6 +101,15 @@ of them, each already had a 1.x opt-in to the 2.0 behaviour (`--quality`,
104
101
  caller could not have prepared for mechanically -- promoting `result_v2` -- was withdrawn
105
102
  rather than shipped early (below).
106
103
 
104
+ **3.0.0 waived the 90-day half of step 2.**
105
+
106
+ Its deprecations date from 2.10.9 (2026-10-22, Python 3.9) and 2.14.0 (2026-10-24, FFmpeg 5.x), so
107
+ the policy's earliest 3.0.0 was 2027-01-22; the maintainer decided not to wait. The other half held:
108
+ ten minor releases (2.15-2.24) carried both deprecations, and each had a way to prepare -- run
109
+ Python 3.10+ and FFmpeg 6.1+. The `cut.py` defaults that changed in 3.0 were not deprecations but
110
+ decisions recorded in 2.x (`docs/design-decisions.md`), and each already had an opt-in flag in 2.x
111
+ (`--edit-list`, `--keep-hevc`, `--vfr-guard sampled`).
112
+
107
113
  ## What 2.0 changed
108
114
 
109
115
  `contract --json`'s `removed` list is the machine-readable form of this table, one entry per
@@ -123,18 +129,30 @@ value (a number went to `metrics`, the same key holding `null` went to `details`
123
129
  could not know where to look without knowing the value first, and every consumer of every tool's
124
130
  JSON would have been rewritten for that. The flat keys, typed per tool in `output_schema`, stay.
125
131
 
132
+ ## What 3.0 changed
133
+
134
+ `contract --json`'s `removed` list carries the two floor changes (`removed_in: 3.0.0`); the
135
+ `cut.py` rows were decided in 2.x and recorded in `docs/design-decisions.md` ("Decided for 3.0").
136
+
137
+ | What changed in 3.0.0 | Deprecated in | 3.0 behaviour | Migrating from 2.x |
138
+ |---|---|---|---|
139
+ | Running on FFmpeg 5.x (`requirements.ffmpeg` `>=5.0`) | 2.14.0 | `requirements.ffmpeg`/`ffprobe` are `>=6.1`. Every tool refuses an `ffmpeg`/`ffprobe` whose version it can read as older than 6.1 (`kind: missing_tool`, exit 127, `error.reason: ffmpeg_unsupported`, `details: {version, required}`); a git/vendor build whose version is unreadable is not refused. `doctor` lists it in `unsupported` with `ok: false`; the `npx` installer prints the same "not supported" line. CI dropped its 5.1.1 job | Install FFmpeg 6.1 or later (Ubuntu 24.04's apt package is 6.1) |
140
+ | Running on Python 3.9 (`requirements.python` `>=3.9`) | 2.10.9 | `requirements.python` is `>=3.10 (standard library only)`. The `npx` installer skips an older interpreter (and warns when none is found); `doctor` lists it in `unsupported` with `ok: false`. CI's floor is 3.10 | Run the scripts with Python 3.10 or later |
141
+ | `cut.py` defaults: `-avoid_negative_ts make_zero` on a single `.mp4`/`.mov` stream copy; x264 for an SDR HEVC source's re-encode; `--vfr-guard average` | decided in 2.x (`docs/design-decisions.md`) | the edit list is kept by default (`edit_list: true`, the picture starts at `--start`); an SDR HEVC source's re-encode stays HEVC; `--vfr-guard` defaults to `sampled`. `--edit-list` and `--keep-hevc` are still accepted | `--no-edit-list`, `--no-keep-hevc` and `--vfr-guard average` restore the 2.x behaviour |
142
+ | `cut.py`'s `precision` and `keyframe_snapped` | decided in 2.x (`docs/design-decisions.md`) | `precision` is the least exact segment's, equal to `least_exact_precision` (which stays); `keyframe_snapped` equals `start_snapped`: a copied start really moved (boolean, or `null` under `--dry-run`). `contract_version` is `2.0` | Read `start_snapped`/`keyframe_snapped` for "did the start move"; a 2.x "stream copy end to end" test is `reencoded: false` |
143
+
126
144
  ## Skill
127
145
 
128
146
  ```json
129
147
  {
130
- "contract_version": "1.0",
131
- "deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.10.9", "replacement": "Python 3.10 or later; ...", "removed_in": "3.0.0", "where": "behaviour"},
132
- {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
133
- "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
134
- "skill": {"id": "ffmpeg-skill", "version": "2.24.1", "execution_mode": "local", "kind": "execution",
148
+ "contract_version": "2.0",
149
+ "deprecated": [],
150
+ "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
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.1.0", "execution_mode": "local", "kind": "execution",
135
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
136
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
137
- "requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.10.9, removed in 3.0.0; 3.10+ recommended)", "ffmpeg": ">=5.0 (5.x deprecated since 2.14.0, removed in 3.0.0; 6.1+ recommended)", "ffprobe": ">=5.0"},
155
+ "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
138
156
  "execution": {"shell": false, "arbitrary_executables": false, "network": false,
139
157
  "model_downloads": {"when": "...", "in_process": {"faster-whisper": "..."},
140
158
  "child_process": {"parakeet-mlx": "...", "openai-whisper": "..."},
@@ -249,8 +267,9 @@ too, beside a converted clip). A colour a tool draws from a hex or named value (
249
267
  `color`/`gradients`, `waveform.py`'s plate and waves) lands in the same BT.709: from FFmpeg 7.1
250
268
  an untagged picture is labelled for the drawing filters, before 7.1 the drawing runs on an RGB copy
251
269
  and only the pixels it touched are taken from it (`_common.drawn_chain()`); a grey changes nothing.
252
- `cut.py --keep-hevc` (opt-in) re-encodes an SDR HEVC source as x265 8-bit BT.709 instead of x264; an HDR or
253
- BT.2020 source keeps the Main10 line with its own colour tags, with or without the flag. The
270
+ `cut.py` re-encodes an SDR HEVC source as x265 8-bit BT.709 (the default since 3.0.0, `--keep-hevc`;
271
+ `--no-keep-hevc` uses x264 as 2.x did); an HDR or BT.2020 source keeps the Main10 line with its own
272
+ colour tags either way. The
254
273
  encoder each value needs is listed under the tool's optional capabilities (`--codec hevc` and
255
274
  so on). `export.py` refuses `--codec`: its presets decide the codec.
256
275
 
@@ -465,9 +484,15 @@ acompressor A->A`) by the io-spec token, so the flag width does not matter; fixt
465
484
  both live in `tests/fixtures/`.
466
485
 
467
486
  `ffmpeg-skill doctor` exits 0 when every required capability is available, 1 when one is
468
- missing, 2 when none is missing but a required one is unknown. `ok` is true only for 0. The
487
+ missing or the Python/FFmpeg is below the floor (`unsupported`, 3.0.0), 2 when none of those
488
+ holds but a required capability is unknown. `ok` is true only for 0. The
469
489
  keys of 0.9.0 (`available`, `missing`, `missing_optional`, `ok`) are unchanged.
470
490
 
491
+ `doctor`'s `unsupported` (3.0.0) lists one line per requirements floor this machine is below: a
492
+ Python older than 3.10, or an `ffmpeg` whose version reads as older than 6.1 (a git/vendor version
493
+ that is not a release number is never listed). The lines are also printed to stderr, and a
494
+ non-empty list makes `ok` false and the exit code non-zero; `[]` on a supported machine.
495
+
471
496
  One capability can be neither: `encoder:libx264` (`encoder:libx265`) absent from the build while
472
497
  `h264_videotoolbox` (`hevc_videotoolbox`) is in it, on a machine that can run VideoToolbox (the
473
498
  check `--hw` uses: macOS on Apple Silicon). The tools encode with VideoToolbox there without
@@ -483,7 +508,9 @@ the tool needs from a build, not which encoder satisfies it on a given machine.
483
508
  `{"<tool>": {"usable": "yes"|"no"|"unknown", "missing": [...], "fix": "...", "unknown": [...], "covered_by": {...}}}`.
484
509
  `missing`/`unknown` list only that tool's own required capabilities that are in that state
485
510
  (`missing` is absent when there is none, same for `unknown`); `fix` is a one-line, plain-language
486
- remedy for each missing capability, joined with "; " when there is more than one. This exists so
511
+ remedy for each missing capability, joined with "; " when there is more than one. Since 3.0.0 a
512
+ Python or FFmpeg below the floor makes every tool `usable: "no"`, with doctor's `unsupported`
513
+ lines repeated under the tool's `unsupported` and the upgrade in `fix`. This exists so
487
514
  a caller does not have to cross-reference `available`/`missing` against each tool's own required
488
515
  capabilities by hand to answer "can I run `caption.py` on this machine right now" -- `doctor`
489
516
  passing overall does not mean every tool is usable (a plain Homebrew `ffmpeg` on macOS is `ok`
@@ -585,7 +612,7 @@ it never moves `ok` or any tool's `usable`.
585
612
  (`{"mode", "count", "clusters", "assets", "missing", "overlays"}`) on `caption.py` and
586
613
  `graphics.py`, and `text_renderer` (`"ass"`|`"drawtext"`), `script` and `ass` (the generated file,
587
614
  when one was written) on `graphics.py`. `--json-brief` carries `emoji.mode` and `emoji.count` only.
588
- All additive: `contract_version` stays 1.0.
615
+ All additive: `contract_version` stayed 1.0 (it became 2.0 in 3.0.0).
589
616
 
590
617
  ## Invocation
591
618
 
@@ -682,11 +709,11 @@ Per-tool keys added after 2.2.2, all additive:
682
709
  | `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 |
683
710
  | `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 |
684
711
  | `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` |
685
- | `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. `precision` and `keyframe_snapped` keep their meaning (`keyframe_snapped` is `precision == "packet"`, a stream copy end to end); `least_exact_precision` differs from `precision` only for an audio-only join of copied and re-encoded parts. `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 |
686
- | `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 run with `--edit-list`, which keeps the MP4 edit list the copy writes instead of `-avoid_negative_ts make_zero` (the default, unchanged): 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. Under `--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` under `--edit-list`), a `notes` line names `--accurate`, and `--edit-list` for a single `.mp4`/`.mov` copy whose start snapped. 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) |
712
+ | `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 |
713
+ | `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) |
687
714
  | `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` |
688
715
  | `cut_reencode_reasons` | `batch.py` (and its `results` rows) | a row whose recipe ran `cut.py` lists each `reencode_reason` its `cut.py` calls named, once per call, beside `cut_reencoded`; the top level counts them across the folder (`{}` when no call re-encoded, `null` when no `cut.py` step ran) on a completed batch, as `cut_stream_copy` does: a failure document carries `results`, whose rows hold each row's reasons; a re-encoded call in a row served from a cache an earlier 2.x batch wrote, which recorded no reasons, counts as `unknown`. `cut_stream_copy` is unchanged |
689
- | `vfr_check` | `cut.py` (a video input that could still stream-copy) | the sampled frame timing, `{heuristic: true, measured, method: "sampled", guard, windows, deltas}`; `measured` is `sampled_cfr`, `vfr` or `inconclusive`. `--vfr-guard average` (the default) keeps the 2.x guard: a source whose nominal and average frame rates differ (`variable_frame_rate_suspected`) re-encodes as `--accurate` (reason `vfr`, stderr says "variable-frame-rate"), and only then is the timing sampled and reported, with a `notes` line naming `--vfr-guard sampled` when it measured `sampled_cfr`; the key is absent otherwise. `--vfr-guard sampled` samples before every copy and re-encodes on `vfr` (reason `vfr`) or `inconclusive` (fewer than 20 intervals; `vfr_inconclusive`); `--vfr-guard off` samples and keeps the copy, with a `notes` line. Absent for audio-only input or output, `--accurate`, `--codec` and a pending `--dry-run` input |
716
+ | `vfr_check` | `cut.py` (a video input that could still stream-copy) | the sampled frame timing, `{heuristic: true, measured, method: "sampled", guard, windows, deltas}`; `measured` is `sampled_cfr`, `vfr` or `inconclusive`. `--vfr-guard average` (2.x's default) keeps the 2.x guard: a source whose nominal and average frame rates differ (`variable_frame_rate_suspected`) re-encodes as `--accurate` (reason `vfr`, stderr says "variable-frame-rate"), and only then is the timing sampled and reported, with a `notes` line naming `--vfr-guard sampled` when it measured `sampled_cfr`; the key is absent otherwise. `--vfr-guard sampled` (the default since 3.0.0) samples before every copy and re-encodes on `vfr` (reason `vfr`) or `inconclusive` (fewer than 20 intervals; `vfr_inconclusive`); `--vfr-guard off` samples and keeps the copy, with a `notes` line. Absent for audio-only input or output, `--accurate`, `--codec` and a pending `--dry-run` input |
690
717
  | `join_check`, `segment_end_snap_seconds` | `cut.py --segments` | `join_check`: a video stream-copy join measured by demuxing, `{packets, expected_packets, max_step_seconds, audio_offset_ms, audio_parts_checked, ok}`. `expected_packets` counts the source's video packets from each part's start keyframe to its end (or the end of the video); `max_step_seconds` is the largest presentation step, `null` when `vfr_check` measured anything but `sampled_cfr` (a `--vfr-guard off` join of variable timing: count only), and each step at a join must be the step its parts predict within 1 ms. `audio_offset_ms`: per part, its sound's shift minus its picture's in ms (+ = sound late), from audio packets matched by CRC32 to the source stream shaped like the joined audio (FFmpeg's default selection may copy one other than `a:0`; an MPEG-TS source's AAC is hashed without its ADTS headers); `null` for a part with no unique match (digital silence, a periodic tone), and as a whole when the source has no audio or the video check failed. `audio_parts_checked` counts the measured parts. `ok` is the conjunction (count, steps, every measured part within 5 ms); `ok: false` means the join was re-cut (`concat_fallback`) and this is the failed measurement. `null` for a single segment, audio, `--dry-run`, any re-encode forced for every segment, a segment that re-encoded, or a join re-cut before a copy was tried (open GOPs, a later B-frame segment starting between keyframes, parts that differ, a concat error). `segment_end_snap_seconds`: per segment, the signed seconds its end moved to the keyframe the copied part stops at, the first at or after the requested end (`null` for a part that reaches the end of the video, a part of a source with no B-frames, cut to its requested end as in 2.5.1, or a Matroska/MPEG-TS part, which is not snapped); `null` as a whole unless the output is those copied parts |
691
718
 
692
719
  Keys added in 2.18, all additive. They make a result carry what the VDC paper
@@ -723,6 +750,7 @@ lost 40 s of sound with no error):
723
750
  | `probe.start_time` | `probe.py` and every output probe | where the file's timeline starts (the container's start time; MPEG-TS starts at about 1.4 s). A `-ss`, a cut's `--start` and the decode check's reads count from here |
724
751
  | `probe.stream_counts` | `probe.py` and every output probe | `{video, audio}`: how many picture streams (a cover picture is not one) and sound streams the file carries |
725
752
  | `probe.video.tagged_duration` | `probe.py` and every output probe | the video stream's DURATION tag in seconds (Matroska/WebM give no stream duration), as `audio.tagged_duration` since 2.20 |
753
+ | `probe.timecode` | `probe.py` and every output probe | `{value, drop_frame, source}` (added in 3.0.0): the source's start timecode, the first `HH:MM:SS[:;.]FF` `timecode` tag from the video stream, then a `tmcd` data stream, then the container. `drop_frame` is true for a `;` or `.` before the frames field; `source` is `stream:<ffprobe index>` or `format`. All three are `null` when nothing carries one; a malformed tag is skipped, not repaired |
726
754
 
727
755
  Keys added after 2.21.1, all additive (requests on X: a map of what a silence cut removed, and a
728
756
  pointer to cuts that may sit in a breath):
@@ -760,7 +788,19 @@ auto` found engines and none transcribed, the refusal is `kind: input` with `rea
760
788
  engines auto passed over for a language that is not English), `engine` (the first one it names)
761
789
  and `engines: [{engine, reason, detail}]`, where `detail` carries an engine's own error line. A
762
790
  named `--engine` that is not installed is `kind: missing_tool`; one that is installed and failed
763
- is `kind: input`, `reason: "engine_failed"`, with `engine` and `detail`.
791
+ is `kind: input`, `reason: "engine_failed"`, with `engine` and `detail`. Since 3.1.0 the "no local
792
+ speech-to-text engine found" refusal itself is `kind: missing_tool` with `reason:
793
+ "asr_engine_missing"` (it was `kind: input`): a missing dependency, like a named engine that is
794
+ not installed.
795
+
796
+ An ffmpeg run that fails keeps `kind: ffmpeg` and exit code 1, and since 3.1.0 carries
797
+ `error.reason` and a `hint` when ffmpeg's own message names a cause a caller can act on
798
+ (`_common.runner.FFMPEG_FAILURE_CLASSES`): `disk_full` ("No space left on device"),
799
+ `encoder_missing` and `filter_missing` (`details.name`; the build lacks it, `doctor` says how to
800
+ get one that has it), `input_incomplete` ("moov atom not found": a cut-off recording or an
801
+ unfinished copy, which `--repair` cannot rebuild) and `input_unreadable`. A tool that copies the
802
+ source's subtitle and data streams retried the whole encode without them after any failure; it
803
+ now retries only when the source has such a stream and the failure is none of these.
764
804
 
765
805
  `check.py` also gains an informational `subtitles` row on **every** platform:
766
806
  `PASS` when every soft subtitle stream carries a language tag, `WARN` when one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.24.1",
3
+ "version": "3.1.0",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -110,8 +110,11 @@ mechanism, and write the skip/relaxation reason as a full sentence
110
110
  explaining the underlying platform behaviour — not just "flaky on
111
111
  Windows" — so a future reader doesn't have to re-derive it from the CI log.
112
112
 
113
- ## FFmpeg 5.x (the 5.1.1 static-build CI job)
113
+ ## FFmpeg 5.x (the 5.1.1 static-build CI job, removed in 3.0.0)
114
114
 
115
+ Historical: 3.0.0 removed FFmpeg 5.x support (deprecated in 2.14.0) and this CI job with it, so
116
+ the tools now refuse a build older than 6.1 and its version branch for `boxborderw` (below) is
117
+ gone. Kept as a record of what 5.x did differently.
115
118
  Found the day the job was added (#146); 6.1+ behaves the same on all three OSes, so none of
116
119
  these had ever shown up before.
117
120
 
@@ -122,9 +125,9 @@ these had ever shown up before.
122
125
  sides of it disappear from its neighbourhood test. 6.1+ passes every frame regardless.
123
126
  Dropped the option; scores are also indexed by frame number now, missing frames counting as 0.
124
127
  - **`drawtext` `boxborderw=v|h` (and the four-value form) is 6.1+.** 5.x and 6.0 fail the whole
125
- filter with "Error setting option boxborderw to value 9|16". `_common.drawtext_boxborderw()`
126
- emits the larger single value on older builds (`_common.ffmpeg_version()` parses
127
- `ffmpeg -version` once; it is the only place the tools branch on a version string). Both names
128
+ filter with "Error setting option boxborderw to value 9|16". Through 2.x
129
+ `_common.drawtext_boxborderw()` emitted the larger single value on older builds
130
+ (`_common.ffmpeg_version()` parses `ffmpeg -version` once); since 3.0.0 it always writes `v|h`. Both names
128
131
  are still spelled exactly that way: `_common` is a package since the refactor release after
129
132
  1.15.0, and its `__init__.py` re-exports every name the single module defined, so
130
133
  `_common.drawtext_boxborderw()`, `_common.ffmpeg_version()` and `_common.bt709_tag_args()`
@@ -5,9 +5,9 @@ toolchain on real files, not from spec sheets.
5
5
 
6
6
  | Source | What you see | What it means for the edit |
7
7
  |---|---|---|
8
- | iPhone (iOS 15+) HDR video | `hdr_format: Dolby Vision profile 8`, HLG transfer, 10-bit HEVC, `variable_frame_rate_suspected: true`, `rotation` -90/90 for portrait, extra timecode/metadata tracks | Every re-encode keeps HDR automatically (HEVC Main10 + tags). Use `color.py --to-sdr` first only when the destination is SDR-only (X, LinkedIn, most web players). Only the first audio track is used. Lossless cuts fall back to accurate cuts because of VFR. |
8
+ | iPhone (iOS 15+) HDR video | `hdr_format: Dolby Vision profile 8`, HLG transfer, 10-bit HEVC, `variable_frame_rate_suspected: true`, `rotation` -90/90 for portrait, extra timecode/metadata tracks | Every re-encode keeps HDR automatically (HEVC Main10 + tags). Use `color.py --to-sdr` first only when the destination is SDR-only (X, LinkedIn, most web players). Only the first audio track is used. Lossless cuts fall back to accurate cuts when the sampled frame timing is variable (`cut.py`'s `vfr_check`). |
9
9
  | iPhone SDR / older | HEVC or H.264 4K60, mono AAC | Mono audio: `audio.py --stereo` before music/ducking if the deliverable expects stereo. |
10
- | GoPro HERO | HEVC 4K 10-bit **SDR** (`hdr: false`, `yuv420p10le`), stereo, GPMF data track | 10-bit does not mean HDR; do not tone-map. Re-encodes are 8-bit H.264 unless the user wants a 10-bit master (`export.py --preset prores`). |
10
+ | GoPro HERO | HEVC 4K 10-bit **SDR** (`hdr: false`, `yuv420p10le`), stereo, GPMF data track | 10-bit does not mean HDR; do not tone-map. Re-encodes are 8-bit H.264 (a `cut.py` re-encode stays 8-bit HEVC since 3.0.0) unless the user wants a 10-bit master (`export.py --preset prores`). |
11
11
  | DJI drone | HEVC 4K 50/60, **no audio** | Audio-dependent steps (sync, silence, loudness, ducking) do not apply; add narration or music with `audio.py --replace/--music`. D-Log profiles look flat: `probe.py --analyze` -> `looks_like_log` -> `color.py --lut`. |
12
12
  | Android screen recording | H.264, odd sizes (720x1600, 1298x1080), 18-120 fps, strongly VFR | Conform with `fit.py --fps 30` (or 60) before anything else; check aspect with `check.py`. |
13
13
  | Zoom / Teams recordings | H.264 720p-1080p, low bitrate, long, often VFR | `silence.py` and `scenes.py` work; expect long processing on hour-long files; `--fast` for previews. |