ffmpeg-skill 1.14.0 → 1.15.0

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.
@@ -768,11 +768,11 @@ def doctor() -> Dict[str, Any]:
768
768
  "ok": not missing_required and not unknown_required,
769
769
  "tools": _tool_usability(state),
770
770
  "gpu_encoders": _gpu_encoders(listings["encoders"]),
771
- "fonts": _fonts_capability(),
771
+ "fonts": _fonts_capability(probe=True),
772
772
  }
773
773
 
774
774
 
775
- def _fonts_capability() -> Dict[str, Any]:
775
+ def _fonts_capability(probe: bool = True) -> Dict[str, Any]:
776
776
  """The default drawtext family (issue #66) plus, since 1.12, one entry per script the tools
777
777
  can detect: which languages this machine can actually RENDER, not just which filters exist.
778
778
 
@@ -781,7 +781,7 @@ def _fonts_capability() -> Dict[str, Any]:
781
781
  here follows). Informational like the default font and gpu_encoders: a machine with no Thai
782
782
  font is not a broken install, it is a machine that must not be asked to burn Thai captions.
783
783
  """
784
- from _common import SCRIPTS, font_for_script, script_font_status
784
+ from _common import SCRIPTS, font_for_script, script_font_status, emoji_support
785
785
 
786
786
  font = _default_font()
787
787
  result = _font_available(font)
@@ -794,7 +794,12 @@ def _fonts_capability() -> Dict[str, Any]:
794
794
  # distinction the tools refuse or continue on.
795
795
  status = script_font_status(script)
796
796
  scripts[script] = {"status": status, "file": font_for_script(script) if status == "available" else None}
797
- return {"default_font": font, "status": result["status"], "detail": result["detail"], "scripts": scripts}
797
+ # 1.15: emoji are a separate question from the writing system, and the only honest answer is a
798
+ # render (an installed colour emoji font proves nothing -- libass on this build may still draw
799
+ # it monochrome). `probe=False` -- `contract --json --static`, and every static/JSON-only path -- skips
800
+ # that render, exactly as it skips the rest of the environment detection.
801
+ return {"default_font": font, "status": result["status"], "detail": result["detail"],
802
+ "scripts": scripts, "emoji": emoji_support(probe=probe)}
798
803
 
799
804
 
800
805
  def _fonts_summary_line(fonts: Dict[str, Any]) -> str:
@@ -819,6 +824,16 @@ def _fonts_summary_line(fonts: Dict[str, Any]) -> str:
819
824
  parts.append("no font for " + " ".join(by_state["missing"]))
820
825
  if by_state["unknown"]:
821
826
  parts.append("unknown (no fontconfig) " + " ".join(by_state["unknown"]))
827
+ emoji = fonts.get("emoji") or {}
828
+ mode = emoji.get("mode")
829
+ if mode == "color":
830
+ parts.append("emoji colour")
831
+ elif mode == "png":
832
+ parts.append("emoji colour (assets)")
833
+ elif mode == "mono":
834
+ parts.append("emoji monochrome -- --emoji-assets DIR for colour")
835
+ elif mode:
836
+ parts.append("emoji none")
822
837
  return "; ".join(parts)
823
838
 
824
839
 
@@ -38,10 +38,11 @@ import re
38
38
  import sys
39
39
  import unicodedata
40
40
  from pathlib import Path
41
- from typing import List, Optional, Tuple
41
+ from typing import Dict, List, Optional, Tuple
42
42
 
43
43
  from _platforms import PLATFORMS, PLATFORM_CHOICES, ass_units, resolve as resolve_platform
44
- from _common import STATE, brand_states_font, char_script, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs
44
+ from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
45
+ from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, LEADING_VOWELS, NO_SPACE_SCRIPTS, _char_em, _is_mark, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, char_script, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs
45
46
 
46
47
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
47
48
 
@@ -298,89 +299,41 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
298
299
  return out
299
300
 
300
301
 
301
- # --------------------------------------------------------------------------- readable cues (1.12)
302
- # Average advance width per character, in em (a fraction of the font size). Proportional Latin text
303
- # averages a bit over half an em; CJK and Thai are drawn on a full-width grid; Arabic/Hebrew and
304
- # Devanagari sit in between. These are deliberately averages, not per-glyph metrics: measuring the
305
- # real advance needs a font parser (no stdlib one) and would still be wrong for libass's own
306
- # shaping, while a cue wrapped from an average is right to within a character on every line.
307
- # (Latin is measured per character from LATIN_EM below, not from this average.)
308
- ADVANCE_EM = {"ja": 1.0, "zh": 1.0, "ko": 1.0, "th": 1.0, "hi": 0.7, "ar": 0.6, "he": 0.6,
309
- "ru": 0.55, "el": 0.55, "latin": 0.55}
310
302
  # How much of the frame width a caption line may use. libass's own default SRT margins are 10 of a
311
303
  # 384-wide script (2.6 % a side); 5 % a side is the safe area every platform check in this repo uses.
312
304
  SAFE_WIDTH_FRACTION = 0.9
313
- # Scripts written without spaces: a line breaks between any two characters.
314
- NO_SPACE_SCRIPTS = ("ja", "zh", "ko", "th")
315
- # Per-character Latin advances in em, read off DejaVu Sans (the default caption family, and close
316
- # enough to any other proportional sans for a wrap) and rounded UP: a capital runs 0.56-0.99 em
317
- # against the single 0.55 average that used to stand for all of Latin, so an all-caps caption --
318
- # the style most burn-ins use -- overflowed the safe area and was silently re-wrapped by libass
319
- # past --max-lines. Rounding up is the safe direction: libass re-wraps a too-long line, it never
320
- # un-wraps a short one. Characters outside the table fall back by class (0.7 uppercase/digit,
321
- # 0.57 lowercase and anything else Latin-ish).
322
- LATIN_EM = {
323
- ' ': 0.32, '!': 0.41, '"': 0.46, '#': 0.84, '$': 0.64, '%': 0.96, '&': 0.78, "'": 0.28,
324
- '(': 0.4, ')': 0.4, '*': 0.5, '+': 0.84, ',': 0.32, '-': 0.37, '.': 0.32, '/': 0.34, '0': 0.64,
325
- '1': 0.64, '2': 0.64, '3': 0.64, '4': 0.64, '5': 0.64, '6': 0.64, '7': 0.64, '8': 0.64,
326
- '9': 0.64, ':': 0.34, ';': 0.34, '<': 0.84, '=': 0.84, '>': 0.84, '?': 0.54, '@': 1.0,
327
- 'A': 0.69, 'B': 0.69, 'C': 0.7, 'D': 0.78, 'E': 0.64, 'F': 0.58, 'G': 0.78, 'H': 0.76,
328
- 'I': 0.3, 'J': 0.3, 'K': 0.66, 'L': 0.56, 'M': 0.87, 'N': 0.75, 'O': 0.79, 'P': 0.61,
329
- 'Q': 0.79, 'R': 0.7, 'S': 0.64, 'T': 0.62, 'U': 0.74, 'V': 0.69, 'W': 0.99, 'X': 0.69,
330
- 'Y': 0.62, 'Z': 0.69, '[': 0.4, '\\': 0.34, ']': 0.4, '^': 0.84, '_': 0.5, '`': 0.5, 'a': 0.62,
331
- 'b': 0.64, 'c': 0.55, 'd': 0.64, 'e': 0.62, 'f': 0.36, 'g': 0.64, 'h': 0.64, 'i': 0.28,
332
- 'j': 0.28, 'k': 0.58, 'l': 0.28, 'm': 0.98, 'n': 0.64, 'o': 0.62, 'p': 0.64, 'q': 0.64,
333
- 'r': 0.42, 's': 0.53, 't': 0.4, 'u': 0.64, 'v': 0.6, 'w': 0.82, 'x': 0.6, 'y': 0.6, 'z': 0.53,
334
- '{': 0.64, '|': 0.34, '}': 0.64, '~': 0.84
335
- }
336
- # Thai and Lao write some vowels BEFORE the consonant they belong to: the break must not land
337
- # between them and the base that follows.
338
- LEADING_VOWELS = set(range(0x0E40, 0x0E45)) | set(range(0x0EC0, 0x0EC5))
339
-
340
-
341
- def _is_mark(ch: str) -> bool:
342
- """A character that hangs off the one before it: a combining mark (any script) or one of the
343
- Thai/Lao vowel signs and tone marks, which are Mn/Mc but carry no combining class."""
344
- return unicodedata.combining(ch) != 0 or unicodedata.category(ch) in ("Mn", "Mc")
345
-
346
-
347
- def _char_em(ch: str) -> float:
348
- # CJK punctuation and the fullwidth forms (、。,!? and U+FF01-FF60) are drawn on the same
349
- # full-width grid as the ideographs they sit between, even though they are not "Han" to a
350
- # script detector -- measuring them as Latin under-counts a wrapped CJK line by a character.
351
- cp = ord(ch)
352
- # A combining mark is drawn on top of (or under) its base and advances the pen by nothing:
353
- # charging it a full em wrapped Thai and Devanagari lines far shorter than they needed to be.
354
- if unicodedata.combining(ch) != 0 or unicodedata.category(ch) == "Mn":
355
- return 0.0
356
- if 0x3000 <= cp <= 0x303F or 0xFF01 <= cp <= 0xFF60 or 0xFFE0 <= cp <= 0xFFE6:
357
- return 1.0
358
- script = char_script(ch)
359
- if script == "latin":
360
- if ch in LATIN_EM:
361
- return LATIN_EM[ch]
362
- if ch.isupper() or ch.isdigit():
363
- return 0.7
364
- return 0.57
365
- return ADVANCE_EM.get(script, 0.55)
366
-
367
-
368
- def text_width_em(text: str) -> float:
369
- """Width of `text` in em, from the per-script average advance table."""
370
- return sum(_char_em(ch) for ch in text)
305
+ # ORPHAN_MIN_EM: one full-width CJK/Thai character plus a hair. A last line narrower than this is a
306
+ # single stranded character -- eval 14's th1 (a lone 'ล') and dl3 (a lone '行').
307
+ ORPHAN_MIN_EM = 1.1
371
308
 
372
309
 
373
310
  def _atoms(line: str) -> List[Tuple[str, bool]]:
374
311
  """Break a line into the smallest pieces a wrap may separate -- one atom per CJK/Thai
375
- character, one per whitespace-delimited word otherwise -- each with whether a space stood
376
- before it in the original. The flag is what puts the text back together exactly as written:
377
- "Hello 世界" keeps its space, "世界です" gains none."""
312
+ character, one per emoji cluster, one per whitespace-delimited word otherwise -- each with
313
+ whether a space stood before it in the original. The flag is what puts the text back together
314
+ exactly as written: "Hello 世界" keeps its space, "世界です" gains none."""
378
315
  out: List[Tuple[str, bool]] = []
379
316
  word = ""
380
317
  spaced = False # a space stands before the atom being built
381
318
  pending = False # a space stands before the NEXT atom
382
319
  attach_next = False # a leading Thai/Lao vowel is waiting for its base consonant
383
- for ch in line:
320
+ # An emoji cluster is one atom: a wrap must never land inside a ZWJ sequence, a flag pair or
321
+ # between a base and its skin-tone modifier (the same rule combining marks already follow).
322
+ clusters = {i: len(cl) for i, cl in emoji_clusters(line)}
323
+ i = 0
324
+ while i < len(line):
325
+ ch = line[i]
326
+ if i in clusters:
327
+ cluster = line[i:i + clusters[i]]
328
+ if word:
329
+ out.append((word, spaced))
330
+ word = ""
331
+ out.append((cluster, pending))
332
+ pending = False
333
+ attach_next = False
334
+ i += clusters[i]
335
+ continue
336
+ i += 1
384
337
  if char_script(ch) in NO_SPACE_SCRIPTS:
385
338
  if word:
386
339
  out.append((word, spaced))
@@ -414,29 +367,240 @@ def _join(left: str, atom: str, spaced: bool) -> str:
414
367
  return left + (" " if spaced else "") + atom
415
368
 
416
369
 
417
- def wrap_text(text: str, max_em: float) -> List[str]:
370
+ def _break_spaced(first: str, second: str) -> bool:
371
+ """Did a space stand at the break between these two wrapped lines? Only spaced scripts put one
372
+ there -- a CJK/Thai break sits between two characters that were written with nothing between
373
+ them, and re-joining them with a space would insert a character the cue never had."""
374
+ if not first or not second:
375
+ return False
376
+ return char_script(first[-1]) not in NO_SPACE_SCRIPTS and char_script(second[0]) not in NO_SPACE_SCRIPTS \
377
+ and char_script(first[-1]) != "emoji" and char_script(second[0]) != "emoji"
378
+
379
+
380
+ def _fix_orphans(lines: List[str], max_em: float) -> List[str]:
381
+ """No last line that is a single stranded atom.
382
+
383
+ Greedy wrapping leaves one character alone whenever the line before it filled exactly: eval 14
384
+ produced a Thai cue ending in a lone `ล` and a Japanese one ending in a lone `行`. While the
385
+ last line is one atom narrower than ORPHAN_MIN_EM, the last atom of the line above moves down
386
+ onto it -- but only while the result still fits and the line above does not become an orphan
387
+ itself, so a two-word cue is never made worse."""
388
+ lines = list(lines)
389
+ for _ in range(len(lines)):
390
+ if len(lines) < 2:
391
+ break
392
+ tail = _atoms(lines[-1])
393
+ if len(tail) != 1 or text_width_em(lines[-1]) >= ORPHAN_MIN_EM:
394
+ break
395
+ prev = _atoms(lines[-2])
396
+ if len(prev) < 2:
397
+ break
398
+ moved, spaced = prev[-1]
399
+ new_prev = ""
400
+ for atom, sp in prev[:-1]:
401
+ new_prev = _join(new_prev, atom, sp)
402
+ new_last = _join(moved, tail[0][0], _break_spaced(lines[-2], lines[-1]))
403
+ if text_width_em(new_last) > max_em or text_width_em(new_prev) < ORPHAN_MIN_EM:
404
+ break
405
+ lines[-2], lines[-1] = new_prev, new_last
406
+ return lines
407
+
408
+
409
+ def _rebalance(lines: List[str], max_em: float) -> List[str]:
410
+ """Move each break to the one that minimises the widest line of the pair, without changing the
411
+ line count.
412
+
413
+ Greedy wrapping fills line 1 to the brim and leaves line 2 short, which is what split eval 14's
414
+ `"A third line the tool times for me"` mid-phrase. Only spaced scripts are rebalanced: a
415
+ non-spaced script has no phrase structure in its atom list, so moving the break there only
416
+ moves the ragged edge. A break is never placed before a punctuation-only atom."""
417
+ if len(lines) < 2:
418
+ return lines
419
+ out = list(lines)
420
+ for i in range(len(out) - 1):
421
+ first, second = out[i], out[i + 1]
422
+ tail_atoms = _atoms(second)
423
+ if tail_atoms:
424
+ tail_atoms[0] = (tail_atoms[0][0], _break_spaced(first, second))
425
+ atoms = _atoms(first) + tail_atoms
426
+ if not atoms or any(char_script(ch) in NO_SPACE_SCRIPTS for ch in first + second):
427
+ continue
428
+ best = None
429
+ for cut in range(1, len(atoms)):
430
+ if not atoms[cut][1]:
431
+ continue # only break where a space stood
432
+ if all(not ch.isalnum() for ch in atoms[cut][0]):
433
+ continue # never strand punctuation at the start of a line
434
+ a = b = ""
435
+ for atom, sp in atoms[:cut]:
436
+ a = _join(a, atom, sp)
437
+ for atom, sp in atoms[cut:]:
438
+ b = _join(b, atom, sp)
439
+ wa, wb = text_width_em(a), text_width_em(b)
440
+ if max(wa, wb) > max_em:
441
+ continue
442
+ key = (max(wa, wb), abs(wa - wb))
443
+ if best is None or key < best[0]:
444
+ best = (key, a, b)
445
+ if best is not None:
446
+ out[i], out[i + 1] = best[1], best[2]
447
+ return out
448
+
449
+
450
+ def wrap_text(text: str, max_em: float, *, balance: bool = True) -> List[str]:
418
451
  """Wrap `text` to lines no wider than `max_em` em, keeping the manual breaks it already has.
419
452
 
420
453
  An atom wider than the whole line (one very long word) is left alone on its line rather than
421
- cut mid-word: an over-long line is readable, a chopped word is not.
454
+ cut mid-word: an over-long line is readable, a chopped word is not. Two post-passes then make
455
+ the result readable rather than merely legal (1.15): no one-character orphan line, and for
456
+ spaced scripts a break chosen to minimise the widest line instead of greedily.
422
457
  """
423
458
  lines: List[str] = []
424
459
  for raw in text.split("\n"):
425
460
  if not raw.strip():
426
461
  continue
427
462
  current = ""
463
+ chunk: List[str] = []
428
464
  for atom, spaced in _atoms(raw):
429
465
  candidate = _join(current, atom, spaced)
430
466
  if current and text_width_em(candidate) > max_em:
431
- lines.append(current)
467
+ chunk.append(current)
432
468
  current = atom
433
469
  else:
434
470
  current = candidate
435
471
  if current:
436
- lines.append(current)
472
+ chunk.append(current)
473
+ if balance and len(chunk) > 1:
474
+ fixed = _fix_orphans(chunk, max_em)
475
+ rebalanced = _rebalance(fixed, max_em)
476
+ if len(rebalanced) == len(chunk):
477
+ chunk = rebalanced
478
+ else:
479
+ chunk = fixed
480
+ lines.extend(chunk)
437
481
  return lines or [text]
438
482
 
439
483
 
484
+ # --------------------------------------------------------------------------- emoji (1.15)
485
+ def _cue_lines(text: str) -> List[str]:
486
+ return [l for l in text.split("\n")]
487
+
488
+
489
+ def plan_emoji(cues, args, play_w, play_h, brand=None):
490
+ """Decide how this run draws the emoji in `cues`, and where each PNG goes.
491
+
492
+ Returns (cues, plan) where `cues` may have had its emoji replaced by EMOJI_SENTINEL (the PNG
493
+ route) or stripped (`--emoji none`), and `plan` is the `emoji` result key plus the overlay
494
+ entries the filter graph needs. `None` plan means "nothing to do": no emoji in the text.
495
+ """
496
+ clusters_all = [cl for _s, _e, t in cues for _i, cl in emoji_clusters(t)]
497
+ if not clusters_all:
498
+ return cues, None
499
+ assets = resolve_emoji_assets(getattr(args, "emoji_assets", None), None, brand)
500
+ want = getattr(args, "emoji", "auto")
501
+ support = emoji_support(assets, probe=True)
502
+ mode = support["mode"] if want == "auto" else want
503
+ if want == "color" and not support["libass_color"]:
504
+ die("--emoji color: this ffmpeg renders emoji monochrome through libass "
505
+ f"({support['detail']}) -- pass --emoji-assets DIR for colour, or --emoji mono",
506
+ kind="input")
507
+ if want == "png" and not assets:
508
+ die("--emoji png: no emoji assets directory resolved -- " + EMOJI_ASSET_HINT, kind="input")
509
+ plan = {"mode": mode, "count": len(clusters_all),
510
+ "clusters": sorted({emoji_codepoint_name(cl) for cl in clusters_all}),
511
+ "assets": assets, "missing": [], "overlays": []}
512
+ if mode == "none":
513
+ out = []
514
+ for start, end, text in cues:
515
+ for cl in {cl for _i, cl in emoji_clusters(text)}:
516
+ text = text.replace(cl, "")
517
+ out.append((start, end, re.sub(r"[ \t]{2,}", " ", text).strip()))
518
+ info("emoji: stripped from the drawn text (--emoji none)")
519
+ return out, plan
520
+ if mode in ("color", "mono"):
521
+ if mode == "mono":
522
+ info("warning: emoji rendered monochrome (no colour path on this ffmpeg; "
523
+ "--emoji-assets DIR for colour). " + support["detail"])
524
+ return cues, plan
525
+ # --- the PNG overlay route -------------------------------------------------------------
526
+ if not play_w or not play_h:
527
+ return cues, plan
528
+ scale = float(getattr(args, "emoji_scale", 1.0) or 1.0)
529
+ # --animate moves the TEXT (\fad/\fscx in the ASS); the PNG has to move with it, or the emoji
530
+ # pops in against a line that is still fading up. These match the \fad values below.
531
+ fade_in, fade_out = {"fade": (0.2, 0.2), "pop": (0.08, 0.12),
532
+ "slide": (0.15, 0.15)}.get(getattr(args, "animate", None) or "none", (0.0, 0.0))
533
+ size_px = args.size * play_h / 288.0
534
+ margin_px = args.margin * play_h / 288.0
535
+ line_h = size_px * 1.2
536
+ box_px = size_px * scale
537
+ align = ALIGN[args.position]
538
+ out_cues = []
539
+ for start, end, text in cues:
540
+ lines = _cue_lines(text)
541
+ n = len(lines)
542
+ new_lines = []
543
+ for i, line in enumerate(lines):
544
+ if align in (7, 8, 9):
545
+ y_top = margin_px + i * line_h
546
+ elif align in (4, 5, 6):
547
+ y_top = play_h / 2.0 - (n * line_h) / 2.0 + i * line_h
548
+ else:
549
+ y_top = play_h - margin_px - (n - i) * line_h
550
+ line_w = text_width_em(line, scale) * size_px
551
+ if align in (1, 4, 7):
552
+ x0 = margin_px
553
+ elif align in (3, 6, 9):
554
+ x0 = play_w - margin_px - line_w
555
+ else:
556
+ x0 = (play_w - line_w) / 2.0
557
+ # libass lays an RTL line out right-to-left, so the LOGICAL prefix of a cluster
558
+ # occupies the RIGHT end of the rendered line. Measuring the prefix from the left
559
+ # edge put the PNG on top of the text, mirrored, on every Arabic/Hebrew cue (1.15.0).
560
+ rtl = detect_script(line) in BIDI_SCRIPTS
561
+ rebuilt = ""
562
+ cursor = 0
563
+ for idx, cluster in emoji_clusters(line):
564
+ prefix = line[:idx]
565
+ asset = emoji_asset_for(cluster, assets)
566
+ name = emoji_codepoint_name(cluster)
567
+ if not asset:
568
+ if name not in plan["missing"]:
569
+ plan["missing"].append(name)
570
+ rebuilt += line[cursor:idx + len(cluster)]
571
+ cursor = idx + len(cluster)
572
+ continue
573
+ if rtl:
574
+ x = x0 + line_w - text_width_em(prefix + cluster, scale) * size_px
575
+ else:
576
+ x = x0 + text_width_em(prefix, scale) * size_px
577
+ y = y_top + (line_h - box_px) / 2.0
578
+ plan["overlays"].append({
579
+ "asset": asset, "cluster": name,
580
+ "x": int(round(max(0.0, min(x, play_w - box_px)))),
581
+ "y": int(round(max(0.0, min(y, play_h - box_px)))),
582
+ "start": round(start, 3), "end": round(end, 3), "box": int(round(box_px)),
583
+ "fade_in": round(min(fade_in, max(0.0, (end - start) / 2.0)), 3),
584
+ "fade_out": round(min(fade_out, max(0.0, (end - start) / 2.0)), 3)})
585
+ rebuilt += line[cursor:idx] + EMOJI_SENTINEL
586
+ cursor = idx + len(cluster)
587
+ rebuilt += line[cursor:]
588
+ new_lines.append(rebuilt)
589
+ out_cues.append((start, end, "\n".join(new_lines)))
590
+ # `or 60` would swallow the one value that means "no overlays at all".
591
+ _max = getattr(args, "emoji_max", None)
592
+ limit = 60 if _max is None else int(_max)
593
+ if len(plan["overlays"]) > limit:
594
+ die(f"{len(plan['overlays'])} emoji overlays would be built for this job (limit {limit}, "
595
+ "--emoji-max raises it); ffmpeg's filter graph and the per-frame cost both grow "
596
+ "linearly -- split the job, or use --emoji none", kind="input")
597
+ if plan["missing"]:
598
+ info("warning: no PNG in the assets directory for " + ", ".join(plan["missing"]) +
599
+ " -- those clusters are drawn by the text font instead")
600
+ plan["box_px"] = int(round(box_px))
601
+ return out_cues, plan
602
+
603
+
440
604
  def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float], max_lines: int,
441
605
  min_duration: float, offset: float) -> Tuple[List[Tuple[float, float, str]], dict]:
442
606
  """Shift, wrap, split and lengthen cues so they can actually be read.
@@ -447,7 +611,7 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
447
611
  proportion to their text; a cue shorter than `min_duration` is lengthened, never past the next
448
612
  cue's start. Returns the new cues and a count of what changed.
449
613
  """
450
- stats = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0}
614
+ stats = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0, "rebalanced": 0}
451
615
  staged: List[Tuple[float, float, str]] = []
452
616
  for start, end, text in cues:
453
617
  if offset:
@@ -461,6 +625,8 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
461
625
  lines = wrap_text(text, max_em)
462
626
  if lines != [l for l in text.split("\n") if l.strip()]:
463
627
  stats["wrapped"] += 1
628
+ if lines != wrap_text(text, max_em, balance=False):
629
+ stats["rebalanced"] += 1
464
630
  if len(lines) > max_lines:
465
631
  chunks = [lines[i:i + max_lines] for i in range(0, len(lines), max_lines)]
466
632
  weights = [max(1.0, sum(len(l) for l in c)) for c in chunks]
@@ -488,7 +654,7 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
488
654
 
489
655
  def report_layout(stats: dict) -> None:
490
656
  """One info line, only when a cue actually changed."""
491
- parts = [f"{stats[k]} {k}" for k in ("shifted", "wrapped", "split", "extended", "dropped") if stats.get(k)]
657
+ parts = [f"{stats[k]} {k}" for k in ("shifted", "wrapped", "rebalanced", "split", "extended", "dropped") if stats.get(k)]
492
658
  if parts:
493
659
  info("cues: " + ", ".join(parts))
494
660
 
@@ -628,15 +794,15 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
628
794
  ]
629
795
  lines = []
630
796
  for start, end, text in cues:
631
- text = text.replace("\n", "\\N")
632
797
  # ASS Dialogue text treats a literal `{...}` as an override block -- real style/animation
633
798
  # commands, not literal characters. Cue text (from --text, an SRT, or ASR transcription --
634
799
  # all effectively user-controlled) that happens to contain braces would otherwise be
635
800
  # interpreted as those commands (\pos, \t, \fscx, ...), letting caption content reposition,
636
- # rescale, or recolor itself or later text instead of just being read out. No caption needs
637
- # a literal curly brace, so they're dropped outright, matching the "unneeded delimiter
638
- # character -> drop it" call already made for font names (see ass_font_name()).
639
- text = text.replace("{", "").replace("}", "")
801
+ # rescale, or recolor itself or later text instead of just being read out. libass has real
802
+ # escapes for the braces, so 1.15 escapes them (ass_escape) rather than deleting them:
803
+ # a cue that says "use {curly} braces" is read out with its braces, and still cannot open
804
+ # an override block. Newlines become \N in the same pass.
805
+ text = ass_escape(text)
640
806
  fx = ""
641
807
  if args.animate == "fade":
642
808
  fx = "{\\fad(200,200)}"
@@ -649,7 +815,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
649
815
  # split each line into words and give every word an equal share of the cue (\k is in centiseconds)
650
816
  dur_cs = max(1, int(round((end - start) * 100)))
651
817
  segments = body.split("\\N")
652
- words = [w for seg in segments for w in seg.split(" ") if w]
818
+ words = [w for seg in segments for w in seg.split(" ") if w and w.strip(EMOJI_SENTINEL)]
653
819
  # real word timings from the transcript beat both the energy estimate and the even
654
820
  # split -- they are what the speaker actually did, not a proxy for it
655
821
  durs = word_durations_from_timings(getattr(args, "_word_timings", None) or [], start, end, len(words))
@@ -663,8 +829,14 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
663
829
  out_segments = []
664
830
  for seg in segments:
665
831
  ws = [w for w in seg.split(" ") if w]
666
- out_segments.append(" ".join(f"{{\\kf{next(it)}}}{w}" for w in ws))
832
+ # An emoji placeholder is its own ZERO-duration \kf segment: the highlight sweeps
833
+ # past the reserved gap without spending cue time on a glyph nobody sees (rendered
834
+ # and confirmed -- libass keeps the full gap inside a karaoke run).
835
+ out_segments.append(" ".join(
836
+ ("{\\kf0}" + w) if not w.strip(EMOJI_SENTINEL) else f"{{\\kf{next(it)}}}{w}" for w in ws))
667
837
  body = "\\N".join(out_segments)
838
+ if EMOJI_SENTINEL in body:
839
+ body = body.replace(EMOJI_SENTINEL, emoji_placeholder(getattr(args, "_emoji_box_px", size)))
668
840
  lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{body}")
669
841
  with open(path, "w", encoding="utf-8-sig") as fh:
670
842
  fh.write("\n".join(header + lines) + "\n")
@@ -765,6 +937,20 @@ def main() -> int:
765
937
  help="keep the captions out of this destination's UI: the margin becomes the platform's safe "
766
938
  "zone (TikTok's description bar, the Reels/Shorts chrome). An explicit --margin/--position wins")
767
939
  sty.add_argument("--box", action="store_true", help="draw an opaque box behind text instead of an outline")
940
+ emo = ap.add_argument_group("emoji (1.15)")
941
+ emo.add_argument("--emoji", choices=["auto", "color", "png", "mono", "none"], default="auto",
942
+ help="how emoji in the cues are drawn: 'auto' picks the best this machine can do "
943
+ "(doctor --json .fonts.emoji), 'color' insists on a colour-capable libass, "
944
+ "'png' composites the --emoji-assets PNGs, 'mono' draws whatever glyph the text "
945
+ "font has, 'none' strips them")
946
+ emo.add_argument("--emoji-assets", metavar="DIR",
947
+ help="directory of emoji PNGs named by code point (1f389.png, 1f1ef-1f1f5.png) -- "
948
+ "Twemoji's assets/72x72 or Noto Emoji's png/128. Nothing is ever downloaded; "
949
+ "also read from brand.json styles.caption.emoji_assets and FFMPEG_SKILL_EMOJI_ASSETS")
950
+ emo.add_argument("--emoji-scale", type=float, default=1.0,
951
+ help="emoji box as a multiple of the line's font size (default 1.0)")
952
+ emo.add_argument("--emoji-max", type=int, default=60,
953
+ help="most emoji overlays one run may build (default 60)")
768
954
  sty.add_argument("--max-lines", type=int, default=2, help="most lines one cue may occupy; a longer cue is split into consecutive cues (default 2)")
769
955
  sty.add_argument("--min-duration", type=float, default=1.0, help="shortest time a cue stays on screen in seconds, never past the next cue (default 1.0)")
770
956
  anim = ap.add_argument_group("animation (generates ASS; needs --text or --srt input)")
@@ -993,11 +1179,34 @@ def main() -> int:
993
1179
  if not args.fonts_dir:
994
1180
  args.fonts_dir = os.path.dirname(font_file)
995
1181
 
996
- if (args.animate != "none" or args.karaoke) and not args.ass:
1182
+ # Emoji (1.15). Decided once, on the cues the burn will actually use: libass cannot place a
1183
+ # PNG, so the text keeps its place in the ASS (with the gap reserved) and each emoji becomes an
1184
+ # overlay composited after the ass= filter. A cue file that has no emoji costs nothing here.
1185
+ emoji_plan = None
1186
+ emoji_cues = None
1187
+ if not args.ass:
1188
+ if args.text or args.transcribe:
1189
+ src_cues = cues
1190
+ elif planned_cues is not None:
1191
+ src_cues = planned_cues
1192
+ elif os.path.exists(srt_path or ""):
1193
+ src_cues = parse_srt(srt_path)
1194
+ else:
1195
+ src_cues = []
1196
+ if src_cues and has_emoji("\n".join(t for _s, _e, t in src_cues)):
1197
+ emoji_cues, emoji_plan = plan_emoji(src_cues, args, play_w, play_h,
1198
+ brand if args.brand else None)
1199
+ # the PNG route and --emoji none both change the drawn text, so they need the generated ASS
1200
+ force_ass = bool(emoji_plan and (emoji_plan.get("overlays") or emoji_plan.get("mode") == "none"))
1201
+ if emoji_plan and emoji_plan.get("box_px"):
1202
+ args._emoji_box_px = emoji_plan["box_px"]
1203
+
1204
+ if (args.animate != "none" or args.karaoke or force_ass) and not args.ass:
997
1205
  # both sources are already laid out: `cues` above, and srt_path was rewritten in place of
998
1206
  # the caller's file when --offset/--max-lines/--min-duration changed anything
999
- cues_for_ass = cues if (args.text or args.transcribe) else (
1000
- planned_cues if planned_cues is not None else parse_srt(srt_path))
1207
+ cues_for_ass = emoji_cues if emoji_cues is not None else (
1208
+ cues if (args.text or args.transcribe) else (
1209
+ planned_cues if planned_cues is not None else parse_srt(srt_path)))
1001
1210
  if args.karaoke and not getattr(args, "_word_timings", None):
1002
1211
  args._word_timings = whisper_word_timings(srt_path)
1003
1212
  ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
@@ -1040,15 +1249,40 @@ def main() -> int:
1040
1249
  if args.fonts_dir:
1041
1250
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
1042
1251
 
1043
- cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0"]
1252
+ cmd = ffmpeg_base() + ["-i", args.input]
1253
+ chains, emoji_inputs = emoji_filter_chain(emoji_plan or {}, "vsub", "vout") if emoji_plan else ([], [])
1254
+ if chains:
1255
+ for spec in emoji_inputs:
1256
+ cmd += spec
1257
+ asset = spec[-1]
1258
+ if asset not in STATE.plan_inputs:
1259
+ STATE.plan_inputs.append(asset)
1260
+ graph = ";".join([f"[0:v]{vf}[vsub]"] + chains)
1261
+ cmd += ["-filter_complex", graph, "-map", "[vout]"]
1262
+ else:
1263
+ cmd += ["-map", "0:v:0", "-vf", vf]
1044
1264
  if meta.get("audio"):
1045
1265
  cmd += ["-map", f"0:a:{args.audio_stream}"]
1046
- cmd += ["-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
1266
+ cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
1047
1267
  cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
1048
1268
  run(cmd)
1049
1269
  result = probe(output, role="output")
1050
1270
  info(f"wrote {output} ({fmt_secs(result.get('duration'))})")
1051
- emit(output, **({"notes": side_notes} if side_notes else {}))
1271
+ extra = {"notes": side_notes} if side_notes else {}
1272
+ if emoji_plan:
1273
+ notes = list(extra.get("notes") or [])
1274
+ if emoji_plan["mode"] == "mono":
1275
+ notes.append("emoji rendered monochrome (no colour path on this ffmpeg; "
1276
+ "--emoji-assets DIR for colour)")
1277
+ if emoji_plan["mode"] == "none":
1278
+ notes.append("emoji stripped from the drawn text (--emoji none)")
1279
+ if emoji_plan["missing"]:
1280
+ notes.append("no PNG asset for " + ", ".join(emoji_plan["missing"]))
1281
+ if notes:
1282
+ extra["notes"] = notes
1283
+ extra["emoji"] = {k: v for k, v in emoji_plan.items() if k not in ("overlays", "box_px")}
1284
+ extra["emoji"]["overlays"] = len(emoji_plan.get("overlays") or [])
1285
+ emit(output, **extra)
1052
1286
  return 0
1053
1287
 
1054
1288