ffmpeg-skill 1.14.0 → 1.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -4
- package/SKILL.md +4 -4
- package/docs/contract.md +32 -11
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +6 -1
- package/references/gotchas.md +70 -1
- package/references/scripts.md +49 -7
- package/scripts/_ass_overlay.py +155 -0
- package/scripts/_common/__init__.py +187 -0
- package/scripts/_common/color.py +69 -0
- package/scripts/_common/decision.py +415 -0
- package/scripts/_common/emit.py +287 -0
- package/scripts/_common/probe.py +382 -0
- package/scripts/_common/runner.py +1056 -0
- package/scripts/_common/text.py +980 -0
- package/scripts/_contract.py +19 -4
- package/scripts/caption.py +326 -92
- package/scripts/graphics.py +295 -19
- package/scripts/overlay.py +35 -2
- package/scripts/_common.py +0 -2516
package/scripts/_common.py
DELETED
|
@@ -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}"
|