ffmpeg-skill 1.13.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.
@@ -38,9 +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
- 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
43
+ from _platforms import PLATFORMS, PLATFORM_CHOICES, ass_units, resolve as resolve_platform
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
44
46
 
45
47
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
46
48
 
@@ -297,89 +299,41 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
297
299
  return out
298
300
 
299
301
 
300
- # --------------------------------------------------------------------------- readable cues (1.12)
301
- # Average advance width per character, in em (a fraction of the font size). Proportional Latin text
302
- # averages a bit over half an em; CJK and Thai are drawn on a full-width grid; Arabic/Hebrew and
303
- # Devanagari sit in between. These are deliberately averages, not per-glyph metrics: measuring the
304
- # real advance needs a font parser (no stdlib one) and would still be wrong for libass's own
305
- # shaping, while a cue wrapped from an average is right to within a character on every line.
306
- # (Latin is measured per character from LATIN_EM below, not from this average.)
307
- ADVANCE_EM = {"ja": 1.0, "zh": 1.0, "ko": 1.0, "th": 1.0, "hi": 0.7, "ar": 0.6, "he": 0.6,
308
- "ru": 0.55, "el": 0.55, "latin": 0.55}
309
302
  # How much of the frame width a caption line may use. libass's own default SRT margins are 10 of a
310
303
  # 384-wide script (2.6 % a side); 5 % a side is the safe area every platform check in this repo uses.
311
304
  SAFE_WIDTH_FRACTION = 0.9
312
- # Scripts written without spaces: a line breaks between any two characters.
313
- NO_SPACE_SCRIPTS = ("ja", "zh", "ko", "th")
314
- # Per-character Latin advances in em, read off DejaVu Sans (the default caption family, and close
315
- # enough to any other proportional sans for a wrap) and rounded UP: a capital runs 0.56-0.99 em
316
- # against the single 0.55 average that used to stand for all of Latin, so an all-caps caption --
317
- # the style most burn-ins use -- overflowed the safe area and was silently re-wrapped by libass
318
- # past --max-lines. Rounding up is the safe direction: libass re-wraps a too-long line, it never
319
- # un-wraps a short one. Characters outside the table fall back by class (0.7 uppercase/digit,
320
- # 0.57 lowercase and anything else Latin-ish).
321
- LATIN_EM = {
322
- ' ': 0.32, '!': 0.41, '"': 0.46, '#': 0.84, '$': 0.64, '%': 0.96, '&': 0.78, "'": 0.28,
323
- '(': 0.4, ')': 0.4, '*': 0.5, '+': 0.84, ',': 0.32, '-': 0.37, '.': 0.32, '/': 0.34, '0': 0.64,
324
- '1': 0.64, '2': 0.64, '3': 0.64, '4': 0.64, '5': 0.64, '6': 0.64, '7': 0.64, '8': 0.64,
325
- '9': 0.64, ':': 0.34, ';': 0.34, '<': 0.84, '=': 0.84, '>': 0.84, '?': 0.54, '@': 1.0,
326
- 'A': 0.69, 'B': 0.69, 'C': 0.7, 'D': 0.78, 'E': 0.64, 'F': 0.58, 'G': 0.78, 'H': 0.76,
327
- 'I': 0.3, 'J': 0.3, 'K': 0.66, 'L': 0.56, 'M': 0.87, 'N': 0.75, 'O': 0.79, 'P': 0.61,
328
- 'Q': 0.79, 'R': 0.7, 'S': 0.64, 'T': 0.62, 'U': 0.74, 'V': 0.69, 'W': 0.99, 'X': 0.69,
329
- 'Y': 0.62, 'Z': 0.69, '[': 0.4, '\\': 0.34, ']': 0.4, '^': 0.84, '_': 0.5, '`': 0.5, 'a': 0.62,
330
- 'b': 0.64, 'c': 0.55, 'd': 0.64, 'e': 0.62, 'f': 0.36, 'g': 0.64, 'h': 0.64, 'i': 0.28,
331
- 'j': 0.28, 'k': 0.58, 'l': 0.28, 'm': 0.98, 'n': 0.64, 'o': 0.62, 'p': 0.64, 'q': 0.64,
332
- '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,
333
- '{': 0.64, '|': 0.34, '}': 0.64, '~': 0.84
334
- }
335
- # Thai and Lao write some vowels BEFORE the consonant they belong to: the break must not land
336
- # between them and the base that follows.
337
- LEADING_VOWELS = set(range(0x0E40, 0x0E45)) | set(range(0x0EC0, 0x0EC5))
338
-
339
-
340
- def _is_mark(ch: str) -> bool:
341
- """A character that hangs off the one before it: a combining mark (any script) or one of the
342
- Thai/Lao vowel signs and tone marks, which are Mn/Mc but carry no combining class."""
343
- return unicodedata.combining(ch) != 0 or unicodedata.category(ch) in ("Mn", "Mc")
344
-
345
-
346
- def _char_em(ch: str) -> float:
347
- # CJK punctuation and the fullwidth forms (、。,!? and U+FF01-FF60) are drawn on the same
348
- # full-width grid as the ideographs they sit between, even though they are not "Han" to a
349
- # script detector -- measuring them as Latin under-counts a wrapped CJK line by a character.
350
- cp = ord(ch)
351
- # A combining mark is drawn on top of (or under) its base and advances the pen by nothing:
352
- # charging it a full em wrapped Thai and Devanagari lines far shorter than they needed to be.
353
- if unicodedata.combining(ch) != 0 or unicodedata.category(ch) == "Mn":
354
- return 0.0
355
- if 0x3000 <= cp <= 0x303F or 0xFF01 <= cp <= 0xFF60 or 0xFFE0 <= cp <= 0xFFE6:
356
- return 1.0
357
- script = char_script(ch)
358
- if script == "latin":
359
- if ch in LATIN_EM:
360
- return LATIN_EM[ch]
361
- if ch.isupper() or ch.isdigit():
362
- return 0.7
363
- return 0.57
364
- return ADVANCE_EM.get(script, 0.55)
365
-
366
-
367
- def text_width_em(text: str) -> float:
368
- """Width of `text` in em, from the per-script average advance table."""
369
- 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
370
308
 
371
309
 
372
310
  def _atoms(line: str) -> List[Tuple[str, bool]]:
373
311
  """Break a line into the smallest pieces a wrap may separate -- one atom per CJK/Thai
374
- character, one per whitespace-delimited word otherwise -- each with whether a space stood
375
- before it in the original. The flag is what puts the text back together exactly as written:
376
- "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."""
377
315
  out: List[Tuple[str, bool]] = []
378
316
  word = ""
379
317
  spaced = False # a space stands before the atom being built
380
318
  pending = False # a space stands before the NEXT atom
381
319
  attach_next = False # a leading Thai/Lao vowel is waiting for its base consonant
382
- 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
383
337
  if char_script(ch) in NO_SPACE_SCRIPTS:
384
338
  if word:
385
339
  out.append((word, spaced))
@@ -413,29 +367,240 @@ def _join(left: str, atom: str, spaced: bool) -> str:
413
367
  return left + (" " if spaced else "") + atom
414
368
 
415
369
 
416
- 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]:
417
451
  """Wrap `text` to lines no wider than `max_em` em, keeping the manual breaks it already has.
418
452
 
419
453
  An atom wider than the whole line (one very long word) is left alone on its line rather than
420
- 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.
421
457
  """
422
458
  lines: List[str] = []
423
459
  for raw in text.split("\n"):
424
460
  if not raw.strip():
425
461
  continue
426
462
  current = ""
463
+ chunk: List[str] = []
427
464
  for atom, spaced in _atoms(raw):
428
465
  candidate = _join(current, atom, spaced)
429
466
  if current and text_width_em(candidate) > max_em:
430
- lines.append(current)
467
+ chunk.append(current)
431
468
  current = atom
432
469
  else:
433
470
  current = candidate
434
471
  if current:
435
- 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)
436
481
  return lines or [text]
437
482
 
438
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
+
439
604
  def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float], max_lines: int,
440
605
  min_duration: float, offset: float) -> Tuple[List[Tuple[float, float, str]], dict]:
441
606
  """Shift, wrap, split and lengthen cues so they can actually be read.
@@ -446,7 +611,7 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
446
611
  proportion to their text; a cue shorter than `min_duration` is lengthened, never past the next
447
612
  cue's start. Returns the new cues and a count of what changed.
448
613
  """
449
- 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}
450
615
  staged: List[Tuple[float, float, str]] = []
451
616
  for start, end, text in cues:
452
617
  if offset:
@@ -460,6 +625,8 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
460
625
  lines = wrap_text(text, max_em)
461
626
  if lines != [l for l in text.split("\n") if l.strip()]:
462
627
  stats["wrapped"] += 1
628
+ if lines != wrap_text(text, max_em, balance=False):
629
+ stats["rebalanced"] += 1
463
630
  if len(lines) > max_lines:
464
631
  chunks = [lines[i:i + max_lines] for i in range(0, len(lines), max_lines)]
465
632
  weights = [max(1.0, sum(len(l) for l in c)) for c in chunks]
@@ -487,7 +654,7 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
487
654
 
488
655
  def report_layout(stats: dict) -> None:
489
656
  """One info line, only when a cue actually changed."""
490
- 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)]
491
658
  if parts:
492
659
  info("cues: " + ", ".join(parts))
493
660
 
@@ -627,15 +794,15 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
627
794
  ]
628
795
  lines = []
629
796
  for start, end, text in cues:
630
- text = text.replace("\n", "\\N")
631
797
  # ASS Dialogue text treats a literal `{...}` as an override block -- real style/animation
632
798
  # commands, not literal characters. Cue text (from --text, an SRT, or ASR transcription --
633
799
  # all effectively user-controlled) that happens to contain braces would otherwise be
634
800
  # interpreted as those commands (\pos, \t, \fscx, ...), letting caption content reposition,
635
- # rescale, or recolor itself or later text instead of just being read out. No caption needs
636
- # a literal curly brace, so they're dropped outright, matching the "unneeded delimiter
637
- # character -> drop it" call already made for font names (see ass_font_name()).
638
- 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)
639
806
  fx = ""
640
807
  if args.animate == "fade":
641
808
  fx = "{\\fad(200,200)}"
@@ -648,7 +815,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
648
815
  # split each line into words and give every word an equal share of the cue (\k is in centiseconds)
649
816
  dur_cs = max(1, int(round((end - start) * 100)))
650
817
  segments = body.split("\\N")
651
- 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)]
652
819
  # real word timings from the transcript beat both the energy estimate and the even
653
820
  # split -- they are what the speaker actually did, not a proxy for it
654
821
  durs = word_durations_from_timings(getattr(args, "_word_timings", None) or [], start, end, len(words))
@@ -662,8 +829,14 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
662
829
  out_segments = []
663
830
  for seg in segments:
664
831
  ws = [w for w in seg.split(" ") if w]
665
- 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))
666
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)))
667
840
  lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{body}")
668
841
  with open(path, "w", encoding="utf-8-sig") as fh:
669
842
  fh.write("\n".join(header + lines) + "\n")
@@ -759,8 +932,25 @@ def main() -> int:
759
932
  sty.add_argument("--shadow", type=float, default=0.0, help="shadow depth (default 0)")
760
933
  sty.add_argument("--bold", action="store_true")
761
934
  sty.add_argument("--position", choices=sorted(ALIGN), default=None, help="on-screen placement (default bottom)")
762
- sty.add_argument("--margin", type=int, default=30, help="vertical margin from the edge (default 30)")
935
+ sty.add_argument("--margin", type=int, default=None, help="vertical margin from the edge in ASS units (default 30, or the --platform safe zone)")
936
+ sty.add_argument("--platform", choices=PLATFORM_CHOICES, default=None,
937
+ help="keep the captions out of this destination's UI: the margin becomes the platform's safe "
938
+ "zone (TikTok's description bar, the Reels/Shorts chrome). An explicit --margin/--position wins")
763
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)")
764
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)")
765
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)")
766
956
  anim = ap.add_argument_group("animation (generates ASS; needs --text or --srt input)")
@@ -792,6 +982,18 @@ def main() -> int:
792
982
  args.outline_color = color_hex(args.outline_color or bc.get("outline", "000000"))
793
983
  args.outline = args.outline if args.outline is not None else (float(bcap.get("outline", 2)) if args.brand else 2.0)
794
984
  args.position = args.position or (bcap.get("position", "bottom") if args.brand else "bottom")
985
+ # --platform: the margin is the fraction of the frame that platform's own UI covers
986
+ # (scripts/_platforms.py). An explicit --margin is the more specific statement and wins;
987
+ # without either, the historical default 30 is unchanged.
988
+ # every tool resolves the spellings people write ('youtube-shorts' is 'shorts') in one place
989
+ args.platform = resolve_platform(args.platform)
990
+ if args.margin is None and args.platform and PLATFORMS[args.platform].get("frame"):
991
+ edge = PLATFORMS[args.platform]["safe"]["top" if args.position.startswith("top") else "bottom"]
992
+ args.margin = ass_units(edge)
993
+ info(f"--platform {args.platform}: caption margin {args.margin} ASS units ({edge * 100:.0f}% of the frame height, "
994
+ f"clear of the app's own UI)")
995
+ if args.margin is None:
996
+ args.margin = 30
795
997
  # a brand's caption.animate is a burn-in default; over --mode mux (soft subtitles) it used
796
998
  # to be applied anyway and then refused as "animation is burn only" -- ignore it there
797
999
  args.animate = args.animate or (bcap.get("animate", "none") if args.brand and args.mode != "mux" else "none")
@@ -977,11 +1179,34 @@ def main() -> int:
977
1179
  if not args.fonts_dir:
978
1180
  args.fonts_dir = os.path.dirname(font_file)
979
1181
 
980
- 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:
981
1205
  # both sources are already laid out: `cues` above, and srt_path was rewritten in place of
982
1206
  # the caller's file when --offset/--max-lines/--min-duration changed anything
983
- cues_for_ass = cues if (args.text or args.transcribe) else (
984
- 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)))
985
1210
  if args.karaoke and not getattr(args, "_word_timings", None):
986
1211
  args._word_timings = whisper_word_timings(srt_path)
987
1212
  ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
@@ -1024,15 +1249,40 @@ def main() -> int:
1024
1249
  if args.fonts_dir:
1025
1250
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
1026
1251
 
1027
- 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]
1028
1264
  if meta.get("audio"):
1029
1265
  cmd += ["-map", f"0:a:{args.audio_stream}"]
1030
- 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)
1031
1267
  cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
1032
1268
  run(cmd)
1033
1269
  result = probe(output, role="output")
1034
1270
  info(f"wrote {output} ({fmt_secs(result.get('duration'))})")
1035
- 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)
1036
1286
  return 0
1037
1287
 
1038
1288
 
package/scripts/check.py CHANGED
@@ -10,7 +10,8 @@ row's `fix` is the command that resolves it; a few of the less obvious FAILs
10
10
  not a restatement of the spec value -- for a caller reporting this to someone
11
11
  who doesn't already know why the spec says what it says.
12
12
 
13
- Platforms: youtube, shorts, reels, tiktok, x, linkedin, broadcast (EBU R128), podcast, custom
13
+ Platforms: youtube, shorts, reels, tiktok, x, linkedin, facebook, broadcast (EBU R128),
14
+ podcast, custom -- one table, shared with export.py and the render.py templates
14
15
 
15
16
  Examples:
16
17
  python3 check.py final.mp4 --platform youtube
@@ -25,19 +26,15 @@ import sys
25
26
  from fractions import Fraction
26
27
  from typing import Any, Dict, List
27
28
 
29
+ from _platforms import PLATFORMS, PLATFORM_CHOICES, spec_of, resolve as resolve_platform
28
30
  from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
29
31
 
30
- SPECS: Dict[str, Dict[str, Any]] = {
31
- "youtube": {"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60, "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
32
- "shorts": {"max_duration": 180, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60, "codecs": ["h264", "hevc"], "max_bytes": 256 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
33
- "reels": {"max_duration": 90, "aspects": ["9:16", "4:5", "1:1"], "min_height": 1080, "fps_max": 60, "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
34
- "tiktok": {"max_duration": 600, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60, "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
35
- "x": {"max_duration": 140, "aspects": ["16:9", "1:1", "9:16"], "min_height": 720, "fps_max": 60, "codecs": ["h264"], "max_bytes": 512 * 1024 ** 2, "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
36
- "linkedin": {"max_duration": 600, "aspects": ["16:9", "1:1", "9:16", "4:5"], "min_height": 720, "fps_max": 60, "codecs": ["h264"], "max_bytes": 5 * 1024 ** 3, "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
37
- "broadcast": {"max_duration": None, "aspects": ["16:9"], "min_height": 1080, "fps_max": 60, "codecs": ["prores", "dnxhd", "h264", "hevc", "mpeg2video"], "max_bytes": None, "lufs": -23, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
38
- "podcast": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None, "codecs": None, "max_bytes": None, "lufs": -16, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
39
- "custom": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None, "codecs": None, "max_bytes": None, "lufs": None, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
40
- }
32
+ # The one delivery table (scripts/_platforms.py): check.py's rows, export.py's presets and the
33
+ # render.py templates all read it, so a platform's loudness spec is stated once. Only the
34
+ # destinations that are compliance targets appear here; youtube-hdr / youtube-av1 are export
35
+ # presets of the youtube target, not separate specs.
36
+ SPECS: Dict[str, Dict[str, Any]] = {name: spec_of(name) for name in sorted(PLATFORMS)
37
+ if PLATFORMS[name]["check"] == name}
41
38
 
42
39
 
43
40
  def measure_loudness(path: str) -> Dict[str, float]:
@@ -66,7 +63,7 @@ def aspect_name(w: int, h: int) -> str:
66
63
  def main() -> int:
67
64
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
68
65
  ap.add_argument("input")
69
- ap.add_argument("--platform", choices=sorted(SPECS), default=None, help="delivery spec to check against (default: youtube, with judgement rows reported as WARN because no platform was named)")
66
+ ap.add_argument("--platform", choices=PLATFORM_CHOICES, default=None, help="delivery spec to check against (default: youtube, with judgement rows reported as WARN because no platform was named)")
70
67
  ap.add_argument("--max-duration", type=float, help="override max duration in seconds")
71
68
  ap.add_argument("--aspect", help="override allowed aspect (e.g. 9:16 or 16:9,1:1)")
72
69
  ap.add_argument("--lufs", type=float, help="override loudness target")
@@ -81,7 +78,7 @@ def main() -> int:
81
78
  # spent a paragraph explaining why they left them alone. Without a named platform the
82
79
  # judgement rows are advisory: WARN, not FAIL, and not counted as failed.
83
80
  named = args.platform is not None
84
- args.platform = args.platform or "youtube"
81
+ args.platform = resolve_platform(args.platform) or "youtube"
85
82
  spec = dict(SPECS[args.platform])
86
83
  if args.max_duration is not None:
87
84
  spec["max_duration"] = args.max_duration
@@ -134,10 +131,10 @@ def main() -> int:
134
131
  row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30 (drops half the frames of 60 fps motion; fine for talking heads, visible on sports/gaming)")
135
132
  row("vfr", "PASS" if not v.get("variable_frame_rate_suspected") else "WARN", "variable" if v.get("variable_frame_rate_suspected") else "constant", "constant", "fit.py --fps N (any re-encode conforms it)")
136
133
  if spec["codecs"]:
137
- row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + args.platform.replace("shorts", "reels").replace("tiktok", "reels").replace("linkedin", "youtube").replace("broadcast", "prores"),
134
+ row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + (PLATFORMS[args.platform].get("preset") or "youtube"),
138
135
  reason="the platform's player may refuse to decode this codec at all, not just look worse")
139
136
  pf = v.get("pix_fmt") or ""
140
- if args.platform in ("reels", "tiktok", "x", "linkedin"):
137
+ if args.platform in ("reels", "tiktok", "x", "linkedin", "facebook"):
141
138
  row("pixel format", "PASS" if pf == "yuv420p" else "FAIL", pf, "yuv420p (8-bit 4:2:0)", "export.py preset re-encodes to yuv420p",
142
139
  reason="QuickTime and iOS commonly reject video that isn't 8-bit 4:2:0")
143
140
  if spec["sdr_only"] and v.get("hdr"):