ffmpeg-skill 2.10.10 → 2.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -338,7 +338,7 @@ The contract is generated from the code that runs, not maintained beside it. For
338
338
 
339
339
  Next to the tool list the document carries a top-level `deprecated` list (1.10): what 2.0.0 removes, since when, the replacement and the surface it lives on. `docs/contract.md` "What 2.0 changes" is written from it.
340
340
 
341
- `contract_version` (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments → argv), the JSON shapes for success and failure (`{"status": "failed", "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification | interrupted", "message": …}}`), and that no tool runs a shell or executes anything other than the named script, `ffmpeg` and `ffprobe`. Field-by-field reference: [docs/contract.md](docs/contract.md).
341
+ `contract_version` (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments → argv), the JSON shapes for success and failure (`{"status": "failed", "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification | interrupted", "message": …}}`; a refusal adds `error.reason`, a stable code such as `output_exists` or `plan_inputs_changed`, and `error.details`, the facts behind it; warnings, fallbacks and skipped steps a run decided on its own are in `diagnostics`), and that no tool runs a shell or executes anything other than the named script, `ffmpeg` and `ffprobe`. Field-by-field reference: [docs/contract.md](docs/contract.md).
342
342
 
343
343
  ### MCP
344
344
 
@@ -388,7 +388,8 @@ The short list for humans. The agent-facing version, with the reasoning, is the
388
388
  - **Non-Latin text picks a font by script (1.12).** Japanese, Chinese, Korean, Arabic, Hebrew, Devanagari, Thai, Cyrillic and Greek cues, titles and overlays resolve a font file that covers them automatically, and a machine with no such font fails the job (`kind: input`) instead of rendering boxes. `doctor --json`'s `fonts.scripts` says which languages this machine can render; `--lang ja|ko` disambiguates Han-only text; an explicit `--font`/`--font-file` is always kept.
389
389
  - **Silence detection finds nothing?** The default threshold is −35 dBFS. The tool prints a hint with the track's measured level; raise the threshold (`silence.py --threshold -25`) or shorten `--min-silence`.
390
390
  - **Sync results carry a confidence.** Below 0.3, or an offset near the edge of the analysis window, is probably wrong: enlarge `--analyze-seconds` or find a clap. Recordings over ten minutes from separate devices need `sync.py --fix-drift`.
391
- - **Outputs are never overwritten silently.** An existing output path is refused (`kind: input`, before anything runs); pass `--overwrite` where a replacement is intended.
391
+ - **Outputs are never overwritten silently.** An existing output path is refused (`kind: input`, `error.reason: output_exists`, before anything runs); pass `--overwrite` where a replacement is intended.
392
+ - **Nothing happens silently.** A refusal names its `error.reason` and `error.details`; a stale output lock taken over, a run without a lock and a plan input nobody could fingerprint are recorded in `diagnostics` and on stderr. A refused plan and a lock takeover also append a line to an audit log (`<plan>.audit.jsonl`, or `render.py --audit-log FILE`).
392
393
  - **Long chains belong in a plan.** Three hand-chained re-encodes lose quality and are hard to change; `render.py` runs the whole edit from one JSON file, and `--dry-run` shows every ffmpeg command before anything is written.
393
394
 
394
395
  ## FAQ
@@ -403,7 +404,7 @@ ffmpeg-skill edits footage you already have, on this machine, with FFmpeg tools
403
404
 
404
405
  ### Does it validate the filter graph before running?
405
406
 
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.
407
+ No. `--dry-run` prints the ffmpeg command a writing tool would run and does not start ffmpeg, so ffmpeg never parses that filter graph. ffprobe still runs when the tool probes an input that exists. Unless `--overwrite` is passed, an existing output is refused (`kind: input`) before ffmpeg. An output that is the same file as an input is refused either way. No media file is written, and `verified` is false; `--plan FILE` does write the plan JSON. A path that is not on disk yet is not measured: the probe is a stub (0×0, duration 0), so a number taken from it is not a measurement. The measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) still run their measurements; only the artifact is skipped, and `silence` with `--filler --transcribe` also runs the speech engine, which may download a model. `overlay` measures the lines of a multi-line `--text` with one drawtext on a generated frame on FFmpeg 8.0, 8.0.1 and 8.1.0, where it draws them one by one. `verify` accepts `--dry-run` and ignores it: its steps run. `--plan FILE` is that dry run written down — the planned argv, the command lines, and a fingerprint of each existing input (size, plus a sha256 of the first and last 8 MiB). `render.py` and `verify.py` refuse `--plan`. `render.py FILE` runs a plan only when those fingerprints still match (a mismatch is refused with each input's expected and actual fingerprint in `error.details`, and logged to `<plan>.audit.jsonl`), by running the tool again with the planned argv, not by executing the recorded ffmpeg lines; `--dry-run` on that run only prints them. `doctor` reports whether the filters, encoders and bitstream filters the contract names are in this build (`ffmpeg -filters`, `-encoders`, `-bsfs`) and which fonts are installed. A name in that listing is not a check of the graph this command would build, and a listed GPU encoder is not a successful encode.
407
408
 
408
409
  ### Can it remove burned-in subtitles or logos?
409
410
 
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.11.0`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -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.11.0", "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,
@@ -257,16 +259,23 @@ asks for the CPU and gets what ffmpeg says about it. What ran is in the result's
257
259
 
258
260
  `--plan FILE` (1.6) is a dry run that also writes a plan document: `{"plan_version": 1,
259
261
  "tool", "argv", "cwd", "inputs": [{"path", "size", "sha256_head_tail"}], "commands",
260
- "output", "verify": [{"tool": "probe"}, {"tool": "check", "platform"}], "notes"}`. It
262
+ "unbound_inputs", "output", "verify": [{"tool": "probe"}, {"tool": "check", "platform"}], "notes"}`. It
261
263
  implies `--dry-run`, so the same execution rules apply; `inputs` covers the `-i` files of the
262
264
  planned commands, every existing file named in argv (a recipe, a still, an SRT) and the
263
- subtitle/LUT/font files a filter reads. Tools that print their document without `--json`
265
+ subtitle/LUT/font files a filter reads; `unbound_inputs` (absolute paths, additive to version 1)
266
+ names the inputs that did not exist yet when the plan was made (an earlier step's output), which
267
+ therefore carry no fingerprint. Tools that print their document without `--json`
264
268
  (`probe`, the analysis tools) still write the plan at exit; `verify.py` and `render.py`
265
269
  refuse `--plan` (their steps run for real; a project file is already a plan). `render.py FILE` executes a plan:
266
- it refuses (`kind: input`) when an input's size or head/tail hash differs from the plan,
270
+ it refuses (`kind: input`, `error.reason: plan_inputs_changed`) when an input's size or head/tail
271
+ hash differs from the plan, with every input's `{path, status, expected, actual}` in
272
+ `error.details.inputs` (`actual` is `"missing"` for a file that is gone) beside `plan_sha256` and
273
+ `checked_at`, and appends one record to the audit log (below); a wrong `plan_version`, an unknown
274
+ tool, a vanished `cwd` or a malformed `argv` is refused the same way under its own reason. It then
267
275
  runs the tool with the planned `argv`, then the verify steps (a `check` step may also carry
268
- `"content": true`, added by hand, for `check.py --content`), and reports `plan`, `tool`,
269
- `tool_result` and `check`. `plan_version` is bumped when the document's shape changes.
276
+ `"content": true`, added by hand, for `check.py --content`), and reports `plan`, `plan_sha256`,
277
+ `tool`, `tool_result`, `check` and `unbound_inputs` (each one also a `diagnostics.warnings` entry:
278
+ nothing checked that file). `plan_version` is bumped when the document's shape changes.
270
279
 
271
280
  ### Repeatability
272
281
 
@@ -696,7 +705,8 @@ file was written. `probe` prints its measurement document directly.
696
705
  Every writing tool also reports what it verified itself (1.7): `verification` lists the
697
706
  steps (`{"step": "probe", "ok": true}`; `loudness` with the measured and target values for
698
707
  `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
708
+ `render.py`; `color_tags` with the tags ffprobe read back for `color.py --retag`, which fails
709
+ with `kind: verification` when they are not the requested ones), and `verified` is `true` only when the artifact was written, probed and every
700
710
  listed step met its target. A `--dry-run` document has `verified: false` and an empty list.
701
711
  `export.py` whose written file misses the platform's loudness spec stays `completed` (the
702
712
  file is valid) with `verified: false` and the fix in `notes`, so a caller keys on one field.
@@ -729,6 +739,39 @@ strings. `retryable` is currently always `false`: none of the kinds are distingu
729
739
  today from a deterministic failure that would fail identically on a blind retry, so nothing
730
740
  here claims otherwise until real exit-code/stderr sniffing exists to back that up.
731
741
 
742
+ A refusal also carries `error.reason`, a stable snake_case code for *why*
743
+ (`contract --json` → `json_output.error_reasons` lists them: `output_exists`, `output_is_input`,
744
+ `output_is_directory`, `output_dir_missing`, `output_dir_not_writable`, `output_locked`, and
745
+ `render.py`'s `plan_*` reasons), and `error.details`, the facts the message is written from, so a
746
+ caller never parses the prose:
747
+
748
+ ```
749
+ "error": {"kind": "input", "message": "refusing to overwrite existing output 'out.mp4': ...",
750
+ "code": "INPUT_INVALID", "retryable": false, "hint": "pass --overwrite, or choose another -o path",
751
+ "reason": "output_exists",
752
+ "details": {"outputs": [{"path": "out.mp4", "resolved": "/abs/out.mp4", "size": 48213, "mtime_ns": 1729660000000000000}]}}
753
+ ```
754
+
755
+ A tool that runs another (`render.py` stages and plans) forwards the child's `reason` and
756
+ `details` in its own `error`. Both are absent on a failure that is not a refusal, like `hint`.
757
+
758
+ **Diagnostics.** What a run decided on its own -- a warning, a fallback it took, a step it
759
+ skipped -- is recorded as it happens (a stderr line too) and lands in the document as
760
+ `diagnostics: {warnings, fallbacks, skipped}`, each entry `{code, message, ...facts}`; it is
761
+ present on success and failure documents alike, only when one list is not empty, and an entry a
762
+ `render.py` stage reported carries `stage`. Codes today: `stale_lock_taken_over` (fallbacks),
763
+ `output_unlocked`, `lock_deadline_unset`, `audit_log_unwritable` and `plan_unbound_inputs`
764
+ (warnings).
765
+
766
+ **Audit log.** Events a later reader must be able to reconstruct are appended as JSON lines,
767
+ `{ts, tool, event, pid, plan_sha256, details}` (`plan_sha256` on plan events): `plan_rejected`,
768
+ `lock_takeover`, `output_unlocked`, and `output_is_input_refused` when a log was named. The file
769
+ is `render.py --audit-log FILE` when given; otherwise a refused plan's `<plan>.audit.jsonl` next
770
+ to the plan, and a lock event's hidden `.<output>.ffskill-audit.jsonl` beside the output. Nothing
771
+ is appended under `--dry-run`. A record that cannot be written is a `diagnostics.warnings` entry
772
+ (`audit_log_unwritable`) and never fails or blocks the run; the refusal's `details.audit_log`
773
+ says `{path, written}` either way.
774
+
732
775
  ## MCP relationship
733
776
 
734
777
  `mcp/server.py` is a transport. It holds no tool table and no schema of its own:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.10.10",
3
+ "version": "2.11.0",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -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`, `error.reason: output_exists`, before anything runs) to replace an existing output unless `--overwrite` is given. Refusals carry `error.reason` and `error.details` (the facts behind the message; `contract --json` → `json_output.error_reasons`), and what a run decided on its own -- a stale output lock taken over, no lock possible, a plan input nobody fingerprinted -- is in `diagnostics: {warnings, fallbacks, skipped}` as well as on stderr. `--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
 
@@ -709,7 +709,7 @@ output; the result says so with `dropped_non_av_streams: true`.
709
709
  render.py --init project.json # starter file
710
710
  render.py project.json [--fast] [--dry-run] [--stop-after STAGE] [--work DIR --keep]
711
711
  render.py project.json --cache DIR [--from STAGE] # reuse identical stages from a previous run
712
- render.py plan.json # execute a plan written by <tool> --plan plan.json
712
+ render.py plan.json [--audit-log FILE] # execute a plan written by <tool> --plan plan.json
713
713
  render.py project.json --export-timeline edit.fcpxml|edit.edl|edit.otio # the cut for an editor, nothing rendered
714
714
  ```
715
715
  `--export-timeline FILE` (2.1) writes the project's cut as an editor timeline instead of
@@ -734,12 +734,19 @@ few frames longer, because `join.py` offsets its crossfades by each part's conta
734
734
  audio included.
735
735
  A plan is a single tool's dry run as an artifact: `cut.py in.mp4 --start 2 --end 8
736
736
  --plan cut.json` writes `{plan_version, tool, argv, inputs (path, size, sha256 of
737
- head+tail), commands, output, verify}` and runs nothing. `render.py cut.json`
738
- re-fingerprints the inputs (refusing, `kind: input`, if any changed since the
739
- plan), runs the tool with the planned argv, then the verify steps (probe; `check`
737
+ head+tail), unbound_inputs, commands, output, verify}` and runs nothing
738
+ (`unbound_inputs`: inputs that did not exist yet, so carry no fingerprint).
739
+ `render.py cut.json` re-fingerprints the inputs (refusing, `kind: input`,
740
+ `error.reason: plan_inputs_changed`, if any changed since the plan: every input's
741
+ `{path, status, expected, actual}` -- `actual` `"missing"` for a file that is gone --
742
+ plus `plan_sha256` and `checked_at` are in `error.details`, and one JSON line goes
743
+ to `<plan>.audit.jsonl`, or to `--audit-log FILE`; a log that cannot be written is
744
+ a `diagnostics.warnings` entry, never the failure, and a `--dry-run` writes none),
745
+ runs the tool with the planned argv, then the verify steps (probe; `check`
740
746
  for a `--platform` or a platform export preset; add `"content": true` to a check
741
- step for the black/frozen/silence rows), and reports `plan`, `tool`,
742
- `tool_result` and `check`. Show the plan to the user, get the yes, execute:
747
+ step for the black/frozen/silence rows), and reports `plan`, `plan_sha256`, `tool`,
748
+ `tool_result`, `check` and `unbound_inputs` (each also a `diagnostics.warnings`
749
+ entry: nothing checked those files). Show the plan to the user, get the yes, execute:
743
750
  one round trip instead of re-deriving the command.
744
751
  `"export": {"preset": "reels", "normalize": true}` forwards `export.py --normalize`
745
752
  so the rendered file meets the platform's loudness without a separate pass. Since
@@ -1497,6 +1504,11 @@ Devanagari/Thai-class scripts and cannot load a colour emoji font: a shaping
1497
1504
  script is refused by name pointing at `caption.py`/`graphics.py`, and
1498
1505
  `--emoji png|color` is refused the same way (`--emoji mono`, the default here,
1499
1506
  draws whatever glyph the text font has; `--emoji none` strips them).
1507
+ A multi-line `--text` is one block: the lines are left-aligned and the block is
1508
+ placed by `--position`. FFmpeg 8.0, 8.0.1 and 8.1.0 draw the font's missing-glyph box
1509
+ at each line break, so on those releases each line gets its own drawtext at the
1510
+ place the block puts it (measured by one drawtext run on a generated frame, also
1511
+ under `--dry-run`); the picture is the one a fixed release draws.
1500
1512
 
1501
1513
  ### sync.py — offset detection, alignment, drift correction
1502
1514
  ```
@@ -1539,7 +1551,7 @@ them.
1539
1551
  ```
1540
1552
  color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|bt2390] [--peak 1000] [--desat 0] [-o OUT]
1541
1553
  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
1554
+ color.py INPUT --retag bt709|bt2020-pq|bt2020-hlg|bt601 [-o OUT] # metadata only, stream copy (checked with ffprobe)
1543
1555
  color.py INPUT --strip-dovi [-o OUT] # drop the Dolby Vision RPU, keep the HLG/HDR10 base layer (stream copy)
1544
1556
  color.py INPUT --correct [--exposure -3..3] [--contrast 0..2] [--saturation 0..2] [--gamma 0.1..10]
1545
1557
  [--temperature 2000..12000] [--tint -1..1] [--lift -1..1] [--gain -1..1]
@@ -1570,6 +1582,20 @@ tetrahedral interpolation (Log→709 conversions, creative looks); blend with
1570
1582
  `--lut-strength`. Everything else in the skill assumes SDR BT.709, so run this
1571
1583
  first on HDR or Log sources.
1572
1584
 
1585
+ `--retag` relabels, never converts. H.264, HEVC, MPEG-2, AV1 and VP9 carry
1586
+ their colour description in the stream itself, and decoders read that before
1587
+ the container's, so the stream copy rewrites it with the codec's
1588
+ `*_metadata` bitstream filter (the coded pictures are untouched) as well as
1589
+ the container's `colr`/Colour. Every run then probes the output, stream and
1590
+ first decoded frame (from FFmpeg 8.1 the two can disagree): when either
1591
+ still reads other tags (a codec without such a filter, or a build that lacks
1592
+ it) or the copy fails, the video is re-encoded with the tags on its frames
1593
+ (`setparams`), `reencoded: true` and a `notes` line say so, and the check is
1594
+ `verification`'s `color_tags` step. Tags that are still wrong after the
1595
+ re-encode fail the run with `kind: verification`. The re-encode is H.264
1596
+ unless `--codec` says otherwise, so an output container that cannot hold it
1597
+ (`.webm`) fails with `kind: ffmpeg` instead of completing with the old tags.
1598
+
1573
1599
  ### audio.py — clean-up, music, ducking, layout
1574
1600
  ```
1575
1601
  audio.py INPUT [--voice [light|medium|strong] | --denoise [--denoise-strength 25]] [--gain dB]
@@ -56,15 +56,16 @@ 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,
63
63
  STATE, _timed_out, _unwatch, _watch, X264_PRESETS
64
64
  )
65
65
  from _common.emit import (
66
- _brief, _BRIEF_DROP, _brief_summary, _CURRENT_CTX, die, emit, info, _plan_at_exit, _plan_inputs, _PLAN_STRIP,
67
- PLAN_VERSION, print_json, _set_current_ctx, write_plan
66
+ absorb_stage, audit, _brief, _BRIEF_DROP, _brief_summary, _CURRENT_CTX, die, emit, info, note, _plan_at_exit, _plan_inputs,
67
+ _plan_unbound_inputs, _PLAN_STRIP, PLAN_VERSION, print_json, record_fallback, record_skip, refuse, _set_current_ctx, warn,
68
+ write_plan
68
69
  )
69
70
  from _common.probe import (
70
71
  analyze_levels, _aspect_string, _bit_depth, decode_pcm_mono, detect_scenes, detect_silences, fingerprint,
@@ -96,7 +97,7 @@ from _common.color import (
96
97
  )
97
98
  from _common.text import (
98
99
  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,
100
+ drawtext_draws_newline_glyph, is_multiline, drawtext_clean, drawtext_line_metrics, NEWLINE_GLYPH_RELEASES,
100
101
  drawtext_shaping, drawtext_text_opts, emoji_asset_for, EMOJI_ASSET_HINT, emoji_clusters, emoji_codepoint_name,
101
102
  _EMOJI_COLOR_FAMILIES, _emoji_color_font, emoji_filter_chain, _emoji_name_candidates, EMOJI_RANGES,
102
103
  _EMOJI_REGIONAL, emoji_support, _EMOJI_SUPPORT_CACHE, _EMOJI_TAIL, escape_drawtext, _family_rank, FC_LANG,
@@ -147,7 +148,7 @@ class _Facade(_types.ModuleType):
147
148
  # defining module (audit 14, P1-1); every other name is a plain re-export of the same object.
148
149
  _LIVE = {
149
150
  "_FFMPEG_VERSION": "runner", "_FFMPEG_RELEASE": "runner", "_FFMPEG_RELEASE_READ": "runner", "_SIGNALS_INSTALLED": "runner",
150
- "_DRAWTEXT_TMPDIR": "runner", "_ENCODERS": "runner", "_CURRENT_CTX": "emit",
151
+ "_DRAWTEXT_TMPDIR": "runner", "_ENCODERS": "runner", "_BSFS": "runner", "_CURRENT_CTX": "emit",
151
152
  }
152
153
 
153
154
  def __getattribute__(self, name):
@@ -190,14 +191,14 @@ __all__ = [
190
191
  "BEAT_REFRACTORY_S", "BEAT_WINDOW_S", "BEAT_SUPPORT_DIVISOR", "BEAT_ALIGN_DIVISOR",
191
192
  "BEAT_Z_FLOOR", "BEAT_Z_SPAN",
192
193
  "_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",
194
+ "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
195
  "_DRAWTEXT_PENDING", "drawtext_shaping", "drawtext_text_opts", "_DRAWTEXT_TMPDIR", "_drawtext_tmpdir",
195
196
  "dry_run_input_pending", "emit", "emoji_asset_for", "EMOJI_ASSET_HINT", "emoji_clusters",
196
197
  "emoji_codepoint_name", "_EMOJI_COLOR_FAMILIES", "_emoji_color_font", "emoji_filter_chain",
197
198
  "_emoji_name_candidates", "EMOJI_RANGES", "_EMOJI_REGIONAL", "emoji_support", "_EMOJI_SUPPORT_CACHE",
198
199
  "_EMOJI_TAIL", "encoder_args", "_ENCODERS", "_env_timeout", "ERROR_CODE", "ERROR_RETRYABLE", "escape_drawtext",
199
200
  "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",
201
+ "FC_UNKNOWN", "ffmpeg_base", "ffmpeg_bsfs", "ffmpeg_encoders", "ffmpeg_exe", "_FFMPEG_RELEASE", "_FFMPEG_VERSION", "ffmpeg_release", "ffmpeg_version", "fingerprint",
201
202
  "flush_drawtext_textfiles", "fold_bt709_matrix", "fmt_secs", "fmt_smpte_time", "fmt_srt_time", "font_covers_script",
202
203
  "font_family_for_script", "font_family_of_file", "FONT_FLAG_HINT", "font_for_script", "FONT_INSTALL_HINT",
203
204
  "fonts_dir_covers_script", "_fraction", "has_emoji", "info", "INSTALL_HINTS", "install_signal_handlers",
@@ -216,6 +217,7 @@ __all__ = [
216
217
  "time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "validate_color", "verify_output",
217
218
  "source_codec_video_args", "drawn_chain", "draws_chroma", "drawn_stream_format", "is_neutral_color", "rgb_island", "label_chain", "matrix_chain", "rgb_chain_to_bt709", "bt709_label_filter", "rgb_to_bt709_filter", "is_rgb", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
218
219
  "_x264_raw", "_ZWJ",
220
+ "absorb_stage", "audit", "note", "_plan_unbound_inputs", "record_fallback", "record_skip", "refuse", "warn",
219
221
  "ASR_ENGINES", "ASR_INSTALL_HINT", "_asr_run", "die_no_engine", "parse_srt", "transcribe",
220
222
  "_transcribe_in", "transcribe_words", "_words_from_openai_whisper_json",
221
223
  "_words_from_whisper_cpp_json", "whisper_word_timings", "write_srt",
@@ -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