ffmpeg-skill 1.15.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 +7 -3
- package/docs/contract.md +11 -10
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +6 -1
- 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/_common.py +0 -3072
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""Pure choices: given facts (a probe document, a codec name, a path, a flag value), return the
|
|
2
|
+
arguments or the value that follows from them.
|
|
3
|
+
|
|
4
|
+
Nothing here starts a subprocess or touches a media file, which is what makes the copy-vs-re-encode
|
|
5
|
+
and encoder-selection rules testable on their own.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import argparse
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
from _common.color import bt709_tag_args, _sdr_bt709
|
|
15
|
+
from _common.emit import die
|
|
16
|
+
from _common.runner import CODECS, STATE, ffmpeg_encoders
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken: float = 0.0) -> str:
|
|
20
|
+
"""The letterbox/pillarbox step shared by fit.py and export.py, as one -vf segment.
|
|
21
|
+
|
|
22
|
+
fill="color": scale to fit, then pad with a solid colour (the historical behaviour).
|
|
23
|
+
fill="blur": the bars are a blurred, scaled-to-cover copy of the same frame -- what every
|
|
24
|
+
phone editor's "make it vertical" does with landscape footage (#139). Built as a small
|
|
25
|
+
graph inside the -vf chain: split, one branch scaled to cover and cropped to the frame
|
|
26
|
+
then boxblur'ed, the other scaled to fit, overlaid centred. Only `filter:boxblur` is
|
|
27
|
+
needed beyond the usual scale/pad set, and that is already required by redact.py.
|
|
28
|
+
`darken` > 0 also dims that background copy by that much brightness (eq), so the picture in
|
|
29
|
+
front reads as the subject instead of competing with a bright blurred copy of itself --
|
|
30
|
+
what `fit.py --fit blur` uses (1.14)."""
|
|
31
|
+
if fill == "blur":
|
|
32
|
+
# boxblur rejects a radius larger than half the smaller dimension ("radius 20, must be
|
|
33
|
+
# <= 8" on a 16 px target); clamp instead of failing an otherwise valid request
|
|
34
|
+
radius = max(1, min(int(blur), max(1, min(out_w, out_h) // 2 - 1)))
|
|
35
|
+
return (f"split[__fitfg][__fitbg];"
|
|
36
|
+
f"[__fitbg]scale={out_w}:{out_h}:force_original_aspect_ratio=increase,crop={out_w}:{out_h},"
|
|
37
|
+
f"boxblur={radius}:2" + (f",eq=brightness=-{darken:g}" if darken else "") + "[__fitbgb];"
|
|
38
|
+
f"[__fitfg]scale={out_w}:{out_h}:force_original_aspect_ratio=decrease[__fitfgs];"
|
|
39
|
+
f"[__fitbgb][__fitfgs]overlay=(W-w)/2:(H-h)/2:format=auto")
|
|
40
|
+
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}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def add_pad_fill_args(parser: "argparse.ArgumentParser") -> None:
|
|
44
|
+
parser.add_argument("--pad-fill", choices=["color", "blur"], default="color",
|
|
45
|
+
help="what fills the letterbox/pillarbox bars under --fit pad: a solid --pad-color (default) or a blurred, scaled-up copy of the frame")
|
|
46
|
+
parser.add_argument("--pad-blur", type=int, default=20, help="blur radius in pixels for --pad-fill blur (default 20)")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# x264 preset names mapped onto SVT-AV1's 0-13 speed scale (lower = slower / better)
|
|
50
|
+
SVT_PRESET = {"ultrafast": 12, "superfast": 11, "veryfast": 10, "faster": 9, "fast": 8, "medium": 6, "slow": 4, "slower": 3, "veryslow": 2, "placebo": 1}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def default_output(input_path: str, suffix: str, ext: Optional[str] = None) -> str:
|
|
54
|
+
p = Path(input_path)
|
|
55
|
+
new_ext = ext if ext else p.suffix.lstrip(".") or "mp4"
|
|
56
|
+
return str(p.with_name(f"{p.stem}_{suffix}.{new_ext}"))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class MissingFpsError(ValueError):
|
|
60
|
+
"""parse_time() saw an hh:mm:ss:ff SMPTE timecode but no fps was given to convert it -- distinct
|
|
61
|
+
from a plain ValueError so a caller that falls back to treating unparseable text as a literal
|
|
62
|
+
line (e.g. caption.py's free-text cue format) can still fail loudly on this one, instead of
|
|
63
|
+
silently swallowing a mistyped/missing --fps as an auto-timed line of digits."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def concat_list_line(path: str) -> str:
|
|
67
|
+
"""One `file '...'` line for the concat demuxer. The demuxer reads backslash as an escape
|
|
68
|
+
inside the quoted form, so a Windows path (C:\\Users\\...\\part000.mp4) must be written
|
|
69
|
+
with forward slashes -- ffmpeg opens either spelling on Windows -- and a single quote in the
|
|
70
|
+
name is closed, escaped and reopened. Shared by cut.py (multi-segment) and sequence.py."""
|
|
71
|
+
escaped = str(path).replace("\\", "/").replace("'", "'\\''")
|
|
72
|
+
return f"file '{escaped}'"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def fmt_secs(value: Optional[float]) -> str:
|
|
76
|
+
"""`12.345s`, or `?s` when the probe had no duration (MPEG-TS without a duration tag, a
|
|
77
|
+
stream whose container and streams all omit it). Every writing tool prints the duration
|
|
78
|
+
of what it wrote; formatting None with :.3f used to raise TypeError after a successful
|
|
79
|
+
encode, in 25+ scripts."""
|
|
80
|
+
return "?s" if value is None else f"{value:.3f}s"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
84
|
+
"""Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250'), SRT '00:01:30,250',
|
|
85
|
+
or -- when `fps` is given -- SMPTE non-drop-frame timecode 'hh:mm:ss:ff' ('00:01:30:15')."""
|
|
86
|
+
v = value.strip().replace(",", ".")
|
|
87
|
+
if not v:
|
|
88
|
+
raise ValueError("empty time")
|
|
89
|
+
if "@" in v:
|
|
90
|
+
# 1.9: 'hh:mm:ss:ff@29.97' names the timecode's rate explicitly (docs/design-decisions.md,
|
|
91
|
+
# time grammar); it overrides the source fps a tool passed in, and is meaningless without
|
|
92
|
+
# the four-part form
|
|
93
|
+
v, _, rate = v.rpartition("@")
|
|
94
|
+
if "@" in v:
|
|
95
|
+
raise ValueError(f"'{value}': only one @fps suffix is allowed")
|
|
96
|
+
try:
|
|
97
|
+
fps = float(rate)
|
|
98
|
+
except ValueError:
|
|
99
|
+
raise ValueError(f"bad @fps suffix in '{value}' (expected a number such as @29.97)")
|
|
100
|
+
if fps <= 0:
|
|
101
|
+
raise ValueError(f"bad @fps suffix in '{value}': the rate must be positive")
|
|
102
|
+
if len(v.split(":")) != 4:
|
|
103
|
+
raise ValueError(f"'{value}': the @fps suffix belongs to an hh:mm:ss:ff timecode, not to seconds or mm:ss")
|
|
104
|
+
parts = v.split(":")
|
|
105
|
+
if len(parts) == 4:
|
|
106
|
+
if fps is None or fps <= 0:
|
|
107
|
+
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)")
|
|
108
|
+
h, m, s, f = parts
|
|
109
|
+
if "." in f:
|
|
110
|
+
raise ValueError(f"bad SMPTE timecode: {value}")
|
|
111
|
+
frame, whole_fps = int(f), int(round(fps))
|
|
112
|
+
if not (0 <= frame < whole_fps):
|
|
113
|
+
raise ValueError(f"bad SMPTE timecode '{value}': frame {frame} is out of range for {fps:g} fps (0-{whole_fps - 1})")
|
|
114
|
+
# Non-drop-frame: the timecode counts whole_fps frames per timecode-second, so the real
|
|
115
|
+
# time is the total frame count over the true rate (at 29.97 an hour of timecode is
|
|
116
|
+
# 3596.4 s of video). This is exactly what fmt_smpte_time() inverts; before, the two
|
|
117
|
+
# disagreed by ~0.1 % on the fractional NTSC rates and drifted apart over long files.
|
|
118
|
+
total_frames = (int(h) * 3600 + int(m) * 60 + int(s)) * whole_fps + frame
|
|
119
|
+
return total_frames / fps
|
|
120
|
+
if len(parts) > 3:
|
|
121
|
+
raise ValueError(f"bad time: {value}")
|
|
122
|
+
total = 0.0
|
|
123
|
+
for part in parts:
|
|
124
|
+
try:
|
|
125
|
+
total = total * 60 + float(part)
|
|
126
|
+
except ValueError:
|
|
127
|
+
# not the interpreter's "could not convert string to float: 'zz'" (review 9)
|
|
128
|
+
raise ValueError(f"'{value}': not a time")
|
|
129
|
+
return total
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def time_arg(value: str, flag: str, fps: Optional[float] = None) -> float:
|
|
133
|
+
"""parse_time() for a command-line flag: SMPTE hh:mm:ss:ff resolves with the input's fps when
|
|
134
|
+
the caller has one, and every parse failure is a `kind: input` refusal naming the flag (so
|
|
135
|
+
`--json` callers get a failure document, never a traceback)."""
|
|
136
|
+
try:
|
|
137
|
+
return parse_time(value, fps)
|
|
138
|
+
except MissingFpsError as e:
|
|
139
|
+
die(f"{flag} {value!r}: {e}")
|
|
140
|
+
except ValueError as e:
|
|
141
|
+
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)")
|
|
142
|
+
return 0.0 # unreachable
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def signed_time_arg(value: str, flag: str, fps: Optional[float] = None) -> float:
|
|
146
|
+
"""time_arg() for a flag that may also be negative (an offset, not a point in time): a single
|
|
147
|
+
leading '-'/'+' is taken as the sign and the rest goes through the ordinary time grammar, so
|
|
148
|
+
`--offset -00:00:02`, `--offset -1.5` and `--offset 0:02` all mean what they read as."""
|
|
149
|
+
text = (value or "").strip()
|
|
150
|
+
sign = 1.0
|
|
151
|
+
if text[:1] in "+-":
|
|
152
|
+
sign = -1.0 if text[0] == "-" else 1.0
|
|
153
|
+
text = text[1:].strip()
|
|
154
|
+
if not text:
|
|
155
|
+
die(f"{flag} {value!r}: not a time (use seconds, mm:ss, hh:mm:ss.ms, or hh:mm:ss:ff)")
|
|
156
|
+
return sign * time_arg(text, flag, fps)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def fmt_srt_time(seconds: float) -> str:
|
|
160
|
+
if seconds < 0:
|
|
161
|
+
seconds = 0.0
|
|
162
|
+
ms = int(round(seconds * 1000))
|
|
163
|
+
h, rem = divmod(ms, 3_600_000)
|
|
164
|
+
m, rem = divmod(rem, 60_000)
|
|
165
|
+
s, ms = divmod(rem, 1000)
|
|
166
|
+
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def fmt_smpte_time(seconds: float, fps: float) -> str:
|
|
170
|
+
"""SMPTE non-drop-frame timecode 'hh:mm:ss:ff' for a real fps (not the fractional NTSC rates
|
|
171
|
+
-- 29.97/59.94 need drop-frame counting to stay wall-clock accurate, which this does not do)."""
|
|
172
|
+
if seconds < 0:
|
|
173
|
+
seconds = 0.0
|
|
174
|
+
whole_fps = int(round(fps))
|
|
175
|
+
total_frames = int(round(seconds * fps))
|
|
176
|
+
frame = total_frames % whole_fps
|
|
177
|
+
secs_total = total_frames // whole_fps
|
|
178
|
+
h, rem = divmod(secs_total, 3600)
|
|
179
|
+
m, s = divmod(rem, 60)
|
|
180
|
+
return f"{h:02d}:{m:02d}:{s:02d}:{frame:02d}"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def escape_filter_path(path: str) -> str:
|
|
184
|
+
"""Escape a file path for use as a filter option value (subtitles=, ass=, lut3d=file=, fontfile=, fontsdir=).
|
|
185
|
+
|
|
186
|
+
A filter option value is parsed twice: the graph parser splits filters on `,` / `;` and options
|
|
187
|
+
on `:`, then the filter's own option parser splits key=value pairs on `:` again. A character that
|
|
188
|
+
must survive both passes needs two levels of escaping, so a Windows drive letter `D:/x.srt` is
|
|
189
|
+
written `D\\\\:/x.srt`; with a single backslash the second pass still splits at the colon and
|
|
190
|
+
ffmpeg reads `/x.srt` as the next option (`Unable to parse "original_size" option value`).
|
|
191
|
+
Backslashes are turned into forward slashes first (ffmpeg accepts them on Windows), so a backslash
|
|
192
|
+
never has to be escaped itself; `,`, `;`, `[` and `]` are graph-level characters and survive with
|
|
193
|
+
one backslash. `'` is special: the graph parser also treats a quote as the start of a quoted
|
|
194
|
+
token, so a single `\\'` is consumed by the first pass and "Ryo's Mac/cues.srt" reaches the
|
|
195
|
+
filter as "Ryos Mac/cues.srt" (Unable to open ...). Three backslashes survive both passes
|
|
196
|
+
(measured on 6.1 and 7.1 with subtitles=, ass= and lut3d=file=).
|
|
197
|
+
"""
|
|
198
|
+
if os.path.isfile(path) and path not in STATE.plan_inputs:
|
|
199
|
+
STATE.plan_inputs.append(path) # a plan binds subtitle/LUT/font files too (review 6)
|
|
200
|
+
p = str(Path(path))
|
|
201
|
+
p = p.replace("\\", "/")
|
|
202
|
+
p = p.replace(":", "\\\\:")
|
|
203
|
+
p = p.replace("'", "\\\\\\'")
|
|
204
|
+
for ch in (",", ";", "[", "]"):
|
|
205
|
+
p = p.replace(ch, "\\" + ch)
|
|
206
|
+
return p
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def cfr_args(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> List[str]:
|
|
210
|
+
"""Force a constant frame rate on output when the source looks VFR (or fps is given).
|
|
211
|
+
|
|
212
|
+
VFR sources (phone/screen recordings) drift against audio after cuts and joins,
|
|
213
|
+
so every re-encoding script passes this to conform them automatically.
|
|
214
|
+
"""
|
|
215
|
+
v = (meta or {}).get("video") or {}
|
|
216
|
+
if fps is None and not v.get("variable_frame_rate_suspected"):
|
|
217
|
+
return []
|
|
218
|
+
rate = fps or v.get("fps") or 30.0
|
|
219
|
+
rate = round(rate) if abs(rate - round(rate)) < 0.02 else rate
|
|
220
|
+
return ["-fps_mode", "cfr", "-r", f"{rate:g}"]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any]] = None, keep_bt709: bool = True) -> List[str]:
|
|
224
|
+
"""The one place that turns (--codec, --quality, --preset, source) into encoder options.
|
|
225
|
+
|
|
226
|
+
h264 -> x264 8-bit BT.709 (refuses HDR: 8-bit H.264 cannot carry it); hevc -> x265, Main10
|
|
227
|
+
with the source's tags for HDR, 8-bit BT.709 otherwise; av1 -> SVT-AV1 (libaom fallback),
|
|
228
|
+
10-bit for HDR; prores -> ProRes 422 HQ, source tags kept. 1.8: chosen by --codec; without
|
|
229
|
+
it video_args() does what it always did (x264 for SDR, x265 Main10 for HDR).
|
|
230
|
+
"""
|
|
231
|
+
v = (meta or {}).get("video") or {}
|
|
232
|
+
hdr = bool(v.get("hdr"))
|
|
233
|
+
cs = v.get("color_space") or "bt2020nc"
|
|
234
|
+
prim = v.get("color_primaries") or "bt2020"
|
|
235
|
+
trc = v.get("color_transfer") or "arib-std-b67"
|
|
236
|
+
hdr_tags = ["-colorspace", cs, "-color_primaries", prim, "-color_trc", trc]
|
|
237
|
+
if codec == "h264":
|
|
238
|
+
if hdr:
|
|
239
|
+
die(f"--codec h264 cannot carry HDR ({v.get('hdr_format') or 'BT.2020'}): 8-bit H.264 is SDR only",
|
|
240
|
+
hint="run color.py --to-sdr first, or use --codec hevc / av1 / prores, which keep the source's HDR")
|
|
241
|
+
return _x264_raw(crf, preset, keep_bt709)
|
|
242
|
+
if codec == "hevc":
|
|
243
|
+
if hdr:
|
|
244
|
+
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}"
|
|
245
|
+
return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
|
|
246
|
+
"-x265-params", x265] + hdr_tags + ["-movflags", "+faststart"]
|
|
247
|
+
params, extra = _sdr_bt709("libx265") if keep_bt709 else ("", [])
|
|
248
|
+
return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-tag:v", "hvc1",
|
|
249
|
+
"-x265-params", "log-level=error" + (":" + params if params else "")] + extra + ["-movflags", "+faststart"]
|
|
250
|
+
if codec == "av1":
|
|
251
|
+
pix = "yuv420p10le" if hdr else "yuv420p"
|
|
252
|
+
if "libsvtav1" in ffmpeg_encoders():
|
|
253
|
+
args = ["-c:v", "libsvtav1", "-preset", str(SVT_PRESET.get(preset, 6)), "-crf", str(min(63, crf)), "-pix_fmt", pix]
|
|
254
|
+
if hdr:
|
|
255
|
+
args += hdr_tags
|
|
256
|
+
elif keep_bt709:
|
|
257
|
+
params, extra = _sdr_bt709("libsvtav1")
|
|
258
|
+
args += (["-svtav1-params", params] if params else []) + extra
|
|
259
|
+
elif "libaom-av1" in ffmpeg_encoders():
|
|
260
|
+
args = ["-c:v", "libaom-av1", "-crf", str(min(63, crf)), "-b:v", "0", "-cpu-used", "6", "-row-mt", "1", "-pix_fmt", pix]
|
|
261
|
+
args += hdr_tags if hdr else (_sdr_bt709("libaom-av1")[1] if keep_bt709 else [])
|
|
262
|
+
else:
|
|
263
|
+
die("--codec av1 needs an AV1 encoder (libsvtav1 or libaom-av1) and this ffmpeg build has neither", kind="missing_tool",
|
|
264
|
+
hint="install an ffmpeg built with SVT-AV1 (most distribution builds are), or use --codec hevc")
|
|
265
|
+
return args + ["-movflags", "+faststart"]
|
|
266
|
+
if codec == "prores":
|
|
267
|
+
if "prores_ks" not in ffmpeg_encoders():
|
|
268
|
+
die("--codec prores needs the prores_ks encoder and this ffmpeg build lacks it", kind="missing_tool")
|
|
269
|
+
return ["-c:v", "prores_ks", "-profile:v", "3", "-vendor", "apl0", "-pix_fmt", "yuv422p10le"] + (hdr_tags if hdr else [])
|
|
270
|
+
die(f"unknown --codec {codec!r} (one of {', '.join(CODECS)})")
|
|
271
|
+
return []
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _x264_raw(crf: int, preset: str, keep_bt709: bool = True) -> List[str]:
|
|
275
|
+
args = ["-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
|
|
276
|
+
if keep_bt709:
|
|
277
|
+
args += bt709_tag_args("libx264")
|
|
278
|
+
return args
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) -> List[str]:
|
|
282
|
+
"""SDR H.264 encoder args -- or, when --codec named another encoder, that encoder's SDR args
|
|
283
|
+
(color.py's --to-sdr path builds its own H.264 line; the flag still has to reach it)."""
|
|
284
|
+
if STATE.codec and STATE.codec != "h264":
|
|
285
|
+
return encoder_args(STATE.codec, crf, preset, None, keep_bt709)
|
|
286
|
+
return _x264_raw(crf, preset, keep_bt709)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium") -> List[str]:
|
|
290
|
+
"""Encoder args that preserve what the source is.
|
|
291
|
+
|
|
292
|
+
SDR sources -> H.264 8-bit tagged BT.709 (x264_args). HDR sources (HDR10/PQ, HLG,
|
|
293
|
+
Dolby Vision base layer, BT.2020) -> HEVC Main10 with the source's own colour tags,
|
|
294
|
+
so cutting/captioning/fitting an iPhone HDR clip stays HDR instead of becoming a
|
|
295
|
+
washed-out file mislabelled as BT.709. Use color.py --to-sdr when SDR is wanted.
|
|
296
|
+
"""
|
|
297
|
+
if STATE.codec:
|
|
298
|
+
return encoder_args(STATE.codec, crf, preset, meta)
|
|
299
|
+
v = (meta or {}).get("video") or {}
|
|
300
|
+
if not v.get("hdr"):
|
|
301
|
+
return x264_args(crf, preset)
|
|
302
|
+
cs = v.get("color_space") or "bt2020nc"
|
|
303
|
+
prim = v.get("color_primaries") or "bt2020"
|
|
304
|
+
trc = v.get("color_transfer") or "arib-std-b67"
|
|
305
|
+
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}"
|
|
306
|
+
return ["-c:v", "libx265", "-preset", preset, "-crf", str(min(51, crf + 2)), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
|
|
307
|
+
"-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def aac_args(bitrate: str = "192k") -> List[str]:
|
|
311
|
+
return ["-c:a", "aac", "-b:a", bitrate]
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
AUDIO_CODECS = {
|
|
315
|
+
".wav": ["-c:a", "pcm_s16le"],
|
|
316
|
+
".flac": ["-c:a", "flac"],
|
|
317
|
+
".mp3": ["-c:a", "libmp3lame", "-q:a", "0"],
|
|
318
|
+
".m4a": ["-c:a", "aac", "-b:a", "256k"],
|
|
319
|
+
".aac": ["-c:a", "aac", "-b:a", "256k"],
|
|
320
|
+
".ogg": ["-c:a", "libvorbis", "-q:a", "6"],
|
|
321
|
+
".opus": ["-c:a", "libopus", "-b:a", "128k"],
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def audio_codec_for(output_path: str, default_bitrate: str = "192k") -> List[str]:
|
|
326
|
+
"""Pick an audio codec that the output container can actually hold."""
|
|
327
|
+
ext = os.path.splitext(output_path)[1].lower()
|
|
328
|
+
return list(AUDIO_CODECS.get(ext, ["-c:a", "aac", "-b:a", default_bitrate]))
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def is_audio_output(output_path: str) -> bool:
|
|
332
|
+
"""True when the output extension is an audio-only container (.wav, .flac, .mp3, .m4a, .aac, .ogg, .opus).
|
|
333
|
+
|
|
334
|
+
Such a file cannot hold a video stream and, for .wav, cannot hold compressed audio: scripts use
|
|
335
|
+
this to drop the picture (-vn) and to pick the codec from the extension instead of AAC.
|
|
336
|
+
"""
|
|
337
|
+
return os.path.splitext(output_path)[1].lower() in AUDIO_CODECS
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def db_to_linear(db: float) -> float:
|
|
341
|
+
return 10 ** (db / 20.0)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
BRAND_DEFAULTS: Dict[str, Any] = {
|
|
345
|
+
"font": "DejaVu Sans",
|
|
346
|
+
"font_file": None,
|
|
347
|
+
"colors": {"primary": "FFD200", "text": "FFFFFF", "outline": "000000", "background": "101418", "accent": "1E6F8E"},
|
|
348
|
+
"logo": None,
|
|
349
|
+
"logo_position": "top-right",
|
|
350
|
+
"logo_scale": 160,
|
|
351
|
+
"logo_opacity": 0.9,
|
|
352
|
+
"safe_margin": 48,
|
|
353
|
+
"caption": {"size": 26, "position": "bottom", "animate": "pop", "karaoke": False, "bold": True, "outline": 2},
|
|
354
|
+
# 1.12: one place for the caption look every project shares. `styles.caption` is the documented
|
|
355
|
+
# spelling (`{font, size, colour, box, position}`, British or American "colour"); the older
|
|
356
|
+
# top-level `caption` block still works and `styles.caption` wins where both name the same key.
|
|
357
|
+
"styles": {},
|
|
358
|
+
"lang": None,
|
|
359
|
+
"loudness": {"lufs": -14, "tp": -1},
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def load_brand(path: Optional[str]) -> Dict[str, Any]:
|
|
364
|
+
"""Load brand.json (fonts, colours, logo, safe margins, caption defaults); missing keys fall back to defaults."""
|
|
365
|
+
import copy
|
|
366
|
+
brand = copy.deepcopy(BRAND_DEFAULTS)
|
|
367
|
+
brand["_stated"] = {}
|
|
368
|
+
if not path:
|
|
369
|
+
return brand
|
|
370
|
+
if not os.path.exists(path):
|
|
371
|
+
die(f"brand file not found: {path}")
|
|
372
|
+
try:
|
|
373
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
374
|
+
except ValueError as exc:
|
|
375
|
+
die(f"brand file is not valid JSON: {exc}")
|
|
376
|
+
base = Path(path).resolve().parent
|
|
377
|
+
for k, v in data.items():
|
|
378
|
+
if isinstance(v, dict) and isinstance(brand.get(k), dict):
|
|
379
|
+
brand[k].update(v)
|
|
380
|
+
else:
|
|
381
|
+
brand[k] = v
|
|
382
|
+
for key in ("logo", "font_file"):
|
|
383
|
+
if brand.get(key) and not os.path.isabs(brand[key]):
|
|
384
|
+
brand[key] = str(base / brand[key])
|
|
385
|
+
brand["_path"] = str(path)
|
|
386
|
+
# What the FILE said, separate from BRAND_DEFAULTS' filler: a brand.json that never mentions
|
|
387
|
+
# a font must not read as "the caller chose a font" (which would switch font-by-script off).
|
|
388
|
+
brand["_stated"] = data
|
|
389
|
+
return brand
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def brand_states_font(brand: Dict[str, Any]) -> bool:
|
|
393
|
+
"""Did the brand FILE actually name a font (top-level `font`, `caption.font` or
|
|
394
|
+
`styles.caption.font`)? BRAND_DEFAULTS always supplies one, so the merged document can never
|
|
395
|
+
answer this -- and treating the default filler as the caller's choice switched font-by-script
|
|
396
|
+
off for every branded job (review 10)."""
|
|
397
|
+
stated = brand.get("_stated") or {}
|
|
398
|
+
if stated.get("font"):
|
|
399
|
+
return True
|
|
400
|
+
for block in (stated.get("caption"), (stated.get("styles") or {}).get("caption")):
|
|
401
|
+
if isinstance(block, dict) and block.get("font"):
|
|
402
|
+
return True
|
|
403
|
+
return False
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def brand_caption_style(brand: Dict[str, Any]) -> Dict[str, Any]:
|
|
407
|
+
"""The effective caption style of a brand file: the top-level `caption` block updated with
|
|
408
|
+
`styles.caption`, with `colour` normalised to `color`. Explicit flags still beat both."""
|
|
409
|
+
style: Dict[str, Any] = dict(brand.get("caption") or {})
|
|
410
|
+
extra = (brand.get("styles") or {}).get("caption") or {}
|
|
411
|
+
style.update(extra)
|
|
412
|
+
if "colour" in style and "color" not in style:
|
|
413
|
+
style["color"] = style.pop("colour")
|
|
414
|
+
style.pop("colour", None)
|
|
415
|
+
return style
|