ffmpeg-skill 1.11.0 → 1.12.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.
@@ -28,15 +28,19 @@ Examples:
28
28
  python3 caption.py input.mp4 --srt subs.srt --font "Noto Sans CJK JP" --size 28 --position top
29
29
  python3 caption.py --text cues.txt --write-srt cues.srt # only produce the SRT
30
30
  python3 caption.py input.mp4 --text cues.txt # generate + burn in one go
31
+ python3 caption.py input.mp4 --text cues_ko.txt --lang ko # a font that covers the script is picked automatically
32
+ python3 caption.py input.mp4 --srt subs.srt --offset -0.4 --max-lines 2 --min-duration 1.2
31
33
  """
32
34
  import argparse
35
+ import json
33
36
  import os
34
37
  import re
35
38
  import sys
39
+ import unicodedata
36
40
  from pathlib import Path
37
41
  from typing import List, Optional, Tuple
38
42
 
39
- from _common import STATE, 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 _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
40
44
 
41
45
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
42
46
 
@@ -293,6 +297,310 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
293
297
  return out
294
298
 
295
299
 
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
+ # How much of the frame width a caption line may use. libass's own default SRT margins are 10 of a
310
+ # 384-wide script (2.6 % a side); 5 % a side is the safe area every platform check in this repo uses.
311
+ 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)
370
+
371
+
372
+ def _atoms(line: str) -> List[Tuple[str, bool]]:
373
+ """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."""
377
+ out: List[Tuple[str, bool]] = []
378
+ word = ""
379
+ spaced = False # a space stands before the atom being built
380
+ pending = False # a space stands before the NEXT atom
381
+ attach_next = False # a leading Thai/Lao vowel is waiting for its base consonant
382
+ for ch in line:
383
+ if char_script(ch) in NO_SPACE_SCRIPTS:
384
+ if word:
385
+ out.append((word, spaced))
386
+ word = ""
387
+ if out and (attach_next or _is_mark(ch)):
388
+ # never break between a base and the mark (or the leading vowel) that belongs to
389
+ # it: the line would start with an orphaned tone mark or vowel sign
390
+ out[-1] = (out[-1][0] + ch, out[-1][1])
391
+ else:
392
+ out.append((ch, pending))
393
+ pending = False
394
+ attach_next = ord(ch) in LEADING_VOWELS
395
+ elif ch.isspace():
396
+ if word:
397
+ out.append((word, spaced))
398
+ word = ""
399
+ pending = True
400
+ else:
401
+ if not word:
402
+ spaced, pending = pending, False
403
+ word += ch
404
+ if word:
405
+ out.append((word, spaced))
406
+ return out
407
+
408
+
409
+ def _join(left: str, atom: str, spaced: bool) -> str:
410
+ """Put an atom back on a line, restoring the space that stood before it."""
411
+ if not left:
412
+ return atom
413
+ return left + (" " if spaced else "") + atom
414
+
415
+
416
+ def wrap_text(text: str, max_em: float) -> List[str]:
417
+ """Wrap `text` to lines no wider than `max_em` em, keeping the manual breaks it already has.
418
+
419
+ 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.
421
+ """
422
+ lines: List[str] = []
423
+ for raw in text.split("\n"):
424
+ if not raw.strip():
425
+ continue
426
+ current = ""
427
+ for atom, spaced in _atoms(raw):
428
+ candidate = _join(current, atom, spaced)
429
+ if current and text_width_em(candidate) > max_em:
430
+ lines.append(current)
431
+ current = atom
432
+ else:
433
+ current = candidate
434
+ if current:
435
+ lines.append(current)
436
+ return lines or [text]
437
+
438
+
439
+ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float], max_lines: int,
440
+ min_duration: float, offset: float) -> Tuple[List[Tuple[float, float, str]], dict]:
441
+ """Shift, wrap, split and lengthen cues so they can actually be read.
442
+
443
+ `offset` moves every cue (a transcript that runs early/late); `max_em` wraps each cue to the
444
+ safe area at the chosen size (None when no video geometry is known, e.g. --write-srt alone);
445
+ a cue needing more than `max_lines` lines is split into consecutive cues sharing its time in
446
+ proportion to their text; a cue shorter than `min_duration` is lengthened, never past the next
447
+ cue's start. Returns the new cues and a count of what changed.
448
+ """
449
+ stats = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0}
450
+ staged: List[Tuple[float, float, str]] = []
451
+ for start, end, text in cues:
452
+ if offset:
453
+ start, end = start + offset, end + offset
454
+ if end <= 0:
455
+ stats["dropped"] += 1
456
+ continue
457
+ start = max(0.0, start)
458
+ stats["shifted"] += 1
459
+ if max_em and max_em > 0:
460
+ lines = wrap_text(text, max_em)
461
+ if lines != [l for l in text.split("\n") if l.strip()]:
462
+ stats["wrapped"] += 1
463
+ if len(lines) > max_lines:
464
+ chunks = [lines[i:i + max_lines] for i in range(0, len(lines), max_lines)]
465
+ weights = [max(1.0, sum(len(l) for l in c)) for c in chunks]
466
+ total_w = sum(weights)
467
+ t = start
468
+ for chunk, weight in zip(chunks, weights):
469
+ seg = (end - start) * weight / total_w
470
+ staged.append((t, min(end, t + seg), "\n".join(chunk)))
471
+ t += seg
472
+ stats["split"] += len(chunks) - 1
473
+ continue
474
+ text = "\n".join(lines)
475
+ staged.append((start, end, text))
476
+ out: List[Tuple[float, float, str]] = []
477
+ for i, (start, end, text) in enumerate(staged):
478
+ if min_duration and end - start < min_duration:
479
+ limit = staged[i + 1][0] if i + 1 < len(staged) else None
480
+ new_end = start + min_duration if limit is None else min(start + min_duration, limit)
481
+ if new_end > end:
482
+ stats["extended"] += 1
483
+ end = new_end
484
+ out.append((start, end, text))
485
+ return out, stats
486
+
487
+
488
+ def report_layout(stats: dict) -> None:
489
+ """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)]
491
+ if parts:
492
+ info("cues: " + ", ".join(parts))
493
+
494
+
495
+ def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[float]:
496
+ """How many em fit on one caption line at the chosen size, or None without video geometry.
497
+
498
+ --size is in ASS points against a 288-line script (what libass's force_style uses), so the
499
+ rendered pixel size is size * play_h / 288.
500
+ """
501
+ if not play_w or not play_h or not args.size:
502
+ return None
503
+ size_px = args.size * play_h / 288.0
504
+ if size_px <= 0:
505
+ return None
506
+ return (play_w * SAFE_WIDTH_FRACTION) / size_px
507
+
508
+
509
+ def parse_ass_dialogue(path: str) -> str:
510
+ """The spoken text of an ASS file, for script detection -- style/override blocks stripped."""
511
+ text = []
512
+ for line in read_text_or_die(path, "--ass").lstrip("\ufeff").splitlines():
513
+ if not line.startswith("Dialogue:"):
514
+ continue
515
+ fields = line.split(",", 9)
516
+ if len(fields) == 10:
517
+ text.append(re.sub(r"\{[^}]*\}", "", fields[9]))
518
+ return "\n".join(text)
519
+
520
+
521
+ def shift_ass_file(src: str, dst: str, offset: float) -> int:
522
+ """Copy an ASS file with every Dialogue start/end moved by `offset` seconds."""
523
+ def shift(stamp: str) -> str:
524
+ h, m, rest = stamp.split(":")
525
+ secs = int(h) * 3600 + int(m) * 60 + float(rest) + offset
526
+ secs = max(0.0, secs)
527
+ cs = int(round(secs * 100))
528
+ hh, rem = divmod(cs, 360000)
529
+ mm, rem = divmod(rem, 6000)
530
+ ss, cc = divmod(rem, 100)
531
+ return f"{hh}:{mm:02d}:{ss:02d}.{cc:02d}"
532
+
533
+ n = 0
534
+ out = []
535
+ for line in Path(src).read_text(encoding="utf-8-sig").splitlines():
536
+ if line.startswith("Dialogue:"):
537
+ head, sep, rest = line.partition(":")
538
+ fields = rest.split(",")
539
+ if len(fields) >= 3:
540
+ try:
541
+ fields[1], fields[2] = shift(fields[1].strip()), shift(fields[2].strip())
542
+ line = head + sep + ",".join(fields)
543
+ n += 1
544
+ except (ValueError, IndexError):
545
+ pass
546
+ out.append(line)
547
+ Path(dst).write_text("\n".join(out) + "\n", encoding="utf-8-sig")
548
+ return n
549
+
550
+
551
+ def whisper_word_timings(srt_path: Optional[str]) -> List[Tuple[float, float, str]]:
552
+ """Word timings from a whisper JSON transcript sitting next to the SRT, if there is one.
553
+
554
+ whisper (and faster-whisper, and whisper.cpp's --output-json) can emit per-word start/end
555
+ times; when they are there, --karaoke should follow the real speech instead of splitting the
556
+ cue evenly. Looked for as <stem>.json and <stem>.words.json next to the SRT, in either the
557
+ {"segments": [{"words": [{"word": ..., "start": ..., "end": ...}]}]} or a bare
558
+ {"words": [...]} shape. Anything unreadable is simply "no word timings".
559
+ """
560
+ if not srt_path:
561
+ return []
562
+ stem = os.path.splitext(srt_path)[0]
563
+ for cand in (stem + ".words.json", stem + ".json"):
564
+ if not os.path.exists(cand):
565
+ continue
566
+ try:
567
+ data = json.loads(Path(cand).read_text(encoding="utf-8"))
568
+ except (OSError, ValueError):
569
+ continue
570
+ raw = []
571
+ if isinstance(data, dict):
572
+ raw = list(data.get("words") or [])
573
+ for seg in data.get("segments") or []:
574
+ raw.extend((seg or {}).get("words") or [])
575
+ words = []
576
+ for w in raw:
577
+ try:
578
+ text = str(w.get("word") or w.get("text") or "").strip()
579
+ if text:
580
+ words.append((float(w["start"]), float(w["end"]), text))
581
+ except (AttributeError, KeyError, TypeError, ValueError):
582
+ continue
583
+ if words:
584
+ info(f"karaoke: word timings from {os.path.basename(cand)} ({len(words)} words)")
585
+ return sorted(words)
586
+ return []
587
+
588
+
589
+ def word_durations_from_timings(words: List[Tuple[float, float, str]], start: float, end: float,
590
+ n_words: int) -> Optional[List[int]]:
591
+ """Centiseconds per word for one cue, from real word timings; None when they don't cover it."""
592
+ inside = [w for w in words if w[1] > start + 0.01 and w[0] < end - 0.01]
593
+ if len(inside) != n_words or n_words <= 0:
594
+ return None
595
+ total_cs = max(1, int(round((end - start) * 100)))
596
+ bounds = [max(start, inside[0][0])] + [max(start, min(end, w[1])) for w in inside]
597
+ out = [max(1, int(round((bounds[i + 1] - bounds[i]) * 100))) for i in range(n_words)]
598
+ out[-1] += total_cs - sum(out)
599
+ if out[-1] < 1:
600
+ return None
601
+ return out
602
+
603
+
296
604
  def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int, play_h: int, video: str = None) -> None:
297
605
  """Write a styled ASS file with optional animation and word-by-word highlight."""
298
606
  def t(sec: float) -> str:
@@ -341,11 +649,15 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
341
649
  dur_cs = max(1, int(round((end - start) * 100)))
342
650
  segments = body.split("\\N")
343
651
  words = [w for seg in segments for w in seg.split(" ") if w]
344
- if getattr(args, "karaoke_timing", "even") == "energy" and video:
345
- durs = word_durations_from_audio(video, start, end, len(words), getattr(args, "audio_stream", 0))
346
- else:
347
- per = max(1, dur_cs // max(1, len(words)))
348
- durs = [per] * len(words)
652
+ # real word timings from the transcript beat both the energy estimate and the even
653
+ # split -- they are what the speaker actually did, not a proxy for it
654
+ durs = word_durations_from_timings(getattr(args, "_word_timings", None) or [], start, end, len(words))
655
+ if durs is None:
656
+ if getattr(args, "karaoke_timing", "even") == "energy" and video:
657
+ durs = word_durations_from_audio(video, start, end, len(words), getattr(args, "audio_stream", 0))
658
+ else:
659
+ per = max(1, dur_cs // max(1, len(words)))
660
+ durs = [per] * len(words)
349
661
  it = iter(durs)
350
662
  out_segments = []
351
663
  for seg in segments:
@@ -391,6 +703,23 @@ def ass_font_name(name: str) -> str:
391
703
  return name
392
704
 
393
705
 
706
+ def _glue_negative_offset(argv: List[str]) -> List[str]:
707
+ """`--offset -0:00:02` reads as a flag to argparse, not a value: only bare negative NUMBERS
708
+ are exempt from the "starts with -" rule, and a negative timecode is not one. Join the pair
709
+ into `--offset=-0:00:02` so the documented grammar works in the shape people type it."""
710
+ out: List[str] = []
711
+ i = 0
712
+ while i < len(argv):
713
+ nxt = argv[i + 1] if i + 1 < len(argv) else ""
714
+ if argv[i] == "--offset" and nxt.startswith("-") and not nxt.startswith("--"):
715
+ out.append(f"--offset={nxt}")
716
+ i += 2
717
+ continue
718
+ out.append(argv[i])
719
+ i += 1
720
+ return out
721
+
722
+
394
723
  def main() -> int:
395
724
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
396
725
  ap.add_argument("input", nargs="?", help="video to burn captions into (omit with --write-srt to only generate)")
@@ -407,7 +736,11 @@ def main() -> int:
407
736
  src.add_argument("--ass", help="ASS file to burn (styles inside the file are used)")
408
737
  src.add_argument("--text", help="plain text cue file to convert into SRT (see format above)")
409
738
  src.add_argument("--transcribe", action="store_true", help="generate the SRT from the audio with a local speech-to-text engine if one is installed (whisper-cli / whisper / faster-whisper); never required")
410
- src.add_argument("--language", help="language code for --transcribe (e.g. en, ja; default auto), also tagged on the subtitle stream with --mode mux")
739
+ src.add_argument("--language", "--lang", help="language code (e.g. en, ja, zh, ko): the language for --transcribe (default auto), "
740
+ "the tag on the subtitle stream with --mode mux, and the hint that says whether Han-only "
741
+ "text is Chinese, Japanese or Korean when a font is picked by script")
742
+ src.add_argument("--offset", default="0", help="shift every cue by TIME (seconds, mm:ss, hh:mm:ss.ms or "
743
+ "hh:mm:ss:ff; a leading - shifts earlier); works for --text, --srt and --ass")
411
744
  src.add_argument("--model", default="base", help="whisper model name/path for --transcribe (default base)")
412
745
  src.add_argument("--write-srt", help="where to save the generated SRT (default: <text>.srt)")
413
746
  src.add_argument("--auto-seconds", type=float, default=3.0, help="duration for cues without timing (default 3)")
@@ -428,6 +761,8 @@ def main() -> int:
428
761
  sty.add_argument("--position", choices=sorted(ALIGN), default=None, help="on-screen placement (default bottom)")
429
762
  sty.add_argument("--margin", type=int, default=30, help="vertical margin from the edge (default 30)")
430
763
  sty.add_argument("--box", action="store_true", help="draw an opaque box behind text instead of an outline")
764
+ 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
+ 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)")
431
766
  anim = ap.add_argument_group("animation (generates ASS; needs --text or --srt input)")
432
767
  anim.add_argument("--animate", choices=["none", "fade", "pop", "slide"], default=None, help="per-cue entrance animation (default none, or brand caption.animate)")
433
768
  anim.add_argument("--karaoke", action="store_true", help="word-by-word highlight (fills from --color to --highlight-color across each cue)")
@@ -439,14 +774,21 @@ def main() -> int:
439
774
  enc.add_argument("--crf", type=int, default=18)
440
775
  enc.add_argument("--preset", default="medium", choices=X264_PRESETS)
441
776
  add_common(ap)
442
- args = ap.parse_args()
777
+ args = ap.parse_args(_glue_negative_offset(sys.argv[1:]))
443
778
  apply_common(args)
444
779
 
445
780
  brand = load_brand(args.brand)
446
- bc, bcap = brand["colors"], brand["caption"]
447
- args.font = args.font or brand.get("font") or "DejaVu Sans"
781
+ bc, bcap = brand["colors"], brand_caption_style(brand)
782
+ # A brand file that never names a font is not an explicit font: BRAND_DEFAULTS always
783
+ # supplies one, so asking the merged document would turn font-by-script off for every job
784
+ # that passes --brand at all.
785
+ font_explicit = bool(args.font) or bool(args.brand and brand_states_font(brand))
786
+ args.language = args.language or (brand.get("lang") if args.brand else None)
787
+ if args.brand and bcap.get("box") and not args.box:
788
+ args.box = True
789
+ args.font = args.font or (bcap.get("font") if args.brand else None) or brand.get("font") or "DejaVu Sans"
448
790
  args.size = args.size if args.size is not None else (bcap.get("size", 24) if args.brand else 24)
449
- args.color = color_hex(args.color or bc.get("text", "FFFFFF"))
791
+ args.color = color_hex(args.color or (bcap.get("color") if args.brand else None) or bc.get("text", "FFFFFF"))
450
792
  args.outline_color = color_hex(args.outline_color or bc.get("outline", "000000"))
451
793
  args.outline = args.outline if args.outline is not None else (float(bcap.get("outline", 2)) if args.brand else 2.0)
452
794
  args.position = args.position or (bcap.get("position", "bottom") if args.brand else "bottom")
@@ -469,6 +811,12 @@ def main() -> int:
469
811
  if args.animate != "none" or args.karaoke:
470
812
  die("--animate/--karaoke render pixels into the picture and require --mode burn")
471
813
 
814
+ args.offset = signed_time_arg(str(args.offset), "--offset")
815
+ if args.max_lines < 1:
816
+ die("--max-lines must be at least 1")
817
+ if args.min_duration < 0:
818
+ die("--min-duration cannot be negative")
819
+
472
820
  meta = None
473
821
  if args.input:
474
822
  meta = probe(args.input)
@@ -483,6 +831,22 @@ def main() -> int:
483
831
  if fps_for_tc is None and meta is not None:
484
832
  fps_for_tc = meta.get("video", {}).get("fps")
485
833
 
834
+ play_w = play_h = None
835
+ if meta and meta.get("video"):
836
+ play_w, play_h = meta["video"]["width"], meta["video"]["height"]
837
+ if meta["video"].get("rotation") in (90, -90, 270, -270):
838
+ play_w, play_h = play_h, play_w
839
+
840
+ def lay_out(cue_list):
841
+ """Wrap to the safe area, split past --max-lines, lengthen to --min-duration, shift by
842
+ --offset -- the one place every cue source goes through, so an SRT, a cue file and a
843
+ transcript all come out equally readable."""
844
+ out, stats = layout_cues(cue_list, max_em=max_line_em(args, play_w, play_h),
845
+ max_lines=args.max_lines, min_duration=args.min_duration,
846
+ offset=args.offset)
847
+ report_layout(stats)
848
+ return out, any(stats.values())
849
+
486
850
  srt_path = args.srt
487
851
  if args.transcribe:
488
852
  if not args.input:
@@ -497,10 +861,15 @@ def main() -> int:
497
861
  if os.path.exists(srt_path) and not getattr(args, "overwrite", False):
498
862
  info(f"warning: {srt_path} already exists and will be replaced by the transcript (pass --overwrite to confirm)")
499
863
  cues = transcribe(args.input, srt_path, args.language, args.model, args.audio_stream)
864
+ args._word_timings = whisper_word_timings(srt_path)
865
+ cues, changed = lay_out(cues)
866
+ if changed:
867
+ write_srt(cues, srt_path)
500
868
  info(f"wrote {srt_path} ({len(cues)} cues)")
501
869
  args.text = None
502
870
  if args.text:
503
871
  cues = parse_text_cues(args.text, args.auto_seconds, args.gap, fps_for_tc)
872
+ cues, _ = lay_out(cues)
504
873
  if args.write_srt:
505
874
  srt_path = args.write_srt
506
875
  elif args.input:
@@ -522,8 +891,48 @@ def main() -> int:
522
891
 
523
892
  output = args.output or default_output(args.input, "captioned")
524
893
 
894
+ # An SRT or ASS the caller wrote is never edited in place: when --offset/--max-lines/
895
+ # --min-duration change it, the adjusted copy is written next to the output and burned instead.
896
+ # The path is repointed in BOTH modes: --dry-run/--plan must describe the job the real run
897
+ # executes, so the planned command names the adjusted copy the real run burns. Only the
898
+ # WRITING waits for a real run (the rule every side file in this tool follows), which is why
899
+ # the cues are kept in hand below for the font sample and the ASS generator.
900
+ planned_cues: Optional[List[Tuple[float, float, str]]] = None
901
+ side_notes: List[str] = []
902
+ ass_sample_path = args.ass
903
+ if args.srt and not (args.text or args.transcribe) and os.path.exists(srt_path or ""):
904
+ adjusted, changed = lay_out(parse_srt(srt_path))
905
+ if changed:
906
+ new_srt = os.path.splitext(output)[0] + "_adjusted.srt"
907
+ if STATE.dry_run:
908
+ info(f"[dry-run] would write {new_srt} ({len(adjusted)} cues, adjusted from {os.path.basename(srt_path)})")
909
+ else:
910
+ write_srt(adjusted, new_srt)
911
+ info(f"wrote {new_srt} ({len(adjusted)} cues, adjusted from {os.path.basename(srt_path)})")
912
+ srt_path = new_srt
913
+ planned_cues = adjusted
914
+ side_notes.append(f"the burned subtitles are {new_srt}, the adjusted copy of "
915
+ f"{os.path.basename(args.srt)} this run writes (--offset/--max-lines/--min-duration); "
916
+ "re-run this command without --dry-run to produce it")
917
+ if args.ass and args.offset and os.path.exists(args.ass):
918
+ shifted = os.path.splitext(output)[0] + "_offset.ass"
919
+ if STATE.dry_run:
920
+ info(f"[dry-run] would write {shifted} (cues shifted by {args.offset:+g} s)")
921
+ else:
922
+ n = shift_ass_file(args.ass, shifted, args.offset)
923
+ info(f"wrote {shifted} ({n} cues shifted by {args.offset:+g} s)")
924
+ args.ass = shifted
925
+ side_notes.append(f"the burned subtitles are {shifted}, the offset copy of "
926
+ f"{os.path.basename(ass_sample_path)} this run writes; re-run this command "
927
+ "without --dry-run to produce it")
928
+ # a side file this run has planned but (under --dry-run) not written is still the file the
929
+ # command names, so its absence must not be reported as a missing input
930
+ planned_only = STATE.dry_run and planned_cues is not None
931
+ planned_ass = STATE.dry_run and bool(args.ass) and args.ass != ass_sample_path
932
+
525
933
  if args.mode == "mux":
526
- if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and (args.text or args.transcribe))):
934
+ if not srt_path or (not os.path.exists(srt_path) and not planned_only
935
+ and not (STATE.dry_run and (args.text or args.transcribe))):
527
936
  die(f"SRT file not found: {srt_path}")
528
937
  codec = mux_subtitle_codec(output)
529
938
  # Keep any subtitle track(s) the input already has (e.g. chaining --mode mux once per
@@ -550,8 +959,31 @@ def main() -> int:
550
959
  emit(output)
551
960
  return 0
552
961
 
962
+ # A font that covers the text, before anything is rendered: non-Latin cues in a Latin-only
963
+ # family come out as empty boxes, and ffmpeg exits 0 all the same (see references/gotchas.md).
964
+ if args.ass:
965
+ sample = parse_ass_dialogue(ass_sample_path) if ass_sample_path and os.path.exists(ass_sample_path) else ""
966
+ elif args.text or args.transcribe:
967
+ sample = "\n".join(t for _, _, t in cues)
968
+ elif planned_cues is not None:
969
+ sample = "\n".join(t for _, _, t in planned_cues)
970
+ else:
971
+ sample = "\n".join(t for _, _, t in parse_srt(srt_path)) if os.path.exists(srt_path or "") else ""
972
+ _script, font_file, font_family = script_font_for_text(
973
+ sample, lang=args.language, font=args.font, font_explicit=font_explicit,
974
+ fonts_dir=args.fonts_dir)
975
+ if font_file:
976
+ args.font = font_family or args.font
977
+ if not args.fonts_dir:
978
+ args.fonts_dir = os.path.dirname(font_file)
979
+
553
980
  if (args.animate != "none" or args.karaoke) and not args.ass:
554
- cues_for_ass = cues if (args.text or args.transcribe) else parse_srt(srt_path)
981
+ # both sources are already laid out: `cues` above, and srt_path was rewritten in place of
982
+ # 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))
985
+ if args.karaoke and not getattr(args, "_word_timings", None):
986
+ args._word_timings = whisper_word_timings(srt_path)
555
987
  ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
556
988
  w, h = meta["video"]["width"], meta["video"]["height"]
557
989
  if meta["video"].get("rotation") in (90, -90, 270, -270):
@@ -565,13 +997,14 @@ def main() -> int:
565
997
  generated_ass = False
566
998
 
567
999
  if args.ass:
568
- if not generated_ass and not os.path.exists(args.ass):
1000
+ if not generated_ass and not os.path.exists(args.ass) and not planned_ass:
569
1001
  die(f"ASS file not found: {args.ass}")
570
1002
  vf = f"ass={escape_filter_path(args.ass)}"
571
1003
  if args.fonts_dir:
572
1004
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
573
1005
  else:
574
- if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and (args.text or args.transcribe))):
1006
+ if not srt_path or (not os.path.exists(srt_path) and not planned_only
1007
+ and not (STATE.dry_run and (args.text or args.transcribe))):
575
1008
  die(f"SRT file not found: {srt_path}")
576
1009
  style = [
577
1010
  f"FontName={ass_font_name(args.font)}",
@@ -599,7 +1032,7 @@ def main() -> int:
599
1032
  run(cmd)
600
1033
  result = probe(output, role="output")
601
1034
  info(f"wrote {output} ({fmt_secs(result.get('duration'))})")
602
- emit(output)
1035
+ emit(output, **({"notes": side_notes} if side_notes else {}))
603
1036
  return 0
604
1037
 
605
1038
 
@@ -15,13 +15,14 @@ Examples:
15
15
  python3 graphics.py talk.mp4 --template title --title "Episode 12" --subtitle "The math of video" --start 0 --end 4
16
16
  python3 graphics.py talk.mp4 --template progress --brand brand.json
17
17
  python3 graphics.py intro.mp4 --template countdown --from 5 --start 1 --end 6
18
+ python3 graphics.py talk.mp4 --template lower-third --name "김민준" --title "감독" --lang ko
18
19
  python3 graphics.py clip.mp4 --template chapter --title "Part 2 — Setup" --position top-left --start 0 --end 5
19
20
  """
20
21
  import argparse
21
22
  import sys
22
23
  from typing import List, Optional
23
24
 
24
- from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS, time_arg, fmt_secs
25
+ from _common import aac_args, add_common, brand_caption_style, script_font_for_text, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS, time_arg, fmt_secs
25
26
 
26
27
  TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
27
28
 
@@ -30,9 +31,11 @@ def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
30
31
  return f"0x{color_hex(hex_rgb)}@{alpha:g}"
31
32
 
32
33
 
33
- def font_opts(brand: dict, font: Optional[str], font_file: Optional[str]) -> str:
34
+ def font_opts(brand: dict, font: Optional[str], font_file: Optional[str], script_file: Optional[str] = None) -> str:
34
35
  if font_file or brand.get("font_file"):
35
36
  return f"fontfile={escape_filter_path(font_file or brand['font_file'])}"
37
+ if script_file: # a font picked by the script of the text itself (1.12)
38
+ return f"fontfile={escape_filter_path(script_file)}"
36
39
  resolved = default_font_file(font or brand.get("font", "DejaVu Sans"))
37
40
  if resolved:
38
41
  return f"fontfile={escape_filter_path(resolved)}"
@@ -60,6 +63,8 @@ def main() -> int:
60
63
  ap.add_argument("--text-color", help="override text colour RRGGBB")
61
64
  ap.add_argument("--font")
62
65
  ap.add_argument("--font-file")
66
+ ap.add_argument("--lang", help="language code of the text (e.g. ja, zh, ko): the hint that says whether Han-only "
67
+ "text is Chinese, Japanese or Korean when a font is picked by script")
63
68
  ap.add_argument("--scale", type=float, default=1.0, help="size multiplier (default 1)")
64
69
  ap.add_argument("--crf", type=int, default=18)
65
70
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
@@ -72,7 +77,17 @@ def main() -> int:
72
77
  text_c = color_hex(args.text_color or brand["colors"]["text"])
73
78
  bg = color_hex(brand["colors"].get("background", "101418"))
74
79
  margin = int(brand.get("safe_margin", 48))
75
- fo = font_opts(brand, args.font, args.font_file)
80
+ style = brand_caption_style(brand) # styles.caption is shared with caption.py
81
+ if args.brand and style.get("font") and not args.font:
82
+ args.font = style["font"]
83
+ if args.brand and style.get("color") and not args.text_color:
84
+ text_c = color_hex(style["color"])
85
+ args.lang = args.lang or (brand.get("lang") if args.brand else None)
86
+ # a font that covers the text before drawtext renders boxes instead of glyphs (1.12)
87
+ _script, script_file, _family = script_font_for_text(
88
+ " ".join(t for t in (args.name, args.title, args.subtitle) if t),
89
+ lang=args.lang, font=args.font, font_explicit=bool(args.font), font_file=args.font_file or brand.get("font_file"))
90
+ fo = font_opts(brand, args.font, args.font_file, script_file)
76
91
 
77
92
  meta = probe(args.input)
78
93
  if not meta.get("video"):