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.
- package/README.md +12 -4
- package/SKILL.md +4 -4
- package/docs/contract.md +32 -11
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +6 -1
- package/references/gotchas.md +70 -1
- package/references/scripts.md +49 -7
- package/scripts/_ass_overlay.py +155 -0
- package/scripts/_common/__init__.py +187 -0
- package/scripts/_common/color.py +69 -0
- package/scripts/_common/decision.py +415 -0
- package/scripts/_common/emit.py +287 -0
- package/scripts/_common/probe.py +382 -0
- package/scripts/_common/runner.py +1056 -0
- package/scripts/_common/text.py +980 -0
- package/scripts/_contract.py +19 -4
- package/scripts/caption.py +326 -92
- package/scripts/graphics.py +295 -19
- package/scripts/overlay.py +35 -2
- package/scripts/_common.py +0 -2516
|
@@ -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
|