ffmpeg-skill 1.14.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.
@@ -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