ffmpeg-skill 2.10.9 → 2.10.11
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 +2 -2
- package/SKILL.md +1 -1
- package/bin/install.js +2 -2
- package/docs/contract.md +12 -9
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +9 -1
- package/references/scripts.md +21 -2
- package/scripts/_common/__init__.py +5 -5
- package/scripts/_common/drawtext.py +65 -6
- package/scripts/_common/runner.py +19 -0
- package/scripts/_common/text.py +1 -1
- package/scripts/_contract.py +13 -10
- package/scripts/color.py +142 -41
- package/scripts/overlay.py +66 -22
package/README.md
CHANGED
|
@@ -403,7 +403,7 @@ ffmpeg-skill edits footage you already have, on this machine, with FFmpeg tools
|
|
|
403
403
|
|
|
404
404
|
### Does it validate the filter graph before running?
|
|
405
405
|
|
|
406
|
-
No. `--dry-run` prints the ffmpeg command a writing tool would run and does not start ffmpeg, so ffmpeg never parses that filter graph. ffprobe still runs when the tool probes an input that exists. Unless `--overwrite` is passed, an existing output is refused (`kind: input`) before ffmpeg. An output that is the same file as an input is refused either way. No media file is written, and `verified` is false; `--plan FILE` does write the plan JSON. A path that is not on disk yet is not measured: the probe is a stub (0×0, duration 0), so a number taken from it is not a measurement. The measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) still run their measurements; only the artifact is skipped, and `silence` with `--filler --transcribe` also runs the speech engine, which may download a model. `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, 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.
|
|
406
|
+
No. `--dry-run` prints the ffmpeg command a writing tool would run and does not start ffmpeg, so ffmpeg never parses that filter graph. ffprobe still runs when the tool probes an input that exists. Unless `--overwrite` is passed, an existing output is refused (`kind: input`) before ffmpeg. An output that is the same file as an input is refused either way. No media file is written, and `verified` is false; `--plan FILE` does write the plan JSON. A path that is not on disk yet is not measured: the probe is a stub (0×0, duration 0), so a number taken from it is not a measurement. The measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) still run their measurements; only the artifact is skipped, and `silence` with `--filler --transcribe` also runs the speech engine, which may download a model. `overlay` measures the lines of a multi-line `--text` with one drawtext on a generated frame on FFmpeg 8.0, 8.0.1 and 8.1.0, where it draws them one by one. `verify` accepts `--dry-run` and ignores it: its steps run. `--plan FILE` is that dry run written down — the planned argv, the command lines, and a fingerprint of each existing input (size, plus a sha256 of the first and last 8 MiB). `render.py` and `verify.py` refuse `--plan`. `render.py FILE` runs a plan only when those fingerprints still match, 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.
|
|
407
407
|
|
|
408
408
|
### Can it remove burned-in subtitles or logos?
|
|
409
409
|
|
|
@@ -419,7 +419,7 @@ The encode path a tool will actually take is Apple VideoToolbox, for h264, hevc
|
|
|
419
419
|
|
|
420
420
|
## FFmpeg compatibility
|
|
421
421
|
|
|
422
|
-
The tools need FFmpeg 5.0 or later and Python 3.9 or later (standard library only); Python 3.10+ is recommended: 3.9 still works but is deprecated since 2.
|
|
422
|
+
The tools need FFmpeg 5.0 or later and Python 3.9 or later (standard library only); Python 3.10+ is recommended: 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 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:
|
|
423
423
|
|
|
424
424
|
| FFmpeg | `-filters` row layout | Source |
|
|
425
425
|
|---|---|---|
|
package/SKILL.md
CHANGED
|
@@ -9,7 +9,7 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
|
|
|
9
9
|
|
|
10
10
|
Shared flags (every script): `--dry-run`; `--json` (output path, its probe, the commands run); `--json-brief` (status/output/verified + a `summary`; prefer on writing steps); `--fast` (preview quality); `--progress` (percent/ETA); `--timeout SECONDS` (`kind: timeout`, default 1800); `--overwrite` (step 7); `--plan FILE` (a dry run `render.py FILE` runs later; refuses if an input changed). Re-encoding tools also take `--hw` (Apple Silicon GPU, big files; `FFMPEG_SKILL_HW=1` = default, not export), `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264 (`cut.py --keep-hevc`: HEVC source stays HEVC), HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
|
|
11
11
|
|
|
12
|
-
Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) may still run ffmpeg/ffprobe, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`); `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
|
|
12
|
+
Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`; `overlay` for a multi-line `--text` on FFmpeg 8.0-8.1.0) may still run ffmpeg/ffprobe, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`); `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
|
|
13
13
|
|
|
14
14
|
## Workflow (in this order)
|
|
15
15
|
|
package/bin/install.js
CHANGED
|
@@ -47,9 +47,9 @@ if (has('--help') || has('-h')) {
|
|
|
47
47
|
// The probe also prints major.minor, so the installer can warn about a deprecated interpreter.
|
|
48
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
49
|
|
|
50
|
-
// 2.
|
|
50
|
+
// 2.10.9 deprecates Python 3.9 (docs/contract.md "Deprecation policy"; `contract --json`'s
|
|
51
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.
|
|
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
53
|
|
|
54
54
|
function pythonDeprecation(version) {
|
|
55
55
|
return version === '3.9' ? PY39_DEPRECATION : null;
|
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`2.10.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.10.11`) | 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`
|
|
@@ -81,12 +81,12 @@ 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.
|
|
84
|
+
naming the surface (`cli`, `json`, `mcp`, `behaviour`). It was empty in 2.0.0. Since 2.10.9 it lists one
|
|
85
85
|
entry: **running on Python 3.9** (`where: behaviour`, `removed_in: 3.0.0`). CPython 3.9 reached end of life
|
|
86
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
87
|
(`requirements.python` stays `>=3.9`, and CI keeps testing it). It is deprecated, and Python 3.10+ is
|
|
88
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.
|
|
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
90
|
Like every deprecation warning it is stderr only: `doctor --json`'s document has no key for it.
|
|
91
91
|
`tests/test_contract.py` refuses a package.json major bump that still lists an entry that major should remove, or that
|
|
92
92
|
comes before step 2's window has passed -- unless the waiver is written here, as 2.0.0's is.
|
|
@@ -123,12 +123,12 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
123
123
|
```json
|
|
124
124
|
{
|
|
125
125
|
"contract_version": "1.0",
|
|
126
|
-
"deprecated": [{"what": "running on Python 3.9 (the requirements.python floor)", "since": "2.
|
|
126
|
+
"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"}],
|
|
127
127
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
128
|
-
"skill": {"id": "ffmpeg-skill", "version": "2.10.
|
|
128
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.10.11", "execution_mode": "local", "kind": "execution",
|
|
129
129
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
130
130
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
131
|
-
"requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.
|
|
131
|
+
"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", "ffprobe": ">=5.0"},
|
|
132
132
|
"execution": {"shell": false, "arbitrary_executables": false, "network": false,
|
|
133
133
|
"model_downloads": {"when": "...", "in_process": {"faster-whisper": "..."},
|
|
134
134
|
"child_process": {"parakeet-mlx": "...", "openai-whisper": "..."},
|
|
@@ -163,7 +163,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
|
|
|
163
163
|
| `produces_artifact` | writes a file (media, PNG, HTML, EDL) |
|
|
164
164
|
| `verification` | `{required, tools}`: which tools to run on the output afterwards |
|
|
165
165
|
| `requires_visual_verification` | the picture changed; run `ffmpeg-skill/look` and inspect the PNG |
|
|
166
|
-
| `reencodes_video`, `reencodes_audio` | `"always"` / `"never"` / `"conditional"`, meaning *when that stream is present in the input* — not whether the tool touches the file at all. `"conditional"` tools (`cut`, `export`, `render`, `batch`, `verify`, `caption`, `color`) carry a `reencode_note` explaining what it depends on — for `caption`, `--mode burn` (default) always re-encodes both streams, `--mode mux` copies both untouched; for `color`, `--strip-dovi` and `--retag` are a stream copy of both (retag only re-encodes if the copy attempt fails), while `--to-sdr` / `--lut` / `--correct` always re-encode both. Several visual tools (`fit`, `overlay`, `graphics`, `join`, `multicam`, `silence`) are `"always"` on audio too: this codebase never mixes `-c:v` re-encode with `-c:a copy` in one call, so a caller cannot assume the original audio codec survives just because only the picture changed |
|
|
166
|
+
| `reencodes_video`, `reencodes_audio` | `"always"` / `"never"` / `"conditional"`, meaning *when that stream is present in the input* — not whether the tool touches the file at all. `"conditional"` tools (`cut`, `export`, `render`, `batch`, `verify`, `caption`, `color`) carry a `reencode_note` explaining what it depends on — for `caption`, `--mode burn` (default) always re-encodes both streams, `--mode mux` copies both untouched; for `color`, `--strip-dovi` and `--retag` are a stream copy of both (retag only re-encodes if the copy attempt fails or ffprobe still reads the old tags on the copy), while `--to-sdr` / `--lut` / `--correct` always re-encode both. Several visual tools (`fit`, `overlay`, `graphics`, `join`, `multicam`, `silence`) are `"always"` on audio too: this codebase never mixes `-c:v` re-encode with `-c:a copy` in one call, so a caller cannot assume the original audio codec survives just because only the picture changed |
|
|
167
167
|
| `audio_only` | accepts an audio-only input (WAV, MP3, M4A, FLAC, OGG, Opus) |
|
|
168
168
|
| `video_required` | refuses an input without a video stream ("input has no video stream") |
|
|
169
169
|
| | `join` has `audio_only: true` and `video_required: false` since 0.9.1: audio-only inputs are joined as audio (no `look` needed then); mixing audio and video inputs is refused |
|
|
@@ -212,7 +212,9 @@ exceptions are stated per tool in the contract's `dry_run` field: `probe` and `c
|
|
|
212
212
|
read-only (ffprobe still runs); `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`,
|
|
213
213
|
`loudness` and `stabilize` still run their ffmpeg/ffprobe measurements (the analysis is the
|
|
214
214
|
tool's job; only the artifact is skipped, including side files such as `--edl`, `--sheet` or a
|
|
215
|
-
generated `.ass`),
|
|
215
|
+
generated `.ass`), `overlay` measures the lines of a multi-line `--text` with one drawtext on a
|
|
216
|
+
generated frame on FFmpeg 8.0, 8.0.1 and 8.1.0 (it draws them one by one there), and `verify`
|
|
217
|
+
does not support dry-run (its steps run). `SKILL.md` and
|
|
216
218
|
`references/scripts.md` repeat the same list; the contract is the authority.
|
|
217
219
|
|
|
218
220
|
`--codec h264|hevc|av1|prores` and `--quality N` (1.8) are on every tool that re-encodes,
|
|
@@ -696,7 +698,8 @@ file was written. `probe` prints its measurement document directly.
|
|
|
696
698
|
Every writing tool also reports what it verified itself (1.7): `verification` lists the
|
|
697
699
|
steps (`{"step": "probe", "ok": true}`; `loudness` with the measured and target values for
|
|
698
700
|
`loudness.py` and the platform presets of `export.py`; `check` with the platform for
|
|
699
|
-
`render.py
|
|
701
|
+
`render.py`; `color_tags` with the tags ffprobe read back for `color.py --retag`, which fails
|
|
702
|
+
with `kind: verification` when they are not the requested ones), and `verified` is `true` only when the artifact was written, probed and every
|
|
700
703
|
listed step met its target. A `--dry-run` document has `verified: false` and an empty list.
|
|
701
704
|
`export.py` whose written file misses the platform's loudness spec stays `completed` (the
|
|
702
705
|
file is valid) with `verified: false` and the fix in `notes`, so a caller keys on one field.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.11",
|
|
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",
|
|
@@ -179,7 +179,15 @@ these had ever shown up before.
|
|
|
179
179
|
`ametadata=mode=select`; do not use `aselect` for removal.
|
|
180
180
|
- **FFmpeg 8.0, 8.0.1 and 8.1.0 draw the font's missing-glyph box at each line break of a multi-line
|
|
181
181
|
`drawtext`** (each line is shaped with its newline; fixed in 8.0.2/8.1.1). `ffmpeg_version()` cannot
|
|
182
|
-
tell 8.1.0 from 8.1.1; `drawtext_draws_newline_glyph()` reads the point release.
|
|
182
|
+
tell 8.1.0 from 8.1.1; `drawtext_draws_newline_glyph()` reads the point release. graphics.py routes
|
|
183
|
+
such a label through libass; overlay.py draws one drawtext per line at positions a drawtext run
|
|
184
|
+
measured (`drawtext_line_metrics()`), since the multi-line block's own `text_w` carries the box.
|
|
185
|
+
- **`-colorspace`/`-color_primaries`/`-color_trc` are not tags on 7.1+.** They feed the filter
|
|
186
|
+
graph's colourspace negotiation: a bt709-tagged H.264 re-encoded with `smpte170m` options came
|
|
187
|
+
out with a converted matrix and bt709 primaries/transfer (8.0.1, 8.1.3). Label the frames with
|
|
188
|
+
`setparams` (or the encoder's own VUI params) instead. On a stream copy they change only the
|
|
189
|
+
container: H.264/HEVC/MPEG-2/AV1/VP9 keep their own description, which decoders read first, so
|
|
190
|
+
rewrite it with the codec's `*_metadata` bitstream filter (`color.py --retag`).
|
|
183
191
|
- **Not a 7.1 issue, found while chasing it: `color --correct` desaturates a bt709-*tagged*
|
|
184
192
|
source by ~8 % at identity settings on 6.1 and 7.1 alike** (113.6 → 103.9 saturation_avg):
|
|
185
193
|
the RGB stages (exposure/colortemperature/colorbalance) make swscale go yuv→rgb with the
|
package/references/scripts.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Script reference
|
|
2
2
|
|
|
3
|
-
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; every re-encoding tool and `export.py` also take `--hw`/`--no-hw` (h264/hevc/prores on Apple VideoToolbox, Apple Silicon only, CRF mapped to `-q:v`; AV1 stays on the CPU; a job VideoToolbox itself refuses -- the encoder cannot open or its compression session fails -- is re-encoded on the CPU and reported in `hw.fallback`/`hw.notes`, any other failure is not retried; a GPU encode adds a `notes` line that its quality is not CRF-equivalent; `FFMPEG_SKILL_HW=1` makes it the default for every tool except `export.py`, whose delivery presets need `--hw` itself; `render.py`/`batch.py --hw`/`--no-hw` apply to every stage, export included; results name the `encoder` that ran); every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, default 18 -- 30 for `proxy.py`; up to 63 for av1; ignored by prores). 2.0 removed `--crf` from these tools (it was `--quality`'s alias); `export.py` keeps its own `--crf`, which is not an alias. Every tool refuses (`kind: input`, before anything runs) to replace an existing output unless `--overwrite` is given. `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither `--codec` nor `--quality`; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize`/`join` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
|
|
3
|
+
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; every re-encoding tool and `export.py` also take `--hw`/`--no-hw` (h264/hevc/prores on Apple VideoToolbox, Apple Silicon only, CRF mapped to `-q:v`; AV1 stays on the CPU; a job VideoToolbox itself refuses -- the encoder cannot open or its compression session fails -- is re-encoded on the CPU and reported in `hw.fallback`/`hw.notes`, any other failure is not retried; a GPU encode adds a `notes` line that its quality is not CRF-equivalent; `FFMPEG_SKILL_HW=1` makes it the default for every tool except `export.py`, whose delivery presets need `--hw` itself; `render.py`/`batch.py --hw`/`--no-hw` apply to every stage, export included; results name the `encoder` that ran); every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, default 18 -- 30 for `proxy.py`; up to 63 for av1; ignored by prores). 2.0 removed `--crf` from these tools (it was `--quality`'s alias); `export.py` keeps its own `--crf`, which is not an alias. Every tool refuses (`kind: input`, before anything runs) to replace an existing output unless `--overwrite` is given. `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither `--codec` nor `--quality`; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize`/`join` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), `overlay` measures a multi-line `--text`'s lines on FFmpeg 8.0, 8.0.1 and 8.1.0 (one drawtext on a generated frame), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
|
|
4
4
|
|
|
5
5
|
## Time grammar (every time-taking flag, 1.9)
|
|
6
6
|
|
|
@@ -1497,6 +1497,11 @@ Devanagari/Thai-class scripts and cannot load a colour emoji font: a shaping
|
|
|
1497
1497
|
script is refused by name pointing at `caption.py`/`graphics.py`, and
|
|
1498
1498
|
`--emoji png|color` is refused the same way (`--emoji mono`, the default here,
|
|
1499
1499
|
draws whatever glyph the text font has; `--emoji none` strips them).
|
|
1500
|
+
A multi-line `--text` is one block: the lines are left-aligned and the block is
|
|
1501
|
+
placed by `--position`. FFmpeg 8.0, 8.0.1 and 8.1.0 draw the font's missing-glyph box
|
|
1502
|
+
at each line break, so on those releases each line gets its own drawtext at the
|
|
1503
|
+
place the block puts it (measured by one drawtext run on a generated frame, also
|
|
1504
|
+
under `--dry-run`); the picture is the one a fixed release draws.
|
|
1500
1505
|
|
|
1501
1506
|
### sync.py — offset detection, alignment, drift correction
|
|
1502
1507
|
```
|
|
@@ -1539,7 +1544,7 @@ them.
|
|
|
1539
1544
|
```
|
|
1540
1545
|
color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|bt2390] [--peak 1000] [--desat 0] [-o OUT]
|
|
1541
1546
|
color.py INPUT --lut grade.cube [--lut-strength 0..1] [-o OUT]
|
|
1542
|
-
color.py INPUT --retag bt709|bt2020-pq|bt2020-hlg|bt601 [-o OUT] # metadata only, stream copy
|
|
1547
|
+
color.py INPUT --retag bt709|bt2020-pq|bt2020-hlg|bt601 [-o OUT] # metadata only, stream copy (checked with ffprobe)
|
|
1543
1548
|
color.py INPUT --strip-dovi [-o OUT] # drop the Dolby Vision RPU, keep the HLG/HDR10 base layer (stream copy)
|
|
1544
1549
|
color.py INPUT --correct [--exposure -3..3] [--contrast 0..2] [--saturation 0..2] [--gamma 0.1..10]
|
|
1545
1550
|
[--temperature 2000..12000] [--tint -1..1] [--lift -1..1] [--gain -1..1]
|
|
@@ -1570,6 +1575,20 @@ tetrahedral interpolation (Log→709 conversions, creative looks); blend with
|
|
|
1570
1575
|
`--lut-strength`. Everything else in the skill assumes SDR BT.709, so run this
|
|
1571
1576
|
first on HDR or Log sources.
|
|
1572
1577
|
|
|
1578
|
+
`--retag` relabels, never converts. H.264, HEVC, MPEG-2, AV1 and VP9 carry
|
|
1579
|
+
their colour description in the stream itself, and decoders read that before
|
|
1580
|
+
the container's, so the stream copy rewrites it with the codec's
|
|
1581
|
+
`*_metadata` bitstream filter (the coded pictures are untouched) as well as
|
|
1582
|
+
the container's `colr`/Colour. Every run then probes the output, stream and
|
|
1583
|
+
first decoded frame (from FFmpeg 8.1 the two can disagree): when either
|
|
1584
|
+
still reads other tags (a codec without such a filter, or a build that lacks
|
|
1585
|
+
it) or the copy fails, the video is re-encoded with the tags on its frames
|
|
1586
|
+
(`setparams`), `reencoded: true` and a `notes` line say so, and the check is
|
|
1587
|
+
`verification`'s `color_tags` step. Tags that are still wrong after the
|
|
1588
|
+
re-encode fail the run with `kind: verification`. The re-encode is H.264
|
|
1589
|
+
unless `--codec` says otherwise, so an output container that cannot hold it
|
|
1590
|
+
(`.webm`) fails with `kind: ffmpeg` instead of completing with the old tags.
|
|
1591
|
+
|
|
1573
1592
|
### audio.py — clean-up, music, ducking, layout
|
|
1574
1593
|
```
|
|
1575
1594
|
audio.py INPUT [--voice [light|medium|strong] | --denoise [--denoise-strength 25]] [--gain dB]
|
|
@@ -56,7 +56,7 @@ from _common.runner import (
|
|
|
56
56
|
child_limit, _CHILDREN, _cleanup_partial_output, _cmdline, CODECS, Context, DEFAULT_TIMEOUT,
|
|
57
57
|
_DRAWTEXT_PENDING, _DRAWTEXT_TMPDIR, _drawtext_tmpdir, dry_run_input_pending, _ENCODERS, _env_timeout,
|
|
58
58
|
ERROR_CODE, ERROR_RETRYABLE, EVEN_SCALE, _execute, _fail, ffmpeg_base, ffmpeg_encoders, ffmpeg_exe, _FFMPEG_RELEASE, _FFMPEG_VERSION,
|
|
59
|
-
ffmpeg_release, ffmpeg_version, flush_drawtext_textfiles, INSTALL_HINTS, install_signal_handlers, _is_ffmpeg, _limit_for,
|
|
59
|
+
ffmpeg_bsfs, ffmpeg_release, ffmpeg_version, flush_drawtext_textfiles, INSTALL_HINTS, install_signal_handlers, _is_ffmpeg, _limit_for,
|
|
60
60
|
_odd_dimension_retry, _on_signal, _OutputLock, _pid_dead, place_output, PROBE_TIMEOUT, _progress_line,
|
|
61
61
|
read_text_or_die, refuse_output_is_input, _remember_output, require_tool, run, run_analysis, _run_captured,
|
|
62
62
|
run_keeping_subtitles, run_tool, _run_with_progress, shell_quote, sibling_temp, _SIGNALS_INSTALLED, _stage_existing_output,
|
|
@@ -96,7 +96,7 @@ from _common.color import (
|
|
|
96
96
|
)
|
|
97
97
|
from _common.text import (
|
|
98
98
|
ADVANCE_EM, BIDI_SCRIPTS, _char_em, char_script, default_font_file, detect_script, drawtext_boxborderw, drawtext_center_align,
|
|
99
|
-
drawtext_draws_newline_glyph, is_multiline, NEWLINE_GLYPH_RELEASES,
|
|
99
|
+
drawtext_draws_newline_glyph, is_multiline, drawtext_clean, drawtext_line_metrics, NEWLINE_GLYPH_RELEASES,
|
|
100
100
|
drawtext_shaping, drawtext_text_opts, emoji_asset_for, EMOJI_ASSET_HINT, emoji_clusters, emoji_codepoint_name,
|
|
101
101
|
_EMOJI_COLOR_FAMILIES, _emoji_color_font, emoji_filter_chain, _emoji_name_candidates, EMOJI_RANGES,
|
|
102
102
|
_EMOJI_REGIONAL, emoji_support, _EMOJI_SUPPORT_CACHE, _EMOJI_TAIL, escape_drawtext, _family_rank, FC_LANG,
|
|
@@ -147,7 +147,7 @@ class _Facade(_types.ModuleType):
|
|
|
147
147
|
# defining module (audit 14, P1-1); every other name is a plain re-export of the same object.
|
|
148
148
|
_LIVE = {
|
|
149
149
|
"_FFMPEG_VERSION": "runner", "_FFMPEG_RELEASE": "runner", "_FFMPEG_RELEASE_READ": "runner", "_SIGNALS_INSTALLED": "runner",
|
|
150
|
-
"_DRAWTEXT_TMPDIR": "runner", "_ENCODERS": "runner", "_CURRENT_CTX": "emit",
|
|
150
|
+
"_DRAWTEXT_TMPDIR": "runner", "_ENCODERS": "runner", "_BSFS": "runner", "_CURRENT_CTX": "emit",
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
def __getattribute__(self, name):
|
|
@@ -190,14 +190,14 @@ __all__ = [
|
|
|
190
190
|
"BEAT_REFRACTORY_S", "BEAT_WINDOW_S", "BEAT_SUPPORT_DIVISOR", "BEAT_ALIGN_DIVISOR",
|
|
191
191
|
"BEAT_Z_FLOOR", "BEAT_Z_SPAN",
|
|
192
192
|
"_onset_strength", "_pick_onsets", "_autocorrelation_peak", "_grid_score",
|
|
193
|
-
"default_font_file", "default_output", "DEFAULT_TIMEOUT", "detect_script", "die", "drawtext_boxborderw", "drawtext_center_align", "drawtext_draws_newline_glyph", "is_multiline", "NEWLINE_GLYPH_RELEASES",
|
|
193
|
+
"default_font_file", "default_output", "DEFAULT_TIMEOUT", "detect_script", "die", "drawtext_boxborderw", "drawtext_center_align", "drawtext_draws_newline_glyph", "is_multiline", "drawtext_clean", "drawtext_line_metrics", "NEWLINE_GLYPH_RELEASES",
|
|
194
194
|
"_DRAWTEXT_PENDING", "drawtext_shaping", "drawtext_text_opts", "_DRAWTEXT_TMPDIR", "_drawtext_tmpdir",
|
|
195
195
|
"dry_run_input_pending", "emit", "emoji_asset_for", "EMOJI_ASSET_HINT", "emoji_clusters",
|
|
196
196
|
"emoji_codepoint_name", "_EMOJI_COLOR_FAMILIES", "_emoji_color_font", "emoji_filter_chain",
|
|
197
197
|
"_emoji_name_candidates", "EMOJI_RANGES", "_EMOJI_REGIONAL", "emoji_support", "_EMOJI_SUPPORT_CACHE",
|
|
198
198
|
"_EMOJI_TAIL", "encoder_args", "_ENCODERS", "_env_timeout", "ERROR_CODE", "ERROR_RETRYABLE", "escape_drawtext",
|
|
199
199
|
"escape_filter_path", "EVEN_SCALE", "_execute", "_fail", "_family_rank", "FC_LANG", "_fc_list_fonts",
|
|
200
|
-
"FC_UNKNOWN", "ffmpeg_base", "ffmpeg_encoders", "ffmpeg_exe", "_FFMPEG_RELEASE", "_FFMPEG_VERSION", "ffmpeg_release", "ffmpeg_version", "fingerprint",
|
|
200
|
+
"FC_UNKNOWN", "ffmpeg_base", "ffmpeg_bsfs", "ffmpeg_encoders", "ffmpeg_exe", "_FFMPEG_RELEASE", "_FFMPEG_VERSION", "ffmpeg_release", "ffmpeg_version", "fingerprint",
|
|
201
201
|
"flush_drawtext_textfiles", "fold_bt709_matrix", "fmt_secs", "fmt_smpte_time", "fmt_srt_time", "font_covers_script",
|
|
202
202
|
"font_family_for_script", "font_family_of_file", "FONT_FLAG_HINT", "font_for_script", "FONT_INSTALL_HINT",
|
|
203
203
|
"fonts_dir_covers_script", "_fraction", "has_emoji", "info", "INSTALL_HINTS", "install_signal_handlers",
|
|
@@ -8,7 +8,7 @@ import os
|
|
|
8
8
|
import re
|
|
9
9
|
import shutil
|
|
10
10
|
import subprocess
|
|
11
|
-
from typing import Dict, Optional
|
|
11
|
+
from typing import Dict, List, Optional
|
|
12
12
|
from _common.decision import escape_filter_path
|
|
13
13
|
from _common.runner import STATE, _DRAWTEXT_PENDING, _drawtext_tmpdir, ffmpeg_exe, ffmpeg_release, ffmpeg_version
|
|
14
14
|
|
|
@@ -124,6 +124,17 @@ def drawtext_center_align() -> str:
|
|
|
124
124
|
return ":text_align=C" if ffmpeg_version() >= (6, 1) else ""
|
|
125
125
|
|
|
126
126
|
|
|
127
|
+
def drawtext_clean(text: "Optional[str]") -> str:
|
|
128
|
+
"""The text drawtext_text_opts() writes to its file: `\r\n` and `\r` become `\n`, a tab,
|
|
129
|
+
vertical tab or form feed becomes a space, other control characters are stripped, and trailing
|
|
130
|
+
newlines are dropped (drawtext ignores one trailing newline and draws a second as a blank
|
|
131
|
+
line, which would centre the block half a line too high; a label that is only newlines stays
|
|
132
|
+
as it is)."""
|
|
133
|
+
cleaned = (text or "").replace("\r\n", "\n").replace("\r", "\n")
|
|
134
|
+
cleaned = re.sub(r"[\x00-\x08\x0e-\x1f\x7f]", "", re.sub(r"[\t\x0b\x0c]", " ", cleaned))
|
|
135
|
+
return cleaned.rstrip("\n") or cleaned
|
|
136
|
+
|
|
137
|
+
|
|
127
138
|
def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
|
|
128
139
|
r"""`textfile=<path>:expansion=none` for drawtext -- the one route that is provably safe for
|
|
129
140
|
every character on every build shape this repo uses.
|
|
@@ -145,11 +156,7 @@ def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
|
|
|
145
156
|
printed plan therefore names a path that no longer exists once the run is over, which is the
|
|
146
157
|
same promise every other temp file in this skill makes.
|
|
147
158
|
"""
|
|
148
|
-
cleaned = (text
|
|
149
|
-
cleaned = re.sub(r"[\x00-\x08\x0e-\x1f\x7f]", "", re.sub(r"[\t\x0b\x0c]", " ", cleaned))
|
|
150
|
-
# drawtext ignores one trailing newline and draws a second as a blank line, which would
|
|
151
|
-
# centre the block half a line too high; a label that is only newlines stays as it is
|
|
152
|
-
cleaned = cleaned.rstrip("\n") or cleaned
|
|
159
|
+
cleaned = drawtext_clean(text)
|
|
153
160
|
import hashlib
|
|
154
161
|
name = "t_" + hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:16] + ".txt"
|
|
155
162
|
if tmpdir is None:
|
|
@@ -157,3 +164,55 @@ def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
|
|
|
157
164
|
path = os.path.join(tmpdir, name)
|
|
158
165
|
_DRAWTEXT_PENDING[path] = cleaned
|
|
159
166
|
return f"textfile={escape_filter_path(path)}:expansion=none"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def drawtext_line_metrics(lines: "List[str]", font_opt: str, fontsize: int) -> "List[Optional[Dict[str, float]]]":
|
|
170
|
+
"""Each line's drawtext metrics as this ffmpeg measures it ALONE, on one line, where the
|
|
171
|
+
8.0/8.0.1/8.1.0 newline bug cannot reach it: {w: text_w, top: top_a, bottom: bottom_d, lh},
|
|
172
|
+
None for an empty line (drawtext measures it as no glyphs).
|
|
173
|
+
|
|
174
|
+
The numbers come out of drawtext itself: its y expression print()s them (libavutil's eval
|
|
175
|
+
function, logged at error level), one drawtext per line on a generated one-frame picture --
|
|
176
|
+
no input is read and nothing is written but the line files, in a temporary directory that is
|
|
177
|
+
removed afterwards. It runs under --dry-run too (run_analysis), so a plan places the lines
|
|
178
|
+
where a run does. The y expression is evaluated at configuration and again per frame; the
|
|
179
|
+
last value of each line wins."""
|
|
180
|
+
import tempfile
|
|
181
|
+
from _common.emit import die
|
|
182
|
+
from _common.runner import flush_drawtext_textfiles, run_analysis
|
|
183
|
+
out: "List[Optional[Dict[str, float]]]" = [None] * len(lines)
|
|
184
|
+
with tempfile.TemporaryDirectory(prefix="ffmpeg-skill-measure-") as td:
|
|
185
|
+
chain = []
|
|
186
|
+
for i, line in enumerate(lines):
|
|
187
|
+
if line:
|
|
188
|
+
chain.append(f"drawtext={drawtext_text_opts(line, tmpdir=td)}:{font_opt}:fontsize={fontsize}:x=0:"
|
|
189
|
+
f"y='print({-(1000 + i)},16)+print(text_w,16)+print(top_a,16)+print(bottom_d,16)+print(lh,16)'")
|
|
190
|
+
if not chain:
|
|
191
|
+
return out
|
|
192
|
+
cmd = [ffmpeg_exe("ffmpeg")[0] or "ffmpeg", "-hide_banner", "-nostdin", "-v", "error", "-f", "lavfi",
|
|
193
|
+
"-i", "color=c=black:s=16x16:r=1", "-frames:v", "1", "-vf", ",".join(chain), "-f", "null", "-"]
|
|
194
|
+
try:
|
|
195
|
+
flush_drawtext_textfiles(cmd)
|
|
196
|
+
proc = run_analysis(cmd, record=True)
|
|
197
|
+
finally:
|
|
198
|
+
for path in [p for p in _DRAWTEXT_PENDING if p.startswith(td)]:
|
|
199
|
+
del _DRAWTEXT_PENDING[path]
|
|
200
|
+
current, values = None, {} # type: Optional[int], Dict[int, List[float]]
|
|
201
|
+
# 8.x prefixes each value with "[Eval @ 0x...] ", 6.1 prints it bare
|
|
202
|
+
for m in re.finditer(r"^(?:\[Eval @ [^\]]*\] )?(-?[0-9]+(?:\.[0-9]+)?|-?nan|-?inf)\s*$", proc.stderr, re.M):
|
|
203
|
+
try:
|
|
204
|
+
v = float(m.group(1))
|
|
205
|
+
except ValueError:
|
|
206
|
+
continue
|
|
207
|
+
if v <= -1000 and v == int(v):
|
|
208
|
+
current = int(-v) - 1000
|
|
209
|
+
values[current] = []
|
|
210
|
+
elif current is not None:
|
|
211
|
+
values[current].append(v)
|
|
212
|
+
for i, line in enumerate(lines):
|
|
213
|
+
got = values.get(i) if line else None
|
|
214
|
+
if line and (not got or len(got) != 4):
|
|
215
|
+
die("could not measure a line of --text with drawtext (no print() output from ffmpeg)", kind="ffmpeg")
|
|
216
|
+
if got:
|
|
217
|
+
out[i] = {"w": got[0], "top": got[1], "bottom": got[2], "lh": got[3]}
|
|
218
|
+
return out
|
|
@@ -205,6 +205,7 @@ CODECS = ("h264", "hevc", "av1", "prores")
|
|
|
205
205
|
|
|
206
206
|
|
|
207
207
|
_ENCODERS: Optional[set] = None
|
|
208
|
+
_BSFS: Optional[set] = None
|
|
208
209
|
|
|
209
210
|
|
|
210
211
|
class Context:
|
|
@@ -1372,6 +1373,24 @@ def ffmpeg_encoders() -> set:
|
|
|
1372
1373
|
return _ENCODERS
|
|
1373
1374
|
|
|
1374
1375
|
|
|
1376
|
+
def ffmpeg_bsfs() -> set:
|
|
1377
|
+
"""Names from `ffmpeg -bsfs`, read once, empty when it cannot be read; from ffprobe under
|
|
1378
|
+
--dry-run, for the reason ffmpeg_encoders() gives. color.py --retag rewrites an H.264/HEVC
|
|
1379
|
+
stream's colour description with h264_metadata/hevc_metadata only when the build has it."""
|
|
1380
|
+
global _BSFS
|
|
1381
|
+
if _BSFS is None:
|
|
1382
|
+
_BSFS = set()
|
|
1383
|
+
tool = "ffprobe" if STATE.dry_run else "ffmpeg"
|
|
1384
|
+
limit = min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else PROBE_TIMEOUT
|
|
1385
|
+
try:
|
|
1386
|
+
out = subprocess.run([ffmpeg_exe(tool)[0] or tool, "-hide_banner", "-bsfs"], stdout=subprocess.PIPE,
|
|
1387
|
+
stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=limit).stdout
|
|
1388
|
+
_BSFS = set(re.findall(r"^\s*([a-z0-9_]+)\s*$", out, re.M))
|
|
1389
|
+
except (OSError, subprocess.SubprocessError):
|
|
1390
|
+
pass
|
|
1391
|
+
return _BSFS
|
|
1392
|
+
|
|
1393
|
+
|
|
1375
1394
|
def read_text_or_die(path: str, flag: str) -> str:
|
|
1376
1395
|
"""Read a caller-supplied UTF-8 text file (a cue list, chapters, notes) or fail as kind input
|
|
1377
1396
|
with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback."""
|
package/scripts/_common/text.py
CHANGED
|
@@ -32,7 +32,7 @@ from _common.emoji import (
|
|
|
32
32
|
from _common.drawtext import (
|
|
33
33
|
drawtext_boxborderw, SHAPING_SCRIPTS, BIDI_SCRIPTS, _SHAPING_BUILD_CACHE, drawtext_shaping, needs_shaping,
|
|
34
34
|
escape_drawtext, drawtext_text_opts, drawtext_center_align, NEWLINE_GLYPH_RELEASES,
|
|
35
|
-
drawtext_draws_newline_glyph, is_multiline,
|
|
35
|
+
drawtext_draws_newline_glyph, is_multiline, drawtext_clean, drawtext_line_metrics,
|
|
36
36
|
)
|
|
37
37
|
from _common.wrap import (
|
|
38
38
|
ADVANCE_EM, NO_SPACE_SCRIPTS, NO_BOUNDARY_SCRIPTS, LATIN_EM, LEADING_VOWELS, _is_mark, _char_em,
|
package/scripts/_contract.py
CHANGED
|
@@ -185,7 +185,10 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
185
185
|
video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
186
186
|
"color": dict(role="execution", inputs=["video asset", ".cube LUT (--lut)"], outputs=["video artifact with converted colour"],
|
|
187
187
|
required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut / --correct"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
|
|
188
|
-
{"capability": "filter:lut3d", "when": "--lut"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability":
|
|
188
|
+
{"capability": "filter:lut3d", "when": "--lut"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability": "bsf:h264_metadata", "when": "--retag on H.264 (without it the video is re-encoded)"},
|
|
189
|
+
{"capability": "bsf:hevc_metadata", "when": "--retag on HEVC (without it the video is re-encoded)"},
|
|
190
|
+
{"capability": "bsf:mpeg2_metadata", "when": "--retag on MPEG-2 (without it the video is re-encoded)"}, {"capability": "bsf:av1_metadata", "when": "--retag on AV1 (without it the video is re-encoded)"},
|
|
191
|
+
{"capability": "bsf:vp9_metadata", "when": "--retag on VP9 (without it the video is re-encoded)"}, {"capability": X265, "when": "--lut on an HDR source"}, {"capability": AAC, "when": "re-encode"},
|
|
189
192
|
{"capability": "filter:exposure", "when": "--correct"}, {"capability": "filter:eq", "when": "--correct"},
|
|
190
193
|
{"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"},
|
|
191
194
|
{"capability": "filter:colorlevels", "when": "--correct with any --levels-*"}, {"capability": "filter:curves", "when": "--correct --curves"}],
|
|
@@ -240,6 +243,7 @@ DRY_RUN_ANALYSIS = {
|
|
|
240
243
|
"loudness": "the loudnorm measurement pass runs so input_i and the planned pass-2 command are real; the normalised output is not written",
|
|
241
244
|
"check": "read-only tool; the loudness measurement (or, when it is skipped, the audio row's volumedetect) and the --content decode run under --dry-run too, so every row is present",
|
|
242
245
|
"stabilize": "vidstabdetect (pass 1, into a temp file) runs; the stabilised output (pass 2) is not written",
|
|
246
|
+
"overlay": "only for a multi-line --text on FFmpeg 8.0, 8.0.1 or 8.1.0: one drawtext measures each line on a generated one-frame picture (the input is not read), so the planned per-line positions are the ones a run uses; otherwise no ffmpeg runs and the composited output is never written",
|
|
243
247
|
}
|
|
244
248
|
DRY_RUN_NOTES = {
|
|
245
249
|
"probe": "read-only tool; --dry-run changes nothing (ffprobe still runs)",
|
|
@@ -287,7 +291,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
|
|
|
287
291
|
"loudness": dict(video="never", audio="always"),
|
|
288
292
|
"silence": dict(video="always", audio="always", note="removing gaps requires cutting on non-keyframe boundaries"),
|
|
289
293
|
"join": dict(video="always", audio="always"),
|
|
290
|
-
"color": dict(video="conditional", audio="conditional", note="--strip-dovi and --retag (when the stream copy succeeds) are -c copy of both streams; --retag falls back to re-encoding only if the copy attempt fails; --to-sdr / --lut / --correct always re-encode both"),
|
|
294
|
+
"color": dict(video="conditional", audio="conditional", note="--strip-dovi and --retag (when the stream copy succeeds) are -c copy of both streams; --retag falls back to re-encoding only if the copy attempt fails or ffprobe still reads the old tags on the copy (a codec with no metadata bitstream filter here); --to-sdr / --lut / --correct always re-encode both"),
|
|
291
295
|
"proxy": dict(video="always", audio="conditional", note="video is always re-encoded at proxy-grade quality; audio is re-encoded when present, dropped entirely with --no-audio or when the source has none"),
|
|
292
296
|
"export": dict(video="conditional", audio="conditional", note="--preset copy is -c:v copy -c:a copy (no re-encode); every other preset re-encodes both"),
|
|
293
297
|
"check": dict(video="never", audio="never", note="read-only, no artifact"),
|
|
@@ -444,8 +448,6 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
444
448
|
"sources": {"type": "array", "description": "1.18: [{path, offset_s, confidence, drift_ppm}], one per SOURCE; the only per-source shape once more than one SOURCE is given"}}
|
|
445
449
|
elif name == "look":
|
|
446
450
|
extra = {"outputs": {"type": "array", "items": {"type": "string"}}}
|
|
447
|
-
elif name == "overlay":
|
|
448
|
-
extra = {"notes": {"type": "array", "items": {"type": "string"}, "description": "a multi-line --text on FFmpeg 8.0, 8.0.1 or 8.1.0, whose drawtext draws a box at each line break (fixed in 8.0.2 and 8.1.1)"}}
|
|
449
451
|
elif name == "render":
|
|
450
452
|
extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"]},
|
|
451
453
|
"plan": {"type": "string", "description": "when the argument was a plan.json (written by <tool> --plan): its path"},
|
|
@@ -473,7 +475,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
473
475
|
elif name == "color":
|
|
474
476
|
extra = {"dropped_non_av_streams": {"type": "boolean"},
|
|
475
477
|
"sdr_path": {"enum": ["tonemap", "gamut"], "description": "--to-sdr only: tonemap for PQ / HLG / Dolby Vision (and --force on an untagged file); gamut for BT.2020 primaries on an SDR transfer, converted to BT.709 without a tone map"},
|
|
476
|
-
"notes": {"type": "array", "items": {"type": "string"}, "description": "--to-sdr
|
|
478
|
+
"notes": {"type": "array", "items": {"type": "string"}, "description": "--to-sdr: which path was taken and why; --retag: why the video was re-encoded (the stream copy failed, or ffprobe still read the old tags on it)"},
|
|
479
|
+
"reencoded": {"type": "boolean", "description": "--retag: true when the stream copy could not carry the tags and the video was re-encoded with them on its frames"},
|
|
477
480
|
"measurements": {"type": "object", "description": "--correct only: signalstats levels of input and output"}}
|
|
478
481
|
elif name == "loop":
|
|
479
482
|
extra = {"boomerang": {"type": "boolean", "description": "--boomerang: frames 0..N-1 then N-2..1, repeated, so neither turnaround frame is shown twice"},
|
|
@@ -832,9 +835,9 @@ def ffmpeg_security(versions: Dict[str, Optional[str]], sources: Optional[Dict[s
|
|
|
832
835
|
return out
|
|
833
836
|
|
|
834
837
|
|
|
835
|
-
# 2.
|
|
838
|
+
# 2.10.9 deprecates Python 3.9 (docs/contract.md "Deprecation policy"; DEPRECATED below). The same
|
|
836
839
|
# line is printed by bin/install.js (PY39_DEPRECATION there; a test keeps the two equal).
|
|
837
|
-
PY39_DEPRECATION = ("warning: Python 3.9 is deprecated since ffmpeg-skill 2.
|
|
840
|
+
PY39_DEPRECATION = ("warning: Python 3.9 is deprecated since ffmpeg-skill 2.10.9 (CPython 3.9 reached end of life "
|
|
838
841
|
"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+.")
|
|
839
842
|
|
|
840
843
|
|
|
@@ -1404,7 +1407,7 @@ DEPRECATED: List[Dict[str, str]] = [
|
|
|
1404
1407
|
# CPython 3.9 reached end of life in October 2025. Raising the floor is a break, so 2.x keeps
|
|
1405
1408
|
# running on it (CI still tests 3.9) and doctor / bin/install.js print PY39_DEPRECATION.
|
|
1406
1409
|
{"what": "running on Python 3.9 (the requirements.python floor)",
|
|
1407
|
-
"since": "2.
|
|
1410
|
+
"since": "2.10.9", "removed_in": "3.0.0", "where": "behaviour",
|
|
1408
1411
|
"replacement": "Python 3.10 or later; on 3.9, doctor and the npx installer print a one-line warning"},
|
|
1409
1412
|
]
|
|
1410
1413
|
|
|
@@ -1644,7 +1647,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1644
1647
|
},
|
|
1645
1648
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"],
|
|
1646
1649
|
},
|
|
1647
|
-
"requirements": {"python": ">=3.9 (standard library only; 3.9 deprecated since 2.
|
|
1650
|
+
"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", "ffprobe": ">=5.0", "node": ">=16 (npx installer only)"},
|
|
1648
1651
|
"execution": {
|
|
1649
1652
|
"shell": False,
|
|
1650
1653
|
"arbitrary_executables": False,
|
|
@@ -1690,7 +1693,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1690
1693
|
"json_output": {
|
|
1691
1694
|
"success": {"status": "completed", "exit_code": 0, "stdout": "one JSON document (output_schema)"},
|
|
1692
1695
|
"failure": {"status": "failed", "exit_code": "non-zero (127 when ffmpeg/ffprobe is missing)", "stdout": "{\"status\": \"failed\", \"exit_code\": N, \"error\": {\"kind\": ..., \"message\": ...}, \"commands\": [...]} when --json was given", "stderr": "human-readable message"},
|
|
1693
|
-
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached); exit 1", "interrupted": "the tool received SIGINT or SIGTERM: the running ffmpeg (or sibling script) was stopped and its partial output removed; exit 130 or 143"},
|
|
1696
|
+
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached), color.py --retag (output written, but ffprobe does not read the requested colour tags on it); exit 1", "interrupted": "the tool received SIGINT or SIGTERM: the running ffmpeg (or sibling script) was stopped and its partial output removed; exit 130 or 143"},
|
|
1694
1697
|
"success_criterion": "exit 0 AND the output exists AND is non-empty AND ffprobe reads a stream from it; only then is status completed printed and the output probe attached",
|
|
1695
1698
|
},
|
|
1696
1699
|
"capabilities": caps,
|
package/scripts/color.py
CHANGED
|
@@ -18,11 +18,12 @@ Examples:
|
|
|
18
18
|
python3 color.py flat.mp4 --correct --levels-in-black 16 --levels-in-white 235 --curves medium_contrast
|
|
19
19
|
"""
|
|
20
20
|
import argparse
|
|
21
|
+
import json
|
|
21
22
|
import os
|
|
22
23
|
import sys
|
|
23
|
-
from typing import List
|
|
24
|
+
from typing import List, Optional
|
|
24
25
|
|
|
25
|
-
from _common import STATE, add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
|
|
26
|
+
from _common import STATE, add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, ffmpeg_bsfs, ffmpeg_exe, info, probe, run, run_analysis, run_keeping_subtitles, x264_args, X264_PRESETS, fmt_secs
|
|
26
27
|
|
|
27
28
|
TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
|
|
28
29
|
|
|
@@ -193,6 +194,144 @@ def is_bt2020_sdr(v: dict) -> bool:
|
|
|
193
194
|
and v.get("color_transfer") not in ("smpte2084", "arib-std-b67")
|
|
194
195
|
|
|
195
196
|
|
|
197
|
+
# --retag targets: ffprobe's names (colorspace, primaries, transfer) and the ITU-T H.273 code points
|
|
198
|
+
# h264_metadata/hevc_metadata write into the VUI (matrix_coefficients, colour_primaries,
|
|
199
|
+
# transfer_characteristics).
|
|
200
|
+
RETAG = {
|
|
201
|
+
"bt709": (("bt709", "bt709", "bt709"), (1, 1, 1)),
|
|
202
|
+
"bt2020-pq": (("bt2020nc", "bt2020", "smpte2084"), (9, 9, 16)),
|
|
203
|
+
"bt2020-hlg": (("bt2020nc", "bt2020", "arib-std-b67"), (9, 9, 18)),
|
|
204
|
+
"bt601": (("smpte170m", "smpte170m", "smpte170m"), (6, 6, 6)),
|
|
205
|
+
}
|
|
206
|
+
# codecs whose colour description lives in the bitstream, and the bitstream filter that rewrites it
|
|
207
|
+
# without touching a coded picture (each in every FFmpeg from 5.0: tests/fixtures/ffmpeg_bsfs_*)
|
|
208
|
+
RETAG_BSF = {"h264": "h264_metadata", "hevc": "hevc_metadata", "mpeg2video": "mpeg2_metadata",
|
|
209
|
+
"av1": "av1_metadata", "vp9": "vp9_metadata"}
|
|
210
|
+
# VP9 has only a colour space (its primaries and transfer are the container's); vp9_metadata's names
|
|
211
|
+
VP9_SPACE = {"bt709": "bt709", "smpte170m": "smpte170", "bt2020nc": "bt2020"}
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def retag_bsf(meta: dict, target: str) -> List[str]:
|
|
215
|
+
"""`-bsf:v:0 <codec>_metadata=...` writing `target`'s colour description into the first video
|
|
216
|
+
stream's own headers (H.264/HEVC VUI, MPEG-2 sequence display extension, AV1 sequence header,
|
|
217
|
+
VP9 frame header), or [] when the codec has no such filter (ProRes, DNxHD, MPEG-4 Part 2 ...)
|
|
218
|
+
or this build lacks it.
|
|
219
|
+
|
|
220
|
+
A stream copy with -colorspace/-color_primaries/-color_trc changes only the container's
|
|
221
|
+
description (mp4 `colr`, Matroska Colour); decoders, ffprobe included, read the stream's own
|
|
222
|
+
description first, so a source that already carried tags there kept them while the tool
|
|
223
|
+
reported success. The filter rewrites those headers and nothing else: the copy stays
|
|
224
|
+
lossless. Where the filter has a range field it gets the source's probed range (pc/tv),
|
|
225
|
+
because h264_metadata/hevc_metadata set it to limited when the VUI had no signal type at all;
|
|
226
|
+
an unknown range is left alone."""
|
|
227
|
+
v = meta["video"]
|
|
228
|
+
codec = v.get("codec") or ""
|
|
229
|
+
bsf = RETAG_BSF.get(codec)
|
|
230
|
+
if not bsf or bsf not in ffmpeg_bsfs():
|
|
231
|
+
return []
|
|
232
|
+
names, (matrix, prim, trc) = RETAG[target]
|
|
233
|
+
rng = v.get("color_range") if v.get("color_range") in ("tv", "pc") else None
|
|
234
|
+
if codec == "vp9":
|
|
235
|
+
opts = f"color_space={VP9_SPACE[names[0]]}" + (f":color_range={rng}" if rng else "")
|
|
236
|
+
elif codec == "av1":
|
|
237
|
+
opts = (f"color_primaries={prim}:transfer_characteristics={trc}:matrix_coefficients={matrix}"
|
|
238
|
+
+ (f":color_range={rng}" if rng else ""))
|
|
239
|
+
else:
|
|
240
|
+
opts = f"colour_primaries={prim}:transfer_characteristics={trc}:matrix_coefficients={matrix}"
|
|
241
|
+
if rng and codec != "mpeg2video":
|
|
242
|
+
opts += f":video_full_range_flag={1 if rng == 'pc' else 0}"
|
|
243
|
+
return ["-bsf:v:0", f"{bsf}={opts}"]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def retag_mismatch(output: str, want: tuple) -> "Optional[str]":
|
|
247
|
+
"""None when the output carries `want` (colorspace, primaries, transfer), else what was read
|
|
248
|
+
-- the check behind every completed --retag.
|
|
249
|
+
|
|
250
|
+
Two readings, because they disagree: ffprobe's stream tags (from 8.1 the container's `colr`
|
|
251
|
+
wins there over an H.264 VUI that still says bt709, while 6.1 and 8.0 show the VUI) and the
|
|
252
|
+
first decoded frame's, which is what a player or filter graph gets. Each stream tag must be
|
|
253
|
+
the requested one; a frame tag must be the requested one or unspecified (8.1's ProRes decoder
|
|
254
|
+
leaves its frames untagged and players read the container's)."""
|
|
255
|
+
got = probe(output, role="output")["video"]
|
|
256
|
+
stream = (got.get("color_space"), got.get("color_primaries"), got.get("color_transfer"))
|
|
257
|
+
keys = ("color_space", "color_primaries", "color_transfer")
|
|
258
|
+
proc = run_analysis([ffmpeg_exe("ffprobe")[0] or "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_frames",
|
|
259
|
+
"-read_intervals", "%+#1", "-show_entries", "frame=" + ",".join(keys), "-of", "json", output],
|
|
260
|
+
check=False)
|
|
261
|
+
try:
|
|
262
|
+
frames = json.loads(proc.stdout or "{}").get("frames") or [{}]
|
|
263
|
+
except ValueError:
|
|
264
|
+
frames = [{}]
|
|
265
|
+
frame = tuple(frames[0].get(k) for k in keys)
|
|
266
|
+
if stream == tuple(want) and all(f in (None, "unknown", w) for f, w in zip(frame, want)):
|
|
267
|
+
return None
|
|
268
|
+
shown = "/".join(str(t) for t in stream)
|
|
269
|
+
return shown if frame in ((None,) * 3, stream) else f"{shown} (decoded frames: {'/'.join(str(t) for t in frame)})"
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def retag(args: argparse.Namespace, meta: dict, has_audio: bool) -> int:
|
|
273
|
+
"""--retag: rewrite the colour description, never the pixels.
|
|
274
|
+
|
|
275
|
+
First a stream copy of every stream: the container options plus, where the codec keeps its
|
|
276
|
+
own description, its rewrite (retag_bsf). When the copy fails, or ffprobe still reads other
|
|
277
|
+
tags on its output (a codec with no such filter here), the video is re-encoded with the tags
|
|
278
|
+
put on the frames (`setparams`): from FFmpeg 7.1 the encoder takes them from the frames and the output options feed the
|
|
279
|
+
filter graph's colourspace negotiation, which converted the matrix of a tagged source and left
|
|
280
|
+
its primaries and transfer as they were (measured on 8.0.1 and 8.1.3), while setparams labels
|
|
281
|
+
the frames on 5.x-8.x alike. Every completed run has been checked: ffprobe reads the requested
|
|
282
|
+
tags on the output (`verification`'s `color_tags` step), or the run fails with kind `verification`."""
|
|
283
|
+
names = RETAG[args.retag][0]
|
|
284
|
+
output = args.output or default_output(args.input, "retag")
|
|
285
|
+
ext = os.path.splitext(output)[1].lower()
|
|
286
|
+
tags = ["-colorspace", names[0], "-color_primaries", names[1], "-color_trc", names[2]]
|
|
287
|
+
bsf = retag_bsf(meta, args.retag)
|
|
288
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy"] + bsf + tags
|
|
289
|
+
if ext in (".mp4", ".mov", ".m4v"):
|
|
290
|
+
cmd += ["-movflags", "+faststart"]
|
|
291
|
+
cmd.append(output)
|
|
292
|
+
proc = run(cmd, check=False)
|
|
293
|
+
notes: List[str] = []
|
|
294
|
+
reencoded = proc.returncode != 0
|
|
295
|
+
if not reencoded and not STATE.dry_run:
|
|
296
|
+
wrong = retag_mismatch(output, names)
|
|
297
|
+
if wrong:
|
|
298
|
+
reencoded = True
|
|
299
|
+
notes.append(f"a stream copy cannot rewrite the colour tags of {meta['video'].get('codec')} here "
|
|
300
|
+
f"(the output still read {wrong}), so the video was re-encoded with the tags on its frames")
|
|
301
|
+
info("note: " + notes[-1])
|
|
302
|
+
elif reencoded:
|
|
303
|
+
notes.append("the stream copy failed, so the video was re-encoded with the tags on its frames")
|
|
304
|
+
dropped_streams = False
|
|
305
|
+
if reencoded:
|
|
306
|
+
# The copy path (-map 0 -c copy) keeps every stream -- extra audio tracks, subtitles,
|
|
307
|
+
# chapters, attached pictures -- byte-for-byte; -c:s/-c:d copy here keeps that same
|
|
308
|
+
# guarantee for subtitle/data streams even though video/audio must be re-encoded. Only if
|
|
309
|
+
# THAT also fails (e.g. a subtitle codec genuinely incompatible with the target container)
|
|
310
|
+
# do we drop to video+selected-audio-only, and even then we say so explicitly rather than
|
|
311
|
+
# silently reporting "completed" with streams missing.
|
|
312
|
+
info("re-encoding video/audio with the new tags (subtitles/data streams kept)")
|
|
313
|
+
label = ["-vf", f"setparams=color_primaries={names[1]}:color_trc={names[2]}:colorspace={names[0]}"]
|
|
314
|
+
enc = x264_args(args.crf, args.preset, keep_bt709=False) + (aac_args() if has_audio else [])
|
|
315
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
|
|
316
|
+
"-map", "0:s?", "-map", "0:d?"] + label + enc + ["-c:s", "copy", "-c:d", "copy", output]
|
|
317
|
+
proc2 = run(cmd, check=False)
|
|
318
|
+
if proc2.returncode != 0:
|
|
319
|
+
info("re-encode with subtitles/data streams kept also failed; dropping them")
|
|
320
|
+
dropped_streams = True
|
|
321
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"] + label + enc + [output]
|
|
322
|
+
run(cmd)
|
|
323
|
+
wrong = None if STATE.dry_run else retag_mismatch(output, names)
|
|
324
|
+
if wrong:
|
|
325
|
+
die(f"--retag {args.retag}: the re-encoded output still reads {wrong}, not {'/'.join(names)}",
|
|
326
|
+
kind="verification", hint=f"the output was written to {output} but is not tagged as asked")
|
|
327
|
+
info(f"wrote {output} (tags -> {args.retag})")
|
|
328
|
+
check = [] if STATE.dry_run else [{"step": "color_tags", "ok": True, "color_space": names[0],
|
|
329
|
+
"color_primaries": names[1], "color_transfer": names[2]}]
|
|
330
|
+
emit(output, reencoded=reencoded, dropped_non_av_streams=dropped_streams, verification=check,
|
|
331
|
+
**({"notes": notes} if notes else {}))
|
|
332
|
+
return 0
|
|
333
|
+
|
|
334
|
+
|
|
196
335
|
def main() -> int:
|
|
197
336
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
198
337
|
ap.add_argument("input")
|
|
@@ -261,45 +400,7 @@ def main() -> int:
|
|
|
261
400
|
return 0
|
|
262
401
|
|
|
263
402
|
if args.retag:
|
|
264
|
-
|
|
265
|
-
"bt709": ["bt709", "bt709", "bt709"],
|
|
266
|
-
"bt2020-pq": ["bt2020nc", "bt2020", "smpte2084"],
|
|
267
|
-
"bt2020-hlg": ["bt2020nc", "bt2020", "arib-std-b67"],
|
|
268
|
-
"bt601": ["smpte170m", "smpte170m", "smpte170m"],
|
|
269
|
-
}[args.retag]
|
|
270
|
-
output = args.output or default_output(args.input, "retag")
|
|
271
|
-
ext = os.path.splitext(output)[1].lower()
|
|
272
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy",
|
|
273
|
-
"-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]]
|
|
274
|
-
if ext in (".mp4", ".mov", ".m4v"):
|
|
275
|
-
cmd += ["-movflags", "+faststart"]
|
|
276
|
-
cmd.append(output)
|
|
277
|
-
proc = run(cmd, check=False)
|
|
278
|
-
dropped_streams = False
|
|
279
|
-
if proc.returncode != 0:
|
|
280
|
-
# Some codecs cannot carry retagged colour info without a bitstream filter, so the
|
|
281
|
-
# stream copy above fails and we fall back to re-encoding video+audio. The copy path
|
|
282
|
-
# (-map 0 -c copy) keeps every stream -- extra audio tracks, subtitles, chapters,
|
|
283
|
-
# attached pictures -- byte-for-byte; -c:s/-c:d copy here keeps that same guarantee
|
|
284
|
-
# for subtitle/data streams even though video/audio must be re-encoded. Only if THAT
|
|
285
|
-
# also fails (e.g. a subtitle codec genuinely incompatible with the target container)
|
|
286
|
-
# do we drop to video+selected-audio-only, and even then we say so explicitly rather
|
|
287
|
-
# than silently reporting "completed" with streams missing.
|
|
288
|
-
info("stream copy could not rewrite tags, re-encoding video/audio (subtitles/data streams kept)")
|
|
289
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?",
|
|
290
|
-
"-map", "0:s?", "-map", "0:d?"] + x264_args(args.crf, args.preset, keep_bt709=False)
|
|
291
|
-
cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]]
|
|
292
|
-
cmd += (aac_args() if has_audio else []) + ["-c:s", "copy", "-c:d", "copy"] + [output]
|
|
293
|
-
proc2 = run(cmd, check=False)
|
|
294
|
-
if proc2.returncode != 0:
|
|
295
|
-
info("re-encode with subtitles/data streams kept also failed; dropping them")
|
|
296
|
-
dropped_streams = True
|
|
297
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"] + x264_args(args.crf, args.preset, keep_bt709=False)
|
|
298
|
-
cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
|
|
299
|
-
run(cmd)
|
|
300
|
-
info(f"wrote {output} (tags -> {args.retag})")
|
|
301
|
-
emit(output, reencoded=proc.returncode != 0, dropped_non_av_streams=dropped_streams)
|
|
302
|
-
return 0
|
|
403
|
+
return retag(args, meta, has_audio)
|
|
303
404
|
|
|
304
405
|
measurements = None
|
|
305
406
|
sdr_extra: dict = {}
|
package/scripts/overlay.py
CHANGED
|
@@ -21,11 +21,12 @@ Examples:
|
|
|
21
21
|
python3 overlay.py bg.mp4 --video greenscreen.mp4 --chromakey 0x00ff00 --chromakey-similarity 0.15
|
|
22
22
|
"""
|
|
23
23
|
import argparse
|
|
24
|
+
import re
|
|
24
25
|
import sys
|
|
25
26
|
from typing import List, Optional
|
|
26
27
|
|
|
27
28
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
28
|
-
from _common import STATE, script_font_for_text, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
29
|
+
from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
|
|
29
30
|
|
|
30
31
|
# Per-edge margins: a platform's UI does not cover the same fraction of every edge (TikTok's
|
|
31
32
|
# like column is 14 % of the width, its description block 22 % of the height), so a position
|
|
@@ -87,6 +88,56 @@ def alpha_expr(opacity: float, start: Optional[float], end: Optional[float], fad
|
|
|
87
88
|
return "max(0," + "*".join(parts) + ")"
|
|
88
89
|
|
|
89
90
|
|
|
91
|
+
def _num(v: float) -> str:
|
|
92
|
+
"""A measured pixel value as an expression literal (no exponent form)."""
|
|
93
|
+
return f"{v:.6f}".rstrip("0").rstrip(".")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def per_line_drawtext(lines: List[str], x: str, y: str, font_opt: str, args: argparse.Namespace,
|
|
97
|
+
box: List[str], timing: List[str]) -> str:
|
|
98
|
+
"""The drawtext chain for a multi-line --text on a release whose drawtext draws a box at each
|
|
99
|
+
line break (drawtext_draws_newline_glyph): one drawtext per line, each line alone, placed
|
|
100
|
+
where one multi-line drawtext puts it on a fixed release.
|
|
101
|
+
|
|
102
|
+
That block's layout is drawtext's own (measure_text/draw_text): text_w is the widest line,
|
|
103
|
+
text_h = lh*(lines-1) + the first line's top_a + the last line's bottom_d, the lines are
|
|
104
|
+
left-aligned at x, and line i's baseline is y + top_a + i*lh. Each line is measured alone
|
|
105
|
+
(drawtext_line_metrics), so the box the bug adds is in no number; x and y keep the caller's
|
|
106
|
+
position expressions with text_w/text_h replaced by the block's values, and each line is
|
|
107
|
+
drawn with y_align=baseline at its own baseline. The outlines of every line are drawn before
|
|
108
|
+
any line's text, as one drawtext draws them, so a descender's outline never covers the next
|
|
109
|
+
line's glyphs; the --box is drawn once, by the first line, at the block's size (boxw/boxh).
|
|
110
|
+
Bit-identical to one multi-line drawtext on 8.1.3 for every named position, with and without
|
|
111
|
+
--box; a font whose glyph boxes are not whole pixels (no hinting) can sit one row lower, since
|
|
112
|
+
drawtext reports top_a rounded up."""
|
|
113
|
+
m = drawtext_line_metrics(lines, font_opt, args.font_size)
|
|
114
|
+
seen = [d for d in m if d]
|
|
115
|
+
lh = seen[0]["lh"]
|
|
116
|
+
width = max(d["w"] for d in seen)
|
|
117
|
+
# an empty first/last line has no glyphs: drawtext's -32000/64 start values, as it measures them
|
|
118
|
+
top = m[0]["top"] if m[0] else -500.0
|
|
119
|
+
bottom = m[-1]["bottom"] if m[-1] else -500.0
|
|
120
|
+
height = lh * (len(lines) - 1) + top + bottom
|
|
121
|
+
|
|
122
|
+
def block(expr: str) -> str:
|
|
123
|
+
return re.sub(r"\btext_h\b", _num(height), re.sub(r"\btext_w\b", _num(width), expr))
|
|
124
|
+
|
|
125
|
+
bx, by = block(x), block(y)
|
|
126
|
+
passes = ([[f"borderw={args.border}", f"bordercolor={args.border_color}", "fontcolor=black@0"],
|
|
127
|
+
["fontcolor=" + args.font_color]] if args.border else [["fontcolor=" + args.font_color]])
|
|
128
|
+
filters = []
|
|
129
|
+
for n, paint in enumerate(passes):
|
|
130
|
+
for i, line in enumerate(lines):
|
|
131
|
+
if not line:
|
|
132
|
+
continue
|
|
133
|
+
opts = [drawtext_text_opts(line), f"fontsize={args.font_size}", f"x={bx}", f"y={by}+{_num(top + i * lh)}",
|
|
134
|
+
"y_align=baseline", font_opt] + paint + timing[:1]
|
|
135
|
+
if box and n == 0 and not filters:
|
|
136
|
+
opts += box + [f"boxw={_num(width)}", f"boxh={_num(height)}"]
|
|
137
|
+
filters.append("drawtext=" + ":".join(opts + timing[1:]))
|
|
138
|
+
return ",".join(filters)
|
|
139
|
+
|
|
140
|
+
|
|
90
141
|
def main() -> int:
|
|
91
142
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
92
143
|
ap.add_argument("input")
|
|
@@ -144,7 +195,6 @@ def main() -> int:
|
|
|
144
195
|
add_common(ap)
|
|
145
196
|
args = ap.parse_args()
|
|
146
197
|
apply_common(args)
|
|
147
|
-
notes: List[str] = []
|
|
148
198
|
|
|
149
199
|
brand = load_brand(args.brand)
|
|
150
200
|
if args.logo:
|
|
@@ -335,29 +385,23 @@ def main() -> int:
|
|
|
335
385
|
x, y = position_exprs(args.position, args.margin, text_mode=True, margins=safe_margins)
|
|
336
386
|
# 1.15: the text goes in a FILE with expansion off, so `'` and `%` survive verbatim
|
|
337
387
|
# (they used to be dropped by escape_drawtext) and no character can reach the graph parser.
|
|
388
|
+
font_opt = (f"fontfile={escape_filter_path(args.font_file)}" if args.font_file
|
|
389
|
+
else f"font='{escape_drawtext(args.font)}'")
|
|
390
|
+
alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None), end, args.fade)
|
|
391
|
+
timing = ([f"alpha='{alpha}'"] if alpha != "1" else []) + ([f"enable='{enable}'"] if enable else [])
|
|
392
|
+
box = ["box=1", f"boxcolor={args.box_color}", "boxborderw=12"] if args.box else []
|
|
338
393
|
if is_multiline(args.text) and drawtext_draws_newline_glyph():
|
|
339
|
-
#
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
"--text: draw multi-line text with graphics.py or caption.py, or upgrade FFmpeg")
|
|
343
|
-
info("warning: " + notes[-1])
|
|
344
|
-
opts = [drawtext_text_opts(args.text), f"fontsize={args.font_size}", f"x={x}", f"y={y}",
|
|
345
|
-
f"borderw={args.border}", f"bordercolor={args.border_color}"]
|
|
346
|
-
if args.font_file:
|
|
347
|
-
opts.append(f"fontfile={escape_filter_path(args.font_file)}")
|
|
394
|
+
# this release's drawtext draws the font's missing-glyph box at every line break and
|
|
395
|
+
# counts it in text_w: one drawtext per line, placed where one block would put it
|
|
396
|
+
drawing = per_line_drawtext(drawtext_clean(args.text).split("\n"), x, y, font_opt, args, box, timing)
|
|
348
397
|
else:
|
|
349
|
-
opts.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
opts.append(f"alpha='{alpha}'")
|
|
354
|
-
if args.box:
|
|
355
|
-
opts += ["box=1", f"boxcolor={args.box_color}", "boxborderw=12"]
|
|
356
|
-
if enable:
|
|
357
|
-
opts.append(f"enable='{enable}'")
|
|
398
|
+
opts = [drawtext_text_opts(args.text), f"fontsize={args.font_size}", f"x={x}", f"y={y}",
|
|
399
|
+
f"borderw={args.border}", f"bordercolor={args.border_color}", font_opt,
|
|
400
|
+
f"fontcolor={args.font_color}"] + timing[:1] + box + timing[1:]
|
|
401
|
+
drawing = "drawtext=" + ":".join(opts)
|
|
358
402
|
# a font/border/box colour with chroma lands in the BT.709 the output is tagged with
|
|
359
403
|
colours = [args.font_color] + ([args.border_color] if args.border else []) + ([args.box_color] if args.box else [])
|
|
360
|
-
cmd += ["-vf", drawn_chain(
|
|
404
|
+
cmd += ["-vf", drawn_chain(drawing, meta, colours), "-map", "0:v:0"]
|
|
361
405
|
if meta.get("audio"):
|
|
362
406
|
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
363
407
|
|
|
@@ -367,7 +411,7 @@ def main() -> int:
|
|
|
367
411
|
if not STATE.dry_run:
|
|
368
412
|
result = probe(output, role="output")
|
|
369
413
|
info(f"wrote {output} ({fmt_secs(result['duration'])})")
|
|
370
|
-
emit(output, dropped_non_av_streams=dropped_streams
|
|
414
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
371
415
|
return 0
|
|
372
416
|
|
|
373
417
|
|