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.
@@ -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