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 +4 -3
- package/SKILL.md +1 -1
- package/docs/contract.md +53 -10
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +9 -1
- package/references/scripts.md +34 -8
- package/scripts/_common/__init__.py +9 -7
- package/scripts/_common/drawtext.py +65 -6
- package/scripts/_common/emit.py +154 -2
- package/scripts/_common/runner.py +155 -33
- package/scripts/_common/text.py +1 -1
- package/scripts/_contract.py +59 -8
- package/scripts/color.py +142 -41
- package/scripts/overlay.py +66 -22
- package/scripts/render.py +91 -31
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.
|
|
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.
|
|
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`),
|
|
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
|
|
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
|
|
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`, `
|
|
269
|
-
`tool_result` and `
|
|
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
|
|
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.
|
|
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
|
package/references/scripts.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Script reference
|
|
2
2
|
|
|
3
|
-
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; every re-encoding tool and `export.py` also take `--hw`/`--no-hw` (h264/hevc/prores on Apple VideoToolbox, Apple Silicon only, CRF mapped to `-q:v`; AV1 stays on the CPU; a job VideoToolbox itself refuses -- the encoder cannot open or its compression session fails -- is re-encoded on the CPU and reported in `hw.fallback`/`hw.notes`, any other failure is not retried; a GPU encode adds a `notes` line that its quality is not CRF-equivalent; `FFMPEG_SKILL_HW=1` makes it the default for every tool except `export.py`, whose delivery presets need `--hw` itself; `render.py`/`batch.py --hw`/`--no-hw` apply to every stage, export included; results name the `encoder` that ran); every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, default 18 -- 30 for `proxy.py`; up to 63 for av1; ignored by prores). 2.0 removed `--crf` from these tools (it was `--quality`'s alias); `export.py` keeps its own `--crf`, which is not an alias. Every tool refuses (`kind: input`, before anything runs) to replace an existing output unless `--overwrite` is given. `--codec hevc` on SDR writes 8-bit BT.709 HEVC (`hvc1`), `av1` uses SVT-AV1 (libaom fallback), `prores` is 422 HQ and needs an explicit `-o NAME.mov` (or `.mkv`), `h264` refuses an HDR source (`kind: input`, run `color.py --to-sdr` first). `export.py` keeps choosing the codec from its preset and has neither `--codec` nor `--quality`; a `render.py` project cannot choose a codec either -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize`/`join` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
|
|
3
|
+
Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--json-brief` (since 1.11.0: the same JSON result trimmed to `status`, `output`, `dry_run`, `verified`, a compact `summary` of the output probe -- duration_s, width, height, fps, vcodec, acodec, channels, and lufs when measured -- the tool's own keys, and the count of commands instead of the command lines; it implies `--json`, leaves `--json`'s own output untouched, and failures print the usual full failure document), `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `--plan FILE` (the dry run written as a plan document that `render.py FILE` executes later; see render.py), `-o OUT`; every re-encoding tool and `export.py` also take `--hw`/`--no-hw` (h264/hevc/prores on Apple VideoToolbox, Apple Silicon only, CRF mapped to `-q:v`; AV1 stays on the CPU; a job VideoToolbox itself refuses -- the encoder cannot open or its compression session fails -- is re-encoded on the CPU and reported in `hw.fallback`/`hw.notes`, any other failure is not retried; a GPU encode adds a `notes` line that its quality is not CRF-equivalent; `FFMPEG_SKILL_HW=1` makes it the default for every tool except `export.py`, whose delivery presets need `--hw` itself; `render.py`/`batch.py --hw`/`--no-hw` apply to every stage, export included; results name the `encoder` that ran); every editing tool that re-encodes (not `export.py`, whose preset decides the codec) also takes `--codec h264|hevc|av1|prores` (the encoder for the re-encode; default x264 for SDR, x265 Main10 for HDR, unchanged) and `--quality N` (CRF scale, default 18 -- 30 for `proxy.py`; up to 63 for av1; ignored by prores). 2.0 removed `--crf` from these tools (it was `--quality`'s alias); `export.py` keeps its own `--crf`, which is not an alias. Every tool refuses (`kind: input`, `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
|
|
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
|
|
738
|
-
|
|
739
|
-
|
|
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 `
|
|
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,
|
|
67
|
-
PLAN_VERSION, print_json, _set_current_ctx,
|
|
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
|
|
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
|