ffmpeg-skill 1.14.0 → 1.15.1

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