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.
- package/README.md +7 -5
- package/SKILL.md +1 -0
- package/docs/contract.md +37 -8
- package/mcp/server.py +28 -13
- package/package.json +1 -1
- package/references/gotchas.md +2 -1
- package/references/scripts.md +40 -14
- package/scripts/_ass_overlay.py +234 -1
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/asr.py +3 -0
- package/scripts/_common/color.py +55 -0
- package/scripts/_common/decision.py +80 -0
- package/scripts/_common/emit.py +8 -0
- package/scripts/_common/emoji.py +76 -9
- package/scripts/_common/probe.py +2 -1
- package/scripts/_common/runner.py +148 -7
- package/scripts/_common/timeline.py +39 -0
- package/scripts/_common/wrap.py +92 -42
- package/scripts/_contract.py +46 -15
- package/scripts/audio.py +9 -3
- package/scripts/batch.py +149 -11
- package/scripts/broll.py +2 -2
- package/scripts/caption.py +44 -19
- package/scripts/check.py +1 -1
- package/scripts/color.py +2 -42
- package/scripts/cut.py +37 -9
- package/scripts/fit.py +25 -3
- package/scripts/graphics.py +11 -6
- package/scripts/join.py +135 -14
- package/scripts/look.py +31 -12
- package/scripts/multicam.py +6 -1
- package/scripts/overlay.py +20 -7
- package/scripts/redact.py +2 -2
- package/scripts/render.py +30 -23
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +6 -2
- package/scripts/sync.py +28 -4
package/scripts/_ass_overlay.py
CHANGED
|
@@ -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
|
-
|
|
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",
|
package/scripts/_common/asr.py
CHANGED
|
@@ -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,
|
package/scripts/_common/color.py
CHANGED
|
@@ -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
|
package/scripts/_common/emit.py
CHANGED
|
@@ -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
|
package/scripts/_common/emoji.py
CHANGED
|
@@ -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 `abcdef`
|
|
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
|
|
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
|
|
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
|
package/scripts/_common/probe.py
CHANGED
|
@@ -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
|
|