ffmpeg-skill 1.15.0 → 1.16.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.
@@ -1,3072 +0,0 @@
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
- from __future__ import annotations
8
-
9
- import json
10
- import math
11
- import os
12
- import platform
13
- import argparse
14
- import re
15
- import shutil
16
- import subprocess
17
- import sys
18
- import unicodedata
19
- from fractions import Fraction
20
- from pathlib import Path
21
- from typing import Any, Dict, List, Optional, Sequence, Tuple
22
-
23
- # Every script prints paths, help text and reports that may contain non-ASCII (Japanese examples,
24
- # arrows). On Windows the console streams default to a legacy code page and raise
25
- # UnicodeEncodeError; make them UTF-8 with replacement so a --help never crashes on encoding.
26
- for _stream in (sys.stdout, sys.stderr):
27
- try:
28
- if getattr(_stream, "encoding", "").lower().replace("-", "") != "utf8":
29
- _stream.reconfigure(encoding="utf-8", errors="replace")
30
- except (AttributeError, ValueError):
31
- pass
32
-
33
- INSTALL_HINTS = {
34
- "Darwin": " brew install ffmpeg-full (the plain ffmpeg formula lacks subtitles/drawtext/zscale)",
35
- "Linux": (
36
- " Debian/Ubuntu: sudo apt install ffmpeg\n"
37
- " Fedora: sudo dnf install ffmpeg\n"
38
- " Arch: sudo pacman -S ffmpeg"
39
- ),
40
- "Windows": (
41
- " winget install Gyan.FFmpeg\n"
42
- " or: choco install ffmpeg\n"
43
- " or download a build from https://ffmpeg.org/download.html and add it to PATH"
44
- ),
45
- }
46
-
47
-
48
- # `kind` (below) is the machine-readable failure axis: input / missing_tool / ffmpeg / output
49
- # since 0.1, plus timeout (1.3), verification (1.4.3) and interrupted (1.4.10). `ERROR_CODE` is an
50
- # additive, purely informational refinement layered on top for agents that want a stable enum to
51
- # switch on instead of pattern-matching `kind` strings -- a static 1:1 relabelling of the same
52
- # buckets, not a new taxonomy. It intentionally does NOT introduce categories this codebase cannot actually
53
- # distinguish today (e.g. a separate ffprobe-vs-ffmpeg code, or an environment-vs-content-cause
54
- # split of ffmpeg failures): every ffmpeg subprocess failure is currently one undifferentiated
55
- # bucket regardless of whether ffmpeg rejected a bad filter argument or died from a full disk,
56
- # and every "kind": "input" failure covers both a missing file and a bad flag value alike. Adding
57
- # codes for distinctions the code can't actually make would be guessing, not reporting -- if a
58
- # future call site can genuinely tell capability-missing apart from bad-argument (see doctor()'s
59
- # available/missing/unknown states, which already model this for detection but aren't wired into
60
- # any die() call), split ERROR_CODE then, with evidence, not speculatively now.
61
- ERROR_CODE = {
62
- "input": "INPUT_INVALID",
63
- "missing_tool": "DEPENDENCY_MISSING",
64
- "ffmpeg": "FFMPEG_EXECUTION_FAILED",
65
- "output": "OUTPUT_INVALID",
66
- "timeout": "TIMEOUT",
67
- "verification": "VERIFICATION_FAILED",
68
- "interrupted": "INTERRUPTED",
69
- }
70
-
71
- # Wall-clock ceiling for one ffmpeg/ffprobe invocation, in seconds. A hung ffmpeg (a build
72
- # that deadlocks on a filter combination, a stalled network mount, an input that never ends)
73
- # used to hang the calling agent with it, with no error document and no way out short of
74
- # killing the process by hand. The ceiling is generous on purpose: it exists to turn a hang
75
- # into a reported failure, not to police slow encodes. --timeout and FFMPEG_SKILL_TIMEOUT
76
- # override it; 0 disables it.
77
- DEFAULT_TIMEOUT = 1800.0
78
- PROBE_TIMEOUT = 120.0
79
-
80
-
81
- def _env_timeout() -> float:
82
- try:
83
- return max(0.0, float(os.environ.get("FFMPEG_SKILL_TIMEOUT", DEFAULT_TIMEOUT)))
84
- except ValueError:
85
- return DEFAULT_TIMEOUT
86
-
87
- # None of the four kinds above are retryable in practice: an "input"/"missing_tool" failure is
88
- # always deterministic (the same bad path or absent binary fails identically every time), and a
89
- # "ffmpeg"/"output" failure -- while it COULD in principle be caused by a transient environment
90
- # condition (full disk, OOM) rather than a bad command -- is never distinguishable from a
91
- # deterministic content-cause failure without exit-code/stderr sniffing this codebase does not do.
92
- # Reporting retryable=True for a code we can't actually back up would invite an agent into a blind
93
- # retry loop against a command that will fail the same way every time; false-for-everything is the
94
- # honest answer until real sniffing exists to justify anything else.
95
- ERROR_RETRYABLE = False
96
-
97
-
98
- _FFMPEG_VERSION: "Optional[Tuple[int, int]]" = None
99
-
100
-
101
- def ffmpeg_version() -> "Tuple[int, int]":
102
- """(major, minor) of the FFmpeg build on PATH, parsed once from `ffprobe -version`; (0, 0)
103
- when it cannot be read. ffprobe rather than ffmpeg because --dry-run promises never to run
104
- ffmpeg (docs/contract.md: ffmpeg_execution "none") while ffprobe always may, and the two
105
- ship from the same build. Used only to pick between two spellings of an option where FFmpeg
106
- changed behaviour between releases (the tools otherwise never branch on the version: doctor's
107
- capability listing is the source of truth for what a build can do)."""
108
- global _FFMPEG_VERSION
109
- if _FFMPEG_VERSION is None:
110
- _FFMPEG_VERSION = (0, 0)
111
- try:
112
- out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
113
- timeout=PROBE_TIMEOUT).stdout
114
- m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)", out)
115
- if m:
116
- _FFMPEG_VERSION = (int(m.group(1)), int(m.group(2)))
117
- else:
118
- # git / vendor builds print "N-115000-g..." or a date, never major.minor; the
119
- # libavutil major is still there and maps one-to-one onto the FFmpeg major
120
- # (56=4, 57=5, 58=6, 59=7, 60=8). Without this every version branch took the
121
- # oldest spelling on such builds: on 7.1 that skipped bt709_tag_args()'s
122
- # workaround and an untagged source got a real matrix conversion.
123
- m = re.search(r"^libavutil\s+(\d+)\.", out, re.M)
124
- if m:
125
- major = int(m.group(1)) - 52
126
- if major >= 4:
127
- _FFMPEG_VERSION = (major, 0)
128
- except (OSError, subprocess.TimeoutExpired):
129
- # (0, 0) = unknown: every version branch then takes the older, universally accepted
130
- # spelling, the same "unknown is not missing" stance doctor takes.
131
- pass
132
- return _FFMPEG_VERSION
133
-
134
-
135
- def drawtext_boxborderw(vertical: int, horizontal: int) -> str:
136
- """drawtext's per-side `boxborderw=top|right|bottom|left` (and the two-value `v|h` form)
137
- arrived in FFmpeg 6.1; 5.x and 6.0 reject the `|` with "Error setting option boxborderw"
138
- (found by the FFmpeg 5.1.1 CI job, #146). Older builds get the larger single value."""
139
- if ffmpeg_version() >= (6, 1):
140
- return f"{vertical}|{horizontal}"
141
- return str(max(vertical, horizontal))
142
-
143
-
144
- def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken: float = 0.0) -> str:
145
- """The letterbox/pillarbox step shared by fit.py and export.py, as one -vf segment.
146
-
147
- fill="color": scale to fit, then pad with a solid colour (the historical behaviour).
148
- fill="blur": the bars are a blurred, scaled-to-cover copy of the same frame -- what every
149
- phone editor's "make it vertical" does with landscape footage (#139). Built as a small
150
- graph inside the -vf chain: split, one branch scaled to cover and cropped to the frame
151
- then boxblur'ed, the other scaled to fit, overlaid centred. Only `filter:boxblur` is
152
- needed beyond the usual scale/pad set, and that is already required by redact.py.
153
- `darken` > 0 also dims that background copy by that much brightness (eq), so the picture in
154
- front reads as the subject instead of competing with a bright blurred copy of itself --
155
- what `fit.py --fit blur` uses (1.14)."""
156
- if fill == "blur":
157
- # boxblur rejects a radius larger than half the smaller dimension ("radius 20, must be
158
- # <= 8" on a 16 px target); clamp instead of failing an otherwise valid request
159
- radius = max(1, min(int(blur), max(1, min(out_w, out_h) // 2 - 1)))
160
- return (f"split[__fitfg][__fitbg];"
161
- f"[__fitbg]scale={out_w}:{out_h}:force_original_aspect_ratio=increase,crop={out_w}:{out_h},"
162
- f"boxblur={radius}:2" + (f",eq=brightness=-{darken:g}" if darken else "") + "[__fitbgb];"
163
- f"[__fitfg]scale={out_w}:{out_h}:force_original_aspect_ratio=decrease[__fitfgs];"
164
- f"[__fitbgb][__fitfgs]overlay=(W-w)/2:(H-h)/2:format=auto")
165
- return f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease,pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2:color={color}"
166
-
167
-
168
- def add_pad_fill_args(parser: "argparse.ArgumentParser") -> None:
169
- parser.add_argument("--pad-fill", choices=["color", "blur"], default="color",
170
- help="what fills the letterbox/pillarbox bars under --fit pad: a solid --pad-color (default) or a blurred, scaled-up copy of the frame")
171
- parser.add_argument("--pad-blur", type=int, default=20, help="blur radius in pixels for --pad-fill blur (default 20)")
172
-
173
-
174
- def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]" = None, **extra: Any) -> "None":
175
- """Exit with a message. Under --json also print a machine-readable failure document
176
- (status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged.
177
-
178
- `extra` fields are added to the failure document: a tool whose *result* failed (check.py's
179
- platform rows, render.py's check stage, batch.py's per-item results, verify.py's steps) keeps
180
- reporting that detail while the top-level status says failed. Before 1.4.3 those four printed
181
- `status: "completed"` next to a non-zero exit code, so a caller keying on the status alone
182
- read a failed delivery as a success."""
183
- hint = extra.pop("hint", None)
184
- ctx = ctx or STATE # 1.10: the optional per-request Context (2.0 makes it required); STATE is the default instance
185
- _set_current_ctx(ctx) # the atexit hook has no argument: it reads the ctx emit()/die() last used
186
- ctx.plan = None # a failed run plans nothing (the exit hook must not write a plan for it)
187
- STATE.plan = None # the hook falls back to STATE when nothing passed a ctx; a failed run plans nothing there either
188
- sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
189
- if ctx.json:
190
- doc: Dict[str, Any] = {
191
- "status": "failed", "exit_code": code,
192
- "error": {
193
- "kind": kind, "message": msg,
194
- "code": ERROR_CODE.get(kind, "INTERNAL_ERROR"),
195
- "retryable": ERROR_RETRYABLE,
196
- },
197
- "commands": list(ctx.commands),
198
- }
199
- if hint:
200
- doc["error"]["hint"] = hint
201
- doc.update(extra)
202
- print_json(doc)
203
- sys.exit(code)
204
-
205
-
206
- def info(msg: str, ctx: "Optional[Context]" = None) -> None:
207
- # under --dry-run nothing is written; do not let scripts claim otherwise
208
- ctx = ctx or STATE
209
- if msg.startswith("wrote ") and ctx.dry_run:
210
- msg = "[dry-run] would write " + msg[len("wrote "):]
211
- sys.stderr.write(f"{msg}\n")
212
-
213
-
214
- def require_tool(name: str) -> str:
215
- """Return the absolute path of ffmpeg/ffprobe or exit with install steps."""
216
- path = shutil.which(name)
217
- if path:
218
- return path
219
- system = platform.system()
220
- hint = INSTALL_HINTS.get(system, " See https://ffmpeg.org/download.html")
221
- die(
222
- f"'{name}' was not found on PATH.\n"
223
- f"Install FFmpeg (which includes ffprobe) for {system}:\n{hint}",
224
- code=127, kind="missing_tool",
225
- )
226
- return "" # unreachable
227
-
228
-
229
- X264_PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow", "placebo")
230
- CODECS = ("h264", "hevc", "av1", "prores")
231
- # x264 preset names mapped onto SVT-AV1's 0-13 speed scale (lower = slower / better)
232
- SVT_PRESET = {"ultrafast": 12, "superfast": 11, "veryfast": 10, "faster": 9, "fast": 8, "medium": 6, "slow": 4, "slower": 3, "veryslow": 2, "placebo": 1}
233
- _ENCODERS: Optional[set] = None
234
-
235
-
236
- class Context:
237
- """Per-process settings that the shared flags (--dry-run, --json, --progress, --fast) set once.
238
-
239
- Scripts read it as attributes (``STATE.dry_run``); the dict-style shims that once served
240
- older call sites are gone. Keeping it a single explicit object rather than module globals
241
- makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
242
- """
243
-
244
- __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec")
245
-
246
- def __init__(self) -> None:
247
- self.reset()
248
-
249
- def reset(self) -> None:
250
- self.dry_run = False # print ffmpeg commands, run nothing (ffprobe still runs)
251
- self.json = False # emit() prints a JSON document instead of the output path
252
- self.json_brief = False # --json-brief: the same document trimmed to the fields a caller acts on
253
- self.progress = False # run() streams percent / ETA to stderr for ffmpeg
254
- self.fast = False # x264 preset forced to veryfast
255
- self.duration_hint: Optional[float] = None # expected output length, for the progress percent
256
- self.commands: List[str] = [] # every ffmpeg command line, for --json and --dry-run
257
- self.timeout: float = _env_timeout() # seconds per ffmpeg invocation, 0 = none
258
- self.overwrite = False # --overwrite: an existing output may be replaced
259
- self.written: set = set() # output paths this process has written itself
260
- self.preexisting: dict = {} # output path -> (size, mtime_ns) of a file that was there before we ran
261
- self.plan: Optional[str] = None # --plan FILE: write the dry-run as a plan document (implies --dry-run)
262
- self.plan_written = False # write_plan() ran (emit or the exit hook), so the hook does not write twice
263
- self.plan_inputs: List[str] = [] # side inputs (srt/ass/lut/font files) a tool named through escape_filter_path
264
- self.codec: Optional[str] = None # --codec: encoder for the re-encode (None = x264 for SDR, x265 for HDR)
265
-
266
-
267
-
268
- STATE = Context()
269
-
270
- # The atexit plan hook takes no arguments, so emit()/die() record the Context they were given
271
- # here; nothing passed a ctx = it stays None and the hook falls back to STATE, as before (1.10).
272
- _CURRENT_CTX: "Optional[Context]" = None
273
-
274
-
275
- def _set_current_ctx(ctx: "Context") -> None:
276
- global _CURRENT_CTX
277
- _CURRENT_CTX = ctx
278
-
279
-
280
- def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
281
- """Add the flags every script shares. `codec=False` is for a tool that re-encodes but whose
282
- preset decides the encoder (export.py): it must not advertise --codec/--quality in its schema."""
283
- g = ap.add_argument_group("agent options")
284
- g.add_argument("--dry-run", action="store_true", help="print the ffmpeg commands that would run, run nothing")
285
- g.add_argument("--json", action="store_true", help="print a JSON result (output, probe, commands) on stdout instead of the path")
286
- g.add_argument("--json-brief", action="store_true",
287
- help="like --json but trimmed: status, output, dry_run, verified, a compact summary of the output probe, this tool's own keys, and the command count instead of the command lines (failures print the full failure document, unchanged)")
288
- g.add_argument("--progress", action="store_true", help="show percent / ETA on stderr while ffmpeg encodes")
289
- g.add_argument("--fast", action="store_true", help="preview quality: x264 preset veryfast (overrides --preset) for quick iterations")
290
- if "--timeout" not in ap._option_string_actions: # verify.py defines its own per-step --timeout; apply_common reads either
291
- g.add_argument("--timeout", type=float, default=None, metavar="SECONDS",
292
- help=f"kill an ffmpeg run past this many seconds, kind=timeout (default {DEFAULT_TIMEOUT:.0f}; 0 = no limit)")
293
- g.add_argument("--overwrite", action="store_true",
294
- help="allow replacing an existing output (warned today, refused from 2.0)")
295
- g.add_argument("--plan", metavar="FILE",
296
- help="write the dry run as a plan (inputs fingerprinted, commands, expected output, verify steps) that render.py FILE executes later; implies --dry-run")
297
- if codec and "--crf" in ap._option_string_actions:
298
- # --crf became an alias of --quality in 1.8; 1.10 deprecates it (removed in 2.0, see the
299
- # `deprecated` list in `contract --json` and docs/contract.md "What 2.0 changes"). Marked
300
- # here, once, rather than in each re-encoding tool's own parser.
301
- crf = ap._option_string_actions["--crf"]
302
- # The flag's own default moves aside so apply_common() can tell an explicit --crf (in any
303
- # spelling argparse accepts, including the --cr / --c abbreviations) from the default;
304
- # apply_common() puts _CRF_DEFAULT back when the flag was absent.
305
- global _CRF_DEFAULT
306
- _CRF_DEFAULT = crf.default
307
- crf.deprecated_default = crf.default # the schema still advertises it (_contract._json_type)
308
- crf.default = None
309
- if "deprecated" not in (crf.help or ""):
310
- # the nine tools that declare --crf with no help string used to fall through this and
311
- # never show the mark at all (review 9)
312
- crf.help = (crf.help or "x264 CRF when re-encoding (default 18)") + " (deprecated: use --quality)"
313
- # only the tools that re-encode (they declare --crf before add_common): one encoder choice
314
- # resolved in video_args(), the 2.0 encoder abstraction pre-shipped in 1.8 (docs/roadmap.md)
315
- g.add_argument("--codec", choices=CODECS, default=None,
316
- help="video encoder for the re-encode: h264 (x264, the default for SDR), hevc (x265, the default for HDR), av1 (SVT-AV1 or libaom), prores (422 HQ, needs a .mov/.mkv output); HDR sources keep their colour on hevc/av1/prores")
317
- g.add_argument("--quality", type=int, default=None, metavar="N",
318
- help="encoder quality on the CRF scale (lower = better; 18 visually lossless for x264/x265, up to 63 for av1); overrides --crf, ignored by prores")
319
-
320
-
321
- # The declared default of a deprecated --crf, parked by add_common() (one parser per process).
322
- _CRF_DEFAULT: Optional[int] = None
323
-
324
-
325
- def apply_common(args: "argparse.Namespace") -> None:
326
- # Was --crf typed? add_common() parked the flag's default (None in its place) on every tool
327
- # whose --crf is deprecated, i.e. the ones that also have --quality; export.py's --crf is not
328
- # an alias and keeps its own default. Scanning sys.argv for "--crf" instead missed the unique
329
- # prefixes argparse accepts (--cr, --c) and never ran for batch.py's recipe steps (review 9).
330
- crf_explicit = hasattr(args, "quality") and getattr(args, "crf", None) is not None
331
- if hasattr(args, "quality") and hasattr(args, "crf") and args.crf is None:
332
- args.crf = _CRF_DEFAULT
333
- STATE.plan = getattr(args, "plan", None) or None
334
- STATE.dry_run = bool(getattr(args, "dry_run", False)) or bool(STATE.plan)
335
- if STATE.plan:
336
- # tools that print their document instead of calling emit() (probe, and the analysis
337
- # tools without --json) still get their plan written, at exit, unless die() ran (review 6)
338
- import atexit
339
- atexit.register(_plan_at_exit)
340
- STATE.json_brief = bool(getattr(args, "json_brief", False))
341
- # --json-brief is a shorter --json, not a second output mode: it implies it, so a caller that
342
- # passes only --json-brief still gets a JSON document (and --json --json-brief is the brief one).
343
- STATE.json = bool(getattr(args, "json", False)) or STATE.json_brief
344
- STATE.progress = bool(getattr(args, "progress", False))
345
- STATE.fast = bool(getattr(args, "fast", False))
346
- STATE.overwrite = bool(getattr(args, "overwrite", False))
347
- if getattr(args, "timeout", None) is not None:
348
- STATE.timeout = max(0.0, float(args.timeout))
349
- if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
350
- args.preset = "veryfast"
351
- STATE.codec = getattr(args, "codec", None) or None
352
- quality = getattr(args, "quality", None)
353
- if quality is not None:
354
- top = 63 if STATE.codec == "av1" else 51
355
- if not 0 <= int(quality) <= top:
356
- die(f"--quality must be between 0 and {top} for {STATE.codec or 'h264'} (CRF scale; 18 is visually lossless), got {quality}")
357
- args.crf = int(quality) # every tool reads args.crf; --quality is the codec-neutral spelling of it
358
- if STATE.codec == "prores" and hasattr(args, "output"):
359
- out = getattr(args, "output", None)
360
- if not out:
361
- # every tool defaults its output to the source's extension (or .mp4): ProRes in an .mp4
362
- # fails inside ffmpeg with "codec not currently supported in container" (review 7)
363
- die("--codec prores needs an explicit -o NAME.mov (or .mkv): the default output name keeps the source's container, which cannot hold ProRes",
364
- hint="give -o NAME.mov")
365
- if os.path.splitext(str(out))[1].lower() not in (".mov", ".mkv"):
366
- die(f"--codec prores needs a .mov (or .mkv) output; {os.path.basename(str(out))} cannot hold ProRes",
367
- hint="give -o NAME.mov")
368
- crf = getattr(args, "crf", None)
369
- # The warning the deprecation policy asks for, only when the caller typed the flag (see
370
- # crf_explicit above). export.py has no --quality (its preset chooses the encoder), so its
371
- # --crf is not an alias and is not deprecated: warn only where --quality exists.
372
- if crf is not None and crf_explicit:
373
- info("warning: --crf is deprecated since 1.10.0; use --quality N (the same CRF scale, codec-neutral). --crf is removed in 2.0.")
374
- top = 63 if STATE.codec == "av1" else 51
375
- if crf is not None and not 0 <= int(crf) <= top:
376
- die(f"--crf must be between 0 and {top} ({'SVT-AV1' if STATE.codec == 'av1' else 'x264/x265'} scale; 18 is visually lossless), got {crf}")
377
- install_signal_handlers()
378
-
379
-
380
- # The child processes this tool is waiting on right now (an ffmpeg, or a sibling script under
381
- # run_tool), with the command whose partial output would need removing. A signal handler
382
- # reads it; the runners keep it current. Before 1.4.9 a SIGTERM to the tool (a cancelled MCP
383
- # call, a supervisor's stop, a closed terminal) killed only the Python parent: ffmpeg carried on
384
- # as an orphan, finished a file nobody verified, and the caller got no JSON at all; SIGINT was a
385
- # KeyboardInterrupt traceback with the partial left on disk.
386
- _CHILDREN: List[Tuple[subprocess.Popen, Sequence[str]]] = []
387
- _SIGNALS_INSTALLED = False
388
-
389
-
390
- def _on_signal(signum: int, frame: Any) -> None:
391
- import signal as _signal
392
- name = {getattr(_signal, "SIGINT", None): "SIGINT", getattr(_signal, "SIGTERM", None): "SIGTERM"}.get(signum, str(signum))
393
- for proc, cmd in list(_CHILDREN):
394
- try:
395
- proc.terminate() # ffmpeg exits promptly on SIGTERM; a sibling script runs this same handler
396
- try:
397
- proc.wait(timeout=5)
398
- except subprocess.TimeoutExpired:
399
- proc.kill()
400
- proc.wait()
401
- except OSError:
402
- pass
403
- if cmd:
404
- _cleanup_partial_output(cmd)
405
- _CHILDREN.clear()
406
- die(f"interrupted by {name}: the running command was stopped and its partial output removed; nothing was written",
407
- code=128 + signum, kind="interrupted")
408
-
409
-
410
- def install_signal_handlers() -> None:
411
- """SIGINT/SIGTERM stop the child, remove its partial output and exit with a failure document
412
- (kind: interrupted, exit 130/143). Main thread only; on Windows SIGTERM is never delivered,
413
- SIGINT (Ctrl-C) is."""
414
- global _SIGNALS_INSTALLED
415
- if _SIGNALS_INSTALLED:
416
- return
417
- import signal as _signal
418
- import threading
419
- if threading.current_thread() is not threading.main_thread():
420
- return
421
- for sig in (getattr(_signal, "SIGINT", None), getattr(_signal, "SIGTERM", None)):
422
- if sig is None:
423
- continue
424
- try:
425
- _signal.signal(sig, _on_signal)
426
- except (ValueError, OSError):
427
- pass
428
- _SIGNALS_INSTALLED = True
429
-
430
-
431
- def _watch(proc: subprocess.Popen, cmd: Sequence[str]) -> None:
432
- _CHILDREN.append((proc, cmd))
433
-
434
-
435
- def _unwatch(proc: subprocess.Popen) -> None:
436
- _CHILDREN[:] = [(p, c) for p, c in _CHILDREN if p is not proc]
437
-
438
-
439
- def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any) -> None:
440
- """Final stdout line: the output path, or a JSON document with --json.
441
-
442
- `ctx` is the optional per-request Context added in 1.10 (2.0 makes it required, issue #189 B);
443
- omitted, every read falls back to the process-global STATE as before."""
444
- ctx = ctx or STATE
445
- _set_current_ctx(ctx) # so the atexit hook writes (or skips) this ctx's plan, not STATE's
446
- meta: Dict[str, Any] = {}
447
- if output and not ctx.dry_run:
448
- meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
449
- if ctx.json:
450
- doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
451
- if meta:
452
- doc["probe"] = meta
453
- # What this tool itself verified about its artifact (issue #189 C, "verify as part of the
454
- # contract"): the probe every writing tool runs, plus the measurements a tool adds
455
- # (`verification` extra: loudness after the write, a platform check). `verified` is true
456
- # only when the file was written, probed, and every self-check met its target; a dry run
457
- # verified nothing. Spec failures the tool cannot fix on its own (export's loudness gap)
458
- # keep status completed and say verified: false, so a caller keys on one field.
459
- steps: List[Dict[str, Any]] = ([{"step": "probe", "ok": True}] if meta else []) + list(extra.pop("verification", None) or [])
460
- if output and not ctx.dry_run and os.path.splitext(output)[1].lower() not in MEDIA_EXT:
461
- steps.insert(0, {"step": "exists", "ok": True})
462
- doc["verified"] = not ctx.dry_run and bool(steps) and all(s.get("ok") for s in steps)
463
- doc["verification"] = steps
464
- doc.update(extra)
465
- if os.environ.get("FFMPEG_SKILL_RESULT_V2", "") not in ("", "0"):
466
- doc["result_v2"] = _result_v2(output, meta, dict(extra, verified=doc["verified"], verification=steps))
467
- if ctx.plan:
468
- doc["plan"] = write_plan(ctx.plan, output, extra, ctx=ctx)
469
- print_json(_brief(doc, meta) if ctx.json_brief else doc)
470
- elif ctx.plan:
471
- print(write_plan(ctx.plan, output, extra, ctx=ctx))
472
- elif output:
473
- print(output)
474
-
475
-
476
- # Keys the brief document replaces or drops: the full probe (summarised), the command lines
477
- # (counted), the per-step verification list (its verdict stays as `verified`) and the 2.0 preview.
478
- _BRIEF_DROP = ("probe", "commands", "verification", "result_v2")
479
-
480
-
481
- def _brief_summary(meta: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
482
- """The handful of output facts a caller reports or branches on, from the probe this tool
483
- already ran -- plus the measured loudness when the tool measured one. Keys whose value is
484
- unknown are left out rather than emitted as null."""
485
- video = (meta or {}).get("video") or {}
486
- audio = (meta or {}).get("audio") or {}
487
- summary: Dict[str, Any] = {}
488
- duration = (meta or {}).get("duration")
489
- if duration is not None:
490
- summary["duration_s"] = round(float(duration), 3)
491
- for key, value in (("width", video.get("width")), ("height", video.get("height")), ("fps", video.get("fps")),
492
- ("vcodec", video.get("codec")), ("acodec", audio.get("codec")), ("channels", audio.get("channels"))):
493
- if value is not None:
494
- summary[key] = value
495
- lufs = None
496
- for source, key in ((extra.get("result"), "input_i"), (extra.get("measured"), "input_i")):
497
- if lufs is None and isinstance(source, dict):
498
- lufs = _to_float(source.get(key))
499
- for step in extra.get("verification") or []:
500
- if lufs is None and isinstance(step, dict):
501
- lufs = _to_float(step.get("lufs"))
502
- # a silent file measures -inf, which json.dumps writes as the non-standard -Infinity: the
503
- # brief document stays valid JSON by leaving the key out instead (the full document's own
504
- # `measured`/`result` still carries whatever the tool reported).
505
- if lufs is not None and math.isfinite(lufs):
506
- summary["lufs"] = round(lufs, 2)
507
- return summary
508
-
509
-
510
- def _brief(doc: Dict[str, Any], meta: Dict[str, Any]) -> Dict[str, Any]:
511
- """--json-brief: the same success document with the bulky parts replaced by what a caller
512
- acts on. Same keys, same meanings -- `commands` becomes the count of the command lines,
513
- `probe` becomes `summary` -- plus every tool-specific key the tool itself passed to emit().
514
- Failures are untouched: die() prints the full failure document either way."""
515
- brief: Dict[str, Any] = {"status": doc["status"], "output": doc["output"], "dry_run": doc["dry_run"],
516
- "verified": doc.get("verified", False)}
517
- summary = _brief_summary(meta, doc)
518
- if summary:
519
- brief["summary"] = summary
520
- brief["commands"] = len(doc.get("commands") or [])
521
- for key, value in doc.items():
522
- if key not in brief and key not in _BRIEF_DROP:
523
- brief[key] = value
524
- # the emoji report is a full inventory in the long document; brief keeps the two fields a
525
- # caller branches on (did colour happen, and how many)
526
- if isinstance(brief.get("emoji"), dict):
527
- brief["emoji"] = {k: v for k, v in brief["emoji"].items() if k in ("mode", "count")}
528
- return brief
529
-
530
-
531
- PLAN_VERSION = 1
532
- _PLAN_STRIP = ("--plan", "--dry-run", "--json")
533
-
534
-
535
- def _plan_at_exit() -> None:
536
- ctx = _CURRENT_CTX or STATE
537
- if ctx.plan and not ctx.plan_written:
538
- try:
539
- write_plan(ctx.plan, None, {}, ctx=ctx)
540
- except SystemExit:
541
- pass
542
-
543
-
544
- def fingerprint(path: str) -> Dict[str, Any]:
545
- """Size plus a sha256 over the first and last 8 MiB: enough to notice a re-export, a re-trim
546
- or a swapped file, cheap enough for a multi-GB source (hashing a whole master would make
547
- planning slower than the edit)."""
548
- import hashlib
549
- st = os.stat(path)
550
- h = hashlib.sha256()
551
- chunk = 8 * 1024 * 1024
552
- with open(path, "rb") as f:
553
- h.update(f.read(chunk))
554
- if st.st_size > 2 * chunk:
555
- f.seek(-chunk, os.SEEK_END)
556
- h.update(f.read(chunk))
557
- elif st.st_size > chunk:
558
- h.update(f.read())
559
- return {"path": os.path.abspath(path), "size": st.st_size, "sha256_head_tail": h.hexdigest()}
560
-
561
-
562
- def _plan_inputs(commands: Sequence[str], argv: Sequence[str] = (), ctx: "Optional[Context]" = None) -> List[str]:
563
- """Every existing file the plan depends on: the `-i` inputs of the planned commands, any
564
- existing file named in argv (a recipe, a project, an SRT, a LUT, a still), and the side
565
- inputs tools register through escape_filter_path() (review 6: only `-i` files were bound)."""
566
- import shlex
567
- seen: List[str] = []
568
- for a in list(argv) + list((ctx or STATE).plan_inputs):
569
- if a and not a.startswith("-") and os.path.isfile(a) and a not in seen:
570
- seen.append(a)
571
- for line in commands:
572
- try:
573
- toks = shlex.split(line.split("] ", 1)[1] if line.startswith("[dry-run] ") else line)
574
- except ValueError:
575
- continue
576
- for i, tok in enumerate(toks[:-1]):
577
- if tok == "-i" and os.path.isfile(toks[i + 1]) and toks[i + 1] not in seen:
578
- seen.append(toks[i + 1])
579
- return seen
580
-
581
-
582
- def write_plan(path: str, output: Optional[str], extra: Dict[str, Any], ctx: "Optional[Context]" = None) -> str:
583
- """The dry run as an artifact: what will run, on which exact inputs, producing what, checked
584
- how. `render.py PLAN` executes it after re-fingerprinting the inputs (issue #189 C).
585
-
586
- `ctx` is the Context whose commands and inputs the plan describes (emit()/die() pass the one
587
- they were given); omitted, it is the process-global STATE as before."""
588
- import datetime
589
- ctx = ctx or STATE
590
- argv = [a for a in sys.argv[1:]]
591
- cleaned: List[str] = []
592
- skip = False
593
- for a in argv:
594
- if skip:
595
- skip = False
596
- continue
597
- if a in _PLAN_STRIP:
598
- skip = a == "--plan"
599
- continue
600
- if a.startswith("--plan="):
601
- continue
602
- cleaned.append(a)
603
- tool = os.path.splitext(os.path.basename(sys.argv[0]))[0]
604
- verify: List[Dict[str, Any]] = [{"tool": "probe"}] if output else []
605
- platform = None
606
- if "--platform" in cleaned:
607
- platform = cleaned[cleaned.index("--platform") + 1]
608
- elif tool == "export" and "--preset" in cleaned:
609
- platform = {"youtube": "youtube", "youtube4k": "youtube", "reels": "reels", "x": "x"}.get(cleaned[cleaned.index("--preset") + 1])
610
- if platform and output and tool != "check":
611
- verify.append({"tool": "check", "platform": platform})
612
- doc = {
613
- "plan_version": PLAN_VERSION,
614
- "created": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
615
- "tool": tool,
616
- "argv": cleaned,
617
- "cwd": os.getcwd(),
618
- "inputs": [fingerprint(p) for p in _plan_inputs(ctx.commands, cleaned, ctx)],
619
- "commands": list(ctx.commands),
620
- "output": os.path.abspath(output) if output else None,
621
- "verify": verify,
622
- "notes": list(extra.get("notes") or []),
623
- }
624
- try:
625
- tmp = f"{path}.tmp{os.getpid()}"
626
- with open(tmp, "w", encoding="utf-8") as f:
627
- json.dump(doc, f, indent=2, ensure_ascii=False)
628
- f.write("\n")
629
- os.replace(tmp, path)
630
- except OSError as exc:
631
- die(f"cannot write plan {path}: {exc}", kind="output")
632
- ctx.plan_written = True
633
- info(f"plan written: {path} ({len(doc['commands'])} command(s), {len(doc['inputs'])} input(s)); run it with render.py {path}", ctx)
634
- return path
635
-
636
-
637
- _V2_HANDLED = ("result", "measured", "notes", "dropped_non_av_streams", "verified", "verification")
638
-
639
-
640
- def _result_v2(output: Optional[str], meta: Dict[str, Any], extra: Dict[str, Any]) -> Dict[str, Any]:
641
- """The 2.0 success-document shape, previewed in 1.x as a parallel `result_v2` key when
642
- FFMPEG_SKILL_RESULT_V2=1 (issue #189 B). Every tool gets the same six slots: `output`,
643
- `probe`, `commands`, `metrics` (numbers a caller keys on: loudness's `result`/`measured`
644
- dicts flattened, plus every top-level numeric extra such as `expected_duration` or
645
- `offset_seconds`), `notes` (free text), `dropped` (what did not make it into the output),
646
- and `details` (the tool's remaining extras, unchanged). The 1.x keys stay where they are;
647
- this key is additive and its shape is what 2.0 promotes to the top level."""
648
- metrics: Dict[str, Any] = {}
649
- for key in ("measured", "result"):
650
- if isinstance(extra.get(key), dict):
651
- metrics.update(extra[key])
652
- for key, value in extra.items():
653
- if key not in _V2_HANDLED and isinstance(value, (int, float)) and not isinstance(value, bool):
654
- metrics[key] = value
655
- notes = extra.get("notes")
656
- return {
657
- "schema": 2,
658
- "output": output,
659
- "probe": meta or None,
660
- "commands": list(STATE.commands),
661
- "metrics": metrics,
662
- "notes": list(notes) if isinstance(notes, (list, tuple)) else ([notes] if notes else []),
663
- "dropped": {"non_av_streams": bool(extra.get("dropped_non_av_streams", False))},
664
- "verified": bool(extra.get("verified", False)),
665
- "verification": list(extra.get("verification") or []),
666
- "details": {k: v for k, v in extra.items() if k not in _V2_HANDLED and k not in metrics},
667
- }
668
-
669
-
670
- def _cmdline(cmd: Sequence[str]) -> str:
671
- return " ".join(shell_quote(c) for c in cmd)
672
-
673
-
674
- def _is_ffmpeg(cmd: Sequence[str]) -> bool:
675
- return os.path.basename(cmd[0]).startswith("ffmpeg")
676
-
677
-
678
- def _cleanup_partial_output(cmd: Sequence[str]) -> None:
679
- """A failed ffmpeg command can still have opened its output container (muxer header
680
- written) before erroring out mid-stream -- unlike a failure that happens before ffmpeg ever
681
- touches the output path (a bad filter argument, a missing input), which never creates the
682
- file at all. Both are reported the same way (status: failed), but only the first case used
683
- to leave a stray, usually-0-byte file behind: verify_output()'s cleanup only runs on the
684
- success path, so a failed run() call never routed through it. Remove whatever ffmpeg managed
685
- to write so a caller scanning the output directory after a failure never mistakes a partial
686
- artifact for a real (if unverified) one."""
687
- # run() also executes ffprobe, whose last argument is an INPUT. Never
688
- # interpret a read-only tool's failure as permission to remove that file.
689
- if not _is_ffmpeg(cmd):
690
- return
691
- output = cmd[-1]
692
- if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
693
- return
694
- try:
695
- if not os.path.exists(output):
696
- return
697
- # A file that was already there before this command ran is someone's deliverable, not
698
- # our partial. If ffmpeg died before opening it (bad filter argument, unreadable input:
699
- # the common case) it is byte-for-byte what it was, so leave it alone. Only when ffmpeg
700
- # did open and truncate it (size or mtime changed) is what remains a partial of ours,
701
- # and the original is already gone either way; then removing it is still right.
702
- before = STATE.preexisting.get(os.path.realpath(output))
703
- if before is not None:
704
- st = os.stat(output)
705
- if (st.st_size, st.st_mtime_ns) == before:
706
- return
707
- os.remove(output)
708
- except OSError:
709
- pass
710
-
711
-
712
- def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
713
- # Partial-output cleanup already ran in the caller (_run_captured/_run_with_progress) for
714
- # every failed ffmpeg invocation, not just this check=True path -- see _cleanup_partial_output.
715
- # The process exit code is always 1 for an ffmpeg failure: ffmpeg's own code (1, 69, 218, 234,
716
- # a negative signal number...) varies by build and by the failing stage, and 124/127/130/143
717
- # are reserved for timeout, missing tool and interrupts. The raw code is kept in the JSON
718
- # document as `ffmpeg_returncode` for a caller that wants it. docs/design-decisions.md.
719
- tail = "\n".join(stderr.strip().splitlines()[-15:])
720
- die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=1, kind="ffmpeg", ffmpeg_returncode=returncode)
721
-
722
-
723
- def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
724
- """Refuse an ffmpeg command whose output path resolves to the same file as one of its
725
- inputs. ffmpeg's own "Output same as Input" guard only catches byte-identical path
726
- strings; a relative/absolute pair, a leading "./", a redundant ".." segment, or a symlink
727
- all resolve to the same file but pass that check, so "-o ./same.mp4" on an input opened as
728
- "same.mp4" would otherwise silently let ffmpeg's -y clobber the source mid-encode. Every
729
- write-side script routes through this one run() choke point rather than each computing its
730
- own output path defensively, so the guard lives here once instead of at 25+ call sites."""
731
- output = cmd[-1]
732
- if output in ("-", "pipe:0", "pipe:1") or output.startswith("pipe:") or output.startswith("-"):
733
- return
734
- try:
735
- out_real = os.path.realpath(output)
736
- except OSError:
737
- return
738
- for i, a in enumerate(cmd):
739
- if a == "-i" and i + 1 < len(cmd):
740
- inp = cmd[i + 1]
741
- try:
742
- if os.path.realpath(inp) == out_real:
743
- die(f"refusing to run: output {output!r} is the same file as input {inp!r} "
744
- f"(would overwrite it while ffmpeg is still reading it) -- choose a different --output/-o path",
745
- kind="input")
746
- except OSError:
747
- continue
748
-
749
-
750
- def refuse_output_is_input(output: str, *inputs: str) -> None:
751
- """Tool-level twin of the run() guard, for tools whose final ffmpeg command does not name
752
- the user's input at all. `cut.py --segments` cuts each part into a temp dir and then concats
753
- a list file: the last command's only `-i` is that list, so `-o` equal to the input sailed
754
- through _check_no_overwrite_input() and replaced the source with the join (fourth audit,
755
- P0). Call it once the output path is known, before any part of the input is consumed."""
756
- try:
757
- out_real = os.path.realpath(output)
758
- except OSError:
759
- return
760
- for inp in inputs:
761
- try:
762
- same = os.path.realpath(inp) == out_real
763
- except OSError:
764
- continue
765
- if same:
766
- die(f"refusing to run: output {output!r} is the same file as input {inp!r} "
767
- f"(the result would replace the source) -- choose a different --output/-o path", kind="input")
768
-
769
-
770
- def _check_output_path(cmd: Sequence[str]) -> None:
771
- """An output whose directory does not exist, or that names a directory, is a caller mistake:
772
- say so as `kind: input` before ffmpeg runs, instead of the muxer's "No such file or directory"
773
- as `kind: ffmpeg` (which reads as an encoder failure) or an `OUTPUT_INVALID` after the fact."""
774
- output = cmd[-1]
775
- if output == "-" or output.startswith("pipe:") or output.startswith("-"):
776
- return
777
- if os.path.isdir(output):
778
- die(f"output {output!r} is a directory; pass a file path (e.g. {os.path.join(output, 'result.mp4')!r})")
779
- parent = os.path.dirname(os.path.abspath(output))
780
- if not os.path.isdir(parent):
781
- die(f"output directory {parent!r} does not exist; create it first (this tool never creates directories)")
782
- if not os.access(parent, os.W_OK):
783
- die(f"output directory {parent!r} is not writable")
784
-
785
-
786
- EVEN_SCALE = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
787
-
788
-
789
- def _pid_dead(pid: int) -> bool:
790
- """True only when the process is known not to exist. POSIX: signal 0. Windows: OpenProcess
791
- fails with ERROR_INVALID_PARAMETER (87) for a pid that is not in use; any other outcome
792
- (a handle, or access denied) means it is live. Unknown is treated as live."""
793
- if os.name != "nt":
794
- try:
795
- os.kill(pid, 0)
796
- except ProcessLookupError:
797
- return True
798
- except OSError:
799
- pass
800
- return False
801
- try:
802
- import ctypes
803
- k32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
804
- handle = k32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
805
- if handle:
806
- k32.CloseHandle(handle)
807
- return False
808
- return k32.GetLastError() == 87
809
- except Exception:
810
- return False
811
-
812
-
813
- class _OutputLock:
814
- """Two runs writing the same output at once used to both report `completed` while one of
815
- them described the other's file (sweep F1). A lock file next to the output, created with
816
- O_EXCL and holding the writer's pid, makes the second run refuse as `kind: input`. A lock
817
- whose pid is dead (POSIX) or older than an hour is stale and taken over."""
818
- def __init__(self, output: str) -> None:
819
- self.path: Optional[str] = None
820
- self.fd: Optional[int] = None
821
- if output == "-" or output.startswith("pipe:") or output.startswith("-"):
822
- return
823
- d, base = os.path.split(os.path.abspath(output))
824
- self.path = os.path.join(d, f".{base}.ffskill-lock")
825
-
826
- def __enter__(self) -> "_OutputLock":
827
- if not self.path:
828
- return self
829
- for attempt in (0, 1):
830
- try:
831
- self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
832
- os.write(self.fd, str(os.getpid()).encode())
833
- return self
834
- except FileExistsError:
835
- if attempt == 0 and self._stale():
836
- try:
837
- os.remove(self.path)
838
- except OSError:
839
- pass
840
- continue
841
- die(f"another run is writing {os.path.basename(self.path)[1:-len('.ffskill-lock')]!r} right now "
842
- f"(lock {self.path}); wait for it or choose a different --output/-o path")
843
- except OSError:
844
- return self # unlockable location (read-only dir surfaces elsewhere): proceed without a lock
845
- return self
846
-
847
- def _stale(self) -> bool:
848
- try:
849
- pid = int(open(self.path).read().strip() or "0")
850
- if pid > 0 and _pid_dead(pid):
851
- return True
852
- import time
853
- return time.time() - os.path.getmtime(self.path) > 3600
854
- except (OSError, ValueError):
855
- return True
856
-
857
- def __exit__(self, *exc: Any) -> None:
858
- if self.fd is not None:
859
- try:
860
- os.close(self.fd)
861
- except OSError:
862
- pass
863
- if self.path:
864
- try:
865
- os.remove(self.path)
866
- except OSError:
867
- pass
868
-
869
-
870
- def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
871
- """An odd-sized source (641x359 screen captures, some 4:4:4 masters) fails every yuv420p
872
- encode with "width/height not divisible by 2" (sweep F8, 15 tools). Return the same command
873
- with an even-dimension scale prepended to its -vf chain (or a new -vf when the command had
874
- none); None when the failure is something else or the graph is a -filter_complex the
875
- caller has to fix itself."""
876
- if "not divisible by 2" not in stderr or EVEN_SCALE in cmd or any(EVEN_SCALE in a for a in cmd):
877
- return None
878
- if "-filter_complex" in cmd:
879
- return None
880
- new = list(cmd)
881
- if "-vf" in new:
882
- i = new.index("-vf") + 1
883
- new[i] = EVEN_SCALE + "," + new[i]
884
- return new
885
- if "-c:v" in new and new[new.index("-c:v") + 1] == "copy":
886
- return None
887
- return new[:-1] + ["-vf", EVEN_SCALE, new[-1]]
888
-
889
-
890
- def _check_existing_output(cmd: Sequence[str]) -> None:
891
- """An output path that already exists is someone's file: a previous result, a source the
892
- agent mis-named, a deliverable from another run. ffmpeg's -y (which every command carries so
893
- a run never blocks on a y/N prompt) would replace it without a word. Until 2.0 this only
894
- warns, per docs/contract.md's deprecation policy; FFMPEG_SKILL_NO_OVERWRITE=1 opts into the
895
- 2.0 behaviour (refuse) today, and --overwrite is the explicit consent either way. Paths this
896
- process wrote itself (a two-pass tool, a copy-then-re-encode fallback) are never in question."""
897
- output = cmd[-1]
898
- if output in ("-",) or output.startswith("pipe:") or output.startswith("-"):
899
- return
900
- try:
901
- exists = os.path.isfile(output)
902
- real = os.path.realpath(output)
903
- except OSError:
904
- return
905
- if not exists or real in STATE.written:
906
- return
907
- try:
908
- st = os.stat(output)
909
- STATE.preexisting[real] = (st.st_size, st.st_mtime_ns)
910
- except OSError:
911
- pass
912
- if STATE.overwrite:
913
- return
914
- if os.environ.get("FFMPEG_SKILL_NO_OVERWRITE", "") not in ("", "0"):
915
- die(f"refusing to overwrite existing output {output!r}: pass --overwrite to replace it, or choose another -o path", kind="input")
916
- info(f"warning: {output} already exists and will be overwritten (pass --overwrite to confirm; "
917
- f"from 2.0 an existing output is refused without it, FFMPEG_SKILL_NO_OVERWRITE=1 enables that now)")
918
-
919
-
920
- def _remember_output(cmd: Sequence[str]) -> None:
921
- output = cmd[-1]
922
- if output == "-" or output.startswith("pipe:") or output.startswith("-"):
923
- return
924
- try:
925
- STATE.written.add(os.path.realpath(output))
926
- except OSError:
927
- pass
928
-
929
-
930
- def _timed_out(cmd: Sequence[str], seconds: float) -> "None":
931
- _cleanup_partial_output(cmd)
932
- die(f"{os.path.basename(cmd[0])} exceeded the {seconds:.0f} s time limit and was killed; nothing was written. "
933
- f"Raise --timeout (or FFMPEG_SKILL_TIMEOUT) if the job is genuinely that long, or check the input for a stall",
934
- code=124, kind="timeout")
935
-
936
-
937
- def _stage_existing_output(cmd: Sequence[str]) -> Tuple[List[str], Optional[str], Optional[str]]:
938
- """When the output path already holds someone's file, run ffmpeg against a hidden sibling
939
- temp path and move it over the original only on success.
940
-
941
- ffmpeg's -y truncates the output the moment it opens it, and *when* it opens it depends on
942
- the version: 6.1+ initialises the filter graph first (a bad LUT fails before the file is
943
- touched), 5.x opens the output during option parsing, before any filter runs, so the same
944
- bad LUT leaves a 0-byte file where the deliverable was. No amount of post-failure cleanup
945
- can undo that; the only way to keep an existing file safe across a failed run is for ffmpeg
946
- never to write to it. Same directory, same extension (the muxer is chosen by it), hidden
947
- name, so nothing else changes for the encoder. Returns (command to execute, final path,
948
- temp path); (cmd, None, None) when no staging is needed."""
949
- output = cmd[-1]
950
- if output == "-" or output.startswith("pipe:") or output.startswith("-"):
951
- return list(cmd), None, None
952
- try:
953
- if not os.path.isfile(output) or os.path.realpath(output) in STATE.written:
954
- return list(cmd), None, None
955
- except OSError:
956
- return list(cmd), None, None
957
- d, base = os.path.split(output)
958
- stem, ext = os.path.splitext(base)
959
- tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
960
- return list(cmd[:-1]) + [tmp], output, tmp
961
-
962
-
963
- def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Optional[Context]" = None) -> subprocess.CompletedProcess:
964
- """Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
965
-
966
- ffmpeg invocations are recorded in STATE.commands (for --json), skipped under --dry-run
967
- (a fake successful CompletedProcess is returned so scripts can keep planning), and run
968
- with a progress readout under --progress. ffprobe and other tools always run. An output
969
- path that already exists is written through a temp file and replaced only on success
970
- (see _stage_existing_output), so a failed run never costs the caller the file that was there.
971
-
972
- `ctx` is the optional per-request Context added in 1.10 (2.0 makes it required, issue #189 B);
973
- omitted, the commands and flags are read from the process-global STATE as before.
974
- """
975
- ctx = ctx or STATE
976
- is_ffmpeg = _is_ffmpeg(cmd)
977
- if is_ffmpeg:
978
- _check_no_overwrite_input(cmd)
979
- _check_output_path(cmd)
980
- _check_existing_output(cmd)
981
- ctx.commands.append(_cmdline(cmd))
982
- if not quiet:
983
- info(("[dry-run] $ " if ctx.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd), ctx=ctx)
984
- if ctx.dry_run and is_ffmpeg:
985
- return subprocess.CompletedProcess(list(cmd), 0, "", "")
986
- if is_ffmpeg:
987
- flush_drawtext_textfiles(cmd)
988
- with _OutputLock(cmd[-1] if is_ffmpeg else "-"):
989
- exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
990
- proc = _execute(exec_cmd)
991
- if proc.returncode != 0 and is_ffmpeg:
992
- retry = _odd_dimension_retry(exec_cmd, proc.stderr or "")
993
- if retry is not None:
994
- info("source has odd dimensions; scaling to even before encoding (yuv420p needs it)")
995
- ctx.commands[-1] = _cmdline(retry[:-1] + [cmd[-1]])
996
- proc = _execute(retry)
997
- elif "not divisible by 2" in (proc.stderr or ""):
998
- die("the source has odd dimensions (width or height not divisible by 2) and this tool's filter graph "
999
- "cannot pad them itself; make them even first, e.g. fit.py --width/--height, then retry",
1000
- kind="input")
1001
- if proc.returncode != 0 and check:
1002
- _fail(exec_cmd, proc.returncode, proc.stderr or "")
1003
- if final and tmp:
1004
- if proc.returncode == 0:
1005
- try:
1006
- os.replace(tmp, final)
1007
- except OSError as e:
1008
- _cleanup_partial_output(exec_cmd)
1009
- die(f"could not replace {final} with the new output: {e}", kind="output")
1010
- _remember_output(cmd)
1011
- else:
1012
- _cleanup_partial_output(exec_cmd)
1013
- return proc
1014
-
1015
-
1016
- def _execute(exec_cmd: List[str]) -> subprocess.CompletedProcess:
1017
- """One attempt, never exiting on failure (run() decides after its retries)."""
1018
- if STATE.progress and _is_ffmpeg(exec_cmd) and exec_cmd[-1] != "-":
1019
- return _run_with_progress(exec_cmd, False)
1020
- return _run_captured(exec_cmd, False)
1021
-
1022
-
1023
- def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, record: bool = False) -> subprocess.CompletedProcess:
1024
- """Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats,
1025
- silence detection, loudness, stabilisation pass 1): output to `-f null`, a pipe or a temp
1026
- file, no deliverable written. These are not run() calls -- they run under --dry-run too,
1027
- since a plan built on a fake measurement is not a plan (silence.py used to report "0
1028
- silences" and loudness.py a made-up -20 LUFS under --dry-run) -- but they get the same
1029
- wall-clock limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg`
1030
- failure instead of an exit-0 "0 scenes found" over a file ffmpeg could not read. record=True
1031
- lists the command in the --json `commands` like run() does."""
1032
- if record:
1033
- STATE.commands.append(_cmdline(cmd))
1034
- limit = _limit_for(cmd)
1035
- try:
1036
- proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, timeout=limit)
1037
- except subprocess.TimeoutExpired:
1038
- _timed_out(cmd, limit or 0)
1039
- if check and proc.returncode != 0:
1040
- err = proc.stderr if text else proc.stderr.decode(errors="replace")
1041
- _fail(cmd, proc.returncode, err)
1042
- return proc
1043
-
1044
-
1045
- def dry_run_input_pending(path: str) -> bool:
1046
- """True when a measurement cannot run because its input does not exist yet under --dry-run:
1047
- in a render.py/batch.py plan each stage's input is the previous stage's output, which a dry
1048
- run never wrote. The measurement is then skipped (with a note) rather than failing the plan;
1049
- on a real file the measurement runs even under --dry-run."""
1050
- if STATE.dry_run and not os.path.exists(path):
1051
- info(f"[dry-run] {path} does not exist yet (an earlier dry-run stage would write it); measurement skipped")
1052
- return True
1053
- return False
1054
-
1055
-
1056
- def child_limit(per_call: Optional[float] = None) -> Optional[float]:
1057
- """Wall-clock ceiling for running one sibling script as a subprocess (render/batch/report
1058
- stages, the MCP server's dispatch). A tool runs a handful of ffmpeg/ffprobe calls, each
1059
- under its own --timeout, so the outer ceiling is a multiple of that plus a margin: it never
1060
- fires first on a healthy run, and it is the only thing that ends a child hung for a reason
1061
- that is not ffmpeg (a stuck import, a wedged pipe). None when the per-call limit is 0."""
1062
- limit = STATE.timeout if per_call is None else per_call
1063
- return (limit * 4 + 60) if limit else None
1064
-
1065
-
1066
- def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None) -> subprocess.CompletedProcess:
1067
- """Run a sibling script (`argv[0]` is the script path) under child_limit(). On overrun the
1068
- child is killed and a CompletedProcess is returned whose stdout is this skill's own failure
1069
- document (kind timeout, exit 124), so callers that parse the child's --json see a timeout
1070
- exactly as they would from the child itself."""
1071
- limit = child_limit(per_call)
1072
- child = subprocess.Popen([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
1073
- _watch(child, []) # a sibling script removes its own partial output; there is none of ours to clean
1074
- try:
1075
- out, err = child.communicate(timeout=limit)
1076
- _unwatch(child)
1077
- return subprocess.CompletedProcess(child.args, child.returncode, out, err)
1078
- except subprocess.TimeoutExpired as e:
1079
- child.kill()
1080
- child.communicate()
1081
- _unwatch(child)
1082
- name = os.path.basename(str(argv[0]))
1083
- msg = f"{name} did not finish within {limit:.0f} s (4x the per-ffmpeg --timeout plus 60 s) and was killed"
1084
- doc = {"status": "failed", "exit_code": 124,
1085
- "error": {"kind": "timeout", "message": msg, "code": ERROR_CODE["timeout"], "retryable": ERROR_RETRYABLE},
1086
- "commands": []}
1087
- partial = e.stderr.decode(errors="replace") if isinstance(e.stderr, bytes) else (e.stderr or "")
1088
- return subprocess.CompletedProcess(list(argv), 124, json.dumps(doc), partial + f"\nerror: {msg}\n")
1089
-
1090
-
1091
- def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None, start: float = 0.0,
1092
- *, check: bool = True) -> List[float]:
1093
- """Decode (part of) a file's audio to mono float samples in [-1, 1) at `sample_rate` via a
1094
- single ffmpeg pass under --timeout. Shared by scenes.py (audio envelope for cut scoring) and
1095
- sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else []."""
1096
- ffmpeg = require_tool("ffmpeg")
1097
- cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
1098
- if start:
1099
- cmd += ["-ss", f"{start:.3f}"]
1100
- cmd += ["-i", path]
1101
- if seconds is not None:
1102
- cmd += ["-t", f"{seconds:.3f}"]
1103
- cmd += ["-vn", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
1104
- proc = run_analysis(cmd, check=False, text=False)
1105
- if proc.returncode != 0 or not proc.stdout:
1106
- if check:
1107
- die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
1108
- return []
1109
- n = len(proc.stdout) // 2
1110
- import struct
1111
- return [v / 32768.0 for v in struct.unpack(f"<{n}h", proc.stdout[: n * 2])]
1112
-
1113
-
1114
- def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool = False, remove_mean: bool = False) -> List[float]:
1115
- """RMS per block of `step` samples. full_blocks_only drops a short tail block (sync.py: every
1116
- block must be the same length for the correlation); remove_mean subtracts the envelope's mean
1117
- (sync.py: so silence does not correlate). scenes.py keeps the tail and the absolute level."""
1118
- step = max(1, int(step))
1119
- n = len(samples)
1120
- stop = n - step + 1 if full_blocks_only else n
1121
- env: List[float] = []
1122
- for i in range(0, max(0, stop), step):
1123
- block = samples[i:i + step]
1124
- env.append(math.sqrt(sum(x * x for x in block) / len(block)))
1125
- if remove_mean and env:
1126
- mean = sum(env) / len(env)
1127
- env = [e - mean for e in env]
1128
- return env
1129
-
1130
-
1131
- def child_args() -> List[str]:
1132
- """The shared flags a tool that runs sibling scripts (render.py, batch.py) forwards to them,
1133
- so one `--timeout`/`--overwrite`/`--fast`/`--dry-run` on the outer command governs every
1134
- stage. Before 1.4.3 only --fast and --dry-run were forwarded; a --timeout given to render.py
1135
- stopped at render.py."""
1136
- args: List[str] = []
1137
- if STATE.fast:
1138
- args.append("--fast")
1139
- if STATE.dry_run:
1140
- args.append("--dry-run")
1141
- if STATE.overwrite:
1142
- args.append("--overwrite")
1143
- args += ["--timeout", f"{STATE.timeout:g}"]
1144
- return args
1145
-
1146
-
1147
- def run_keeping_subtitles(cmd: List[str], output: str) -> bool:
1148
- """Run an ffmpeg command that already maps its video/audio, trying first to also
1149
- stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
1150
- there are none). A source whose subtitle codec cannot be copied into the target container
1151
- (e.g. a container change) makes that first attempt fail; retry the same command without the
1152
- extra maps rather than let a tool that never touched subtitles start hard-failing because of
1153
- them. `cmd` is the full argv *without* the output path. Returns True only when the
1154
- retry-without-subtitles path was actually needed (i.e. subtitle/data streams were dropped)."""
1155
- if run(cmd + ["-map", "0:s?", "-map", "0:d?", "-c:s", "copy", "-c:d", "copy", output], check=False).returncode == 0:
1156
- return False
1157
- run(cmd + [output])
1158
- return True
1159
-
1160
-
1161
- def _limit_for(cmd: Sequence[str]) -> Optional[float]:
1162
- """The wall-clock ceiling for this command: ffprobe (and other read-only probes) get a fixed
1163
- short one, ffmpeg the configured one; None means unlimited."""
1164
- if not _is_ffmpeg(cmd):
1165
- return PROBE_TIMEOUT if STATE.timeout else None
1166
- return STATE.timeout or None
1167
-
1168
-
1169
- def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
1170
- """Plain run with stdout/stderr captured."""
1171
- limit = _limit_for(cmd)
1172
- child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
1173
- _watch(child, cmd)
1174
- try:
1175
- out, err = child.communicate(timeout=limit)
1176
- except subprocess.TimeoutExpired:
1177
- child.kill()
1178
- child.communicate()
1179
- _unwatch(child)
1180
- _timed_out(cmd, limit or 0)
1181
- finally:
1182
- _unwatch(child)
1183
- proc = subprocess.CompletedProcess(list(cmd), child.returncode, out, err)
1184
- if proc.returncode == 0 and _is_ffmpeg(cmd):
1185
- _remember_output(cmd)
1186
- if proc.returncode != 0:
1187
- # Cleanup happens for every failed ffmpeg invocation, not just the check=True/_fail()
1188
- # path: a handful of scripts (cut.py, loudness.py, silence.py, sync.py) call run() with
1189
- # check=False so they can compose their own die() message from proc.stderr, but the
1190
- # partial-output risk is identical either way -- and for a script that retries into the
1191
- # same output path after a check=False failure (e.g. color.py's --retag copy-then-
1192
- # reencode fallback), removing the stale partial first is strictly safer than leaving it
1193
- # for -y to overwrite.
1194
- _cleanup_partial_output(cmd)
1195
- if check:
1196
- _fail(cmd, proc.returncode, proc.stderr)
1197
- return proc
1198
-
1199
-
1200
- def _progress_line(done: float, total: float, elapsed: float) -> str:
1201
- if total > 0:
1202
- pct = min(99.9, done / total * 100)
1203
- eta = (elapsed / pct * (100 - pct)) if pct > 0.5 else 0
1204
- return f"\r {pct:5.1f}% {done:7.1f}s / {total:.1f}s ETA {eta:4.0f}s"
1205
- return f"\r {done:7.1f}s encoded"
1206
-
1207
-
1208
- def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
1209
- """Run ffmpeg with -progress on a pipe and print percent/ETA to stderr.
1210
-
1211
- The time limit is checked on a clock, not per progress line: a deadlocked ffmpeg (the very
1212
- case --timeout exists for) prints nothing, so a loop that only looked at the deadline when a
1213
- line arrived waited on it forever. Reader threads drain both pipes; the main loop wakes at
1214
- least twice a second to compare the clock against the limit."""
1215
- import queue
1216
- import threading
1217
- import time
1218
- total = STATE.duration_hint or 0.0
1219
- full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
1220
- t0 = time.time()
1221
- limit = _limit_for(cmd)
1222
- proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
1223
- _watch(proc, cmd)
1224
- assert proc.stdout is not None and proc.stderr is not None
1225
- lines: "queue.Queue[Optional[str]]" = queue.Queue()
1226
- err_chunks: List[str] = []
1227
-
1228
- def pump_out() -> None:
1229
- for line in proc.stdout: # type: ignore[union-attr]
1230
- lines.put(line)
1231
- lines.put(None)
1232
-
1233
- def pump_err() -> None:
1234
- err_chunks.append(proc.stderr.read()) # type: ignore[union-attr]
1235
-
1236
- threading.Thread(target=pump_out, daemon=True).start()
1237
- err_thread = threading.Thread(target=pump_err, daemon=True)
1238
- err_thread.start()
1239
- last = ""
1240
-
1241
- def clear_line() -> None:
1242
- if last:
1243
- sys.stderr.write("\r" + " " * len(last) + "\r")
1244
-
1245
- def timed_out() -> None:
1246
- proc.kill()
1247
- proc.wait()
1248
- clear_line()
1249
- _timed_out(cmd, limit or 0)
1250
-
1251
- while True:
1252
- remaining = (limit - (time.time() - t0)) if limit else None
1253
- if remaining is not None and remaining <= 0:
1254
- timed_out()
1255
- try:
1256
- line = lines.get(timeout=min(0.5, remaining) if remaining is not None else 0.5)
1257
- except queue.Empty:
1258
- continue
1259
- if line is None:
1260
- break
1261
- if line.startswith("out_time_us=") or line.startswith("out_time_ms="):
1262
- try:
1263
- done = int(line.split("=")[1]) / 1_000_000
1264
- except ValueError:
1265
- continue
1266
- msg = _progress_line(done, total, time.time() - t0)
1267
- if msg != last:
1268
- sys.stderr.write(msg)
1269
- sys.stderr.flush()
1270
- last = msg
1271
- try:
1272
- proc.wait(timeout=(max(5.0, limit - (time.time() - t0)) if limit else None))
1273
- except subprocess.TimeoutExpired:
1274
- timed_out()
1275
- _unwatch(proc)
1276
- err_thread.join()
1277
- err = "".join(err_chunks)
1278
- clear_line()
1279
- if proc.returncode == 0:
1280
- _remember_output(cmd)
1281
- if proc.returncode != 0:
1282
- _cleanup_partial_output(cmd)
1283
- if check:
1284
- _fail(cmd, proc.returncode, err)
1285
- return subprocess.CompletedProcess(full, proc.returncode, "", err)
1286
-
1287
-
1288
- def shell_quote(s: str) -> str:
1289
- if not s or any(ch in s for ch in " \t\n\r\\\"';|&<>()[]{}$*?"):
1290
- return "'" + s.replace("'", "'\\''") + "'"
1291
- return s
1292
-
1293
-
1294
- def ffmpeg_base(overwrite: bool = True) -> List[str]:
1295
- cmd = [require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin"]
1296
- cmd.append("-y" if overwrite else "-n")
1297
- return cmd
1298
-
1299
-
1300
- MEDIA_EXT = {".mp4", ".mov", ".mkv", ".webm", ".m4v", ".avi", ".ts", ".mts", ".m2ts", ".mxf", ".3gp", ".wmv", ".gif",
1301
- ".wav", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus", ".aif", ".aiff", ".caf", ".wma", ".png", ".jpg", ".jpeg", ".webp"}
1302
-
1303
-
1304
- def _output_failed(path: str, why: str) -> "None":
1305
- """An ffmpeg run reported success but the artifact is not usable: say so, and do not leave a
1306
- 0-byte file behind that a later step could mistake for a result."""
1307
- try:
1308
- if os.path.exists(path) and os.path.getsize(path) == 0:
1309
- os.remove(path)
1310
- why += " (empty file removed)"
1311
- except OSError:
1312
- pass
1313
- die(f"output verification failed: {path}: {why}", kind="output")
1314
-
1315
-
1316
- def verify_output(path: str) -> Dict[str, Any]:
1317
- """The success criterion for every writing tool: the file exists, is not empty and ffprobe
1318
- can read at least one stream from it. Non-media artifacts (srt, edl, html, md) only need to
1319
- exist and be non-empty. Returns the probe (empty dict for non-media)."""
1320
- if not os.path.exists(path):
1321
- _output_failed(path, "not written")
1322
- if os.path.getsize(path) == 0:
1323
- _output_failed(path, "0 bytes")
1324
- if os.path.splitext(path)[1].lower() not in MEDIA_EXT:
1325
- return {}
1326
- meta = probe(path, role="output")
1327
- if not meta.get("video") and not meta.get("audio"):
1328
- _output_failed(path, "no video or audio stream")
1329
- return meta
1330
-
1331
-
1332
- def probe(path: str, role: str = "input") -> Dict[str, Any]:
1333
- """Return a compact, script-friendly description of a media file.
1334
-
1335
- role="output" marks a file this tool just wrote: a read failure is then reported as an
1336
- output-verification failure (kind "output") instead of an input problem."""
1337
- if not os.path.exists(path):
1338
- if role == "output" and not STATE.dry_run:
1339
- _output_failed(path, "not written")
1340
- if STATE.dry_run:
1341
- # width/height/fps are honestly 0/0/0.0 -- "not measured", matching duration/size_bytes
1342
- # below -- because this is a dry run: the file doesn't exist yet, so there is nothing to
1343
- # probe. Earlier this stub used plausible-looking placeholders (1920x1080x30.0) instead,
1344
- # which some tools' dry-run summary line echoed verbatim as if it were a real computed
1345
- # preview (#77). That was reverted once, because a couple of call sites divided by these
1346
- # values for aspect-ratio math and crashed on a real 0 (join.py, fit.py); those call
1347
- # sites are now guarded to treat 0 as "unknown" and fall back sanely instead of dividing
1348
- # by it, so the stub can finally report the honest, unknown value.
1349
- return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
1350
- "video": {"codec": None, "width": 0, "height": 0, "fps": 0.0, "pix_fmt": None, "hdr": False,
1351
- "color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
1352
- "audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0, "data_streams": 0}
1353
- die(f"input not found: {path}")
1354
- ffprobe = require_tool("ffprobe")
1355
- proc = run(
1356
- [ffprobe, "-v", "error", "-print_format", "json", "-show_format", "-show_streams", "-show_chapters", path],
1357
- quiet=True,
1358
- check=False,
1359
- )
1360
- if proc.returncode != 0:
1361
- if role == "output":
1362
- _output_failed(path, f"ffprobe cannot read it:\n{proc.stderr.strip()}")
1363
- die(f"ffprobe failed on {path}:\n{proc.stderr.strip()}")
1364
- try:
1365
- raw = json.loads(proc.stdout or "{}")
1366
- except ValueError as e:
1367
- if role == "output":
1368
- _output_failed(path, f"ffprobe printed unreadable JSON: {e}")
1369
- die(f"ffprobe printed unreadable JSON for {path}: {e}", kind="ffmpeg")
1370
- fmt = raw.get("format", {})
1371
- streams = raw.get("streams", [])
1372
- video = next((s for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0) == 0), None)
1373
- audio = next((s for s in streams if s.get("codec_type") == "audio"), None)
1374
- subs = [s for s in streams if s.get("codec_type") == "subtitle"]
1375
- data_stream_count = sum(1 for s in streams if s.get("codec_type") in ("data", "attachment"))
1376
-
1377
- duration = _to_float(fmt.get("duration"))
1378
- if duration is None and video:
1379
- duration = _to_float(video.get("duration"))
1380
- if duration is None and audio:
1381
- duration = _to_float(audio.get("duration"))
1382
- if duration and STATE.duration_hint is None:
1383
- STATE.duration_hint = duration
1384
-
1385
- out: Dict[str, Any] = {
1386
- "file": path,
1387
- "format": fmt.get("format_name"),
1388
- "duration": duration,
1389
- "size_bytes": _to_int(fmt.get("size")),
1390
- "bitrate": _to_int(fmt.get("bit_rate")),
1391
- "video": None,
1392
- "audio": None,
1393
- "subtitle_streams": len(subs),
1394
- "data_streams": data_stream_count,
1395
- # container-level chapter markers and the common tags, so metadata.py's result is
1396
- # verifiable the same way every other tool's is (additive keys, 1.x-safe)
1397
- "chapters": [{
1398
- "index": n,
1399
- "start": _to_float(ch.get("start_time")),
1400
- "end": _to_float(ch.get("end_time")),
1401
- "title": (ch.get("tags") or {}).get("title"),
1402
- } for n, ch in enumerate(raw.get("chapters") or [])],
1403
- "tags": {k.lower(): v for k, v in (fmt.get("tags") or {}).items() if k.lower() in ("title", "artist", "album", "comment", "date", "genre")},
1404
- # every subtitle stream in file order: index n here is `-map 0:s:n`
1405
- "subtitle_stream_details": [{
1406
- "index": n,
1407
- "codec": s.get("codec_name"),
1408
- "language": (s.get("tags") or {}).get("language"),
1409
- "title": (s.get("tags") or {}).get("title"),
1410
- } for n, s in enumerate(subs)],
1411
- }
1412
- if video:
1413
- r_rate = _fraction(video.get("r_frame_rate"))
1414
- avg_rate = _fraction(video.get("avg_frame_rate"))
1415
- fps = float(avg_rate) if avg_rate else (float(r_rate) if r_rate else None)
1416
- vfr = bool(r_rate and avg_rate and abs(float(r_rate) - float(avg_rate)) > 0.01)
1417
- w, h = _to_int(video.get("width")), _to_int(video.get("height"))
1418
- rotation = 0
1419
- for sd in video.get("side_data_list", []) or []:
1420
- if "rotation" in sd:
1421
- rotation = int(round(float(sd["rotation"])))
1422
- if "rotate" in (video.get("tags") or {}):
1423
- try:
1424
- rotation = int(video["tags"]["rotate"])
1425
- except ValueError:
1426
- pass
1427
- pix = video.get("pix_fmt") or ""
1428
- trc = video.get("color_transfer") or ""
1429
- prim = video.get("color_primaries") or ""
1430
- hdr = trc in ("smpte2084", "arib-std-b67") or prim == "bt2020"
1431
- dovi = None
1432
- for sd in video.get("side_data_list", []) or []:
1433
- if "dv_profile" in sd or "DOVI" in str(sd.get("side_data_type", "")):
1434
- dovi = {"profile": sd.get("dv_profile"), "level": sd.get("dv_level"), "bl_compatibility_id": sd.get("dv_bl_signal_compatibility_id")}
1435
- if dovi: # a Dolby Vision stream is HDR even when its base layer tags are missing
1436
- hdr = True
1437
- out["video"] = {
1438
- "codec": video.get("codec_name"),
1439
- "profile": video.get("profile"),
1440
- "width": w,
1441
- "height": h,
1442
- "display_aspect": video.get("display_aspect_ratio") or _aspect_string(w, h),
1443
- "fps": round(fps, 3) if fps else None,
1444
- "r_frame_rate": video.get("r_frame_rate"),
1445
- "avg_frame_rate": video.get("avg_frame_rate"),
1446
- "variable_frame_rate_suspected": vfr,
1447
- "pix_fmt": video.get("pix_fmt"),
1448
- "bit_depth": _bit_depth(pix),
1449
- "hdr": hdr,
1450
- # 1.9 (2.0 A1 pre-shipped as a parallel key): true only for a PQ / HLG transfer or Dolby
1451
- # Vision, i.e. a genuinely HDR signal. `hdr` also counts BT.2020 primaries on an SDR
1452
- # transfer ("BT.2020 SDR" in hdr_format) and keeps that meaning until 2.0 renames it.
1453
- "hdr_signal": trc in ("smpte2084", "arib-std-b67") or bool(dovi),
1454
- "hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
1455
- "HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
1456
- "dolby_vision": dovi,
1457
- "color_space": video.get("color_space"),
1458
- "color_primaries": video.get("color_primaries"),
1459
- "color_transfer": video.get("color_transfer"),
1460
- "color_range": video.get("color_range"),
1461
- "rotation": rotation,
1462
- "nb_frames": _to_int(video.get("nb_frames")),
1463
- "bitrate": _to_int(video.get("bit_rate")),
1464
- }
1465
- if audio:
1466
- out["audio"] = {
1467
- "codec": audio.get("codec_name"),
1468
- "channels": _to_int(audio.get("channels")),
1469
- "channel_layout": audio.get("channel_layout"),
1470
- "sample_rate": _to_int(audio.get("sample_rate")),
1471
- "bitrate": _to_int(audio.get("bit_rate")),
1472
- }
1473
- # every audio stream in file order: index n here is `-map 0:a:n` (audio.py --audio-stream n)
1474
- out["audio_streams"] = [{
1475
- "index": n,
1476
- "codec": a.get("codec_name"),
1477
- "channels": _to_int(a.get("channels")),
1478
- "channel_layout": a.get("channel_layout"),
1479
- "sample_rate": _to_int(a.get("sample_rate")),
1480
- "language": (a.get("tags") or {}).get("language"),
1481
- "title": (a.get("tags") or {}).get("title"),
1482
- } for n, a in enumerate(s for s in streams if s.get("codec_type") == "audio")]
1483
- return out
1484
-
1485
-
1486
- def default_output(input_path: str, suffix: str, ext: Optional[str] = None) -> str:
1487
- p = Path(input_path)
1488
- new_ext = ext if ext else p.suffix.lstrip(".") or "mp4"
1489
- return str(p.with_name(f"{p.stem}_{suffix}.{new_ext}"))
1490
-
1491
-
1492
- class MissingFpsError(ValueError):
1493
- """parse_time() saw an hh:mm:ss:ff SMPTE timecode but no fps was given to convert it -- distinct
1494
- from a plain ValueError so a caller that falls back to treating unparseable text as a literal
1495
- line (e.g. caption.py's free-text cue format) can still fail loudly on this one, instead of
1496
- silently swallowing a mistyped/missing --fps as an auto-timed line of digits."""
1497
-
1498
-
1499
- def concat_list_line(path: str) -> str:
1500
- """One `file '...'` line for the concat demuxer. The demuxer reads backslash as an escape
1501
- inside the quoted form, so a Windows path (C:\\Users\\...\\part000.mp4) must be written
1502
- with forward slashes -- ffmpeg opens either spelling on Windows -- and a single quote in the
1503
- name is closed, escaped and reopened. Shared by cut.py (multi-segment) and sequence.py."""
1504
- escaped = str(path).replace("\\", "/").replace("'", "'\\''")
1505
- return f"file '{escaped}'"
1506
-
1507
-
1508
- def _bit_depth(pix_fmt: Optional[str]) -> int:
1509
- """Bits per component from a pixel format name. `"10" in pix` used to read yuv410p (4:1:0
1510
- chroma) as 10-bit; the depth is the number that ends the name (before an le/be suffix):
1511
- yuv420p10le -> 10, gbrp12be -> 12, gray16le -> 16, yuv410p / yuv420p / rgb24 -> 8."""
1512
- m = re.search(r"(\d{1,2})(?:le|be)?$", pix_fmt or "")
1513
- if not m:
1514
- return 8
1515
- n = int(m.group(1))
1516
- if n in (24, 32): # packed 8-bit rgb24/bgr32/rgb0 etc.
1517
- return 8
1518
- if n in (48, 64): # packed 16-bit rgb48/rgba64
1519
- return 16
1520
- return n if 8 <= n <= 16 else 8
1521
-
1522
-
1523
- def fmt_secs(value: Optional[float]) -> str:
1524
- """`12.345s`, or `?s` when the probe had no duration (MPEG-TS without a duration tag, a
1525
- stream whose container and streams all omit it). Every writing tool prints the duration
1526
- of what it wrote; formatting None with :.3f used to raise TypeError after a successful
1527
- encode, in 25+ scripts."""
1528
- return "?s" if value is None else f"{value:.3f}s"
1529
-
1530
-
1531
- def place_output(src: str, dst: str) -> None:
1532
- """Deliver an already-rendered file to `dst` under the same rules as an ffmpeg output:
1533
- the path is checked, an existing file is only replaced through a sibling temp so a
1534
- failed copy never costs the caller what was there, and the result is remembered as ours.
1535
- render.py's final `copyfile()` used to bypass all three."""
1536
- import shutil
1537
- cmd = ["ffmpeg", dst]
1538
- _check_output_path(cmd)
1539
- _check_existing_output(cmd)
1540
- d, base = os.path.split(dst)
1541
- stem, ext = os.path.splitext(base)
1542
- tmp = os.path.join(d, f".{stem}.ffskill-{os.getpid()}{ext}")
1543
- try:
1544
- shutil.copyfile(src, tmp)
1545
- os.replace(tmp, dst)
1546
- except OSError as e:
1547
- try:
1548
- os.remove(tmp)
1549
- except OSError:
1550
- pass
1551
- die(f"could not place {dst}: {e}", kind="output")
1552
- _remember_output(cmd)
1553
-
1554
-
1555
- def parse_time(value: str, fps: Optional[float] = None) -> float:
1556
- """Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250'), SRT '00:01:30,250',
1557
- or -- when `fps` is given -- SMPTE non-drop-frame timecode 'hh:mm:ss:ff' ('00:01:30:15')."""
1558
- v = value.strip().replace(",", ".")
1559
- if not v:
1560
- raise ValueError("empty time")
1561
- if "@" in v:
1562
- # 1.9: 'hh:mm:ss:ff@29.97' names the timecode's rate explicitly (docs/design-decisions.md,
1563
- # time grammar); it overrides the source fps a tool passed in, and is meaningless without
1564
- # the four-part form
1565
- v, _, rate = v.rpartition("@")
1566
- if "@" in v:
1567
- raise ValueError(f"'{value}': only one @fps suffix is allowed")
1568
- try:
1569
- fps = float(rate)
1570
- except ValueError:
1571
- raise ValueError(f"bad @fps suffix in '{value}' (expected a number such as @29.97)")
1572
- if fps <= 0:
1573
- raise ValueError(f"bad @fps suffix in '{value}': the rate must be positive")
1574
- if len(v.split(":")) != 4:
1575
- raise ValueError(f"'{value}': the @fps suffix belongs to an hh:mm:ss:ff timecode, not to seconds or mm:ss")
1576
- parts = v.split(":")
1577
- if len(parts) == 4:
1578
- if fps is None or fps <= 0:
1579
- raise MissingFpsError(f"'{value}' looks like an hh:mm:ss:ff SMPTE timecode, but no fps was given to convert its frame count to seconds (append @fps, e.g. {value}@29.97, or use seconds / mm:ss / hh:mm:ss.ms)")
1580
- h, m, s, f = parts
1581
- if "." in f:
1582
- raise ValueError(f"bad SMPTE timecode: {value}")
1583
- frame, whole_fps = int(f), int(round(fps))
1584
- if not (0 <= frame < whole_fps):
1585
- raise ValueError(f"bad SMPTE timecode '{value}': frame {frame} is out of range for {fps:g} fps (0-{whole_fps - 1})")
1586
- # Non-drop-frame: the timecode counts whole_fps frames per timecode-second, so the real
1587
- # time is the total frame count over the true rate (at 29.97 an hour of timecode is
1588
- # 3596.4 s of video). This is exactly what fmt_smpte_time() inverts; before, the two
1589
- # disagreed by ~0.1 % on the fractional NTSC rates and drifted apart over long files.
1590
- total_frames = (int(h) * 3600 + int(m) * 60 + int(s)) * whole_fps + frame
1591
- return total_frames / fps
1592
- if len(parts) > 3:
1593
- raise ValueError(f"bad time: {value}")
1594
- total = 0.0
1595
- for part in parts:
1596
- try:
1597
- total = total * 60 + float(part)
1598
- except ValueError:
1599
- # not the interpreter's "could not convert string to float: 'zz'" (review 9)
1600
- raise ValueError(f"'{value}': not a time")
1601
- return total
1602
-
1603
-
1604
- def time_arg(value: str, flag: str, fps: Optional[float] = None) -> float:
1605
- """parse_time() for a command-line flag: SMPTE hh:mm:ss:ff resolves with the input's fps when
1606
- the caller has one, and every parse failure is a `kind: input` refusal naming the flag (so
1607
- `--json` callers get a failure document, never a traceback)."""
1608
- try:
1609
- return parse_time(value, fps)
1610
- except MissingFpsError as e:
1611
- die(f"{flag} {value!r}: {e}")
1612
- except ValueError as e:
1613
- die(f"{flag} {value!r}: {e} (use seconds, mm:ss, hh:mm:ss.ms, or hh:mm:ss:ff at the source's fps or with an explicit @fps suffix)")
1614
- return 0.0 # unreachable
1615
-
1616
-
1617
- def signed_time_arg(value: str, flag: str, fps: Optional[float] = None) -> float:
1618
- """time_arg() for a flag that may also be negative (an offset, not a point in time): a single
1619
- leading '-'/'+' is taken as the sign and the rest goes through the ordinary time grammar, so
1620
- `--offset -00:00:02`, `--offset -1.5` and `--offset 0:02` all mean what they read as."""
1621
- text = (value or "").strip()
1622
- sign = 1.0
1623
- if text[:1] in "+-":
1624
- sign = -1.0 if text[0] == "-" else 1.0
1625
- text = text[1:].strip()
1626
- if not text:
1627
- die(f"{flag} {value!r}: not a time (use seconds, mm:ss, hh:mm:ss.ms, or hh:mm:ss:ff)")
1628
- return sign * time_arg(text, flag, fps)
1629
-
1630
-
1631
- def fmt_srt_time(seconds: float) -> str:
1632
- if seconds < 0:
1633
- seconds = 0.0
1634
- ms = int(round(seconds * 1000))
1635
- h, rem = divmod(ms, 3_600_000)
1636
- m, rem = divmod(rem, 60_000)
1637
- s, ms = divmod(rem, 1000)
1638
- return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
1639
-
1640
-
1641
- def fmt_smpte_time(seconds: float, fps: float) -> str:
1642
- """SMPTE non-drop-frame timecode 'hh:mm:ss:ff' for a real fps (not the fractional NTSC rates
1643
- -- 29.97/59.94 need drop-frame counting to stay wall-clock accurate, which this does not do)."""
1644
- if seconds < 0:
1645
- seconds = 0.0
1646
- whole_fps = int(round(fps))
1647
- total_frames = int(round(seconds * fps))
1648
- frame = total_frames % whole_fps
1649
- secs_total = total_frames // whole_fps
1650
- h, rem = divmod(secs_total, 3600)
1651
- m, s = divmod(rem, 60)
1652
- return f"{h:02d}:{m:02d}:{s:02d}:{frame:02d}"
1653
-
1654
-
1655
- def escape_filter_path(path: str) -> str:
1656
- """Escape a file path for use as a filter option value (subtitles=, ass=, lut3d=file=, fontfile=, fontsdir=).
1657
-
1658
- A filter option value is parsed twice: the graph parser splits filters on `,` / `;` and options
1659
- on `:`, then the filter's own option parser splits key=value pairs on `:` again. A character that
1660
- must survive both passes needs two levels of escaping, so a Windows drive letter `D:/x.srt` is
1661
- written `D\\\\:/x.srt`; with a single backslash the second pass still splits at the colon and
1662
- ffmpeg reads `/x.srt` as the next option (`Unable to parse "original_size" option value`).
1663
- Backslashes are turned into forward slashes first (ffmpeg accepts them on Windows), so a backslash
1664
- never has to be escaped itself; `,`, `;`, `[` and `]` are graph-level characters and survive with
1665
- one backslash. `'` is special: the graph parser also treats a quote as the start of a quoted
1666
- token, so a single `\\'` is consumed by the first pass and "Ryo's Mac/cues.srt" reaches the
1667
- filter as "Ryos Mac/cues.srt" (Unable to open ...). Three backslashes survive both passes
1668
- (measured on 6.1 and 7.1 with subtitles=, ass= and lut3d=file=).
1669
- """
1670
- if os.path.isfile(path) and path not in STATE.plan_inputs:
1671
- STATE.plan_inputs.append(path) # a plan binds subtitle/LUT/font files too (review 6)
1672
- p = str(Path(path))
1673
- p = p.replace("\\", "/")
1674
- p = p.replace(":", "\\\\:")
1675
- p = p.replace("'", "\\\\\\'")
1676
- for ch in (",", ";", "[", "]"):
1677
- p = p.replace(ch, "\\" + ch)
1678
- return p
1679
-
1680
-
1681
- def default_font_file(font_name: str) -> Optional[str]:
1682
- """Resolve `font_name` to a concrete on-disk font file, so a caller can tell drawtext
1683
- `fontfile=<path>` instead of `font=<name>`, when possible.
1684
-
1685
- On some real Windows ffmpeg builds (winget's gyan.dev 9.x), drawtext's own fontconfig
1686
- resolution crashes with an access violation whenever it has to resolve a font by family name
1687
- -- with or without a valid fonts.conf on FONTCONFIG_FILE. `fontfile=` is the only form
1688
- confirmed not to crash (#100), since it never touches fontconfig at all. `font_name` itself is
1689
- ignored on Windows for that reason: a fixed, near-universally-present system font is used
1690
- instead of trying to resolve the requested family (which would crash the same way).
1691
-
1692
- On Linux/macOS this is best-effort and uses the real requested family: `fc-match` reports the
1693
- same file fontconfig would resolve `font_name` to anyway, so a caller gets the identical font,
1694
- just already resolved to a path -- fontfile= skips a redundant fontconfig lookup and equally
1695
- sidesteps the same class of crash if it exists on some build there too, but the fallback below
1696
- (returning None) is exercised routinely there, not just on failure.
1697
-
1698
- Returns None when nothing could be resolved (fc-match missing/unavailable, or no well-known
1699
- Windows font file present); the caller falls back to font=<font_name>, the prior behaviour.
1700
- """
1701
- if platform.system() == "Windows":
1702
- windir = os.environ.get("WINDIR", "C:\\Windows")
1703
- fonts = Path(windir) / "Fonts"
1704
- # The requested family first: a file whose name starts with the family name with spaces
1705
- # removed (Noto Sans CJK JP -> NotoSansCJKjp-Regular.otf, Meiryo -> meiryo.ttc), then the
1706
- # common CJK system fonts when the request looks CJK (so Japanese text does not render as
1707
- # boxes in Arial), and Arial only as the last resort.
1708
- wanted = re.sub(r"[^a-z0-9]", "", (font_name or "").lower())
1709
- try:
1710
- files = sorted(fonts.iterdir()) if fonts.is_dir() else []
1711
- except OSError:
1712
- files = []
1713
- if wanted:
1714
- for f in files:
1715
- stem = re.sub(r"[^a-z0-9]", "", f.stem.lower())
1716
- if f.suffix.lower() in (".ttf", ".otf", ".ttc") and stem.startswith(wanted):
1717
- return str(f)
1718
- if any(k in wanted for k in ("cjk", "gothic", "mincho", "meiryo", "yugoth", "msgothic", "malgun", "simhei", "simsun", "jp", "kr", "sc", "tc")):
1719
- for name in ("NotoSansCJKjp-Regular.otf", "NotoSansCJK-Regular.ttc", "meiryo.ttc", "YuGothM.ttc", "msgothic.ttc", "malgun.ttf", "msyh.ttc"):
1720
- if (fonts / name).exists():
1721
- return str(fonts / name)
1722
- candidate = fonts / "arial.ttf"
1723
- return str(candidate) if candidate.exists() else None
1724
- exe = shutil.which("fc-match")
1725
- if not exe:
1726
- return None
1727
- try:
1728
- proc = subprocess.run([exe, "--format=%{file}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5)
1729
- except (subprocess.TimeoutExpired, OSError):
1730
- return None
1731
- if proc.returncode != 0:
1732
- return None
1733
- path = proc.stdout.splitlines()[0].strip() if proc.stdout.strip() else ""
1734
- return path if path and os.path.exists(path) else None
1735
-
1736
-
1737
- # --------------------------------------------------------------------------- script detection
1738
- # 1.12: non-Latin caption/overlay text used to render as tofu (empty boxes) whenever the default
1739
- # family carried no glyphs for it -- silently, because fontconfig substitutes SOMETHING for every
1740
- # request and ffmpeg exits 0 either way. The tools now detect the script of the text they are about
1741
- # to draw and resolve a font file that actually covers it; nothing found is a failed job, not a
1742
- # warning (a video full of boxes is not a delivery).
1743
- SCRIPTS = ("ja", "zh", "ko", "ar", "he", "hi", "bn", "ta", "th", "lo", "ru", "el", "latin")
1744
-
1745
- LANGUAGE_NAMES = {
1746
- "ja": "Japanese", "zh": "Chinese", "ko": "Korean", "ar": "Arabic", "he": "Hebrew",
1747
- "hi": "Devanagari (Hindi/Marathi/Nepali)", "th": "Thai", "ru": "Cyrillic (Russian and others)",
1748
- "el": "Greek", "latin": "Latin", "bn": "Bengali", "ta": "Tamil", "lo": "Lao",
1749
- }
1750
-
1751
- # fontconfig's own :lang= codes for each script we detect (zh uses zh-cn, the Simplified subset
1752
- # every CJK font that claims zh carries; the rest are the plain two-letter codes).
1753
- FC_LANG = {"ja": "ja", "zh": "zh-cn", "ko": "ko", "ar": "ar", "he": "he", "hi": "hi", "th": "th", "ru": "ru", "el": "el",
1754
- "bn": "bn", "ta": "ta", "lo": "lo"}
1755
-
1756
- # Families tried in order, best first. The names are matched case-insensitively against the start
1757
- # of any family fontconfig reports for a file, so "Noto Sans CJK JP" also matches
1758
- # "Noto Sans CJK JP Black". Anything not listed still qualifies -- it just sorts after these.
1759
- PREFERRED_FAMILIES = {
1760
- "ja": ["Noto Sans CJK JP", "Noto Serif CJK JP", "Noto Sans JP", "Source Han Sans", "IPAPGothic", "IPAGothic", "IPA", "VL Gothic", "TakaoGothic", "WenQuanYi Zen Hei"],
1761
- "zh": ["Noto Sans CJK SC", "Noto Serif CJK SC", "Noto Sans SC", "Source Han Sans", "WenQuanYi Zen Hei", "WenQuanYi Micro Hei", "Droid Sans Fallback"],
1762
- "ko": ["Noto Sans CJK KR", "Noto Serif CJK KR", "Noto Sans KR", "Source Han Sans K", "NanumGothic", "Nanum Gothic", "Malgun Gothic", "WenQuanYi Zen Hei"],
1763
- "ar": ["Noto Sans Arabic", "Noto Naskh Arabic", "Amiri", "Scheherazade", "DejaVu Sans", "FreeSans", "FreeSerif"],
1764
- "he": ["Noto Sans Hebrew", "Noto Serif Hebrew", "DejaVu Sans", "FreeSans", "FreeSerif"],
1765
- "hi": ["Noto Sans Devanagari", "Noto Serif Devanagari", "Lohit Devanagari", "Mangal", "Nirmala UI", "Samyak Devanagari", "FreeSans", "FreeSerif"],
1766
- "th": ["Noto Sans Thai", "Noto Serif Thai", "Loma", "Garuda", "Waree", "Umpush", "Norasi", "Sarabun", "Leelawadee UI", "FreeSerif"],
1767
- "bn": ["Noto Sans Bengali", "Noto Serif Bengali", "Lohit Bengali", "Mukti Narrow", "Vrinda", "Nirmala UI", "FreeSerif"],
1768
- "ta": ["Noto Sans Tamil", "Noto Serif Tamil", "Lohit Tamil", "Latha", "Nirmala UI", "FreeSerif"],
1769
- "lo": ["Noto Sans Lao", "Noto Serif Lao", "Phetsarath OT", "Souliyo Unicode", "Saysettha OT", "DokChampa", "Leelawadee UI"],
1770
- "ru": ["Noto Sans", "DejaVu Sans", "Liberation Sans", "FreeSans", "FreeSerif"],
1771
- "el": ["Noto Sans", "DejaVu Sans", "Liberation Sans", "FreeSans", "FreeSerif"],
1772
- }
1773
-
1774
- # Windows has no fontconfig: the system fonts are looked up by file name instead, best first.
1775
- WINDOWS_FONTS = {
1776
- "ko": [("malgun.ttf", "Malgun Gothic"), ("gulim.ttc", "Gulim"), ("batang.ttc", "Batang")],
1777
- "zh": [("msyh.ttc", "Microsoft YaHei"), ("simhei.ttf", "SimHei"), ("simsun.ttc", "SimSun")],
1778
- "ja": [("meiryo.ttc", "Meiryo"), ("YuGothM.ttc", "Yu Gothic Medium"), ("YuGothR.ttc", "Yu Gothic"), ("msgothic.ttc", "MS Gothic")],
1779
- "ar": [("tahoma.ttf", "Tahoma"), ("arial.ttf", "Arial")],
1780
- "he": [("tahoma.ttf", "Tahoma"), ("arial.ttf", "Arial")],
1781
- "hi": [("mangal.ttf", "Mangal"), ("Nirmala.ttf", "Nirmala UI"), ("NirmalaB.ttf", "Nirmala UI")],
1782
- "th": [("leelawui.ttf", "Leelawadee UI"), ("leelawad.ttf", "Leelawadee"), ("tahoma.ttf", "Tahoma")],
1783
- "bn": [("Nirmala.ttf", "Nirmala UI"), ("vrinda.ttf", "Vrinda")],
1784
- "ta": [("Nirmala.ttf", "Nirmala UI"), ("latha.ttf", "Latha")],
1785
- "lo": [("leelawui.ttf", "Leelawadee UI"), ("DokChamp.ttf", "DokChampa")],
1786
- "ru": [("arial.ttf", "Arial"), ("segoeui.ttf", "Segoe UI")],
1787
- "el": [("arial.ttf", "Arial"), ("segoeui.ttf", "Segoe UI")],
1788
- }
1789
-
1790
- _SCRIPT_RANGES = (
1791
- ("ko", ((0x1100, 0x11FF), (0x3130, 0x318F), (0xA960, 0xA97F), (0xAC00, 0xD7FF))), # Hangul syllables + Jamo
1792
- ("kana", ((0x3040, 0x309F), (0x30A0, 0x30FF), (0x31F0, 0x31FF), (0xFF66, 0xFF9F))), # hiragana/katakana
1793
- ("han", ((0x3400, 0x4DBF), (0x4E00, 0x9FFF), (0xF900, 0xFAFF), (0x20000, 0x2A6DF))),
1794
- ("ar", ((0x0600, 0x06FF), (0x0750, 0x077F), (0x08A0, 0x08FF), (0xFB50, 0xFDFF), (0xFE70, 0xFEFF))),
1795
- ("he", ((0x0590, 0x05FF), (0xFB1D, 0xFB4F))),
1796
- ("hi", ((0x0900, 0x097F), (0xA8E0, 0xA8FF))),
1797
- ("bn", ((0x0980, 0x09FF),)),
1798
- ("ta", ((0x0B80, 0x0BFF),)),
1799
- ("th", ((0x0E00, 0x0E7F),)),
1800
- ("lo", ((0x0E80, 0x0EFF),)),
1801
- ("ru", ((0x0400, 0x04FF), (0x0500, 0x052F), (0x2DE0, 0x2DFF))),
1802
- ("el", ((0x0370, 0x03FF), (0x1F00, 0x1FFF))),
1803
- )
1804
-
1805
-
1806
- # --------------------------------------------------------------------------- emoji (1.15)
1807
- # Emoji are orthogonal to the writing system: "やった 🎉" is Japanese AND emoji. They are detected
1808
- # separately from detect_script() so a cue's font resolution is still decided by its letters.
1809
- EMOJI_RANGES = (
1810
- (0x1F300, 0x1FAFF), # symbols & pictographs, supplemental, extended-A
1811
- (0x1F000, 0x1F0FF), # mahjong/domino/playing cards
1812
- (0x2600, 0x27BF), # misc symbols + dingbats
1813
- (0x2B00, 0x2BFF), # misc symbols and arrows
1814
- (0xFE0F, 0xFE0F), # VS16 (emoji presentation selector)
1815
- (0x1F1E6, 0x1F1FF), # regional indicators (flags)
1816
- (0x20E3, 0x20E3), # combining enclosing keycap
1817
- (0x1F3FB, 0x1F3FF), # skin-tone modifiers
1818
- )
1819
- # U+200D ZWJ is deliberately NOT in EMOJI_RANGES: it is ordinary Indic/Persian orthography
1820
- # (क्‍ष is ka + virama + ZWJ + ssa) and only becomes emoji glue *between two emoji bases*.
1821
- # Characters that never START a cluster: they bind to whatever stands before them.
1822
- _EMOJI_TAIL = frozenset({0x200D, 0xFE0F, 0x20E3} | set(range(0x1F3FB, 0x1F400)))
1823
- _EMOJI_REGIONAL = range(0x1F1E6, 0x1F200)
1824
- _ZWJ = 0x200D
1825
- _VS15 = 0xFE0E # text-presentation selector: "draw this as a character, not as an emoji"
1826
- _VS16 = 0xFE0F
1827
- _KEYCAP = 0x20E3
1828
- _KEYCAP_BASES = frozenset("0123456789#*")
1829
-
1830
-
1831
- def _is_emoji_char(ch: str) -> bool:
1832
- cp = ord(ch)
1833
- return any(lo <= cp <= hi for lo, hi in EMOJI_RANGES)
1834
-
1835
-
1836
- def _is_emoji_base(ch: str) -> bool:
1837
- """Can this character START an emoji cluster? Pictographs and regional indicators can;
1838
- the joiners and modifiers (ZWJ, VS16, keycap, skin tone) never can -- they only bind to an
1839
- emoji base that already stands before them. Without this, a ZWJ or a VS16 sitting after an
1840
- ordinary letter turned that letter into "an emoji" and the PNG route replaced it with a gap."""
1841
- return ord(ch) not in _EMOJI_TAIL and _is_emoji_char(ch)
1842
-
1843
-
1844
- def emoji_clusters(text: str) -> "List[Tuple[int, str]]":
1845
- """(index in `text`, cluster) for every emoji in it, ZWJ sequences, VS16, keycaps, flag pairs
1846
- and skin-tone modifiers kept together -- 👩‍💻 is one cluster, not three, and 1️⃣ starts at the
1847
- digit even though the digit is not itself an emoji character.
1848
-
1849
- A cluster can only START at an emoji base (a pictograph, a regional indicator) or at a keycap
1850
- base (`0-9 # *`) that is actually followed by U+20E3. A ZWJ is glue *inside* a cluster, never
1851
- a starter and never a tail on its own: `क्‍ष` (Hindi ka + virama + ZWJ + ssa) and `abc‍def`
1852
- contain no emoji. A base explicitly marked with U+FE0E (VS15, text presentation) is likewise
1853
- not an emoji -- the author asked for the character, not the picture.
1854
- """
1855
- out: "List[Tuple[int, str]]" = []
1856
- i = 0
1857
- n = len(text or "")
1858
- while i < n:
1859
- ch = text[i]
1860
- start = i
1861
- if _is_emoji_base(ch):
1862
- j = i + 1
1863
- if j < n and ord(text[j]) == _VS15: # text presentation requested: not an emoji
1864
- i = j + 1
1865
- continue
1866
- elif ch in _KEYCAP_BASES:
1867
- j = i + 1
1868
- if j < n and ord(text[j]) == _VS16:
1869
- j += 1
1870
- if not (j < n and ord(text[j]) == _KEYCAP):
1871
- i += 1
1872
- continue
1873
- j += 1
1874
- else:
1875
- i += 1
1876
- continue
1877
- # extend: modifiers bind rightwards, a ZWJ only when a real emoji base follows it
1878
- while j < n:
1879
- cp = ord(text[j])
1880
- if cp in (_VS16, _KEYCAP) or 0x1F3FB <= cp <= 0x1F3FF:
1881
- j += 1
1882
- continue
1883
- if cp == _ZWJ and j + 1 < n and _is_emoji_base(text[j + 1]):
1884
- j += 2
1885
- continue
1886
- if (j == start + 1 and ord(ch) in _EMOJI_REGIONAL and cp in _EMOJI_REGIONAL):
1887
- j += 1
1888
- continue
1889
- break
1890
- out.append((start, text[start:j]))
1891
- i = j
1892
- return out
1893
-
1894
-
1895
- def has_emoji(text: str) -> bool:
1896
- return bool(emoji_clusters(text or ""))
1897
-
1898
-
1899
- def emoji_codepoint_name(cluster: str) -> str:
1900
- """The asset filename stem for a cluster: lowercase hex code points joined by '-', the
1901
- Twemoji/Noto convention (1f389, 1f469-200d-1f4bb, 1f1ef-1f1f5)."""
1902
- return "-".join(f"{ord(c):x}" for c in cluster)
1903
-
1904
-
1905
- def _emoji_name_candidates(cluster: str) -> "List[str]":
1906
- """Asset stems to try, most specific first: exact, without VS16, without skin tone, the ZWJ
1907
- sequence reduced to its first code point, the bare base."""
1908
- cps = [ord(c) for c in cluster]
1909
- names = [emoji_codepoint_name(cluster)]
1910
-
1911
- def add(seq):
1912
- name = "-".join(f"{c:x}" for c in seq)
1913
- if name and name not in names:
1914
- names.append(name)
1915
- add([c for c in cps if c != 0xFE0F])
1916
- add([c for c in cps if c != 0xFE0F and not (0x1F3FB <= c <= 0x1F3FF)])
1917
- if 0x200D in cps:
1918
- add([cps[0]])
1919
- add([cps[0]])
1920
- return names
1921
-
1922
-
1923
- def emoji_asset_for(cluster: str, assets_dir: "Optional[str]") -> "Optional[str]":
1924
- """The PNG for `cluster` under `assets_dir`, or None when nothing matches."""
1925
- if not assets_dir or not os.path.isdir(assets_dir):
1926
- return None
1927
- for name in _emoji_name_candidates(cluster):
1928
- for ext in (".png", ".PNG"):
1929
- candidate = os.path.join(assets_dir, name + ext)
1930
- if os.path.isfile(candidate):
1931
- return candidate
1932
- return None
1933
-
1934
-
1935
- EMOJI_ASSET_HINT = (
1936
- "point --emoji-assets at a directory of PNGs named by code point (1f389.png): "
1937
- "twemoji/assets/72x72 (Twemoji, CC-BY 4.0) or noto-emoji/png/128 (Noto Emoji, OFL/Apache-2.0) "
1938
- "are the two people already have. The skill has no network at runtime, so the assets must "
1939
- "already exist on this machine -- nothing is ever downloaded")
1940
-
1941
- _EMOJI_COLOR_FAMILIES = ("Noto Color Emoji", "Apple Color Emoji", "Segoe UI Emoji")
1942
- _EMOJI_SUPPORT_CACHE: "Dict[Tuple[Optional[str], bool], Dict[str, Any]]" = {}
1943
-
1944
-
1945
- def _emoji_color_font() -> "Tuple[Optional[str], Optional[str], bool]":
1946
- """(family, file, fontconfig_answered) for the first installed colour emoji family."""
1947
- exe = shutil.which("fc-list")
1948
- if not exe:
1949
- return None, None, False
1950
- for family in _EMOJI_COLOR_FAMILIES:
1951
- try:
1952
- proc = subprocess.run([exe, f":family={family}", "file"], stdout=subprocess.PIPE,
1953
- stderr=subprocess.DEVNULL, text=True, timeout=10)
1954
- except (subprocess.TimeoutExpired, OSError):
1955
- return None, None, False
1956
- if proc.returncode != 0:
1957
- return None, None, False
1958
- for line in proc.stdout.splitlines():
1959
- path = line.split(":", 1)[0].strip()
1960
- if path and os.path.exists(path):
1961
- return family, path, True
1962
- return None, None, True
1963
-
1964
-
1965
- def _libass_color_probe() -> "Optional[bool]":
1966
- """Does THIS ffmpeg render an emoji in colour through libass? Answered by a render, never by
1967
- the font listing: Noto Color Emoji installs happily on builds whose freetype/libass has no
1968
- colour-bitmap path at all, and those render a monochrome outline instead (measured). ~80 ms.
1969
- None means the probe could not be run (no ffmpeg, a failure) -- unknown, not false."""
1970
- exe = shutil.which("ffmpeg")
1971
- if not exe:
1972
- return None
1973
- import tempfile
1974
- with tempfile.TemporaryDirectory() as td:
1975
- srt = os.path.join(td, "e.srt")
1976
- with open(srt, "w", encoding="utf-8") as fh:
1977
- fh.write("1\n00:00:00,000 --> 00:00:01,000\n\U0001F389\n")
1978
- try:
1979
- proc = subprocess.run(
1980
- [exe, "-hide_banner", "-loglevel", "error", "-f", "lavfi",
1981
- "-i", "color=c=black:s=64x64:d=0.04",
1982
- "-vf", "subtitles=" + srt.replace("\\", "/"), "-frames:v", "1",
1983
- "-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
1984
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, timeout=20)
1985
- except (subprocess.TimeoutExpired, OSError):
1986
- return None
1987
- if proc.returncode != 0 or len(proc.stdout) < 64 * 64 * 3:
1988
- return None
1989
- data = proc.stdout
1990
- for i in range(0, 64 * 64 * 3, 3):
1991
- r, g, b = data[i], data[i + 1], data[i + 2]
1992
- if max(r, g, b) - min(r, g, b) > 40:
1993
- return True
1994
- return False
1995
-
1996
-
1997
- def emoji_support(assets: "Optional[str]" = None, probe: bool = True) -> "Dict[str, Any]":
1998
- """What this machine can actually do with emoji, cached per process.
1999
-
2000
- `mode` is `color` when a render probe proves libass draws colour, else `png` when an assets
2001
- directory resolves, else `mono` when some installed face has a glyph at all, else `none`.
2002
- An installed colour emoji font proves nothing on its own -- that is why `libass_color` comes
2003
- from a render (see references/gotchas.md#emoji). `probe=False` (`contract --json --static`, and every
2004
- static/JSON-only path) skips the render entirely and leaves `libass_color` unknown.
2005
- """
2006
- key = (assets or None, bool(probe))
2007
- if key in _EMOJI_SUPPORT_CACHE:
2008
- return dict(_EMOJI_SUPPORT_CACHE[key])
2009
- family, file, fc_answered = _emoji_color_font()
2010
- libass_color = _libass_color_probe() if probe else None
2011
- assets_dir = assets if (assets and os.path.isdir(assets)) else None
2012
- if libass_color:
2013
- mode = "color"
2014
- elif assets_dir:
2015
- mode = "png"
2016
- elif family:
2017
- mode = "mono"
2018
- elif not fc_answered:
2019
- # No fontconfig to ask (a static ffmpeg build, a bare container): the PNG path needs none,
2020
- # so the honest answer is png-or-none, never "none because fc-list is missing".
2021
- mode = "none"
2022
- else:
2023
- mode = "none"
2024
- if not fc_answered:
2025
- detail = "no fontconfig on this machine; the PNG overlay path needs none"
2026
- elif libass_color:
2027
- detail = f"{family or 'an installed face'} renders in colour through libass on this ffmpeg"
2028
- elif family and libass_color is False:
2029
- detail = f"{family} installed but libass renders it monochrome on this build"
2030
- elif family and libass_color is None:
2031
- detail = f"{family} installed; the colour render probe was not run"
2032
- elif assets_dir:
2033
- detail = "no colour emoji family installed; using the PNG assets directory"
2034
- else:
2035
- detail = "no colour emoji family installed and no --emoji-assets directory"
2036
- result = {"mode": mode, "color_font": family, "color_font_file": file,
2037
- "libass_color": libass_color, "assets": assets_dir,
2038
- "detail": detail, "fix": EMOJI_ASSET_HINT}
2039
- _EMOJI_SUPPORT_CACHE[key] = result
2040
- return dict(result)
2041
-
2042
-
2043
- def resolve_emoji_assets(flag: "Optional[str]" = None, project: "Optional[str]" = None,
2044
- brand: "Optional[dict]" = None) -> "Optional[str]":
2045
- """--emoji-assets DIR, else the project key, else brand.json, else FFMPEG_SKILL_EMOJI_ASSETS.
2046
- A directory that was named but does not exist is a failed job, never a silent downgrade."""
2047
- brand = brand or {}
2048
- styles = (brand.get("styles") or {}).get("caption") or {}
2049
- for value, where in ((flag, "--emoji-assets"), (project, "the project's text.emoji_assets"),
2050
- (styles.get("emoji_assets"), "brand.json styles.caption.emoji_assets"),
2051
- (brand.get("emoji_assets"), "brand.json emoji_assets"),
2052
- (os.environ.get("FFMPEG_SKILL_EMOJI_ASSETS"), "FFMPEG_SKILL_EMOJI_ASSETS")):
2053
- if not value:
2054
- continue
2055
- if not os.path.isdir(str(value)):
2056
- die(f"{where}: {value} is not a readable directory -- {EMOJI_ASSET_HINT}", kind="input")
2057
- return str(value)
2058
- return None
2059
-
2060
-
2061
- # --------------------------------------------------------------------------- text measurement (1.12)
2062
- # Moved here in 1.15 so graphics.py's ASS route and the emoji placement share caption.py's table.
2063
- # Average advance width per character, in em (a fraction of the font size). Proportional Latin text
2064
- # averages a bit over half an em; CJK and Thai are drawn on a full-width grid; Arabic/Hebrew and
2065
- # Devanagari sit in between. These are deliberately averages, not per-glyph metrics: measuring the
2066
- # real advance needs a font parser (no stdlib one) and would still be wrong for libass's own
2067
- # shaping, while a cue wrapped from an average is right to within a character on every line.
2068
- # (Latin is measured per character from LATIN_EM below, not from this average.)
2069
- ADVANCE_EM = {"ja": 1.0, "zh": 1.0, "ko": 1.0, "th": 1.0, "hi": 0.7, "ar": 0.6, "he": 0.6,
2070
- "ru": 0.55, "el": 0.55, "latin": 0.55}
2071
- # Scripts written without spaces: a line breaks between any two characters.
2072
- NO_SPACE_SCRIPTS = ("ja", "zh", "ko", "th")
2073
- # Per-character Latin advances in em, read off DejaVu Sans (the default caption family, and close
2074
- # enough to any other proportional sans for a wrap) and rounded UP: a capital runs 0.56-0.99 em
2075
- # against the single 0.55 average that used to stand for all of Latin, so an all-caps caption --
2076
- # the style most burn-ins use -- overflowed the safe area and was silently re-wrapped by libass
2077
- # past --max-lines. Rounding up is the safe direction: libass re-wraps a too-long line, it never
2078
- # un-wraps a short one. Characters outside the table fall back by class (0.7 uppercase/digit,
2079
- # 0.57 lowercase and anything else Latin-ish).
2080
- LATIN_EM = {
2081
- ' ': 0.32, '!': 0.41, '"': 0.46, '#': 0.84, '$': 0.64, '%': 0.96, '&': 0.78, "'": 0.28,
2082
- '(': 0.4, ')': 0.4, '*': 0.5, '+': 0.84, ',': 0.32, '-': 0.37, '.': 0.32, '/': 0.34, '0': 0.64,
2083
- '1': 0.64, '2': 0.64, '3': 0.64, '4': 0.64, '5': 0.64, '6': 0.64, '7': 0.64, '8': 0.64,
2084
- '9': 0.64, ':': 0.34, ';': 0.34, '<': 0.84, '=': 0.84, '>': 0.84, '?': 0.54, '@': 1.0,
2085
- 'A': 0.69, 'B': 0.69, 'C': 0.7, 'D': 0.78, 'E': 0.64, 'F': 0.58, 'G': 0.78, 'H': 0.76,
2086
- 'I': 0.3, 'J': 0.3, 'K': 0.66, 'L': 0.56, 'M': 0.87, 'N': 0.75, 'O': 0.79, 'P': 0.61,
2087
- 'Q': 0.79, 'R': 0.7, 'S': 0.64, 'T': 0.62, 'U': 0.74, 'V': 0.69, 'W': 0.99, 'X': 0.69,
2088
- 'Y': 0.62, 'Z': 0.69, '[': 0.4, '\\': 0.34, ']': 0.4, '^': 0.84, '_': 0.5, '`': 0.5, 'a': 0.62,
2089
- 'b': 0.64, 'c': 0.55, 'd': 0.64, 'e': 0.62, 'f': 0.36, 'g': 0.64, 'h': 0.64, 'i': 0.28,
2090
- 'j': 0.28, 'k': 0.58, 'l': 0.28, 'm': 0.98, 'n': 0.64, 'o': 0.62, 'p': 0.64, 'q': 0.64,
2091
- 'r': 0.42, 's': 0.53, 't': 0.4, 'u': 0.64, 'v': 0.6, 'w': 0.82, 'x': 0.6, 'y': 0.6, 'z': 0.53,
2092
- '{': 0.64, '|': 0.34, '}': 0.64, '~': 0.84
2093
- }
2094
- # Thai and Lao write some vowels BEFORE the consonant they belong to: the break must not land
2095
- # between them and the base that follows.
2096
- LEADING_VOWELS = set(range(0x0E40, 0x0E45)) | set(range(0x0EC0, 0x0EC5))
2097
-
2098
-
2099
- def _is_mark(ch: str) -> bool:
2100
- """A character that hangs off the one before it: a combining mark (any script) or one of the
2101
- Thai/Lao vowel signs and tone marks, which are Mn/Mc but carry no combining class."""
2102
- return unicodedata.combining(ch) != 0 or unicodedata.category(ch) in ("Mn", "Mc")
2103
-
2104
-
2105
- def _char_em(ch: str) -> float:
2106
- # CJK punctuation and the fullwidth forms (、。,!? and U+FF01-FF60) are drawn on the same
2107
- # full-width grid as the ideographs they sit between, even though they are not "Han" to a
2108
- # script detector -- measuring them as Latin under-counts a wrapped CJK line by a character.
2109
- cp = ord(ch)
2110
- # A combining mark is drawn on top of (or under) its base and advances the pen by nothing:
2111
- # charging it a full em wrapped Thai and Devanagari lines far shorter than they needed to be.
2112
- if unicodedata.combining(ch) != 0 or unicodedata.category(ch) in ("Mn", "Cf"):
2113
- # "Cf" catches ZWJ/ZWNJ: an Indic joiner is orthography, and it advances the pen by
2114
- # nothing -- charging it a full em (it used to count as "emoji") shrank a Hindi line.
2115
- return 0.0
2116
- if 0x3000 <= cp <= 0x303F or 0xFF01 <= cp <= 0xFF60 or 0xFFE0 <= cp <= 0xFFE6:
2117
- return 1.0
2118
- script = char_script(ch)
2119
- if script == "emoji":
2120
- # 1.15: an emoji is drawn (or reserved) at a full em box, not at Latin's 0.57 -- counting
2121
- # it as Latin overflowed the safe area on an emoji-heavy line.
2122
- return 1.0
2123
- if script == "latin":
2124
- if ch in LATIN_EM:
2125
- return LATIN_EM[ch]
2126
- if ch.isupper() or ch.isdigit():
2127
- return 0.7
2128
- return 0.57
2129
- return ADVANCE_EM.get(script, 0.55)
2130
-
2131
-
2132
- def text_width_em(text: str, emoji_em: float = 1.0) -> float:
2133
- """Width of `text` in em, from the per-script average advance table. `emoji_em` is what one
2134
- emoji cluster costs (--emoji-scale), so a wrap counts the box that will actually be drawn."""
2135
- total = 0.0
2136
- spans = {i: len(c) for i, c in emoji_clusters(text)}
2137
- i = 0
2138
- while i < len(text):
2139
- if i in spans:
2140
- total += emoji_em
2141
- i += spans[i]
2142
- continue
2143
- total += _char_em(text[i])
2144
- i += 1
2145
- return total
2146
-
2147
-
2148
- def emoji_filter_chain(plan, base_label, out_label, first_input=1):
2149
- """(chains, inputs) that composite the planned PNGs on top of `base_label`.
2150
-
2151
- `inputs` is a list of argv fragments, each ending in the asset path, to be appended to the
2152
- ffmpeg command in order (an overlay that fades needs `-loop 1` on its input so the still has
2153
- a timeline the fade filter can move along; one that does not is a plain `-i`).
2154
- """
2155
- overlays = plan.get("overlays") or []
2156
- if not overlays:
2157
- return [], []
2158
- # Group by everything that makes two uses of the same PNG a different STREAM: the fade is
2159
- # expressed in the cue's own timeline, so two cues cannot share one faded input.
2160
- def _key(o):
2161
- fades = (round(float(o.get("fade_in") or 0.0), 3), round(float(o.get("fade_out") or 0.0), 3))
2162
- window = (round(float(o["start"]), 3), round(float(o["end"]), 3)) if any(fades) else (None, None)
2163
- return (o["asset"], o["box"]) + fades + window
2164
-
2165
- groups: "List[Tuple]" = []
2166
- for o in overlays:
2167
- if _key(o) not in groups:
2168
- groups.append(_key(o))
2169
- chains: List[str] = []
2170
- inputs: "List[List[str]]" = []
2171
- pads: "Dict[Tuple, List[str]]" = {}
2172
- for k, key in enumerate(groups):
2173
- asset, box, fin, fout, gstart, gend = key
2174
- uses = [o for o in overlays if _key(o) == key]
2175
- idx = first_input + k
2176
- labels = [f"e{k}_{j}" for j in range(len(uses))]
2177
- chain = f"[{idx}:v]format=rgba,scale={box}:{box}"
2178
- if fin or fout:
2179
- # -loop 1 gives the still an advancing timeline on the SAME clock as the main video,
2180
- # so the fade times below are the cue's own seconds. The emoji then appears and
2181
- # leaves with the text instead of popping in against a fading line.
2182
- # -t bounds the loop at the cue's end: an unbounded looped still never EOFs and the
2183
- # whole encode hangs (overlay keeps pulling from it after the main video is done).
2184
- inputs.append(["-loop", "1", "-t", f"{gend:.3f}", "-i", asset])
2185
- if fin:
2186
- chain += f",fade=t=in:st={gstart:.3f}:d={fin:.3f}:alpha=1"
2187
- if fout:
2188
- chain += f",fade=t=out:st={max(gstart, gend - fout):.3f}:d={fout:.3f}:alpha=1"
2189
- else:
2190
- inputs.append(["-i", asset])
2191
- if len(labels) > 1:
2192
- chain += f",split={len(labels)}"
2193
- chains.append(chain + "".join(f"[{l}]" for l in labels))
2194
- pads[key] = labels
2195
- cur = base_label
2196
- remaining = {key: list(v) for key, v in pads.items()}
2197
- for j, o in enumerate(overlays):
2198
- label = remaining[_key(o)].pop(0)
2199
- nxt = out_label if j == len(overlays) - 1 else f"eov{j}"
2200
- x = o["x"]
2201
- x = f"'{x}'" if isinstance(x, str) else x
2202
- # No eof_action=pass here: a PNG input is a SINGLE frame at pts 0, and eof_action=pass
2203
- # switches off overlay's default "hold the last frame of the secondary input", so the
2204
- # asset would be composited on frame 0 only and vanish for the rest of the cue (that is
2205
- # exactly what shipped first). eof_action=repeat (the default) holds the still for the
2206
- # whole timeline; enable= is what confines it to the cue's window.
2207
- chains.append(f"[{cur}][{label}]overlay=x={x}:y={o['y']}:"
2208
- f"enable='between(t,{o['start']:.3f},{o['end']:.3f})'[{nxt}]")
2209
- cur = nxt
2210
- return chains, inputs
2211
-
2212
-
2213
- # --------------------------------------------------------------------------- shaping (1.15)
2214
- # Scripts whose correct rendering needs harfbuzz-class reordering and re-clustering (Indic matras,
2215
- # Thai/Lao mark stacking). drawtext does NOT use harfbuzz even in an --enable-libharfbuzz build, so
2216
- # these come out wrong through drawtext on every build and must go through libass. Arabic and
2217
- # Hebrew are NOT here: drawtext's text_shaping uses fribidi, which does bidi and Arabic joining
2218
- # correctly -- they only join this set on a build compiled without fribidi.
2219
- SHAPING_SCRIPTS = frozenset({"hi", "bn", "ta", "te", "kn", "ml", "gu", "pa", "si", "th", "lo", "km", "my"})
2220
- BIDI_SCRIPTS = frozenset({"ar", "he"})
2221
- _SHAPING_BUILD_CACHE: "Dict[str, bool]" = {}
2222
-
2223
-
2224
- def drawtext_shaping() -> "Dict[str, bool]":
2225
- """Which shaping libraries THIS ffmpeg was built with, from -buildconf (falling back to the
2226
- `configuration:` line of -version). Cached per process."""
2227
- if _SHAPING_BUILD_CACHE:
2228
- return dict(_SHAPING_BUILD_CACHE)
2229
- text = ""
2230
- exe = shutil.which("ffmpeg")
2231
- if exe:
2232
- for flag in ("-buildconf", "-version"):
2233
- try:
2234
- proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE,
2235
- stderr=subprocess.STDOUT, text=True, timeout=10)
2236
- except (subprocess.TimeoutExpired, OSError):
2237
- break
2238
- if proc.returncode == 0 and proc.stdout.strip():
2239
- text = proc.stdout
2240
- break
2241
- _SHAPING_BUILD_CACHE.update({"fribidi": "--enable-libfribidi" in text,
2242
- "harfbuzz": "--enable-libharfbuzz" in text})
2243
- return dict(_SHAPING_BUILD_CACHE)
2244
-
2245
-
2246
- def needs_shaping(script: str) -> bool:
2247
- """Whether drawtext would render `script` wrongly on this build."""
2248
- if script in SHAPING_SCRIPTS:
2249
- return True
2250
- return script in BIDI_SCRIPTS and not drawtext_shaping()["fribidi"]
2251
-
2252
-
2253
- def font_family_of_file(path: str) -> "Optional[str]":
2254
- """The family name of a font FILE -- what libass wants, given a --font-file. `fc-scan` reads
2255
- the file directly; without fontconfig the file stem is the honest best guess."""
2256
- if not path or not os.path.isfile(path):
2257
- return None
2258
- exe = shutil.which("fc-scan")
2259
- if exe:
2260
- try:
2261
- proc = subprocess.run([exe, "--format", "%{family[0]}", path], stdout=subprocess.PIPE,
2262
- stderr=subprocess.DEVNULL, text=True, timeout=10)
2263
- if proc.returncode == 0 and proc.stdout.strip():
2264
- return proc.stdout.strip().splitlines()[0].strip()
2265
- except (subprocess.TimeoutExpired, OSError):
2266
- pass
2267
- return Path(path).stem
2268
-
2269
-
2270
- def char_script(ch: str) -> str:
2271
- """The script of one character: one of SCRIPTS, or "latin" for anything else (including
2272
- digits, punctuation and spaces -- they are measured and wrapped like Latin)."""
2273
- cp = ord(ch)
2274
- # 1.15: an emoji cluster is not Latin. detect_script() skips "emoji" the way it skips "latin",
2275
- # so font resolution still follows the letters around it.
2276
- if _is_emoji_char(ch):
2277
- return "emoji"
2278
- for name, ranges in _SCRIPT_RANGES:
2279
- for lo, hi in ranges:
2280
- if lo <= cp <= hi:
2281
- return "ja" if name == "kana" else ("zh" if name == "han" else name)
2282
- return "latin"
2283
-
2284
-
2285
- def detect_script(text: str, lang: "Optional[str]" = None) -> str:
2286
- """Which script `text` is written in, as one of SCRIPTS.
2287
-
2288
- Hangul wins for Korean, any kana makes the whole string Japanese (Japanese mixes kana and
2289
- Han), Han alone is Chinese. Mixed text is decided by character count: the non-Latin script
2290
- with the most characters wins, ties going to whichever appeared first, and text with no
2291
- non-Latin characters at all is "latin". `lang` (a --lang/--language hint, or brand.json's
2292
- `lang`) only resolves the one ambiguity the characters genuinely cannot: Han with no kana
2293
- is Chinese by default but Japanese (or Korean hanja) when the caller says so.
2294
- """
2295
- counts: "Dict[str, int]" = {}
2296
- order: "List[str]" = []
2297
- kana = 0
2298
- for ch in text or "":
2299
- s = char_script(ch)
2300
- if s in ("latin", "emoji"):
2301
- continue
2302
- if ord(ch) in range(0x3040, 0x3100) or ord(ch) in range(0x31F0, 0x3200) or ord(ch) in range(0xFF66, 0xFFA0):
2303
- kana += 1
2304
- if s not in counts:
2305
- order.append(s)
2306
- counts[s] = counts.get(s, 0) + 1
2307
- if kana: # Japanese: the Han characters in the same string are Japanese too
2308
- counts["ja"] = counts.pop("ja", 0) + counts.pop("zh", 0)
2309
- order = [s for s in order if s != "zh"]
2310
- if not counts:
2311
- return "latin"
2312
- best = max(counts, key=lambda s: (counts[s], -order.index(s)))
2313
- hint = (lang or "").strip().lower().replace("_", "-").split("-")[0]
2314
- if not kana and best == "zh" and hint in ("ja", "zh", "ko"):
2315
- return hint # Han-only text: only the caller knows whether it is Chinese, Japanese or hanja
2316
- return best
2317
-
2318
-
2319
- _SCRIPT_FONT_CACHE: "Dict[Tuple[str, Optional[str]], Optional[Tuple[str, str]]]" = {}
2320
-
2321
-
2322
- def _fc_list_fonts(fc_lang: str) -> "Optional[List[Tuple[str, List[str]]]]":
2323
- """(file, families) for every font fontconfig says covers `fc_lang`.
2324
-
2325
- `[]` means fontconfig answered and nothing covers the language; `None` means it could not be
2326
- asked at all (no `fc-list` on PATH, or it failed/timed out) -- the difference between
2327
- "missing" and "unknown", which the caller must not collapse: unknown is not a refusal.
2328
- """
2329
- exe = shutil.which("fc-list")
2330
- if not exe:
2331
- return None
2332
- try:
2333
- proc = subprocess.run([exe, f":lang={fc_lang}", "file", "family"],
2334
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=10)
2335
- except (subprocess.TimeoutExpired, OSError):
2336
- return None
2337
- if proc.returncode != 0:
2338
- return None
2339
- out = []
2340
- for line in proc.stdout.splitlines():
2341
- if ": " not in line:
2342
- continue
2343
- path, _, families = line.partition(": ")
2344
- path = path.strip()
2345
- if not path or not os.path.exists(path):
2346
- continue
2347
- names = [f.replace("\\-", "-").strip() for f in families.split(",") if f.strip()]
2348
- out.append((path, names or [Path(path).stem]))
2349
- return out
2350
-
2351
-
2352
- def _family_rank(families: "Sequence[str]", preferred: "Sequence[str]") -> int:
2353
- for i, want in enumerate(preferred):
2354
- w = want.lower()
2355
- if any(f.lower().startswith(w) for f in families):
2356
- return i
2357
- return len(preferred)
2358
-
2359
-
2360
- def _script_font_entry(script: str, family_hint: "Optional[str]" = None) -> "Optional[Tuple[str, str]]":
2361
- key = (script, family_hint)
2362
- if key in _SCRIPT_FONT_CACHE:
2363
- return _SCRIPT_FONT_CACHE[key]
2364
- _SCRIPT_FONT_CACHE[key] = result = _script_font_uncached(script, family_hint)
2365
- return result
2366
-
2367
-
2368
- FC_UNKNOWN = "unknown" # sentinel: fontconfig could not be asked (absent or failing), not "no font"
2369
-
2370
-
2371
- def _script_font_uncached(script: str, family_hint: "Optional[str]" = None):
2372
- """(file, family), None when nothing covers `script`, or FC_UNKNOWN when it cannot be asked."""
2373
- if script not in FC_LANG:
2374
- return None
2375
- if platform.system() == "Windows":
2376
- fonts = Path(os.environ.get("WINDIR", "C:\\Windows")) / "Fonts"
2377
- for name, family in WINDOWS_FONTS.get(script, []):
2378
- if (fonts / name).exists():
2379
- return str(fonts / name), family
2380
- return None
2381
- preferred = list(PREFERRED_FAMILIES.get(script, []))
2382
- if family_hint:
2383
- preferred.insert(0, family_hint)
2384
- candidates = _fc_list_fonts(FC_LANG[script])
2385
- if candidates is None:
2386
- return FC_UNKNOWN
2387
- if not candidates:
2388
- return None
2389
- scored = []
2390
- for path, families in candidates:
2391
- joined = " ".join(families).lower()
2392
- stem = Path(path).stem.lower()
2393
- # "Unifont Sample" is fontconfig's tofu-with-hex-digits fallback: it "covers" every script
2394
- # by drawing the code point, which is exactly the unreadable result this feature exists to
2395
- # avoid -- it is only ever chosen when nothing else covers the script at all. A *Mono* face
2396
- # is legible but wrong for a caption band, so it sorts after every proportional one.
2397
- last_resort = 1 if "unifont" in joined else 0
2398
- mono = 1 if "mono" in joined else 0
2399
- # regular weights before Bold/Italic/Oblique cuts, so a default caption is not bold by accident
2400
- styled = 1 if any(k in stem for k in ("bold", "italic", "oblique", "light", "thin", "black")) else 0
2401
- scored.append((last_resort, _family_rank(families, preferred), mono, styled, path, families[0]))
2402
- scored.sort(key=lambda row: (row[0], row[1], row[2], row[3], row[4]))
2403
- best = scored[0]
2404
- return best[4], best[5]
2405
-
2406
-
2407
- def font_for_script(script: str, family_hint: "Optional[str]" = None) -> "Optional[str]":
2408
- """A font FILE path that covers `script`, or None when this machine has none.
2409
-
2410
- Linux/macOS ask fontconfig (`fc-list :lang=xx file family`) and rank what it reports by the
2411
- PREFERRED_FAMILIES table; Windows has no fontconfig, so the known system files are probed by
2412
- name. Cached per process: a caption job resolves the same script for every cue.
2413
- """
2414
- entry = _script_font_entry(script, family_hint)
2415
- return entry[0] if entry and entry is not FC_UNKNOWN else None
2416
-
2417
-
2418
- def font_family_for_script(script: str, family_hint: "Optional[str]" = None) -> "Optional[str]":
2419
- """The family NAME of font_for_script()'s file -- what libass wants in an ASS Fontname."""
2420
- entry = _script_font_entry(script, family_hint)
2421
- return entry[1] if entry and entry is not FC_UNKNOWN else None
2422
-
2423
-
2424
- def script_font_status(script: str) -> str:
2425
- """"available" (a font file covers `script`), "missing" (fontconfig answered, none does) or
2426
- "unknown" (there is no working fontconfig to ask). Only "missing" is a refusal."""
2427
- entry = _script_font_entry(script)
2428
- if entry is FC_UNKNOWN:
2429
- return "unknown"
2430
- return "available" if entry else "missing"
2431
-
2432
-
2433
- def font_covers_script(font_name: str, script: str) -> bool:
2434
- """Whether the installed family `font_name` actually carries glyphs for `script`.
2435
-
2436
- `fc-match` cannot answer this: given a family that IS installed it returns that family
2437
- whatever `:lang=` asks for (verified -- `fc-match "DejaVu Sans:lang=zh-cn"` answers
2438
- "DejaVu Sans", which has no Han glyphs at all). `fc-list :lang=xx:family=<name>` does: it
2439
- lists only files that satisfy BOTH, so an empty listing is the proof of no coverage. Unknown
2440
- (no fontconfig at all) counts as covering: a warning nobody can verify is worse than none.
2441
- """
2442
- if script not in FC_LANG or not font_name:
2443
- return True
2444
- exe = shutil.which("fc-list")
2445
- if not exe:
2446
- return True
2447
- try:
2448
- proc = subprocess.run([exe, f":lang={FC_LANG[script]}:family={font_name}", "file"],
2449
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=10)
2450
- except (subprocess.TimeoutExpired, OSError):
2451
- return True
2452
- if proc.returncode != 0:
2453
- return True
2454
- return bool(proc.stdout.strip())
2455
-
2456
-
2457
- # Named flags differ per tool: overlay.py and graphics.py take a font FILE, caption.py takes a
2458
- # directory of faces plus the family name -- naming a flag the tool does not have is worse than
2459
- # naming none, so the hint says both (review 10).
2460
- FONT_FLAG_HINT = "pass a font file (--font-file on overlay.py/graphics.py, --fonts-dir with --font on caption.py)"
2461
- FONT_INSTALL_HINT = ("install fonts-noto-cjk / fonts-noto-core (apt), "
2462
- "brew install --cask font-noto-sans-cjk / font-noto-sans-arabic (mac), or "
2463
- + FONT_FLAG_HINT)
2464
-
2465
-
2466
- def fonts_dir_covers_script(fonts_dir: str, script: str) -> "Optional[bool]":
2467
- """Does any font under `fonts_dir` cover `script`? None when it cannot be checked.
2468
-
2469
- `--fonts-dir` says "also look here", not "this exact face", so it must not switch the
2470
- coverage guarantee off. fontconfig's `fc-scan` reads the files directly (no cache, no
2471
- installed-font database), which is exactly the question: `%{lang}` lists the languages each
2472
- face claims.
2473
- """
2474
- if script not in FC_LANG or not fonts_dir or not os.path.isdir(fonts_dir):
2475
- return None
2476
- exe = shutil.which("fc-scan")
2477
- if not exe:
2478
- return None
2479
- try:
2480
- proc = subprocess.run([exe, "--format", "%{lang}\n", fonts_dir],
2481
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=10)
2482
- except (subprocess.TimeoutExpired, OSError):
2483
- return None
2484
- if proc.returncode != 0:
2485
- return None
2486
- want = FC_LANG[script].lower()
2487
- for line in proc.stdout.splitlines():
2488
- if want in [tag.strip().lower() for tag in line.split("|")]:
2489
- return True
2490
- return False
2491
-
2492
-
2493
- def script_font_for_text(text: str, *, lang: "Optional[str]" = None, font: "Optional[str]" = None,
2494
- font_explicit: bool = False, font_file: "Optional[str]" = None,
2495
- fonts_dir: "Optional[str]" = None
2496
- ) -> "Tuple[str, Optional[str], Optional[str]]":
2497
- """(script, font file, family) to draw `text` with, resolving by script when nothing explicit
2498
- was asked for.
2499
-
2500
- Returns (script, None, None) when the caller's own choice stands: Latin text, an explicit
2501
- --font-file, an explicit --font (which is kept even when fontconfig says it does not cover
2502
- the script -- with one info line saying so, because overriding a user's stated font silently
2503
- is worse than a warning), or a --fonts-dir that does carry the script. Otherwise the resolved
2504
- file is returned with ONE info line naming it.
2505
-
2506
- A script fontconfig says nothing covers is a failed job (tofu is not a delivery). A machine
2507
- with no working fontconfig at all answers "unknown", not "missing": the job continues with
2508
- the caller's font -- libass and drawtext still have their own font backends -- and one info
2509
- line says the coverage could not be verified.
2510
- """
2511
- script = detect_script(text or "", lang)
2512
- if script == "latin":
2513
- return script, None, None
2514
- if font_file:
2515
- return script, None, None
2516
- if font_explicit and font:
2517
- if not font_covers_script(font, script):
2518
- info(f"font: '{font}' does not cover {LANGUAGE_NAMES[script]} text on this machine; keeping it as asked "
2519
- f"(drop --font, or {FONT_FLAG_HINT}, to pick one by script automatically)")
2520
- return script, None, None
2521
- if fonts_dir:
2522
- covered = fonts_dir_covers_script(fonts_dir, script)
2523
- if covered:
2524
- return script, None, None
2525
- if covered is None:
2526
- info(f"font: could not verify that {fonts_dir} covers {LANGUAGE_NAMES[script]} text "
2527
- "(no fc-scan on this machine); using it as given")
2528
- return script, None, None
2529
- info(f"font: no face in {fonts_dir} covers {LANGUAGE_NAMES[script]} text; "
2530
- "picking one by script instead (the directory is still searched first)")
2531
- entry = _script_font_entry(script)
2532
- if entry is FC_UNKNOWN:
2533
- info(f"font: could not verify that this machine can render {LANGUAGE_NAMES[script]} text "
2534
- "(no working fontconfig); rendering with the font as given -- "
2535
- "doctor --json .fonts.scripts reports what is known")
2536
- return script, None, None
2537
- if not entry:
2538
- die(f"no installed font covers {LANGUAGE_NAMES[script]} text on this machine — {FONT_INSTALL_HINT}", kind="input")
2539
- info(f"font: {entry[0]} (covers {script})")
2540
- return script, entry[0], entry[1]
2541
-
2542
-
2543
- def escape_drawtext(text: str) -> str:
2544
- """Escape a FONT NAME for a single-quoted drawtext option value (`font='<this>'`).
2545
-
2546
- Since 1.15 this is no longer the route for drawn TEXT -- use drawtext_text_opts(), which puts
2547
- the text in a file and keeps `\'` and `%` verbatim. It remains the escape for the font-name
2548
- fallback, where the value is a family name that never legitimately contains a quote or a
2549
- percent sign.
2550
-
2551
- Every ffmpeg filter-graph special character (`\\ : % , [ ] ;`) needs a backslash escape
2552
- regardless of the surrounding quotes -- the graph parser still splits on an unescaped `,`/`;`
2553
- or ends an option list on an unescaped `:`/`[`/`]` even while "inside" a quoted value. The
2554
- quote character itself has no reliable backslash escape at all: `\\'` and the POSIX shell
2555
- close-insert-reopen trick both parse fine in a simple `-vf` chain but silently corrupt a
2556
- `-filter_complex` chain that uses explicit `[label]` pads (confirmed by rendering the result:
2557
- trailing option names leak into the picture as literal text). `%` has the same problem as far
2558
- as drawtext's own expansion scanner is concerned. Both are therefore dropped here rather than
2559
- escaped -- which is exactly why drawn text no longer comes through this function.
2560
- """
2561
- text = re.sub(r"[\x00-\x1f\x7f]", "", text)
2562
- return (
2563
- text.replace("'", "")
2564
- .replace("%", "")
2565
- .replace("\\", "\\\\")
2566
- .replace(":", "\\:")
2567
- .replace(",", "\\,")
2568
- .replace("[", "\\[")
2569
- .replace("]", "\\]")
2570
- .replace(";", "\\;")
2571
- )
2572
-
2573
-
2574
- _DRAWTEXT_TMPDIR: "Optional[str]" = None
2575
- _DRAWTEXT_PENDING: "Dict[str, str]" = {}
2576
-
2577
-
2578
- def _drawtext_tmpdir(create: bool = True) -> str:
2579
- """The private, per-run directory drawn-text files live in.
2580
-
2581
- tempfile.mkdtemp() creates it 0700 under a name nobody can guess, which is the whole point:
2582
- the 1.15.0 shape (a fixed, world-writable `/tmp/ffmpeg-skill-text` entered with
2583
- makedirs(exist_ok=True) and content-addressed filenames) let any other user on the machine
2584
- pre-create the directory or plant a symlink at a predictable name, and handed the second
2585
- user of a shared box a PermissionError out of filter construction instead of a `kind: input`
2586
- refusal. The directory is removed when the process ends, whether it succeeded or failed.
2587
- """
2588
- global _DRAWTEXT_TMPDIR
2589
- import tempfile
2590
- if _DRAWTEXT_TMPDIR and os.path.isdir(_DRAWTEXT_TMPDIR):
2591
- return _DRAWTEXT_TMPDIR
2592
- if not create:
2593
- # --dry-run names the path it WOULD use and creates nothing (a dry run writes nothing).
2594
- return os.path.join(tempfile.gettempdir(), "ffmpeg-skill-text-%d" % os.getpid())
2595
- import atexit
2596
- _DRAWTEXT_TMPDIR = tempfile.mkdtemp(prefix="ffmpeg-skill-text-")
2597
- atexit.register(shutil.rmtree, _DRAWTEXT_TMPDIR, True)
2598
- return _DRAWTEXT_TMPDIR
2599
-
2600
-
2601
- def flush_drawtext_textfiles(cmd: "Sequence[str]") -> "List[str]":
2602
- """Write the drawn-text files this command actually names, and return their paths.
2603
-
2604
- The text is registered when the filter STRING is built, but a filter string is not a run:
2605
- graphics.py builds the drawtext graph even on a job that is finally rendered through libass,
2606
- and every tool builds one under --dry-run. Writing here -- from run(), past the dry-run
2607
- return, against the command that is about to be executed -- is what keeps both of those from
2608
- leaving a file behind.
2609
- """
2610
- if not _DRAWTEXT_PENDING:
2611
- return []
2612
- joined = " ".join(str(a) for a in cmd)
2613
- written = []
2614
- for path, body in list(_DRAWTEXT_PENDING.items()):
2615
- # match on the unique file name, not the full path: inside a filter string the path is
2616
- # escaped (a Windows drive colon becomes `C\\:`, and the separators are forward slashes),
2617
- # so the registered spelling never appears verbatim in the command
2618
- if os.path.basename(path) not in joined or os.path.exists(path):
2619
- continue
2620
- # O_NOFOLLOW exists on POSIX only; the directory is private (mkdtemp, 0700) so the
2621
- # symlink guard is belt and braces there and unavailable on Windows
2622
- flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_NOFOLLOW", 0)
2623
- fd = os.open(path, flags, 0o600)
2624
- with os.fdopen(fd, "w", encoding="utf-8") as fh:
2625
- fh.write(body)
2626
- written.append(path)
2627
- return written
2628
-
2629
-
2630
- def drawtext_text_opts(text: str, tmpdir: "Optional[str]" = None) -> str:
2631
- """`textfile=<path>:expansion=none` for drawtext -- the one route that is provably safe for
2632
- every character on every build shape this repo uses.
2633
-
2634
- The filter-graph parser never sees the text at all: only the PATH is parsed, and
2635
- escape_filter_path() already handles that. `expansion=none` switches off drawtext's own
2636
- `%{...}` scanner, which is the reason `%` was unsafe (a bare `\%` logs "Stray %" on one build
2637
- and fails the whole filter chain on another). With the scanner off, `'`, `%`, `:`, `,`, `[`,
2638
- `]`, `;` and `\` all reach the picture verbatim -- 1.15 fixes `overlay.py --text "it's 100%
2639
- done"` losing both characters. Control characters are still stripped: a one-line burnt-in
2640
- label has no use for them.
2641
-
2642
- The file is UTF-8, mode 0600, in a private per-run directory (see _drawtext_tmpdir) that is
2643
- removed when the process ends. It is *registered* here and written by run() only if the
2644
- command about to run actually names it, so --dry-run and the ASS route write nothing; a
2645
- printed plan therefore names a path that no longer exists once the run is over, which is the
2646
- same promise every other temp file in this skill makes.
2647
- """
2648
- cleaned = re.sub(r"[\x00-\x1f\x7f]", "", text or "")
2649
- import hashlib
2650
- name = "t_" + hashlib.sha256(cleaned.encode("utf-8")).hexdigest()[:16] + ".txt"
2651
- if tmpdir is None:
2652
- tmpdir = _drawtext_tmpdir(create=not STATE.dry_run)
2653
- path = os.path.join(tmpdir, name)
2654
- _DRAWTEXT_PENDING[path] = cleaned
2655
- return f"textfile={escape_filter_path(path)}:expansion=none"
2656
-
2657
-
2658
- def cfr_args(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> List[str]:
2659
- """Force a constant frame rate on output when the source looks VFR (or fps is given).
2660
-
2661
- VFR sources (phone/screen recordings) drift against audio after cuts and joins,
2662
- so every re-encoding script passes this to conform them automatically.
2663
- """
2664
- v = (meta or {}).get("video") or {}
2665
- if fps is None and not v.get("variable_frame_rate_suspected"):
2666
- return []
2667
- rate = fps or v.get("fps") or 30.0
2668
- rate = round(rate) if abs(rate - round(rate)) < 0.02 else rate
2669
- return ["-fps_mode", "cfr", "-r", f"{rate:g}"]
2670
-
2671
-
2672
- def bt709_tag_args(encoder: str = "libx264") -> List[str]:
2673
- """Tag an SDR output as BT.709 without touching its pixels.
2674
-
2675
- Up to FFmpeg 7.0 the output options -colorspace/-color_primaries/-color_trc were tags only.
2676
- 7.1 added colourspace negotiation to libavfilter and feeds those options into the graph's
2677
- output constraints, so on a source whose bitstream carries no colour tags (test sources,
2678
- screen recordings, many cameras) the CLI now auto-inserts a *real* matrix conversion (its
2679
- guess for "unknown" is bt601) into every SDR re-encode: a --lut-strength 0 no-op grade
2680
- came back ~24 dB PSNR from its source on 7.1. From 7.1 on, the tags therefore go through
2681
- the encoder's own VUI parameters instead, which libavfilter never sees; a source that is
2682
- genuinely tagged bt601/bt2020 is left alone either way (it keeps its own tags on the old
2683
- path, and the encoder VUI is a label, not a conversion, on the new one).
2684
- """
2685
- if ffmpeg_version() < (7, 1):
2686
- return ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
2687
- if encoder == "libx265":
2688
- return ["-x265-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709"]
2689
- return ["-x264-params", "colorprim=bt709:transfer=bt709:colormatrix=bt709"]
2690
-
2691
-
2692
- def ffmpeg_encoders() -> set:
2693
- """Names from `ffmpeg -encoders`, read once; empty when ffmpeg is missing. Used only to pick
2694
- an AV1 encoder and to refuse --codec av1 / prores before ffmpeg would."""
2695
- global _ENCODERS
2696
- if _ENCODERS is None:
2697
- _ENCODERS = set()
2698
- try:
2699
- out = subprocess.run([shutil.which("ffmpeg") or "ffmpeg", "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
2700
- stderr=subprocess.DEVNULL, text=True, timeout=PROBE_TIMEOUT).stdout
2701
- _ENCODERS = set(re.findall(r"^\s*[VAS][.\w]{5}\s+(\S+)", out, re.M))
2702
- except (OSError, subprocess.SubprocessError):
2703
- pass
2704
- return _ENCODERS
2705
-
2706
-
2707
- def _sdr_bt709(encoder: str) -> "Tuple[str, List[str]]":
2708
- """BT.709 SDR tags for `encoder` as (encoder-params string, extra output options): the two
2709
- spellings bt709_tag_args() picks between, split so a caller that already builds an encoder
2710
- params string can merge them (the option given twice keeps only the last)."""
2711
- if ffmpeg_version() < (7, 1):
2712
- return "", ["-colorspace", "bt709", "-color_primaries", "bt709", "-color_trc", "bt709"]
2713
- if encoder == "libsvtav1":
2714
- return "color-primaries=1:transfer-characteristics=1:matrix-coefficients=1", []
2715
- if encoder == "libaom-av1":
2716
- return "", [] # no VUI params option; an untagged 8-bit stream reads as BT.709 everywhere
2717
- return "colorprim=bt709:transfer=bt709:colormatrix=bt709", []
2718
-
2719
-
2720
- def encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any]] = None, keep_bt709: bool = True) -> List[str]:
2721
- """The one place that turns (--codec, --quality, --preset, source) into encoder options.
2722
-
2723
- h264 -> x264 8-bit BT.709 (refuses HDR: 8-bit H.264 cannot carry it); hevc -> x265, Main10
2724
- with the source's tags for HDR, 8-bit BT.709 otherwise; av1 -> SVT-AV1 (libaom fallback),
2725
- 10-bit for HDR; prores -> ProRes 422 HQ, source tags kept. 1.8: chosen by --codec; without
2726
- it video_args() does what it always did (x264 for SDR, x265 Main10 for HDR).
2727
- """
2728
- v = (meta or {}).get("video") or {}
2729
- hdr = bool(v.get("hdr"))
2730
- cs = v.get("color_space") or "bt2020nc"
2731
- prim = v.get("color_primaries") or "bt2020"
2732
- trc = v.get("color_transfer") or "arib-std-b67"
2733
- hdr_tags = ["-colorspace", cs, "-color_primaries", prim, "-color_trc", trc]
2734
- if codec == "h264":
2735
- if hdr:
2736
- die(f"--codec h264 cannot carry HDR ({v.get('hdr_format') or 'BT.2020'}): 8-bit H.264 is SDR only",
2737
- hint="run color.py --to-sdr first, or use --codec hevc / av1 / prores, which keep the source's HDR")
2738
- return _x264_raw(crf, preset, keep_bt709)
2739
- if codec == "hevc":
2740
- if hdr:
2741
- x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
2742
- return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
2743
- "-x265-params", x265] + hdr_tags + ["-movflags", "+faststart"]
2744
- params, extra = _sdr_bt709("libx265") if keep_bt709 else ("", [])
2745
- return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-tag:v", "hvc1",
2746
- "-x265-params", "log-level=error" + (":" + params if params else "")] + extra + ["-movflags", "+faststart"]
2747
- if codec == "av1":
2748
- pix = "yuv420p10le" if hdr else "yuv420p"
2749
- if "libsvtav1" in ffmpeg_encoders():
2750
- args = ["-c:v", "libsvtav1", "-preset", str(SVT_PRESET.get(preset, 6)), "-crf", str(min(63, crf)), "-pix_fmt", pix]
2751
- if hdr:
2752
- args += hdr_tags
2753
- elif keep_bt709:
2754
- params, extra = _sdr_bt709("libsvtav1")
2755
- args += (["-svtav1-params", params] if params else []) + extra
2756
- elif "libaom-av1" in ffmpeg_encoders():
2757
- args = ["-c:v", "libaom-av1", "-crf", str(min(63, crf)), "-b:v", "0", "-cpu-used", "6", "-row-mt", "1", "-pix_fmt", pix]
2758
- args += hdr_tags if hdr else (_sdr_bt709("libaom-av1")[1] if keep_bt709 else [])
2759
- else:
2760
- die("--codec av1 needs an AV1 encoder (libsvtav1 or libaom-av1) and this ffmpeg build has neither", kind="missing_tool",
2761
- hint="install an ffmpeg built with SVT-AV1 (most distribution builds are), or use --codec hevc")
2762
- return args + ["-movflags", "+faststart"]
2763
- if codec == "prores":
2764
- if "prores_ks" not in ffmpeg_encoders():
2765
- die("--codec prores needs the prores_ks encoder and this ffmpeg build lacks it", kind="missing_tool")
2766
- return ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le"] + (hdr_tags if hdr else [])
2767
- die(f"unknown --codec {codec!r} (one of {', '.join(CODECS)})")
2768
- return []
2769
-
2770
-
2771
- def _x264_raw(crf: int, preset: str, keep_bt709: bool = True) -> List[str]:
2772
- args = ["-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
2773
- if keep_bt709:
2774
- args += bt709_tag_args("libx264")
2775
- return args
2776
-
2777
-
2778
- def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) -> List[str]:
2779
- """SDR H.264 encoder args -- or, when --codec named another encoder, that encoder's SDR args
2780
- (color.py's --to-sdr path builds its own H.264 line; the flag still has to reach it)."""
2781
- if STATE.codec and STATE.codec != "h264":
2782
- return encoder_args(STATE.codec, crf, preset, None, keep_bt709)
2783
- return _x264_raw(crf, preset, keep_bt709)
2784
-
2785
-
2786
- def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium") -> List[str]:
2787
- """Encoder args that preserve what the source is.
2788
-
2789
- SDR sources -> H.264 8-bit tagged BT.709 (x264_args). HDR sources (HDR10/PQ, HLG,
2790
- Dolby Vision base layer, BT.2020) -> HEVC Main10 with the source's own colour tags,
2791
- so cutting/captioning/fitting an iPhone HDR clip stays HDR instead of becoming a
2792
- washed-out file mislabelled as BT.709. Use color.py --to-sdr when SDR is wanted.
2793
- """
2794
- if STATE.codec:
2795
- return encoder_args(STATE.codec, crf, preset, meta)
2796
- v = (meta or {}).get("video") or {}
2797
- if not v.get("hdr"):
2798
- return x264_args(crf, preset)
2799
- cs = v.get("color_space") or "bt2020nc"
2800
- prim = v.get("color_primaries") or "bt2020"
2801
- trc = v.get("color_transfer") or "arib-std-b67"
2802
- x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
2803
- return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
2804
- "-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
2805
-
2806
-
2807
- def aac_args(bitrate: str = "192k") -> List[str]:
2808
- return ["-c:a", "aac", "-b:a", bitrate]
2809
-
2810
-
2811
- AUDIO_CODECS = {
2812
- ".wav": ["-c:a", "pcm_s16le"],
2813
- ".flac": ["-c:a", "flac"],
2814
- ".mp3": ["-c:a", "libmp3lame", "-q:a", "0"],
2815
- ".m4a": ["-c:a", "aac", "-b:a", "256k"],
2816
- ".aac": ["-c:a", "aac", "-b:a", "256k"],
2817
- ".ogg": ["-c:a", "libvorbis", "-q:a", "6"],
2818
- ".opus": ["-c:a", "libopus", "-b:a", "128k"],
2819
- }
2820
-
2821
-
2822
- def audio_codec_for(output_path: str, default_bitrate: str = "192k") -> List[str]:
2823
- """Pick an audio codec that the output container can actually hold."""
2824
- ext = os.path.splitext(output_path)[1].lower()
2825
- return list(AUDIO_CODECS.get(ext, ["-c:a", "aac", "-b:a", default_bitrate]))
2826
-
2827
-
2828
- def is_audio_output(output_path: str) -> bool:
2829
- """True when the output extension is an audio-only container (.wav, .flac, .mp3, .m4a, .aac, .ogg, .opus).
2830
-
2831
- Such a file cannot hold a video stream and, for .wav, cannot hold compressed audio: scripts use
2832
- this to drop the picture (-vn) and to pick the codec from the extension instead of AAC.
2833
- """
2834
- return os.path.splitext(output_path)[1].lower() in AUDIO_CODECS
2835
-
2836
-
2837
- def db_to_linear(db: float) -> float:
2838
- return 10 ** (db / 20.0)
2839
-
2840
-
2841
- def read_text_or_die(path: str, flag: str) -> str:
2842
- """Read a caller-supplied UTF-8 text file (a cue list, chapters, notes) or fail as kind input
2843
- with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback."""
2844
- if os.path.isdir(path):
2845
- # checked first: Windows raises PermissionError, not IsADirectoryError, for a directory
2846
- die(f"{flag}: {path} is a directory, not a text file")
2847
- try:
2848
- with open(path, "r", encoding="utf-8") as fh:
2849
- return fh.read()
2850
- except FileNotFoundError:
2851
- die(f"{flag}: {path} does not exist")
2852
- except IsADirectoryError:
2853
- die(f"{flag}: {path} is a directory, not a text file")
2854
- except UnicodeDecodeError as e:
2855
- die(f"{flag}: {path} is not UTF-8 text ({e.reason} at byte {e.start}); save it as UTF-8")
2856
- except OSError as e:
2857
- die(f"{flag}: cannot read {path}: {e.strerror}")
2858
- return "" # unreachable
2859
-
2860
-
2861
- def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
2862
- """Video keyframe timestamps within +-window seconds of t, ascending. Read with
2863
- -read_intervals so a long file is not scanned end to end; empty when ffprobe cannot say."""
2864
- ffprobe = require_tool("ffprobe")
2865
- lo = max(0.0, t - window)
2866
- proc = run([ffprobe, "-v", "error", "-select_streams", "v:0", "-skip_frame", "nokey",
2867
- "-read_intervals", f"{lo:.3f}%{t + window:.3f}", "-show_entries", "frame=pts_time",
2868
- "-of", "csv=p=0", path], quiet=True, check=False)
2869
- if proc.returncode != 0:
2870
- return []
2871
- out: List[float] = []
2872
- for line in proc.stdout.splitlines():
2873
- try:
2874
- out.append(round(float(line.strip().rstrip(",")), 3))
2875
- except ValueError:
2876
- continue
2877
- return sorted(set(out))
2878
-
2879
-
2880
- def measured_level_dbfs(path: str, seconds: float = 120.0) -> Optional[Dict[str, float]]:
2881
- """Mean and peak level of the first `seconds` of audio (volumedetect), in dBFS; None if unmeasurable.
2882
- Cheap enough to run once as a hint when a threshold-based tool found nothing."""
2883
- ffmpeg = require_tool("ffmpeg")
2884
- proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.0f}", "-i", path, "-vn",
2885
- "-af", "volumedetect", "-f", "null", "-"], check=False)
2886
- m_mean = re.search(r"mean_volume:\s*(-?[0-9.]+) dB", proc.stderr)
2887
- m_max = re.search(r"max_volume:\s*(-?[0-9.]+) dB", proc.stderr)
2888
- if not (m_mean and m_max):
2889
- return None
2890
- return {"mean_dbfs": float(m_mean.group(1)), "peak_dbfs": float(m_max.group(1))}
2891
-
2892
-
2893
- def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
2894
- """Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
2895
-
2896
- Log gammas (S-Log3, V-Log, C-Log, HLG-looking flat profiles) put black around 90-95/255 and
2897
- white below ~235 with low saturation: the image looks grey and flat but is tagged as plain SDR.
2898
- """
2899
- ffmpeg = require_tool("ffmpeg")
2900
- cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-an",
2901
- "-vf", "fps=2,signalstats,metadata=print:file=-", "-f", "null", "-"]
2902
- proc = run_analysis(cmd, check=False)
2903
- vals: Dict[str, List[float]] = {}
2904
- for line in proc.stdout.splitlines():
2905
- if "lavfi.signalstats." in line and "=" in line:
2906
- key, val = line.split("lavfi.signalstats.", 1)[1].split("=", 1)
2907
- try:
2908
- vals.setdefault(key, []).append(float(val))
2909
- except ValueError:
2910
- pass
2911
- if not vals.get("YAVG"):
2912
- return {"error": "no frames analysed"}
2913
- def mean(k: str) -> float:
2914
- v = vals.get(k) or [0.0]
2915
- return sum(v) / len(v)
2916
- ymin, ymax, yavg, sat = min(vals.get("YMIN") or [0]), max(vals.get("YMAX") or [255]), mean("YAVG"), mean("SATAVG")
2917
- # signalstats reports in the source bit depth; normalise everything to an 8-bit scale
2918
- scale = 1.0
2919
- if ymax > 255 or yavg > 255:
2920
- scale = 1 / 4.0 if ymax <= 1023 else (1 / 16.0 if ymax <= 4095 else 1 / 256.0) # 10 / 12 / 16-bit
2921
- ymin, ymax, yavg, sat = ymin * scale, ymax * scale, yavg * scale, sat * scale
2922
- # 5th/95th percentile of per-frame lows/highs is more robust than the absolute min/max
2923
- lows = sorted(x * scale for x in (vals.get("YLOW") or vals.get("YMIN") or [0]))
2924
- highs = sorted(x * scale for x in (vals.get("YHIGH") or vals.get("YMAX") or [255]))
2925
- p_low = lows[len(lows) // 20]
2926
- p_high = highs[-1 - len(highs) // 20]
2927
- looks_log = p_low >= 64 and p_high <= 235 and sat < 40
2928
- return {
2929
- "scale": "8-bit equivalent",
2930
- "y_min": round(ymin, 1), "y_max": round(ymax, 1), "y_avg": round(yavg, 1), "y_low_p5": round(p_low, 1), "y_high_p95": round(p_high, 1),
2931
- "saturation_avg": round(sat, 1),
2932
- "looks_like_log": looks_log,
2933
- "note": ("flat, low-contrast, desaturated picture tagged as SDR: probably a Log profile (S-Log/V-Log/C-Log). "
2934
- "Apply the camera's conversion LUT with color.py --lut" if looks_log else "contrast and saturation look like normal display-referred SDR"),
2935
- }
2936
-
2937
-
2938
- BRAND_DEFAULTS: Dict[str, Any] = {
2939
- "font": "DejaVu Sans",
2940
- "font_file": None,
2941
- "colors": {"primary": "FFD200", "text": "FFFFFF", "outline": "000000", "background": "101418", "accent": "1E6F8E"},
2942
- "logo": None,
2943
- "logo_position": "top-right",
2944
- "logo_scale": 160,
2945
- "logo_opacity": 0.9,
2946
- "safe_margin": 48,
2947
- "caption": {"size": 26, "position": "bottom", "animate": "pop", "karaoke": False, "bold": True, "outline": 2},
2948
- # 1.12: one place for the caption look every project shares. `styles.caption` is the documented
2949
- # spelling (`{font, size, colour, box, position}`, British or American "colour"); the older
2950
- # top-level `caption` block still works and `styles.caption` wins where both name the same key.
2951
- "styles": {},
2952
- "lang": None,
2953
- "loudness": {"lufs": -14, "tp": -1},
2954
- }
2955
-
2956
-
2957
- def load_brand(path: Optional[str]) -> Dict[str, Any]:
2958
- """Load brand.json (fonts, colours, logo, safe margins, caption defaults); missing keys fall back to defaults."""
2959
- import copy
2960
- brand = copy.deepcopy(BRAND_DEFAULTS)
2961
- brand["_stated"] = {}
2962
- if not path:
2963
- return brand
2964
- if not os.path.exists(path):
2965
- die(f"brand file not found: {path}")
2966
- try:
2967
- data = json.loads(Path(path).read_text(encoding="utf-8"))
2968
- except ValueError as exc:
2969
- die(f"brand file is not valid JSON: {exc}")
2970
- base = Path(path).resolve().parent
2971
- for k, v in data.items():
2972
- if isinstance(v, dict) and isinstance(brand.get(k), dict):
2973
- brand[k].update(v)
2974
- else:
2975
- brand[k] = v
2976
- for key in ("logo", "font_file"):
2977
- if brand.get(key) and not os.path.isabs(brand[key]):
2978
- brand[key] = str(base / brand[key])
2979
- brand["_path"] = str(path)
2980
- # What the FILE said, separate from BRAND_DEFAULTS' filler: a brand.json that never mentions
2981
- # a font must not read as "the caller chose a font" (which would switch font-by-script off).
2982
- brand["_stated"] = data
2983
- return brand
2984
-
2985
-
2986
-
2987
- def brand_states_font(brand: Dict[str, Any]) -> bool:
2988
- """Did the brand FILE actually name a font (top-level `font`, `caption.font` or
2989
- `styles.caption.font`)? BRAND_DEFAULTS always supplies one, so the merged document can never
2990
- answer this -- and treating the default filler as the caller's choice switched font-by-script
2991
- off for every branded job (review 10)."""
2992
- stated = brand.get("_stated") or {}
2993
- if stated.get("font"):
2994
- return True
2995
- for block in (stated.get("caption"), (stated.get("styles") or {}).get("caption")):
2996
- if isinstance(block, dict) and block.get("font"):
2997
- return True
2998
- return False
2999
-
3000
-
3001
- def brand_caption_style(brand: Dict[str, Any]) -> Dict[str, Any]:
3002
- """The effective caption style of a brand file: the top-level `caption` block updated with
3003
- `styles.caption`, with `colour` normalised to `color`. Explicit flags still beat both."""
3004
- style: Dict[str, Any] = dict(brand.get("caption") or {})
3005
- extra = (brand.get("styles") or {}).get("caption") or {}
3006
- style.update(extra)
3007
- if "colour" in style and "color" not in style:
3008
- style["color"] = style.pop("colour")
3009
- style.pop("colour", None)
3010
- return style
3011
-
3012
-
3013
- def color_hex(value: str) -> str:
3014
- """Normalise '#ffd200' / 'ffd200' / '0xFFD200' to 'FFD200'."""
3015
- v = str(value).strip().lstrip("#")
3016
- if v.lower().startswith("0x"):
3017
- v = v[2:]
3018
- if len(v) != 6:
3019
- die(f"colour must be RRGGBB, got '{value}'")
3020
- return v.upper()
3021
-
3022
-
3023
- _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
3024
-
3025
-
3026
- def validate_color(value: str, flag: str = "--color") -> str:
3027
- """Refuse a colour argument that isn't a plain ffmpeg colour token (named colour, 0xRRGGBB[AA],
3028
- #RRGGBB[AA], optionally with an @alpha suffix). Every caller that string-formats a colour flag
3029
- straight into a filter graph (color=c=..., tpad=...:color=..., rotate=...:fillcolor=...) must
3030
- validate it first -- ffmpeg filter options are comma/colon-delimited, so an unvalidated value
3031
- containing those characters lets a caller splice in an entirely different filter (a real,
3032
- demonstrated filter-graph injection: --color "black,drawtext=text=..." renders arbitrary burnt-in
3033
- text), not just an odd colour. This is the same "no filter graph accepted from the caller"
3034
- invariant every other typed flag in this codebase already holds to."""
3035
- if not _COLOR_TOKEN_RE.match(value):
3036
- die(f"{flag} must be a plain colour (a name, 0xRRGGBB[AA], or #RRGGBB[AA], optionally @alpha), got '{value}'")
3037
- return value
3038
-
3039
-
3040
- def print_json(obj: Any) -> None:
3041
- sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
3042
-
3043
-
3044
- def _to_float(v: Any) -> Optional[float]:
3045
- try:
3046
- return float(v) if v is not None else None
3047
- except (TypeError, ValueError):
3048
- return None
3049
-
3050
-
3051
- def _to_int(v: Any) -> Optional[int]:
3052
- try:
3053
- return int(v) if v is not None else None
3054
- except (TypeError, ValueError):
3055
- return None
3056
-
3057
-
3058
- def _fraction(v: Optional[str]) -> Optional[Fraction]:
3059
- if not v or v in ("0/0", "0"):
3060
- return None
3061
- try:
3062
- f = Fraction(v)
3063
- return f if f > 0 else None
3064
- except (ValueError, ZeroDivisionError):
3065
- return None
3066
-
3067
-
3068
- def _aspect_string(w: Optional[int], h: Optional[int]) -> Optional[str]:
3069
- if not w or not h:
3070
- return None
3071
- f = Fraction(w, h)
3072
- return f"{f.numerator}:{f.denominator}"