ffmpeg-skill 2.10.10 → 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 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
 
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/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.10`) | any release |
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`
@@ -125,7 +125,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
125
125
  "contract_version": "1.0",
126
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.10", "execution_mode": "local", "kind": "execution",
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
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"},
@@ -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`), and `verify` does not support dry-run (its steps run). `SKILL.md` and
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`), and `verified` is `true` only when the artifact was written, probed and every
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.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
@@ -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 or "").replace("\r\n", "\n").replace("\r", "\n")
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."""
@@ -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,
@@ -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": X265, "when": "--lut on an HDR source"}, {"capability": AAC, "when": "re-encode"},
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 only: which path was taken and why"},
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"},
@@ -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
- tags = {
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 = {}
@@ -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
- # overlay has no libass route to send it to (graphics.py's --text-render auto does)
340
- notes.append("this ffmpeg's drawtext (FFmpeg 8.0, 8.0.1 and 8.1.0; fixed in 8.0.2 and 8.1.1) draws "
341
- "the font's missing-glyph box at the end of every line but the last of a multi-line "
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.append(f"font='{escape_drawtext(args.font)}'")
350
- alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None), end, args.fade)
351
- opts.append(f"fontcolor={args.font_color}")
352
- if alpha != "1":
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("drawtext=" + ":".join(opts), meta, colours), "-map", "0:v:0"]
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, **({"notes": notes} if notes else {}))
414
+ emit(output, dropped_non_av_streams=dropped_streams)
371
415
  return 0
372
416
 
373
417