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.
@@ -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
- bar_h = h1 + (h2 + pad if args.title else 0) + pad * 2
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 + h1 + pad // 2)):
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+{h1 // 2 + (0 if args.subtitle else 0)}:w={int(base * 0.12)}:h={max(2, int(base * 0.006))}:color={ff_color(primary)}:t=fill:{en}")
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+{h1 // 2 + int(base * 0.03)}:alpha='{fade_a}':{en}",
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 + h1 // 2 + int(base * 0.03), outline=max(1.0, h2 / 20.0),
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
- band_h = int(base * 0.30)
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}",
@@ -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
- lang = str((raw or {}).get("language") or "").lower()[:2] if isinstance(raw, dict) else ""
146
- lang = lang if lang in FILLER_WORDS else "en"
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": lang, "source": source, "engine": engine,
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),