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.
- package/README.md +7 -5
- package/SKILL.md +1 -0
- package/docs/contract.md +37 -9
- 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 +235 -2
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/asr.py +3 -0
- package/scripts/_common/color.py +65 -3
- package/scripts/_common/decision.py +89 -1
- package/scripts/_common/emit.py +31 -1
- package/scripts/_common/emoji.py +76 -9
- package/scripts/_common/probe.py +2 -1
- package/scripts/_common/runner.py +280 -22
- package/scripts/_common/timeline.py +39 -0
- package/scripts/_common/wrap.py +92 -42
- package/scripts/_contract.py +45 -16
- package/scripts/audio.py +9 -3
- package/scripts/batch.py +158 -18
- package/scripts/broll.py +2 -2
- package/scripts/caption.py +46 -21
- package/scripts/check.py +1 -1
- package/scripts/color.py +2 -42
- package/scripts/cut.py +37 -9
- package/scripts/export.py +2 -2
- package/scripts/fit.py +25 -3
- package/scripts/graphics.py +11 -6
- 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 +101 -25
- 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
|
|
@@ -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",
|
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
|
@@ -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
|
|
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
|
package/scripts/_common/emit.py
CHANGED
|
@@ -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
|
-
|
|
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",
|