ffmpeg-skill 1.15.0 → 1.15.1

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
@@ -386,8 +386,11 @@ FFmpeg 8 shortened the flag column of `ffmpeg -filters`. A parser anchored on th
386
386
  ## Tested on real footage
387
387
 
388
388
  **What is tested where.** The contract and the test suite (`tests/test_contract.py`,
389
- `tests/test_all.py`) run on Linux, macOS and Windows on every pull request, minus the handful of
390
- POSIX-shim tests listed under [Development](#development). The real-device media corpus
389
+ `tests/test_all.py`, which aggregates one module per tool group `test_analysis.py`,
390
+ `test_editing.py`, `test_audio.py`, `test_picture.py`, `test_delivery.py`,
391
+ `test_orchestration.py` — over the shared footage in `tests/_fixtures.py`) run on Linux, macOS
392
+ and Windows on every pull request, minus the handful of POSIX-shim tests listed under
393
+ [Development](#development). The real-device media corpus
391
394
  (`tests/corpus.py`) has been run on Linux and macOS; the full corpus has **not** been run on
392
395
  Windows yet, and neither has an install by someone other than the maintainer been reproduced
393
396
  there — [issue #143](https://github.com/kajisho5/ffmpeg-skill/issues/143) tracks both. Treat the
@@ -402,7 +405,7 @@ type on every OS.
402
405
  | **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
403
406
  | **exact to the sample** | `cut.py --accurate` on WAV, FLAC (44.1 kHz) and AAC → WAV; WAV stream copy within 2 ms; AAC output +21 ms of encoder priming, reported as `codec_frame` (0.9.1) |
404
407
  | **72 / 72** | agent runs of 24 prompts (12 English edits, 8 Japanese, 4 that must be declined), three repeats, graded by an independent model: routing, honest refusals and user's language 72/72, report format 71/72, visual check whenever the picture changed 24/24 (0.8.4) |
405
- | _pending_ | **1.15.0 shipped, eval pending.** Eval 16 grades it on the 82-prompt set (the 76 plus `em1`–`em4`/`sh1`–`sh2`: emoji captions and title cards, a Hindi lower-third, a Thai lower-third + caption) and re-runs the caption/graphics prompts in every script the set covers. No numbers are claimed until that run exists |
408
+ | **82 / 82** | 1.15.0 run (2026-09-13, one pass per prompt, Sonnet agent, regex grader + focused Opus grader on 28 runs, stills extracted inside the emoji window, check.py re-run on every delivery output) on the set grown to 82 prompts (emoji captions and title cards, a Hindi and a Thai lower-third): routing 82/82, honest refusals and failures 82/82 with 0 false successes and 0 raw ffmpeg calls, report format 82/82 (both iteration-15 label defects closed: `dl8` and `he2` now carry one `Failed:`), user's language 82/82 by regex (81/82 by Opus: one Spanish report with three English labels), non-Latin glyphs 11/11 (Devanagari through `graphics.py` is fixed; Thai lower-third and captions correct), emoji visible in colour in 3/3 runs given PNG assets and reported monochrome in the one that was not, visual check 23/24, delivery 12/13 one encode and 13/13 platform pass, trigger set 40/40, Opus quality mean 4.68. Still open: the caption breaker splits phrases (`dl1`, `dl4` unchanged) — queued for 1.16.0. Tokens per run flat at 73.3k on the same 76. Details in `evals/results/iteration-16.json` |
406
409
  | **76 / 76** | 1.14.0 run (2026-09-13, one pass per prompt, Sonnet agent, regex grader + focused Opus grader on 26 runs, check.py re-run on every delivery output): routing 76/76, honest refusals and failures 76/76 with 0 false successes and 0 raw ffmpeg calls, report format 76/76, user's language 76/76 by regex (75/76 by Opus: one Spanish report with three English labels), visual check 18/18, trigger set 38/38, Opus quality mean 4.58. The delivery templates did their job: 12 of 13 delivery requests went through `render.py --template`, finished in one encode (was 3 of 7) and all 13 pass their platform check (was 7 of 8). Tokens per run flat at 73.4k. Details in `evals/results/iteration-15.json` |
407
410
  | **76 / 76** | 1.13.0 run (2026-09-13, one pass per prompt, Sonnet agent, regex grader + focused Opus grader) on the set grown to 76 prompts: 18 in Thai, Hindi, Hebrew, Russian, Greek, Vietnamese, Indonesian, Turkish and Italian, and 8 delivery requests (TikTok, Reels, Shorts, LinkedIn, Douyin, podcast): routing 76/76, honest refusals and failures 76/76 with 0 false successes and 0 raw ffmpeg calls, report format 76/76, user's language 76/76 across seventeen languages, visual check 18/18, trigger set 38/38, Opus quality mean 4.65 over the 26 new runs. One real defect found: Hindi through `graphics.py` (drawtext) comes out wrong-shaped even though the font covers Devanagari; captions through libass are fine (queued for 1.15.0). Four delivery runs spent a second encode for loudness, which 1.14.0's templates address. Tokens per run flat at 72.3k. Details in `evals/results/iteration-14.json` |
408
411
  | **50 / 50** | 1.12.0 run (2026-09-13, one pass per prompt, Sonnet agent, regex grader + focused Opus grader) on the set grown to 50 prompts with two each in Chinese, Korean, Spanish, Portuguese, French, German and Arabic: routing 50/50, honest refusals and failures 50/50 with 0 false successes and 0 raw ffmpeg calls, report format 50/50, user's language 50/50 across nine languages, visual check 13/13, trigger set 29/29, Opus quality mean 4.83. Every non-Latin caption and lower-third picked a covering font by itself and rendered real glyphs (Arabic shaped and right-to-left); tokens per run unchanged at 72.3k. Details in `evals/results/iteration-13.json` |
@@ -478,6 +481,7 @@ FFmpeg itself:
478
481
 
479
482
  ```bash
480
483
  npm test # tests/test_all.py (end-to-end incl. VFR, rotated, 5.1, HDR10, drifting sources) + tests/test_contract.py
484
+ python3 tests/test_picture.py # one tool group on its own (analysis, editing, audio, picture, delivery, orchestration)
481
485
  npm run release-check # pack, install, contract from the installed copy, MCP == contract, doctor, tests, contract evals
482
486
  npm run demo # python3 demos/build.py: synthetic footage -> every before/after demo + docs/demos/*.gif
483
487
  npm run demo:pipeline # examples/make_demo.sh: the older single end-to-end run of every script
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 (`1.15.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.15.1`) | 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`
@@ -88,19 +88,19 @@ spelling keeps working until 2.0.
88
88
 
89
89
  | What 2.0 removes | Since | Replacement | To be ready today |
90
90
  |---|---|---|---|
91
- | The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.15.0 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
92
- | `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.15.0 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
93
- | `json` and `progress` in the MCP `inputSchema` | 1.15.0 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
94
- | `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.15.0 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
95
- | Overwriting an existing output with only a warning | 1.15.0 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
91
+ | The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.15.1 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
92
+ | `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.15.1 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
93
+ | `json` and `progress` in the MCP `inputSchema` | 1.15.1 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
94
+ | `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.15.1 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
95
+ | Overwriting an existing output with only a warning | 1.15.1 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
96
96
 
97
97
  ## Skill
98
98
 
99
99
  ```json
100
100
  {
101
101
  "contract_version": "1.0",
102
- "deprecated": [{"what": "...", "since": "1.15.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
- "skill": {"id": "ffmpeg-skill", "version": "1.15.0", "execution_mode": "local", "kind": "execution",
102
+ "deprecated": [{"what": "...", "since": "1.15.1", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
+ "skill": {"id": "ffmpeg-skill", "version": "1.15.1", "execution_mode": "local", "kind": "execution",
104
104
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
105
105
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
106
106
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -128,7 +128,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
128
128
  | `output_schema` | what `--json` prints on stdout |
129
129
  | `supports_dry_run`, `dry_run` | whether `--dry-run` plans without running ffmpeg or writing files |
130
130
  | `supports_json` | whether `--json` exists |
131
- | `supports_json_brief` | whether `--json-brief` exists (1.15.0): the same success document with `probe` replaced by a compact `summary` (`duration_s`, `width`, `height`, `fps`, `vcodec`, `acodec`, `channels`, and `lufs` when the tool measured one), `commands` replaced by the number of commands run, and the per-step `verification` list dropped (its verdict stays in `verified`). Tool-specific keys are unchanged, `--json`'s own output is unchanged, and a failure prints the same failure document either way |
131
+ | `supports_json_brief` | whether `--json-brief` exists (1.15.1): the same success document with `probe` replaced by a compact `summary` (`duration_s`, `width`, `height`, `fps`, `vcodec`, `acodec`, `channels`, and `lufs` when the tool measured one), `commands` replaced by the number of commands run, and the per-step `verification` list dropped (its verdict stays in `verified`). Tool-specific keys are unchanged, `--json`'s own output is unchanged, and a failure prints the same failure document either way |
132
132
  | `mutates_input` | always `false`: no tool overwrites its input |
133
133
  | `produces_artifact` | writes a file (media, PNG, HTML, EDL) |
134
134
  | `verification` | `{required, tools}`: which tools to run on the output afterwards |
@@ -450,7 +450,8 @@ measurement dicts flattened, plus any numeric top-level key such as `expected_du
450
450
  1.x keys are not moved; the environment variable only adds the key, and its absence is the
451
451
  default until 2.0.
452
452
 
453
- Success is decided by `verify_output` in `_common.py`, not by the ffmpeg exit code alone:
453
+ Success is decided by `verify_output` in `_common` (`scripts/_common/probe.py`), not by the
454
+ ffmpeg exit code alone:
454
455
  the file must exist, be non-empty and give ffprobe at least one stream. A tool that ran
455
456
  ffmpeg successfully but has no usable artifact fails with `kind: output` (a 0-byte file is
456
457
  removed so a later step cannot mistake it for a result).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.15.0",
3
+ "version": "1.15.1",
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",
@@ -124,7 +124,12 @@ these had ever shown up before.
124
124
  - **`drawtext` `boxborderw=v|h` (and the four-value form) is 6.1+.** 5.x and 6.0 fail the whole
125
125
  filter with "Error setting option boxborderw to value 9|16". `_common.drawtext_boxborderw()`
126
126
  emits the larger single value on older builds (`_common.ffmpeg_version()` parses
127
- `ffmpeg -version` once; it is the only place the tools branch on a version string).
127
+ `ffmpeg -version` once; it is the only place the tools branch on a version string). Both names
128
+ are still spelled exactly that way: `_common` is a package since the refactor release after
129
+ 1.15.0, and its `__init__.py` re-exports every name the single module defined, so
130
+ `_common.drawtext_boxborderw()`, `_common.ffmpeg_version()` and `_common.bt709_tag_args()`
131
+ below resolve through the facade unchanged. The definitions are in `_common/text.py`,
132
+ `_common/runner.py` and `_common/color.py` respectively.
128
133
  - **`showwaves` keeps emitting frames after the audio ends, `-shortest` notwithstanding.** A
129
134
  12 s source came out 14.08 s on 5.1.1. `waveform.py` now also passes `-t <source duration>`.
130
135
  - **`-display_rotation` is 6.0+.** Only the test fixture builder used it (to make a rotated
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env python3
2
+ """Shared helpers for ffmpeg-skill scripts.
3
+
4
+ Standard library only. Locates ffmpeg/ffprobe on PATH, runs them with clear
5
+ error reporting, and provides a compact media probe used by every script.
6
+
7
+ Since the refactor release after 1.15.0 the helpers live in one module per responsibility --
8
+ runner (process execution and timeouts), probe (ffprobe and the measured facts), decision (the
9
+ pure copy-vs-re-encode and capability choices), emit (result documents, die(), info()), color
10
+ (colour tags and the HDR paths) and text (fonts, scripts, emoji, drawtext) -- and this file is a
11
+ facade that re-exports every name they define. `import _common` and `from _common import x` mean
12
+ exactly what they meant when this was one 3072-line module; nothing else about the package is
13
+ part of the contract.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import math
20
+ import os
21
+ import platform
22
+ import re
23
+ import shutil
24
+ import subprocess
25
+ import sys
26
+ import types as _types
27
+ import unicodedata
28
+ from fractions import Fraction
29
+ from pathlib import Path
30
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
31
+
32
+ # The standard-library modules above were module-level names of the old single-file _common and
33
+ # stay reachable as `_common.<mod>`: a test that stands `shutil.which` up differently reaches for
34
+ # `_common.shutil`, and it has to be the one module object every submodule calls through -- which
35
+ # it is, since an `import` binds the same object everywhere.
36
+
37
+ # Every script prints paths, help text and reports that may contain non-ASCII (Japanese examples,
38
+ # arrows). On Windows the console streams default to a legacy code page and raise
39
+ # UnicodeEncodeError; make them UTF-8 with replacement so a --help never crashes on encoding.
40
+ # (First thing the package does, before any submodule can print.)
41
+ for _stream in (sys.stdout, sys.stderr):
42
+ try:
43
+ if getattr(_stream, "encoding", "").lower().replace("-", "") != "utf8":
44
+ _stream.reconfigure(encoding="utf-8", errors="replace")
45
+ except (AttributeError, ValueError):
46
+ pass
47
+
48
+ # The submodules import each other; importing runner first pulls the whole graph in (see the
49
+ # deferred imports at the foot of runner.py and emit.py). The `from ... import ...` lines below
50
+ # are the facade proper: every public and underscore name any caller in scripts/, mcp/, tests/,
51
+ # demos/ or evals/ has ever reached for through `_common`.
52
+
53
+ from _common.runner import (
54
+ add_common, apply_common, _check_existing_output, _check_no_overwrite_input, _check_output_path, child_args,
55
+ child_limit, _CHILDREN, _cleanup_partial_output, _cmdline, CODECS, Context, _CRF_DEFAULT, DEFAULT_TIMEOUT,
56
+ _DRAWTEXT_PENDING, _DRAWTEXT_TMPDIR, _drawtext_tmpdir, dry_run_input_pending, _ENCODERS, _env_timeout,
57
+ ERROR_CODE, ERROR_RETRYABLE, EVEN_SCALE, _execute, _fail, ffmpeg_base, ffmpeg_encoders, _FFMPEG_VERSION,
58
+ ffmpeg_version, flush_drawtext_textfiles, INSTALL_HINTS, install_signal_handlers, _is_ffmpeg, _limit_for,
59
+ _odd_dimension_retry, _on_signal, _OutputLock, _pid_dead, place_output, PROBE_TIMEOUT, _progress_line,
60
+ read_text_or_die, refuse_output_is_input, _remember_output, require_tool, run, run_analysis, _run_captured,
61
+ run_keeping_subtitles, run_tool, _run_with_progress, shell_quote, _SIGNALS_INSTALLED, _stage_existing_output,
62
+ STATE, _timed_out, _unwatch, _watch, X264_PRESETS
63
+ )
64
+ from _common.emit import (
65
+ _brief, _BRIEF_DROP, _brief_summary, _CURRENT_CTX, die, emit, info, _plan_at_exit, _plan_inputs, _PLAN_STRIP,
66
+ PLAN_VERSION, print_json, _result_v2, _set_current_ctx, _V2_HANDLED, write_plan
67
+ )
68
+ from _common.probe import (
69
+ analyze_levels, _aspect_string, _bit_depth, decode_pcm_mono, fingerprint, _fraction, keyframes_near,
70
+ measured_level_dbfs, MEDIA_EXT, _output_failed, probe, rms_envelope, _to_float, _to_int, verify_output
71
+ )
72
+ from _common.decision import (
73
+ aac_args, add_pad_fill_args, audio_codec_for, AUDIO_CODECS, brand_caption_style, BRAND_DEFAULTS,
74
+ brand_states_font, cfr_args, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
75
+ fmt_secs, fmt_smpte_time, fmt_srt_time, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
76
+ signed_time_arg, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
77
+ )
78
+ from _common.color import (
79
+ bt709_tag_args, color_hex, _COLOR_TOKEN_RE, _sdr_bt709, validate_color
80
+ )
81
+ from _common.text import (
82
+ ADVANCE_EM, BIDI_SCRIPTS, _char_em, char_script, default_font_file, detect_script, drawtext_boxborderw,
83
+ drawtext_shaping, drawtext_text_opts, emoji_asset_for, EMOJI_ASSET_HINT, emoji_clusters, emoji_codepoint_name,
84
+ _EMOJI_COLOR_FAMILIES, _emoji_color_font, emoji_filter_chain, _emoji_name_candidates, EMOJI_RANGES,
85
+ _EMOJI_REGIONAL, emoji_support, _EMOJI_SUPPORT_CACHE, _EMOJI_TAIL, escape_drawtext, _family_rank, FC_LANG,
86
+ _fc_list_fonts, FC_UNKNOWN, font_covers_script, font_family_for_script, font_family_of_file, FONT_FLAG_HINT,
87
+ font_for_script, FONT_INSTALL_HINT, fonts_dir_covers_script, has_emoji, _is_emoji_base, _is_emoji_char,
88
+ _is_mark, _KEYCAP, _KEYCAP_BASES, LANGUAGE_NAMES, LATIN_EM, LEADING_VOWELS, _libass_color_probe, needs_shaping,
89
+ NO_SPACE_SCRIPTS, PREFERRED_FAMILIES, resolve_emoji_assets, _SCRIPT_FONT_CACHE, _script_font_entry,
90
+ script_font_for_text, script_font_status, _script_font_uncached, _SCRIPT_RANGES, SCRIPTS, _SHAPING_BUILD_CACHE,
91
+ SHAPING_SCRIPTS, text_width_em, _VS15, _VS16, WINDOWS_FONTS, _ZWJ
92
+ )
93
+
94
+ from _common import color, decision, runner, text # noqa: F401,E402
95
+
96
+ # `_common.emit` and `_common.probe` are the FUNCTIONS, as they have always been -- the
97
+ # from-imports above rebound the package attribute the submodule import had set. The two modules
98
+ # that share a name with a helper are reached through sys.modules instead; nothing outside this
99
+ # package refers to them. Consequently `import _common.emit` / `import _common.probe` bind the
100
+ # function, not the module: reach the modules as `from _common import emit as _` never, use
101
+ # `sys.modules["_common.emit"]` or `importlib.import_module("_common.emit")` instead.
102
+ _emit_module = sys.modules["_common.emit"]
103
+ _probe_module = sys.modules["_common.probe"]
104
+
105
+ _MODULES = (runner, _emit_module, _probe_module, decision, color, text)
106
+
107
+
108
+ class _Facade(_types.ModuleType):
109
+ """The package module's own type, so that rebinding a name on the facade rebinds it on the
110
+ module that defines it.
111
+
112
+ Tests reach into `_common` to stand a name up differently for one case -- `_common._FFMPEG_VERSION
113
+ = (7, 1)`, `mock.patch("_common.<name>")`. While this was one module that simply worked; against
114
+ a package, a plain re-export is a second binding and the defining module goes on calling its
115
+ own. Mirroring the assignment keeps those call sites honest without asking every helper to look
116
+ itself up through the facade. Names mutated in place (STATE, the caches) need none of this --
117
+ the facade re-exports the same object.
118
+ """
119
+
120
+ # The globals a helper REBINDS (`global x; x = ...`) live in their submodule; the facade's
121
+ # own copy is the import-time binding and would read stale. Reads of these names go to the
122
+ # defining module (audit 14, P1-1); every other name is a plain re-export of the same object.
123
+ _LIVE = {
124
+ "_FFMPEG_VERSION": "runner", "_CRF_DEFAULT": "runner", "_SIGNALS_INSTALLED": "runner",
125
+ "_DRAWTEXT_TMPDIR": "runner", "_ENCODERS": "runner", "_CURRENT_CTX": "emit",
126
+ }
127
+
128
+ def __getattribute__(self, name):
129
+ live = _types.ModuleType.__getattribute__(self, "_LIVE")
130
+ if name in live:
131
+ return getattr(sys.modules["_common." + live[name]], name)
132
+ return _types.ModuleType.__getattribute__(self, name)
133
+
134
+ def __setattr__(self, name, value):
135
+ _types.ModuleType.__setattr__(self, name, value)
136
+ if name.startswith("__") and name.endswith("__"):
137
+ return # importlib.reload() rewrites __file__/__spec__: those stay per module (P1-2)
138
+ for _m in _MODULES:
139
+ if name in _m.__dict__:
140
+ _types.ModuleType.__setattr__(_m, name, value)
141
+
142
+ def __delattr__(self, name):
143
+ _types.ModuleType.__delattr__(self, name)
144
+ if name.startswith("__") and name.endswith("__"):
145
+ return
146
+ for _m in _MODULES:
147
+ if name in _m.__dict__:
148
+ _types.ModuleType.__delattr__(_m, name)
149
+
150
+
151
+ sys.modules[__name__].__class__ = _Facade
152
+
153
+ __all__ = [
154
+ "Any", "Dict", "Fraction", "List", "Optional", "Path", "Sequence", "Tuple", "argparse", "json", "math", "os",
155
+ "platform", "re", "shutil", "subprocess", "sys", "unicodedata",
156
+ "aac_args", "add_common", "add_pad_fill_args", "ADVANCE_EM", "analyze_levels", "apply_common", "_aspect_string",
157
+ "audio_codec_for", "AUDIO_CODECS", "BIDI_SCRIPTS", "_bit_depth", "brand_caption_style", "BRAND_DEFAULTS",
158
+ "brand_states_font", "_brief", "_BRIEF_DROP", "_brief_summary", "bt709_tag_args", "cfr_args", "_char_em",
159
+ "char_script", "_check_existing_output", "_check_no_overwrite_input", "_check_output_path", "child_args",
160
+ "child_limit", "_CHILDREN", "_cleanup_partial_output", "_cmdline", "CODECS", "color_hex", "_COLOR_TOKEN_RE",
161
+ "concat_list_line", "Context", "_CRF_DEFAULT", "_CURRENT_CTX", "db_to_linear", "decode_pcm_mono",
162
+ "default_font_file", "default_output", "DEFAULT_TIMEOUT", "detect_script", "die", "drawtext_boxborderw",
163
+ "_DRAWTEXT_PENDING", "drawtext_shaping", "drawtext_text_opts", "_DRAWTEXT_TMPDIR", "_drawtext_tmpdir",
164
+ "dry_run_input_pending", "emit", "emoji_asset_for", "EMOJI_ASSET_HINT", "emoji_clusters",
165
+ "emoji_codepoint_name", "_EMOJI_COLOR_FAMILIES", "_emoji_color_font", "emoji_filter_chain",
166
+ "_emoji_name_candidates", "EMOJI_RANGES", "_EMOJI_REGIONAL", "emoji_support", "_EMOJI_SUPPORT_CACHE",
167
+ "_EMOJI_TAIL", "encoder_args", "_ENCODERS", "_env_timeout", "ERROR_CODE", "ERROR_RETRYABLE", "escape_drawtext",
168
+ "escape_filter_path", "EVEN_SCALE", "_execute", "_fail", "_family_rank", "FC_LANG", "_fc_list_fonts",
169
+ "FC_UNKNOWN", "ffmpeg_base", "ffmpeg_encoders", "_FFMPEG_VERSION", "ffmpeg_version", "fingerprint",
170
+ "flush_drawtext_textfiles", "fmt_secs", "fmt_smpte_time", "fmt_srt_time", "font_covers_script",
171
+ "font_family_for_script", "font_family_of_file", "FONT_FLAG_HINT", "font_for_script", "FONT_INSTALL_HINT",
172
+ "fonts_dir_covers_script", "_fraction", "has_emoji", "info", "INSTALL_HINTS", "install_signal_handlers",
173
+ "is_audio_output", "_is_emoji_base", "_is_emoji_char", "_is_ffmpeg", "_is_mark", "_KEYCAP", "_KEYCAP_BASES",
174
+ "keyframes_near", "LANGUAGE_NAMES", "LATIN_EM", "LEADING_VOWELS", "_libass_color_probe", "_limit_for",
175
+ "load_brand", "measured_level_dbfs", "MEDIA_EXT", "MissingFpsError", "needs_shaping", "NO_SPACE_SCRIPTS",
176
+ "_odd_dimension_retry", "_on_signal", "_output_failed", "_OutputLock", "pad_filters", "parse_time", "_pid_dead",
177
+ "place_output", "_plan_at_exit", "_plan_inputs", "_PLAN_STRIP", "PLAN_VERSION", "PREFERRED_FAMILIES",
178
+ "print_json", "probe", "PROBE_TIMEOUT", "_progress_line", "read_text_or_die", "refuse_output_is_input",
179
+ "_remember_output", "require_tool", "resolve_emoji_assets", "_result_v2", "rms_envelope", "run", "run_analysis",
180
+ "_run_captured", "run_keeping_subtitles", "run_tool", "_run_with_progress", "_SCRIPT_FONT_CACHE",
181
+ "_script_font_entry", "script_font_for_text", "script_font_status", "_script_font_uncached", "_SCRIPT_RANGES",
182
+ "SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote",
183
+ "_SIGNALS_INSTALLED", "signed_time_arg", "_stage_existing_output", "STATE", "SVT_PRESET", "text_width_em",
184
+ "time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "_V2_HANDLED", "validate_color", "verify_output",
185
+ "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
186
+ "_x264_raw", "_ZWJ"
187
+ ]
@@ -0,0 +1,69 @@
1
+ """Colour: the bt709 tagging arguments and the HDR-to-SDR conversion path, plus colour-token
2
+ validation for the tools that take a `--color`.
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import re
7
+ from typing import List, Tuple
8
+ from _common.emit import die
9
+ from _common.runner import ffmpeg_version
10
+
11
+
12
+ def bt709_tag_args(encoder: str = "libx264") -> List[str]:
13
+ """Tag an SDR output as BT.709 without touching its pixels.
14
+
15
+ Up to FFmpeg 7.0 the output options -colorspace/-color_primaries/-color_trc were tags only.
16
+ 7.1 added colourspace negotiation to libavfilter and feeds those options into the graph's
17
+ output constraints, so on a source whose bitstream carries no colour tags (test sources,
18
+ screen recordings, many cameras) the CLI now auto-inserts a *real* matrix conversion (its
19
+ guess for "unknown" is bt601) into every SDR re-encode: a --lut-strength 0 no-op grade
20
+ came back ~24 dB PSNR from its source on 7.1. From 7.1 on, the tags therefore go through
21
+ the encoder's own VUI parameters instead, which libavfilter never sees; a source that is
22
+ genuinely tagged bt601/bt2020 is left alone either way (it keeps its own tags on the old
23
+ path, and the encoder VUI is a label, not a conversion, on the new one).
24
+ """
25
+ if ffmpeg_version() < (7, 1):
26
+ return ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
27
+ if encoder == "libx265":
28
+ return ["-x265-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709"]
29
+ return ["-x264-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709"]
30
+
31
+
32
+ def _sdr_bt709(encoder: str) -> "Tuple[str, List[str]]":
33
+ """BT.709 SDR tags for `encoder` as (encoder-params string, extra output options): the two
34
+ spellings bt709_tag_args() picks between, split so a caller that already builds an encoder
35
+ params string can merge them (the option given twice keeps only the last)."""
36
+ if ffmpeg_version() < (7, 1):
37
+ return "", ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
38
+ if encoder == "libsvtav1":
39
+ return "color-primaries=1:transfer-characteristics=1:matrix-coefficients=1", []
40
+ if encoder == "libaom-av1":
41
+ return "", [] # no VUI params option; an untagged 8-bit stream reads as BT.709 everywhere
42
+ return "colorprim=bt709:transfer=bt709:colormatrix=bt709", []
43
+
44
+
45
+ def color_hex(value: str) -> str:
46
+ """Normalise '#ffd200' / 'ffd200' / '0xFFD200' to 'FFD200'."""
47
+ v = str(value).strip().lstrip("#")
48
+ if v.lower().startswith("0x"):
49
+ v = v[2:]
50
+ if len(v) != 6:
51
+ die(f"colour must be RRGGBB, got '{value}'")
52
+ return v.upper()
53
+
54
+
55
+ _COLOR_TOKEN_RE = re.compile(r"^(0[xX][0-9A-Fa-f]{6,8}|#[0-9A-Fa-f]{6,8}|[A-Za-z][A-Za-z0-9]*)(@(?:0(?:\.\d+)?|1(?:\.0+)?|\.\d+))?$") # alpha is 0..1; "red@2" used to reach ffmpeg
56
+
57
+
58
+ def validate_color(value: str, flag: str = "--color") -> str:
59
+ """Refuse a colour argument that isn't a plain ffmpeg colour token (named colour, 0xRRGGBB[AA],
60
+ #RRGGBB[AA], optionally with an @alpha suffix). Every caller that string-formats a colour flag
61
+ straight into a filter graph (color=c=..., tpad=...:color=..., rotate=...:fillcolor=...) must
62
+ validate it first -- ffmpeg filter options are comma/colon-delimited, so an unvalidated value
63
+ containing those characters lets a caller splice in an entirely different filter (a real,
64
+ demonstrated filter-graph injection: --color "black,drawtext=text=..." renders arbitrary burnt-in
65
+ text), not just an odd colour. This is the same "no filter graph accepted from the caller"
66
+ invariant every other typed flag in this codebase already holds to."""
67
+ if not _COLOR_TOKEN_RE.match(value):
68
+ die(f"{flag} must be a plain colour (a name, 0xRRGGBB[AA], or #RRGGBB[AA], optionally @alpha), got '{value}'")
69
+ return value