ffmpeg-skill 2.7.0 → 2.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/SKILL.md +5 -5
- package/docs/contract.md +15 -6
- package/package.json +1 -1
- package/references/gotchas.md +27 -5
- package/references/scripts.md +79 -5
- package/scripts/_common/__init__.py +6 -6
- package/scripts/_common/asr.py +53 -9
- package/scripts/_common/decision.py +13 -0
- package/scripts/_common/drawtext.py +4 -1
- package/scripts/_common/emit.py +12 -3
- package/scripts/_common/probe.py +92 -0
- package/scripts/_common/runner.py +54 -15
- package/scripts/_contract.py +39 -14
- package/scripts/batch.py +28 -11
- package/scripts/cut.py +1311 -53
- package/scripts/graphics.py +30 -7
- package/scripts/silence.py +32 -3
package/scripts/graphics.py
CHANGED
|
@@ -125,6 +125,10 @@ def main() -> int:
|
|
|
125
125
|
for _attr in ("name", "title", "subtitle", "text", "top", "bottom"):
|
|
126
126
|
_v = getattr(args, _attr, None)
|
|
127
127
|
if isinstance(_v, str):
|
|
128
|
+
# one line-break form for every route: libass draws a bare CR as nothing, drawtext
|
|
129
|
+
# ignores one trailing newline, and lines_of() counts the rest -- so the layout and the
|
|
130
|
+
# drawn lines agree only if the text is normalised here, once
|
|
131
|
+
_v = _v.replace("\r\n", "\n").replace("\r", "\n").rstrip("\n")
|
|
128
132
|
setattr(args, _attr, _v if _v.strip() else None)
|
|
129
133
|
|
|
130
134
|
args.platform = resolve_platform(args.platform)
|
|
@@ -295,6 +299,15 @@ def main() -> int:
|
|
|
295
299
|
slice_overlong=True, sliced=sliced_atoms) if para.strip() else [para])
|
|
296
300
|
return "\n".join(out)
|
|
297
301
|
|
|
302
|
+
def lines_of(text):
|
|
303
|
+
"""How many lines drawtext and libass draw for `text` (a trailing newline is not one)."""
|
|
304
|
+
return max(1, len(str(text or "").replace("\r\n", "\n").replace("\r", "\n").rstrip("\n").split("\n")))
|
|
305
|
+
|
|
306
|
+
def block_h(text, size_px):
|
|
307
|
+
"""The height the layouts below give a label: `size_px` for the first line, as they always
|
|
308
|
+
have, and a line pitch of 1.2 x the size for each further one."""
|
|
309
|
+
return size_px + (lines_of(text) - 1) * int(size_px * 1.2)
|
|
310
|
+
|
|
298
311
|
def add_text(text, drawtext, *, target=None, **el):
|
|
299
312
|
"""One line of template text: a drawtext filter on the old route, an ASS element on the
|
|
300
313
|
new one. The geometry is computed identically either way."""
|
|
@@ -314,7 +327,8 @@ def main() -> int:
|
|
|
314
327
|
h1 = int(base * 0.055)
|
|
315
328
|
h2 = int(base * 0.038)
|
|
316
329
|
pad = int(base * 0.02)
|
|
317
|
-
|
|
330
|
+
name_h = block_h(args.name, h1)
|
|
331
|
+
bar_h = name_h + (block_h(args.title, h2) + pad if args.title else 0) + pad * 2
|
|
318
332
|
bar_w = int(base * 0.62)
|
|
319
333
|
y0 = H - m_bottom - bar_h
|
|
320
334
|
# slide in from the left over 0.4 s, slide out over 0.3 s (overlay evaluates x per frame)
|
|
@@ -328,7 +342,7 @@ def main() -> int:
|
|
|
328
342
|
x_rest, x_off = m_left + tx_pad, -bar_w + tx_pad
|
|
329
343
|
draws = []
|
|
330
344
|
for text, fs, colour, ty in ((args.name, h1, text_c, y0 + pad),
|
|
331
|
-
(args.title, h2, primary, y0 + pad +
|
|
345
|
+
(args.title, h2, primary, y0 + pad + name_h + pad // 2)):
|
|
332
346
|
if not text:
|
|
333
347
|
continue
|
|
334
348
|
draws.append(f"drawtext={drawtext_text_opts(text)}:{fo}:fontsize={fs}:"
|
|
@@ -355,17 +369,23 @@ def main() -> int:
|
|
|
355
369
|
h1 = int(base * 0.11)
|
|
356
370
|
h2 = int(base * 0.045)
|
|
357
371
|
filters.append(f"drawbox=x=0:y=0:w=iw:h=ih:color={ff_color(bg, 0.55)}:t=fill:{en}")
|
|
372
|
+
# the title is centred as a block, so the bar and the subtitle sit below its last line:
|
|
373
|
+
# `title_h` is the block's height, as the one-line layout has always taken h1; a further
|
|
374
|
+
# subtitle line hangs below the first, whose position the one-line layout fixes
|
|
375
|
+
title_h = block_h(args.title, h1)
|
|
376
|
+
sub_y = title_h // 2 + int(base * 0.03)
|
|
377
|
+
sub_extra = block_h(args.subtitle, h2) - h2 if args.subtitle else 0
|
|
358
378
|
add_text(args.title,
|
|
359
379
|
f"drawtext={drawtext_text_opts(args.title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2-{h2 if args.subtitle else 0}:alpha='{fade_a}':{en}",
|
|
360
380
|
size=h1, color=text_c, font=ass_font_family(), align=5, x=W / 2,
|
|
361
381
|
y=H / 2 - (h2 if args.subtitle else 0), outline=max(1.0, h1 / 20.0),
|
|
362
382
|
outline_color="000000", start=s, end=e, fade=(300, 300))
|
|
363
|
-
filters.append(f"drawbox=x=(iw-{int(base * 0.12)})/2:y=(ih)/2+{
|
|
383
|
+
filters.append(f"drawbox=x=(iw-{int(base * 0.12)})/2:y=(ih)/2+{title_h // 2}:w={int(base * 0.12)}:h={max(2, int(base * 0.006))}:color={ff_color(primary)}:t=fill:{en}")
|
|
364
384
|
if args.subtitle:
|
|
365
385
|
add_text(args.subtitle,
|
|
366
|
-
f"drawtext={drawtext_text_opts(args.subtitle)}:{fo}:fontsize={h2}:fontcolor={ff_color(primary)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2+{
|
|
386
|
+
f"drawtext={drawtext_text_opts(args.subtitle)}:{fo}:fontsize={h2}:fontcolor={ff_color(primary)}{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2+{sub_y + sub_extra // 2}:alpha='{fade_a}':{en}",
|
|
367
387
|
size=h2, color=primary, font=ass_font_family(), align=5, x=W / 2,
|
|
368
|
-
y=H / 2 +
|
|
388
|
+
y=H / 2 + sub_y + sub_extra // 2, outline=max(1.0, h2 / 20.0),
|
|
369
389
|
outline_color="000000", start=s, end=e, fade=(300, 300))
|
|
370
390
|
|
|
371
391
|
elif args.template in ("chapter", "bug"):
|
|
@@ -439,10 +459,13 @@ def main() -> int:
|
|
|
439
459
|
hen = f"enable='between(t,{s:.3f},{he:.3f})'"
|
|
440
460
|
h1 = int(base * 0.085)
|
|
441
461
|
bar_h = max(3, int(base * 0.01))
|
|
442
|
-
|
|
462
|
+
hook_title = wrapped(args.title, h1)
|
|
463
|
+
# the base band (30% of the short side) holds two lines; a longer wrapped title (a 9:16
|
|
464
|
+
# hook is four or five) grows the band to the text plus 0.7 of a line of margin, instead of
|
|
465
|
+
# drawing white text on the bare video. The band is clipped at the frame edges.
|
|
466
|
+
band_h = min(H, max(int(base * 0.30), block_h(hook_title, h1) + int(h1 * 0.7)))
|
|
443
467
|
y0 = (H - band_h) // 2
|
|
444
468
|
filters.append(f"drawbox=x=0:y={y0}:w=iw:h={band_h}:color={ff_color(bg, 0.78)}:t=fill:{hen}")
|
|
445
|
-
hook_title = wrapped(args.title, h1)
|
|
446
469
|
add_text(hook_title,
|
|
447
470
|
f"drawtext={drawtext_text_opts(hook_title)}:{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}"
|
|
448
471
|
f"{drawtext_center_align()}:x=(w-text_w)/2:y=(h-text_h)/2:{hen}",
|
package/scripts/silence.py
CHANGED
|
@@ -141,9 +141,38 @@ def resolve_filler(args, meta):
|
|
|
141
141
|
"pass the result with --words.", kind="input")
|
|
142
142
|
|
|
143
143
|
lang = args.filler_lang
|
|
144
|
+
reported_lang = lang
|
|
144
145
|
if lang == "auto":
|
|
145
|
-
|
|
146
|
-
|
|
146
|
+
# the language the transcript says it is in: a --words document's own field, else what the
|
|
147
|
+
# engine was told or detected for this run (`--transcribe`). A language that is known and has
|
|
148
|
+
# no built-in list is refused, as --filler-lang names it: the English list would cut real
|
|
149
|
+
# words of that language (Norwegian "er" is "is"), and a transcript with no language at all
|
|
150
|
+
# (or one that says "auto", which is no language either) keeps the English list it always had
|
|
151
|
+
# the ISO 639-2 spellings of the languages that have a built-in list (the mux tag uses them)
|
|
152
|
+
iso3 = {"eng": "en", "spa": "es", "deu": "de", "ger": "de", "fra": "fr", "fre": "fr",
|
|
153
|
+
"ita": "it", "jpn": "ja", "por": "pt"}
|
|
154
|
+
|
|
155
|
+
def _code(value) -> str:
|
|
156
|
+
value = str(value or "").strip().lower().replace("_", "-")
|
|
157
|
+
if value in ("", "auto"):
|
|
158
|
+
return ""
|
|
159
|
+
# a regional tag names its primary language (ja-JP is ja); a three-letter code that is not
|
|
160
|
+
# in the table stays as it is (est is Estonian, not the Spanish "es"); a word such as
|
|
161
|
+
# "english" is cut to its two-letter code
|
|
162
|
+
primary = value.split("-")[0]
|
|
163
|
+
if primary in iso3:
|
|
164
|
+
return iso3[primary]
|
|
165
|
+
return primary[:2] if len(primary) > 3 else primary
|
|
166
|
+
spoken = _code((raw or {}).get("language")) if isinstance(raw, dict) else ""
|
|
167
|
+
spoken = spoken or _code(transcription.get("language") or transcription.get("detected_language"))
|
|
168
|
+
if spoken and spoken not in FILLER_WORDS and not args.filler_words:
|
|
169
|
+
die(f"the transcript is in {spoken}, which has no built-in filler list. The languages with one are "
|
|
170
|
+
f"{', '.join(sorted(FILLER_WORDS))}; pass --filler-words FILE with your own list for {spoken}, or "
|
|
171
|
+
"--filler-lang for a language that has one.", kind="input")
|
|
172
|
+
lang = spoken if spoken in FILLER_WORDS else "en"
|
|
173
|
+
# with a custom list the run goes ahead, and the result names the language it heard, not the
|
|
174
|
+
# English list it did not use
|
|
175
|
+
reported_lang = spoken or lang
|
|
147
176
|
listname = "builtin"
|
|
148
177
|
if args.filler_words:
|
|
149
178
|
wordlist = set(_word_list(read_text_or_die(args.filler_words, "--filler-words")))
|
|
@@ -174,7 +203,7 @@ def resolve_filler(args, meta):
|
|
|
174
203
|
warnings.append(f"--filler-extra {', '.join(extra_markers)}: a discourse marker, not a "
|
|
175
204
|
"disfluency -- this will cut real sentences.")
|
|
176
205
|
block = {
|
|
177
|
-
"lang":
|
|
206
|
+
"lang": reported_lang, "source": source, "engine": engine,
|
|
178
207
|
**({"transcription": transcription} if engine and transcription else {}),
|
|
179
208
|
"words": sorted(wordlist), "removed": [dict(s) for s in spans],
|
|
180
209
|
"removed_count": len(spans),
|