ffmpeg-skill 3.6.0 → 3.6.2

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.
@@ -20,7 +20,8 @@ a centred title needs no text-width measurement at all. `move` is (x1, y1, x2, y
20
20
  milliseconds relative to the line's own start; `fade` is (in_ms, out_ms); `scale_t` is
21
21
  (t1_ms, t2_ms, percent) for a pop.
22
22
  """
23
- from typing import Any, Dict, List, Optional, Sequence
23
+ import re
24
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
24
25
 
25
26
 
26
27
  # One sentinel character stands in drawn text where an emoji was, so the placeholder is inserted
@@ -54,7 +55,7 @@ def ass_time(sec: float) -> str:
54
55
 
55
56
  def ass_color(hex_rgb: str, alpha: int = 0) -> str:
56
57
  h = str(hex_rgb).lstrip("#")
57
- if len(h) != 6:
58
+ if len(h) != 6 or any(c not in "0123456789abcdefABCDEF" for c in h):
58
59
  raise ValueError(f"colour must be RRGGBB hex, got '{hex_rgb}'")
59
60
  return f"&H{alpha:02X}{h[4:6]}{h[2:4]}{h[0:2]}".upper()
60
61
 
@@ -105,6 +106,238 @@ def ass_text(text: str) -> str:
105
106
  return ass_escape(text)
106
107
 
107
108
 
109
+ # --- SRT markup -------------------------------------------------------------------------------
110
+ # The tags ffmpeg's own SRT reader turns into styling (libavcodec/htmlsubtitles.c): <i> <b> <u>
111
+ # <s>, <font color=...>, and a `{\anN}` position block. caption.py's plain --srt route hands the
112
+ # file to ffmpeg, which draws `<i>Hello</i>` in italics; the routes where caption.py writes its own
113
+ # ASS (--animate, --karaoke, an emoji-forced ASS, the tiktok/reels/shorts templates) escaped the
114
+ # same cue and drew the tags as text, and the wrapper measured them as width. Only these tags are
115
+ # markup here: an unknown `<tag>` and every other `{\...}` block stay text, escaped as before
116
+ # (a caption still cannot open \pos or \t from its cue text).
117
+ _SRT_TAG_RE = re.compile(
118
+ r"<\s*(/?)\s*(i|b|u|s)\s*>" # 1, 2: simple style tags
119
+ r"|<\s*font\b([^<>]*)>" # 3: <font ...attributes>
120
+ r"|<\s*/\s*font\s*>" # closing font
121
+ r"|\{\\an([1-9])\}", # 4: position block
122
+ re.IGNORECASE)
123
+ _FONT_COLOR_RE = re.compile(r"""\bcolor\s*=\s*["']?\s*([#\w]+)""", re.IGNORECASE)
124
+ _NAMED_COLORS = {
125
+ "white": "ffffff", "black": "000000", "red": "ff0000", "lime": "00ff00", "green": "008000",
126
+ "blue": "0000ff", "yellow": "ffff00", "cyan": "00ffff", "aqua": "00ffff",
127
+ "magenta": "ff00ff", "fuchsia": "ff00ff", "silver": "c0c0c0", "gray": "808080",
128
+ "grey": "808080", "maroon": "800000", "olive": "808000", "navy": "000080",
129
+ "purple": "800080", "teal": "008080", "orange": "ffa500"}
130
+
131
+
132
+ def srt_markup_tokens(text: str) -> "List[Tuple[bool, str]]":
133
+ """`text` cut into (is_markup, piece) runs, in order; the pieces concatenate back to `text`."""
134
+ out: "List[Tuple[bool, str]]" = []
135
+ pos = 0
136
+ for m in _SRT_TAG_RE.finditer(text or ""):
137
+ if m.start() > pos:
138
+ out.append((False, text[pos:m.start()]))
139
+ out.append((True, m.group(0)))
140
+ pos = m.end()
141
+ if pos < len(text or ""):
142
+ out.append((False, text[pos:]))
143
+ return out
144
+
145
+
146
+ def strip_srt_markup(text: str) -> str:
147
+ """The words a viewer reads: `text` with the SRT markup tags removed."""
148
+ return "".join(piece for markup, piece in srt_markup_tokens(text) if not markup)
149
+
150
+
151
+ def srt_alignment(text: str) -> "Optional[int]":
152
+ """The `{\\anN}` a cue asks for, or None."""
153
+ for markup, piece in srt_markup_tokens(text):
154
+ m = _SRT_TAG_RE.fullmatch(piece) if markup else None
155
+ if m and m.group(4):
156
+ return int(m.group(4))
157
+ return None
158
+
159
+
160
+ def _font_colour(attrs: str) -> "Optional[str]":
161
+ m = _FONT_COLOR_RE.search(attrs or "")
162
+ if not m:
163
+ return None
164
+ value = m.group(1).lower()
165
+ value = _NAMED_COLORS.get(value, value)
166
+ if value.startswith("0x"):
167
+ value = value[2:]
168
+ value = value.lstrip("#")
169
+ if re.fullmatch(r"[0-9a-f]{6}", value):
170
+ return "&H%s%s%s&" % (value[4:6].upper(), value[2:4].upper(), value[0:2].upper())
171
+ return None # ffmpeg ignores a colour it cannot read (#f00 included); so does this
172
+
173
+
174
+ def _markup_to_ass(tag: str, colours: "List[str]", colour: bool = True) -> str:
175
+ m = _SRT_TAG_RE.fullmatch(tag)
176
+ if not m:
177
+ return ""
178
+ if not colour and not m.group(2) and not m.group(4):
179
+ return "" # a <font> tag where the caller owns the colours (karaoke): dropped
180
+ if m.group(2):
181
+ return "{\\%s%d}" % (m.group(2).lower(), 0 if m.group(1) else 1)
182
+ if m.group(4):
183
+ return "{\\an%s}" % m.group(4)
184
+ if m.group(0).lstrip("< \t").lower().startswith("/"):
185
+ if colours:
186
+ colours.pop()
187
+ return "{\\c%s}" % colours[-1] if colours else "{\\c}"
188
+ colour = _font_colour(m.group(3))
189
+ colours.append(colour or (colours[-1] if colours else ""))
190
+ return "{\\c%s}" % colour if colour else ""
191
+
192
+
193
+ def ass_escape_markup(text: str, colour: bool = True) -> str:
194
+ """ass_escape() for cue text that may carry SRT markup: the tags ffmpeg's SRT reader styles
195
+ become the same ASS overrides it writes for them (`<i>` -> `{\\i1}`, `<font color="#ffff00">`
196
+ -> `{\\c&H00FFFF&}`, `{\\an8}` kept), and everything else is escaped exactly as ass_escape()
197
+ escapes it. `colour=False` drops the <font> tags instead: karaoke's highlight IS the primary
198
+ colour, and a cue colour would replace it."""
199
+ out: "List[str]" = []
200
+ colours: "List[str]" = []
201
+ for markup, piece in srt_markup_tokens(text):
202
+ if markup:
203
+ out.append(_markup_to_ass(piece, colours, colour))
204
+ continue
205
+ escaped = ass_escape(piece)
206
+ if escaped.endswith("\\") and not escaped.endswith("\\​"):
207
+ escaped += "​" # a trailing backslash must not run into the override after it
208
+ out.append(escaped)
209
+ return "".join(out)
210
+
211
+
212
+ def reinsert_srt_markup(marked: str, lines: "Sequence[str]") -> "List[str]":
213
+ """Put `marked`'s markup back into `lines`, a wrap of strip_srt_markup(marked).
214
+
215
+ A wrap keeps every visible character in order and only turns whitespace into a break (or puts a
216
+ break between two characters), so each tag is anchored to a character: a closing tag right
217
+ after the visible character before it, any other tag right before the visible character after
218
+ it. Lines that do not hold the stripped text in order come back unchanged (markup dropped):
219
+ tags would otherwise land in the wrong word.
220
+
221
+ A tag pair that encloses no visible character (`A<font color=red> </font>B`) has no character
222
+ to anchor to, and the rule above would put its closing tag after A and its opening tag before
223
+ B, colouring B. In a gap between two visible characters that holds such a pair, the tags keep
224
+ their source order instead: those before the pair's closing tag go right after the character
225
+ before the gap, the rest right before the character after it (both at the start of the next
226
+ line when the gap's whitespace became the break)."""
227
+ tokens = srt_markup_tokens(marked)
228
+ if not any(markup for markup, _p in tokens):
229
+ return list(lines)
230
+ # (line, column) of every non-space character of the wrapped lines, in reading order
231
+ slots = [(li, ci) for li, line in enumerate(lines) for ci, ch in enumerate(line) if not ch.isspace()]
232
+ if not slots:
233
+ return list(lines)
234
+ split_at = _empty_pair_splits(tokens)
235
+ inserts: "Dict[Tuple[int, int], List[str]]" = {}
236
+ seen = 0 # non-space visible characters passed so far
237
+ for index, (markup, piece) in enumerate(tokens):
238
+ if not markup:
239
+ for ch in piece:
240
+ if ch.isspace():
241
+ continue
242
+ if seen >= len(slots) or lines[slots[seen][0]][slots[seen][1]] != ch:
243
+ return list(lines)
244
+ seen += 1
245
+ continue
246
+ after_prev = (slots[seen - 1][0], slots[seen - 1][1] + 1) if seen else None
247
+ before_next = slots[seen] if seen < len(slots) else None
248
+ split = split_at.get(seen)
249
+ if split is not None:
250
+ # this gap holds a pair that encloses no visible character: keep the source order
251
+ if after_prev is None or before_next is None:
252
+ key = after_prev or before_next
253
+ elif after_prev[0] != before_next[0]:
254
+ key = before_next
255
+ else:
256
+ key = after_prev if index < split else before_next
257
+ elif piece.lstrip("< \t").startswith("/") and after_prev or before_next is None:
258
+ key = after_prev
259
+ else:
260
+ key = before_next
261
+ inserts.setdefault(key, []).append(piece)
262
+ if seen != len(slots):
263
+ return list(lines)
264
+ out = []
265
+ for li, line in enumerate(lines):
266
+ cols = sorted(ci for (l2, ci) in inserts if l2 == li)
267
+ built, last = "", 0
268
+ for ci in cols:
269
+ built += line[last:ci] + "".join(inserts[(li, ci)])
270
+ last = ci
271
+ out.append(built + line[last:])
272
+ return out
273
+
274
+
275
+ def _empty_pair_splits(tokens: "Sequence[Tuple[bool, str]]") -> "Dict[int, int]":
276
+ """For each gap between visible characters (keyed by the number of non-space characters
277
+ before it) that holds a tag pair enclosing none, the token index of the first such pair's
278
+ closing tag."""
279
+ splits: "Dict[int, int]" = {}
280
+ seen = 0
281
+ open_here: "List[str]" = [] # names of the tags opened in the current gap
282
+ for index, (markup, piece) in enumerate(tokens):
283
+ if not markup:
284
+ if piece.strip():
285
+ seen += sum(1 for ch in piece if not ch.isspace())
286
+ open_here = []
287
+ continue
288
+ m = _SRT_TAG_RE.fullmatch(piece)
289
+ if not m or m.group(4):
290
+ continue
291
+ name = (m.group(2) or "font").lower()
292
+ if piece.lstrip("< \t").startswith("/"):
293
+ if name in open_here:
294
+ splits.setdefault(seen, index)
295
+ del open_here[len(open_here) - 1 - open_here[::-1].index(name)]
296
+ else:
297
+ open_here.append(name)
298
+ return splits
299
+
300
+
301
+ def carry_srt_markup(chunks: "Sequence[str]") -> "List[str]":
302
+ """Consecutive cues cut from one marked cue, each made to stand alone: a tag still open at the
303
+ end of a piece is closed there and opened again at the start of the next, and the cue's
304
+ `{\\anN}` is repeated on every piece -- the second half of an italic, top-placed cue stays
305
+ italic and at the top."""
306
+ out: "List[str]" = []
307
+ carried: "List[str]" = []
308
+ # the cue's position, wherever in it the {\anN} stands: an earlier piece takes it too
309
+ align = next((a for a in (srt_alignment(c) for c in chunks) if a is not None), None)
310
+ for chunk in chunks:
311
+ lead = "".join(carried)
312
+ if align is not None and srt_alignment(chunk) is None:
313
+ lead = "{\\an%d}" % align + lead
314
+ opened: "List[str]" = list(carried)
315
+ for markup, piece in srt_markup_tokens(chunk):
316
+ if not markup:
317
+ continue
318
+ m = _SRT_TAG_RE.fullmatch(piece)
319
+ if not m or m.group(4):
320
+ continue
321
+ closing = piece.lstrip("< \t").startswith("/")
322
+ name = (m.group(2) or "font").lower()
323
+ if closing:
324
+ for k in range(len(opened) - 1, -1, -1):
325
+ if _tag_name(opened[k]) == name:
326
+ del opened[k]
327
+ break
328
+ else:
329
+ opened.append(piece)
330
+ tail = "".join("</%s>" % _tag_name(t) for t in reversed(opened))
331
+ out.append(lead + chunk + tail)
332
+ carried = opened
333
+ return out
334
+
335
+
336
+ def _tag_name(tag: str) -> str:
337
+ m = _SRT_TAG_RE.fullmatch(tag)
338
+ return ((m.group(2) if m else None) or "font").lower()
339
+
340
+
108
341
  def text_overlay_ass(elements: Sequence[Dict[str, Any]], *, play_w: int, play_h: int,
109
342
  path: str, fonts_dir: Optional[str] = None) -> str:
110
343
  """Write `elements` as an ASS file at `path` and return the path."""
@@ -52,7 +52,7 @@ for _stream in (sys.stdout, sys.stderr):
52
52
  # demos/ or evals/ has ever reached for through `_common`.
53
53
 
54
54
  from _common.runner import (
55
- add_common, apply_common, _check_existing_output, refuse_existing_outputs, guard_sidecar, _check_no_overwrite_input, _check_output_path, child_args,
55
+ add_common, apply_common, _check_existing_output, refuse_existing_outputs, guard_sidecar, _check_no_overwrite_input, _check_output_path, child_args, option_args,
56
56
  child_limit, _CHILDREN, _cleanup_partial_output, _cmdline, CODECS, Context, DEFAULT_TIMEOUT,
57
57
  _DRAWTEXT_PENDING, _DRAWTEXT_TMPDIR, _drawtext_tmpdir, dry_run_input_pending, _ENCODERS, _env_timeout,
58
58
  ERROR_CODE, ERROR_RETRYABLE, EVEN_SCALE, _execute, _fail, ffmpeg_base, ffmpeg_encoders, ffmpeg_exe, _FFMPEG_RELEASE, _FFMPEG_VERSION,
@@ -85,7 +85,7 @@ from _common.decision import (
85
85
  _onset_strength, _pick_onsets, _autocorrelation_peak, _grid_score,
86
86
  brand_states_font, cfr_args, concat_list_line, db_to_linear, default_output, encoder_args, escape_filter_path,
87
87
  fmt_secs, fmt_smpte_time, fmt_srt_time, frame_size, is_audio_output, load_brand, MissingFpsError, pad_filters, parse_time,
88
- signed_time_arg, source_codec_video_args, drawn_chain, draws_chroma, label_chain, layer_base_chain, matrix_chain, range_chain, rgb_chain_to_bt709, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
88
+ signed_time_arg, source_codec_video_args, drawn_chain, draws_chroma, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, SVT_PRESET, time_arg, video_args, x264_args, _x264_raw
89
89
  )
90
90
  from _common.asr import (
91
91
  ASR_ENGINES, ASR_INSTALL_HINT, _asr_run, die_no_engine, parse_srt, transcribe, _transcribe_in,
@@ -183,7 +183,7 @@ __all__ = [
183
183
  "aac_args", "add_common", "add_pad_fill_args", "ADVANCE_EM", "analyze_levels", "LEGAL_RANGE_CHOICES", "LEGAL_RANGES", "legal_limits", "measure_signal_range", "apply_common", "detect_scenes", "detect_silences", "SCORE_RE", "SIL_RE", "_aspect_string",
184
184
  "audio_codec_for", "AUDIO_CODECS", "BIDI_SCRIPTS", "_bit_depth", "brand_caption_style", "BRAND_DEFAULTS",
185
185
  "brand_states_font", "_brief", "_BRIEF_DROP", "_brief_summary", "bt709_matrix_args", "bt709_matrix_filter", "bt709_tag_args", "cfr_args", "_char_em",
186
- "char_script", "_check_existing_output", "refuse_existing_outputs", "guard_sidecar", "_check_no_overwrite_input", "_check_output_path", "child_args",
186
+ "char_script", "_check_existing_output", "refuse_existing_outputs", "guard_sidecar", "_check_no_overwrite_input", "_check_output_path", "child_args", "option_args",
187
187
  "child_limit", "_CHILDREN", "_cleanup_partial_output", "_cmdline", "CODECS", "color_hex", "_COLOR_TOKEN_RE",
188
188
  "concat_list_line", "Context", "_CURRENT_CTX", "db_to_linear", "decode_pcm_mono", "description_block", "_evidence_rank", "fmt_chapter_time", "propose_chapters",
189
189
  "filler_spans", "FILLER_WORDS", "FILLER_AMBIGUOUS", "FILLER_DISCOURSE_MARKERS",
@@ -216,7 +216,7 @@ __all__ = [
216
216
  "SCRIPTS", "_sdr_bt709", "_set_current_ctx", "_SHAPING_BUILD_CACHE", "SHAPING_SCRIPTS", "shell_quote", "sibling_temp", "still_input_args",
217
217
  "_SIGNALS_INSTALLED", "signed_time_arg", "_stage_existing_output", "STATE", "SVT_PRESET", "text_width_em",
218
218
  "time_arg", "_timed_out", "_to_float", "_to_int", "_unwatch", "validate_color", "verify_output",
219
- "source_codec_video_args", "drawn_chain", "draws_chroma", "drawn_stream_format", "is_neutral_color", "rgb_island", "label_chain", "layer_base_chain", "matrix_chain", "range_chain", "as_limited", "is_full_range", "limited_range_filter", "rgb_chain_to_bt709", "bt709_label_filter", "rgb_to_bt709_filter", "is_rgb", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
219
+ "source_codec_video_args", "drawn_chain", "draws_chroma", "drawn_stream_format", "is_neutral_color", "rgb_island", "label_chain", "layer_base_chain", "matrix_chain", "overlay_format", "range_chain", "as_limited", "is_full_range", "limited_range_filter", "rgb_chain_to_bt709", "bt709_label_filter", "rgb_to_bt709_filter", "is_rgb", "video_args", "_VS15", "_VS16", "_watch", "WINDOWS_FONTS", "write_plan", "x264_args", "X264_PRESETS",
220
220
  "_x264_raw", "_ZWJ",
221
221
  "absorb_stage", "audit", "note", "_plan_unbound_inputs", "record_fallback", "record_skip", "refuse", "warn",
222
222
  "ASR_ENGINES", "ASR_INSTALL_HINT", "_asr_run", "die_no_engine", "parse_srt", "transcribe",
@@ -207,6 +207,9 @@ def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
207
207
  # newline so the cue's own text can never fake the format's block boundary.
208
208
  t = re.sub(r"\n{2,}", "\n", t).strip("\n")
209
209
  fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
210
+ # under batch.py: this SRT as written is the run's own (an edit made after it is not)
211
+ from _common.runner import report_committed
212
+ report_committed(path)
210
213
 
211
214
 
212
215
  def transcribe_result(video: str, out_srt: str, language: Optional[str], model: Optional[str], audio_stream: int = 0,
@@ -312,13 +312,20 @@ def fold_bt709_matrix(cmd: Sequence[str]) -> List[str]:
312
312
  return out[:at] + ["-vf", conv] + out[at:]
313
313
 
314
314
 
315
+ _HEX6 = re.compile(r"^[0-9A-Fa-f]{6}$")
316
+
317
+
315
318
  def color_hex(value: str) -> str:
316
- """Normalise '#ffd200' / 'ffd200' / '0xFFD200' to 'FFD200'."""
319
+ """Normalise '#ffd200' / 'ffd200' / '0xFFD200' to 'FFD200'. Six hex digits only: the result is
320
+ written into libass styles and drawtext/color options, so a six-letter colour name ("yellow")
321
+ used to pass the length check and draw black, reported verified, and a quote or comma reached
322
+ the filter graph."""
317
323
  v = str(value).strip().lstrip("#")
318
324
  if v.lower().startswith("0x"):
319
325
  v = v[2:]
320
- if len(v) != 6:
321
- die(f"colour must be RRGGBB, got '{value}'")
326
+ if not _HEX6.match(v):
327
+ die(f"colour must be six hex digits RRGGBB (e.g. FFD200 or #ffd200), got '{value}'; colour names are not "
328
+ "accepted here")
322
329
  return v.upper()
323
330
 
324
331
 
@@ -337,3 +344,58 @@ def validate_color(value: str, flag: str = "--color") -> str:
337
344
  if not _COLOR_TOKEN_RE.match(value):
338
345
  die(f"{flag} must be a plain colour (a name, 0xRRGGBB[AA], or #RRGGBB[AA], optionally @alpha), got '{value}'")
339
346
  return value
347
+
348
+
349
+ # The tonemap filter reads linear light in units of 100 nits (1.0 = SDR white; its default peak,
350
+ # 10, is 1000 nits), so the linearising zscale uses npl=100. Through 3.6.0 it used npl=--peak:
351
+ # a 1000-nit pixel came out as 1.0, which tonemap took as 100 nits, so nothing reached SDR white
352
+ # (PQ 1000 nits and HLG 100 % -> Y 151 of 235, HLG reference white 91) and raising --peak to a
353
+ # brighter master's darkened the picture further. zimg's HLG decode assumes a 1000-nit display,
354
+ # so an HLG picture always peaks at 1000 nits there, whatever --peak says.
355
+ TONEMAP_REFERENCE_NITS = 100.0
356
+ HLG_PEAK_NITS = 1000.0
357
+
358
+
359
+ def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float = 1000.0, desat: float = 0.0) -> str:
360
+ """PQ / HLG / Dolby Vision to BT.709 SDR: linearise at 100 nits per unit, tone-map the source
361
+ peak (`peak` nits for PQ, 1000 for HLG) to SDR white, re-encode as BT.709 limited range."""
362
+ v = meta["video"]
363
+ trc = v.get("color_transfer") or "smpte2084"
364
+ prim = v.get("color_primaries") or "bt2020"
365
+ space = v.get("color_space") or "bt2020nc"
366
+ source_peak = HLG_PEAK_NITS if trc == "arib-std-b67" else peak
367
+ # zscale needs explicit input tags when the file lacks them
368
+ chain: List[str] = [
369
+ f"zscale=tin={trc}:pin={prim}:min={space}:rin={v.get('color_range') or 'tv'}:t=linear"
370
+ f":npl={TONEMAP_REFERENCE_NITS:g}",
371
+ "format=gbrpf32le",
372
+ "zscale=p=bt709",
373
+ f"tonemap=tonemap={tonemap}:desat={desat:g}:peak={source_peak / TONEMAP_REFERENCE_NITS:g}",
374
+ "zscale=t=bt709:m=bt709:r=tv",
375
+ "format=yuv420p",
376
+ ]
377
+ return ",".join(chain)
378
+
379
+
380
+ def bt2020_sdr_to_bt709_chain(meta: dict) -> str:
381
+ """Gamut conversion only, for BT.2020 primaries on an SDR transfer (probe: `bt2020_or_hdr`
382
+ true, `hdr_signal` false). Such a file is already display-referred SDR: linearising it with
383
+ its own transfer, mapping the primaries to BT.709 and re-applying the BT.709 transfer keeps
384
+ white at white and grey at grey. The tone map the HDR chain adds would treat 100 % SDR white
385
+ as a fraction of a 1000-nit peak and darken the picture (white Y 235 -> 151, grey 126 -> 90)."""
386
+ v = meta["video"]
387
+ trc = v.get("color_transfer") or "bt709"
388
+ space = v.get("color_space") or "bt2020nc"
389
+ return ",".join([
390
+ f"zscale=tin={trc}:pin=bt2020:min={space}:rin={v.get('color_range') or 'tv'}:t=linear",
391
+ "format=gbrpf32le",
392
+ "zscale=p=bt709",
393
+ "zscale=t=bt709:m=bt709:r=tv",
394
+ "format=yuv420p",
395
+ ])
396
+
397
+
398
+ def is_bt2020_sdr(v: dict) -> bool:
399
+ """BT.2020 primaries on a non-HDR transfer and no Dolby Vision: gamut-only --to-sdr."""
400
+ return bool(v.get("bt2020_or_hdr")) and not v.get("hdr_signal") and not v.get("dolby_vision") \
401
+ and v.get("color_transfer") not in ("smpte2084", "arib-std-b67")
@@ -148,7 +148,15 @@ def concat_list_line(path: str) -> str:
148
148
  """One `file '...'` line for the concat demuxer. The demuxer reads backslash as an escape
149
149
  inside the quoted form, so a Windows path (C:\\Users\\...\\part000.mp4) must be written
150
150
  with forward slashes -- ffmpeg opens either spelling on Windows -- and a single quote in the
151
- name is closed, escaped and reopened. Shared by cut.py (multi-segment) and sequence.py."""
151
+ name is closed, escaped and reopened. Shared by cut.py (multi-segment) and sequence.py.
152
+
153
+ A line break has no escape in this format: the demuxer reads each line as its own directive,
154
+ so a name containing one would add `file` entries to the list and pull in files the caller
155
+ never named. Such a path is refused."""
156
+ if "\n" in str(path) or "\r" in str(path):
157
+ refuse("input", "media_path_has_line_break",
158
+ f"{str(path)!r} contains a line break, which ffmpeg's concat list cannot hold (each line is a "
159
+ "directive of its own); rename the file", path=str(path))
152
160
  # only on Windows: on POSIX the quoted form takes a backslash literally, and it is part of the name (#408)
153
161
  escaped = (str(path).replace("\\", "/") if _on_windows() else str(path)).replace("'", "'\\''")
154
162
  return f"file '{escaped}'"
@@ -455,6 +463,20 @@ def cfr_args(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> Lis
455
463
  return ["-fps_mode", "cfr", "-r", f"{rate:g}"]
456
464
 
457
465
 
466
+ def delivered_rate(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> Optional[float]:
467
+ """The constant rate cfr_args() forces on the output (its -r), or None when it forces none and
468
+ the output keeps the source's rate -- what a start timecode is counted at
469
+ (timeline.output_timecode_args)."""
470
+ args = cfr_args(meta, fps)
471
+ return float(args[-1]) if args else None
472
+
473
+
474
+ # how far before a cut an input seek lands, so a start inside the B-frame reorder delay before a
475
+ # keyframe still decodes from the keyframe BEFORE it; the margin is dropped on the output side
476
+ # (cut.py, sync.py --trim-second)
477
+ SEEK_MARGIN = 1.0
478
+
479
+
458
480
  # ------------------------------------------------------- VideoToolbox (--hw)
459
481
  #
460
482
  # CRF -> VideoToolbox -q:v (1-100, higher = better): at each CRF the highest -q:v whose SSIM matched
@@ -856,6 +878,26 @@ def rgb_chain_to_bt709(line_meta: Optional[Dict[str, Any]], pix_fmt: str = "yuva
856
878
  return "," + rgb_to_bt709_filter(pix_fmt)
857
879
 
858
880
 
881
+ def overlay_format(meta: Optional[Dict[str, Any]]) -> str:
882
+ """":format=..." for an `overlay` whose main picture is the one `meta` probes, or "".
883
+
884
+ overlay's default format is `yuv420`, which is 8-bit, and `format=auto` picks an 8-bit one as
885
+ soon as the layer is 8-bit, so through 3.6.0 a cutaway, a redaction, a graphics layer or a PiP
886
+ over a 10-bit HDR or ProRes source took the whole picture to 8 bits (and `overlay --image`'s
887
+ RGBA logo took it through 8-bit RGB) before the 10-bit encoder. On a line that keeps more than
888
+ 8 bits (HDR/BT.2020, --codec prores) and a main picture deeper than 8 bits the overlay runs at
889
+ 10 bits in the main's own chroma subsampling, and an 8-bit layer is widened to meet it (bit
890
+ depth only, no matrix). An alpha line keeps the format its tool chose."""
891
+ v = (meta or {}).get("video") or {}
892
+ if STATE.alpha_line is not None or (v.get("bit_depth") or 8) <= 8:
893
+ return ""
894
+ if not (v.get("bt2020_or_hdr") or keeps_source_tags()):
895
+ return ""
896
+ pix = v.get("pix_fmt") or ""
897
+ sub = "444" if "444" in pix else "422" if "422" in pix else "420"
898
+ return f":format=yuv{sub}p10"
899
+
900
+
859
901
  def draws_chroma(colours: Sequence[Any], line_meta: Optional[Dict[str, Any]]) -> bool:
860
902
  """True when one of `colours` has chroma (is_neutral_color) and the line video_args(line_meta)
861
903
  tags the output BT.709 -- when a drawn colour needs drawn_chain() at all."""
@@ -1004,6 +1046,52 @@ def load_brand(path: Optional[str]) -> Dict[str, Any]:
1004
1046
  return brand
1005
1047
 
1006
1048
 
1049
+ # Every key of a render.py project that names a file. render.py reads each one relative to the
1050
+ # project file (its rel()); a copy of the project written anywhere else -- batch.py's per-item
1051
+ # project in its work directory, multicam.py --write-project -- has to carry them absolute, or
1052
+ # render looks for "bed.wav" beside the copy. A path is (section, key): section None is the
1053
+ # project's top level, "x[]" each entry of the list x. tests/test_contract.py checks that every
1054
+ # rel() in render.py reads one of these.
1055
+ PROJECT_PATH_KEYS = (
1056
+ (None, "output"), (None, "brand"), (None, "chapters"),
1057
+ ("clips[]", "src"), ("clips[].snap", "source"), ("snap", "source"),
1058
+ ("audio", "music"), ("audio", "replace"), ("audio", "effects"),
1059
+ ("captions", "text"), ("captions", "srt"), ("captions", "ass"),
1060
+ ("overlays[]", "image"), ("overlays[]", "video"), ("overlays[]", "font_file"),
1061
+ ("audiogram", "image"),
1062
+ )
1063
+
1064
+
1065
+ def rebase_project_paths(proj: Dict[str, Any], base: "os.PathLike[str] | str") -> Dict[str, Any]:
1066
+ """A copy of a render.py project whose relative file paths (PROJECT_PATH_KEYS) are joined onto
1067
+ `base`, the directory they were written relative to; absolute paths, URLs, non-strings, inline
1068
+ chapter lists and keys the project does not have are left as they are."""
1069
+ import copy
1070
+ out = copy.deepcopy(proj)
1071
+ base = Path(base)
1072
+
1073
+ def fix(obj: Any, key: str) -> None:
1074
+ value = obj.get(key) if isinstance(obj, dict) else None
1075
+ # a URL is left for the tool to refuse by what it is (waveform.py: no network access)
1076
+ if isinstance(value, str) and value and not os.path.isabs(value) and "://" not in value:
1077
+ obj[key] = str(base / value)
1078
+
1079
+ def nodes(section: "Optional[str]") -> List[Any]:
1080
+ found: List[Any] = [out]
1081
+ for part in (section.split(".") if section else []):
1082
+ name, many = (part[:-2], True) if part.endswith("[]") else (part, False)
1083
+ nxt: List[Any] = []
1084
+ for node in found:
1085
+ value = node.get(name) if isinstance(node, dict) else None
1086
+ nxt.extend(value if many and isinstance(value, list) else [] if many else [value])
1087
+ found = nxt
1088
+ return found
1089
+ for section, key in PROJECT_PATH_KEYS:
1090
+ for node in nodes(section):
1091
+ fix(node, key)
1092
+ return out
1093
+
1094
+
1007
1095
  def brand_states_font(brand: Dict[str, Any]) -> bool:
1008
1096
  """Did the brand FILE actually name a font (top-level `font`, `caption.font` or
1009
1097
  `styles.caption.font`)? BRAND_DEFAULTS always supplies one, so the merged document can never
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
  import json
9
9
  import math
10
10
  import os
11
+ import stat
11
12
  import sys
12
13
  from typing import Any, Dict, List, Optional, Sequence, Tuple
13
14
  from _common.runner import Context, ERROR_CODE, ERROR_RETRYABLE, STATE
@@ -208,6 +209,14 @@ def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -
208
209
  continue
209
210
  if item in ("audio_channels", "audio_language") and not out["has_audio"]:
210
211
  continue
212
+ if item == "timecode":
213
+ # the value, not only its presence: a cut from 1 s that wrote the source's 01:00:00:00
214
+ # on frame 30 kept a timecode that lies. A tool that moved the picture's start names
215
+ # the label it wrote (STATE.expected_timecode, "" for none); any other keeps the source's
216
+ want = getattr(STATE, "expected_timecode", None)
217
+ want = src.get("timecode_value") if want is None else want
218
+ (kept if want and out.get("timecode_value") == want else dropped).append(item)
219
+ continue
211
220
  (kept if out.get(item, 0) >= src[item] else dropped).append(item)
212
221
  if not kept and not dropped:
213
222
  return None
@@ -319,7 +328,28 @@ def audit(event: str, details: Dict[str, Any], *, default_path: Optional[str] =
319
328
  rec["plan_sha256"] = plan_sha256
320
329
  rec["details"] = details
321
330
  try:
322
- with open(path, "a", encoding="utf-8") as f:
331
+ # a default path is a hidden name in someone's output directory: never appended to through a
332
+ # symlink planted there. A log the caller named (--audit-log) is opened as named.
333
+ flags = os.O_WRONLY | os.O_APPEND | os.O_CREAT | (0 if ctx.audit_log else getattr(os, "O_NOFOLLOW", 0))
334
+ checked = not ctx.audit_log and not hasattr(os, "O_NOFOLLOW")
335
+ if checked and os.path.islink(path):
336
+ raise OSError(f"{path} is a symbolic link")
337
+ fd = os.open(path, flags, 0o666)
338
+ if checked:
339
+ # no O_NOFOLLOW (Windows): a link that appeared between the look above and the open is
340
+ # caught after it -- the descriptor must be the file now at that name, not a link's
341
+ # target -- and nothing is written through it
342
+ try:
343
+ st, at = os.fstat(fd), os.lstat(path)
344
+ # st_nlink: a link swapped for a hard link to its target before the lstat would
345
+ # otherwise pass the identity check; a log this tool created has one name
346
+ if (stat.S_ISLNK(at.st_mode) or (st.st_dev, st.st_ino) != (at.st_dev, at.st_ino)
347
+ or not stat.S_ISREG(st.st_mode) or st.st_nlink != 1):
348
+ raise OSError(f"{path} was replaced by a link while it was opened")
349
+ except BaseException:
350
+ os.close(fd)
351
+ raise
352
+ with os.fdopen(fd, "a", encoding="utf-8") as f:
323
353
  f.write(json.dumps(json_safe(rec), ensure_ascii=False, sort_keys=True) + "\n")
324
354
  except OSError as exc:
325
355
  warn(f"could not append the {event} record to the audit log {path} ({exc}); the run is unaffected",