ffmpeg-skill 3.6.1 → 3.6.3

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
@@ -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,
@@ -344,3 +344,58 @@ def validate_color(value: str, flag: str = "--color") -> str:
344
344
  if not _COLOR_TOKEN_RE.match(value):
345
345
  die(f"{flag} must be a plain colour (a name, 0xRRGGBB[AA], or #RRGGBB[AA], optionally @alpha), got '{value}'")
346
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")
@@ -463,6 +463,20 @@ def cfr_args(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> Lis
463
463
  return ["-fps_mode", "cfr", "-r", f"{rate:g}"]
464
464
 
465
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
+
466
480
  # ------------------------------------------------------- VideoToolbox (--hw)
467
481
  #
468
482
  # CRF -> VideoToolbox -q:v (1-100, higher = better): at each CRF the highest -q:v whose SSIM matched
@@ -864,6 +878,26 @@ def rgb_chain_to_bt709(line_meta: Optional[Dict[str, Any]], pix_fmt: str = "yuva
864
878
  return "," + rgb_to_bt709_filter(pix_fmt)
865
879
 
866
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
+
867
901
  def draws_chroma(colours: Sequence[Any], line_meta: Optional[Dict[str, Any]]) -> bool:
868
902
  """True when one of `colours` has chroma (is_neutral_color) and the line video_args(line_meta)
869
903
  tags the output BT.709 -- when a drawn colour needs drawn_chain() at all."""
@@ -1012,6 +1046,52 @@ def load_brand(path: Optional[str]) -> Dict[str, Any]:
1012
1046
  return brand
1013
1047
 
1014
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
+
1015
1095
  def brand_states_font(brand: Dict[str, Any]) -> bool:
1016
1096
  """Did the brand FILE actually name a font (top-level `font`, `caption.font` or
1017
1097
  `styles.caption.font`)? BRAND_DEFAULTS always supplies one, so the merged document can never
@@ -209,6 +209,14 @@ def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -
209
209
  continue
210
210
  if item in ("audio_channels", "audio_language") and not out["has_audio"]:
211
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
212
220
  (kept if out.get(item, 0) >= src[item] else dropped).append(item)
213
221
  if not kept and not dropped:
214
222
  return None
@@ -18,9 +18,9 @@ from _common.runner import safe_which as _safe_which
18
18
  # separately from detect_script() so a cue's font resolution is still decided by its letters.
19
19
  EMOJI_RANGES = (
20
20
  (0x1F300, 0x1FAFF), # symbols & pictographs, supplemental, extended-A
21
- (0x1F000, 0x1F0FF), # mahjong/domino/playing cards
22
- (0x2600, 0x27BF), # misc symbols + dingbats
23
- (0x2B00, 0x2BFF), # misc symbols and arrows
21
+ (0x1F000, 0x1F0FF), # mahjong/domino/playing cards (only 🀄 and 🃏 are emoji)
22
+ (0x2600, 0x27BF), # misc symbols + dingbats (Emoji_Presentation only: _is_emoji_char)
23
+ (0x2B00, 0x2BFF), # misc symbols and arrows (likewise)
24
24
  (0xFE0F, 0xFE0F), # VS16 (emoji presentation selector)
25
25
  (0x1F1E6, 0x1F1FF), # regional indicators (flags)
26
26
  (0x20E3, 0x20E3), # combining enclosing keycap
@@ -52,19 +52,80 @@ _KEYCAP = 0x20E3
52
52
  _KEYCAP_BASES = frozenset("0123456789#*")
53
53
 
54
54
 
55
+ def _cps(*spans) -> "frozenset":
56
+ out = set()
57
+ for span in spans:
58
+ lo, hi = span if isinstance(span, tuple) else (span, span)
59
+ out.update(range(lo, hi + 1))
60
+ return frozenset(out)
61
+
62
+
63
+ # The text-symbol blocks in EMOJI_RANGES (U+2600-27BF, U+2B00-2BFF, U+1F000-1F0FF) are mostly
64
+ # *text*: ♪ U+266A, ✓ U+2713 and ★ U+2605 are not emoji at all in Unicode's emoji-data.txt, and
65
+ # ♥ U+2665, ✔ U+2714, ☺ U+263A are emoji only when VS16 asks for the picture -- their default
66
+ # presentation is the character, which the caption font draws. Counting the whole block made
67
+ # SDH music notes and check marks "emoji", and `--emoji none` (what macOS and a Linux box
68
+ # without a colour face get under auto) cut them out of the burned text. In those blocks only
69
+ # Emoji_Presentation=Yes characters start a cluster on their own; an Emoji=Yes one does when
70
+ # VS16 or a skin-tone modifier follows it. Pinned from emoji-data.txt (Unicode 15.1); the
71
+ # pictograph planes keep their block rule.
72
+ _EMOJI_PRESENTATION_SYMBOLS = _cps(
73
+ (0x2614, 0x2615), (0x2648, 0x2653), 0x267F, 0x2693, 0x26A1, (0x26AA, 0x26AB),
74
+ (0x26BD, 0x26BE), (0x26C4, 0x26C5), 0x26CE, 0x26D4, 0x26EA, (0x26F2, 0x26F3), 0x26F5,
75
+ 0x26FA, 0x26FD, 0x2705, (0x270A, 0x270B), 0x2728, 0x274C, 0x274E, (0x2753, 0x2755), 0x2757,
76
+ (0x2795, 0x2797), 0x27B0, 0x27BF, (0x2B1B, 0x2B1C), 0x2B50, 0x2B55, 0x1F004, 0x1F0CF)
77
+ _EMOJI_TEXT_DEFAULT_SYMBOLS = _cps(
78
+ (0x2600, 0x2604), 0x260E, 0x2611, 0x2618, 0x261D, 0x2620, (0x2622, 0x2623), 0x2626, 0x262A,
79
+ (0x262E, 0x262F), (0x2638, 0x263A), 0x2640, 0x2642, (0x265F, 0x2660), 0x2663,
80
+ (0x2665, 0x2666), 0x2668, 0x267B, 0x267E, (0x2692, 0x2697), 0x2699, (0x269B, 0x269C),
81
+ 0x26A0, 0x26A7, (0x26B0, 0x26B1), 0x26C8, 0x26CF, 0x26D1, 0x26D3, 0x26E9, (0x26F0, 0x26F1),
82
+ 0x26F4, (0x26F7, 0x26F9), 0x2702, (0x2708, 0x2709), (0x270C, 0x270D), 0x270F, 0x2712, 0x2714,
83
+ 0x2716, 0x271D, 0x2721, (0x2733, 0x2734), 0x2744, 0x2747, (0x2763, 0x2764), 0x27A1,
84
+ (0x2B05, 0x2B07))
85
+ _SYMBOL_BLOCKS = ((0x2600, 0x27BF), (0x2B00, 0x2BFF), (0x1F000, 0x1F0FF))
86
+
87
+
88
+ def _in_symbol_block(cp: int) -> bool:
89
+ return any(lo <= cp <= hi for lo, hi in _SYMBOL_BLOCKS)
90
+
91
+
55
92
  def _is_emoji_char(ch: str) -> bool:
93
+ """Is this character drawn as an emoji with nothing after it? In the text-symbol blocks only
94
+ the Emoji_Presentation characters are; ♪ ✓ ★ ♥ are text (see _EMOJI_PRESENTATION_SYMBOLS)."""
56
95
  cp = ord(ch)
96
+ if _in_symbol_block(cp):
97
+ return cp in _EMOJI_PRESENTATION_SYMBOLS
57
98
  return any(lo <= cp <= hi for lo, hi in EMOJI_RANGES)
58
99
 
59
100
 
101
+ def _can_be_emoji(ch: str) -> bool:
102
+ """Emoji=Yes: an emoji by default, or a text-default symbol VS16 can turn into one (♥ ✔)."""
103
+ return _is_emoji_char(ch) or ord(ch) in _EMOJI_TEXT_DEFAULT_SYMBOLS
104
+
105
+
60
106
  def _is_emoji_base(ch: str) -> bool:
61
107
  """Can this character START an emoji cluster? Pictographs and regional indicators can;
62
108
  the joiners and modifiers (ZWJ, VS16, keycap, skin tone) never can -- they only bind to an
63
109
  emoji base that already stands before them. Without this, a ZWJ or a VS16 sitting after an
64
- ordinary letter turned that letter into "an emoji" and the PNG route replaced it with a gap."""
110
+ ordinary letter turned that letter into "an emoji" and the PNG route replaced it with a gap.
111
+ A text-default symbol (♥ ✔ ☝) is a base only with the VS16 or modifier that follows it:
112
+ emoji_clusters() checks that, this function answers for the character alone."""
65
113
  return ord(ch) not in _EMOJI_TAIL and _is_emoji_char(ch)
66
114
 
67
115
 
116
+ def _starts_emoji(text: str, i: int) -> bool:
117
+ """Does an emoji cluster start at text[i]? A default-emoji base does; a text-default symbol
118
+ does only when VS16 or a skin-tone modifier follows it (♥️, ☝🏽), the sequences Unicode
119
+ defines as emoji; ♥ alone, and ♪ ✓ ★ always, are characters the font draws."""
120
+ ch = text[i]
121
+ if _is_emoji_base(ch):
122
+ return True
123
+ if ord(ch) in _EMOJI_TEXT_DEFAULT_SYMBOLS and i + 1 < len(text):
124
+ nxt = ord(text[i + 1])
125
+ return nxt == _VS16 or 0x1F3FB <= nxt <= 0x1F3FF
126
+ return False
127
+
128
+
68
129
  def emoji_clusters(text: str) -> "List[Tuple[int, str]]":
69
130
  """(index in `text`, cluster) for every emoji in it, ZWJ sequences, VS16, keycaps, flag pairs
70
131
  and skin-tone modifiers kept together -- 👩‍💻 is one cluster, not three, and 1️⃣ starts at the
@@ -74,7 +135,9 @@ def emoji_clusters(text: str) -> "List[Tuple[int, str]]":
74
135
  base (`0-9 # *`) that is actually followed by U+20E3. A ZWJ is glue *inside* a cluster, never
75
136
  a starter and never a tail on its own: `क्‍ष` (Hindi ka + virama + ZWJ + ssa) and `abc‍def`
76
137
  contain no emoji. A base explicitly marked with U+FE0E (VS15, text presentation) is likewise
77
- not an emoji -- the author asked for the character, not the picture.
138
+ not an emoji -- the author asked for the character, not the picture. A text-default symbol
139
+ (♥ ✔ ☝) starts one only with the VS16 or skin-tone modifier after it, and a symbol that is
140
+ not emoji at all (♪ ✓ ★) never does: the caption font draws those.
78
141
  """
79
142
  out: "List[Tuple[int, str]]" = []
80
143
  i = 0
@@ -82,7 +145,7 @@ def emoji_clusters(text: str) -> "List[Tuple[int, str]]":
82
145
  while i < n:
83
146
  ch = text[i]
84
147
  start = i
85
- if _is_emoji_base(ch):
148
+ if _starts_emoji(text, i):
86
149
  j = i + 1
87
150
  if j < n and ord(text[j]) == _VS15: # text presentation requested: not an emoji
88
151
  i = j + 1
@@ -104,7 +167,9 @@ def emoji_clusters(text: str) -> "List[Tuple[int, str]]":
104
167
  if cp in (_VS16, _KEYCAP) or 0x1F3FB <= cp <= 0x1F3FF:
105
168
  j += 1
106
169
  continue
107
- if cp == _ZWJ and j + 1 < n and _is_emoji_base(text[j + 1]):
170
+ if cp == _ZWJ and j + 1 < n and ord(text[j + 1]) not in _EMOJI_TAIL \
171
+ and _can_be_emoji(text[j + 1]):
172
+ # inside a ZWJ sequence a text-default symbol is emoji glue too: 🏃‍♀ ends in ♀
108
173
  j += 2
109
174
  continue
110
175
  if (j == start + 1 and ord(ch) in _EMOJI_REGIONAL and cp in _EMOJI_REGIONAL):
@@ -345,7 +410,7 @@ def resolve_emoji_assets(flag: "Optional[str]" = None, project: "Optional[str]"
345
410
 
346
411
 
347
412
 
348
- def emoji_filter_chain(plan, base_label, out_label, first_input=1, to_yuv="", base_chain=""):
413
+ def emoji_filter_chain(plan, base_label, out_label, first_input=1, to_yuv="", base_chain="", overlay_opts=""):
349
414
  """(chains, inputs) that composite the planned PNGs on top of `base_label`.
350
415
 
351
416
  `inputs` is a list of argv fragments, each ending in the asset path, to be appended to the
@@ -355,6 +420,8 @@ def emoji_filter_chain(plan, base_label, out_label, first_input=1, to_yuv="", ba
355
420
  converted to the BT.709 the output is tagged with instead of by overlay's BT.601 default;
356
421
  `base_chain` (label_chain(meta, meta): an untagged source's BT.709 label) goes in front of
357
422
  the first overlay, so 7.1+ does not convert the two pictures to one another.
423
+ `overlay_opts` ends each overlay's options: the caller's overlay_format(meta), so a 10-bit
424
+ picture is not composited at overlay's 8-bit default.
358
425
  """
359
426
  overlays = plan.get("overlays") or []
360
427
  if not overlays:
@@ -413,6 +480,6 @@ def emoji_filter_chain(plan, base_label, out_label, first_input=1, to_yuv="", ba
413
480
  # exactly what shipped first). eof_action=repeat (the default) holds the still for the
414
481
  # whole timeline; enable= is what confines it to the cue's window.
415
482
  chains.append(f"[{cur}][{label}]overlay=x={x}:y={o['y']}:"
416
- f"enable='between(t,{o['start']:.3f},{o['end']:.3f})'[{nxt}]")
483
+ f"enable='between(t,{o['start']:.3f},{o['end']:.3f})'{overlay_opts}[{nxt}]")
417
484
  cur = nxt
418
485
  return chains, inputs
@@ -412,6 +412,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
412
412
  "creation_time": 1 if any(k.lower() == "creation_time" for k in (fmt.get("tags") or {})) else 0,
413
413
  "audio_language": 1 if lang and lang.lower() != "und" else 0,
414
414
  "timecode": 1 if out["timecode"].get("value") else 0,
415
+ "timecode_value": out["timecode"].get("value"),
415
416
  "audio_channels": (_to_int(audio.get("channels")) or 0) if audio else 0,
416
417
  "has_video": bool(video), "has_audio": bool(audio),
417
418
  }
@@ -1281,7 +1282,7 @@ def signatures_match(sigs: list, ext: str) -> bool:
1281
1282
  return False
1282
1283
  else:
1283
1284
  codec = str(a.get("codec_name") or "")
1284
- if not (codec.startswith("pcm_") or codec in IN_BAND_CONFIG or ext in TS_EXTS):
1285
+ if not (codec.startswith("pcm_") or codec in IN_BAND_CONFIG or ext.lower() in TS_EXTS):
1285
1286
  return False
1286
1287
  return True
1287
1288