ffmpeg-skill 1.15.1 → 1.16.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.
@@ -121,8 +121,9 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
121
121
  "broll": dict(role="execution", inputs=["A-roll video asset", "one or more B-roll video assets (--insert)"], outputs=["video artifact of exactly the A-roll's length with the B-roll shown during each cutaway window"],
122
122
  required=FF + [X264, AAC, "filter:overlay", "filter:amix"], optional=[HDR_X265],
123
123
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
124
- "metadata": dict(role="execution", inputs=["video or audio asset", "chapters text file (--chapters)"], outputs=["the same streams, stream-copied, with chapter markers and/or title/artist/comment tags written"],
125
- required=FF, optional=[],
124
+ "metadata": dict(role="execution", inputs=["video or audio asset", "chapters text file (--chapters)"], outputs=["the same streams, stream-copied, with chapter markers and/or title/artist/comment tags written", "proposed chapter list and YouTube description block (--auto-chapters --chapters-out/--description-out)"],
125
+ required=FF, optional=[{"capability": "filter:silencedetect", "when": "--auto-chapters"},
126
+ {"capability": "filter:scdet", "when": "--auto-chapters --from scenes|both"}],
126
127
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="bit_exact", deterministic=True),
127
128
  "loop": dict(role="execution", inputs=["video asset"], outputs=["video artifact repeated to the requested count or duration"],
128
129
  required=FF + [X264, AAC], optional=[],
@@ -142,7 +143,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
142
143
  "sequence": dict(role="execution", inputs=["a directory of numbered/globbed still images"], outputs=["video artifact built from the frame sequence"],
143
144
  required=FF + [X264], optional=[],
144
145
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
145
- "caption": dict(role="execution", inputs=["video asset", "SRT/ASS file or timed text (--text)"], outputs=["video artifact with burnt-in captions (--mode burn)", "video artifact with an added soft subtitle stream (--mode mux)", "generated .srt / .ass sidecar"],
146
+ "caption": dict(role="execution", inputs=["video asset", "SRT/ASS file or timed text (--text)"], outputs=["video artifact with burnt-in captions (--mode burn)", "video artifact with one or several language-tagged soft subtitle streams (--mode mux, --srt file:lang repeated)", "generated .srt / .ass sidecar"],
146
147
  required=FF + [X264, AAC, "filter:subtitles"], optional=[{"capability": "filter:ass", "when": "--animate / --karaoke"}, HDR_X265, {"capability": "external:whisper", "when": "--transcribe"},
147
148
  {"capability": "encoder:mov_text", "when": "--mode mux with a .mp4/.m4v/.mov output"}, {"capability": "encoder:webvtt", "when": "--mode mux with a .webm output"}, {"capability": "encoder:srt", "when": "--mode mux with a .mkv output"}],
148
149
  video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
@@ -260,7 +261,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
260
261
  "pad": dict(video="always", audio="always", note="the tpad filter always forces a re-encode of the video stream; audio is re-encoded to AAC when present"),
261
262
  "speedramp": dict(video="always", audio="always", note="setpts/atempo per segment always forces a re-encode of both streams"),
262
263
  "broll": dict(video="always", audio="conditional", note="the overlay graph always re-encodes the video stream; A's audio is stream-copied under --audio a and re-encoded to AAC under --audio b/mix"),
263
- "metadata": dict(video="never", audio="never", note="-c copy on every stream; only the container's chapters and tags change"),
264
+ "metadata": dict(video="never", audio="never", note="-c copy on every stream; only the container's chapters and tags change -- --auto-chapters decodes to measure, but still writes with -c copy"),
264
265
  "loop": dict(video="always", audio="always", note="-stream_loop always re-encodes both streams; the audio codec is always AAC when present"),
265
266
  "insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
266
267
  "background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
@@ -393,6 +394,20 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
393
394
  extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
394
395
  "notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named: youtube was assumed and judgement rows are WARN"},
395
396
  "checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
397
+ elif name == "caption":
398
+ extra = {"caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured') and phrase_breaks (1.16)"},
399
+ "tracks": {"type": "array", "description": "--mode mux: one entry per subtitle stream in the output ({index, file, language, title, codec, default, cues, kept_from_input}); a stream the input already carried has file null and kept_from_input true (1.16)"},
400
+ "subtitle_tracks": {"type": "integer", "description": "--mode mux: how many subtitle streams the output carries"},
401
+ "emoji": {"type": "object", "description": "how the emoji in the text were drawn (mode, overlays, missing)"},
402
+ "notes": {"type": "array", "items": {"type": "string"}}}
403
+ elif name == "metadata":
404
+ extra = {"chapters": {"type": "array", "description": "the chapter markers read back off the written file"},
405
+ "tags": {"type": "object"}, "streams_copied": {"type": "boolean"},
406
+ "auto_chapters": {"type": "object", "description": "--auto-chapters: {source, min_chapter, max_chapters, proposed, kept, titles, chapters, description_block, files}. titles is always 'placeholder' -- the skill proposes where a chapter starts, never what it is called (1.16)"},
407
+ "notes": {"type": "array", "items": {"type": "string"}}}
408
+ elif name == "waveform":
409
+ extra = {"audiogram": {"type": "object", "description": "{style, background ('image' or 'color'), image, position, vis_height, platform, captions, title, stages, verified} -- present on every run, so a plain waveform answers background 'color' (1.16)"},
410
+ "notes": {"type": "array", "items": {"type": "string"}}}
396
411
  elif name == "scenes":
397
412
  extra = {"file": {"type": "string"}, "duration": {"type": "number"}, "scene_count": {"type": "integer"}, "scenes": {"type": "array"}, "audio_peaks": {"type": "array"}}
398
413
  elif name == "silence":
@@ -38,11 +38,23 @@ import re
38
38
  import sys
39
39
  import unicodedata
40
40
  from pathlib import Path
41
- from typing import Dict, List, Optional, Tuple
41
+ from typing import Any, Dict, List, Optional, Tuple
42
42
 
43
43
  from _platforms import PLATFORMS, PLATFORM_CHOICES, ass_units, resolve as resolve_platform
44
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
+ from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, 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
46
+ # The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
47
+ from _common import (SAFE_WIDTH_FRACTION, ORPHAN_MIN_EM, WRAP_MODES, wrap_text, wrap_variants, best_break,
48
+ break_penalty, _is_weak_line, _atoms, _join, _break_spaced, _bare_word, _function_words,
49
+ _split_hyphens, FUNCTION_WORDS, JA_PARTICLES, JA_SENTENCE_END, _fix_orphans, _rebalance)
50
+
51
+ # The breaker's names are caption.py's public surface as much as _common's: every caller and test
52
+ # that reached for `caption.wrap_text` before 1.16 still does.
53
+ __all__ = ["SAFE_WIDTH_FRACTION", "ORPHAN_MIN_EM", "WRAP_MODES", "wrap_text", "wrap_variants",
54
+ "best_break", "break_penalty", "_is_weak_line", "_atoms", "_join", "_break_spaced",
55
+ "_bare_word", "_function_words", "_split_hyphens", "FUNCTION_WORDS", "JA_PARTICLES",
56
+ "JA_SENTENCE_END", "_fix_orphans", "_rebalance", "char_script", "NO_SPACE_SCRIPTS",
57
+ "text_width_em"]
46
58
 
47
59
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
48
60
 
@@ -299,187 +311,6 @@ def word_durations_from_audio(video: str, start: float, end: float, n_words: int
299
311
  return out
300
312
 
301
313
 
302
- # How much of the frame width a caption line may use. libass's own default SRT margins are 10 of a
303
- # 384-wide script (2.6 % a side); 5 % a side is the safe area every platform check in this repo uses.
304
- SAFE_WIDTH_FRACTION = 0.9
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
308
-
309
-
310
- def _atoms(line: str) -> List[Tuple[str, bool]]:
311
- """Break a line into the smallest pieces a wrap may separate -- one atom per CJK/Thai
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."""
315
- out: List[Tuple[str, bool]] = []
316
- word = ""
317
- spaced = False # a space stands before the atom being built
318
- pending = False # a space stands before the NEXT atom
319
- attach_next = False # a leading Thai/Lao vowel is waiting for its base consonant
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
337
- if char_script(ch) in NO_SPACE_SCRIPTS:
338
- if word:
339
- out.append((word, spaced))
340
- word = ""
341
- if out and (attach_next or _is_mark(ch)):
342
- # never break between a base and the mark (or the leading vowel) that belongs to
343
- # it: the line would start with an orphaned tone mark or vowel sign
344
- out[-1] = (out[-1][0] + ch, out[-1][1])
345
- else:
346
- out.append((ch, pending))
347
- pending = False
348
- attach_next = ord(ch) in LEADING_VOWELS
349
- elif ch.isspace():
350
- if word:
351
- out.append((word, spaced))
352
- word = ""
353
- pending = True
354
- else:
355
- if not word:
356
- spaced, pending = pending, False
357
- word += ch
358
- if word:
359
- out.append((word, spaced))
360
- return out
361
-
362
-
363
- def _join(left: str, atom: str, spaced: bool) -> str:
364
- """Put an atom back on a line, restoring the space that stood before it."""
365
- if not left:
366
- return atom
367
- return left + (" " if spaced else "") + atom
368
-
369
-
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]:
451
- """Wrap `text` to lines no wider than `max_em` em, keeping the manual breaks it already has.
452
-
453
- An atom wider than the whole line (one very long word) is left alone on its line rather than
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.
457
- """
458
- lines: List[str] = []
459
- for raw in text.split("\n"):
460
- if not raw.strip():
461
- continue
462
- current = ""
463
- chunk: List[str] = []
464
- for atom, spaced in _atoms(raw):
465
- candidate = _join(current, atom, spaced)
466
- if current and text_width_em(candidate) > max_em:
467
- chunk.append(current)
468
- current = atom
469
- else:
470
- current = candidate
471
- if 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)
481
- return lines or [text]
482
-
483
314
 
484
315
  # --------------------------------------------------------------------------- emoji (1.15)
485
316
  def _cue_lines(text: str) -> List[str]:
@@ -602,7 +433,8 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
602
433
 
603
434
 
604
435
  def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float], max_lines: int,
605
- min_duration: float, offset: float) -> Tuple[List[Tuple[float, float, str]], dict]:
436
+ min_duration: float, offset: float, wrap: str = "phrase",
437
+ lang: Optional[str] = None) -> Tuple[List[Tuple[float, float, str]], dict]:
606
438
  """Shift, wrap, split and lengthen cues so they can actually be read.
607
439
 
608
440
  `offset` moves every cue (a transcript that runs early/late); `max_em` wraps each cue to the
@@ -611,7 +443,8 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
611
443
  proportion to their text; a cue shorter than `min_duration` is lengthened, never past the next
612
444
  cue's start. Returns the new cues and a count of what changed.
613
445
  """
614
- stats = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0, "rebalanced": 0}
446
+ stats = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0, "rebalanced": 0,
447
+ "wrap": wrap, "phrase_breaks": 0}
615
448
  staged: List[Tuple[float, float, str]] = []
616
449
  for start, end, text in cues:
617
450
  if offset:
@@ -622,11 +455,16 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
622
455
  start = max(0.0, start)
623
456
  stats["shifted"] += 1
624
457
  if max_em and max_em > 0:
625
- lines = wrap_text(text, max_em)
458
+ # one greedy fill per cue, three answers off it: what gets burnt in, what the
459
+ # greedy wrap would have given (`rebalanced`) and what 1.15's wrap would have
460
+ # given (`phrase_breaks`). Three wrap_text() calls re-ran the atomiser each time.
461
+ lines, greedy, measured = wrap_variants(text, max_em, mode=wrap, lang=lang)
626
462
  if lines != [l for l in text.split("\n") if l.strip()]:
627
463
  stats["wrapped"] += 1
628
- if lines != wrap_text(text, max_em, balance=False):
464
+ if lines != greedy:
629
465
  stats["rebalanced"] += 1
466
+ if wrap != "measured" and lines != measured:
467
+ stats["phrase_breaks"] += 1
630
468
  if len(lines) > max_lines:
631
469
  chunks = [lines[i:i + max_lines] for i in range(0, len(lines), max_lines)]
632
470
  weights = [max(1.0, sum(len(l) for l in c)) for c in chunks]
@@ -654,7 +492,7 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
654
492
 
655
493
  def report_layout(stats: dict) -> None:
656
494
  """One info line, only when a cue actually changed."""
657
- parts = [f"{stats[k]} {k}" for k in ("shifted", "wrapped", "rebalanced", "split", "extended", "dropped") if stats.get(k)]
495
+ parts = [f"{stats[k]} {k}" for k in ("shifted", "wrapped", "rebalanced", "phrase_breaks", "split", "extended", "dropped") if stats.get(k)]
658
496
  if parts:
659
497
  info("cues: " + ", ".join(parts))
660
498
 
@@ -842,6 +680,93 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
842
680
  fh.write("\n".join(header + lines) + "\n")
843
681
 
844
682
 
683
+ # ------------------------------------------------------- multi-language subtitle tracks (1.16)
684
+
685
+ class AppendPath(argparse.Action):
686
+ """`--srt` repeated, without changing what the contract says `--srt` is.
687
+
688
+ `action="append"` would make the derived JSON Schema an array (`_contract._json_type`), and the
689
+ 1.x guarantee says no argument changes type -- an MCP client that sends `{"srt": "subs.srt"}`
690
+ must keep working exactly as it did. Subclassing Action directly keeps the schema a plain
691
+ string while the CLI collects every occurrence, so repeating the flag is purely additive.
692
+ """
693
+
694
+ def __call__(self, parser, namespace, values, option_string=None):
695
+ current = getattr(namespace, self.dest, None)
696
+ if not isinstance(current, list):
697
+ current = [] if current is None else [current]
698
+ current.append(values)
699
+ setattr(namespace, self.dest, current)
700
+
701
+
702
+ # BCP-47-ish: a 2-3 letter primary subtag, optionally followed by script/region/variant subtags.
703
+ LANG_TOKEN_RE = re.compile(r"^[a-z]{2,3}(-[A-Za-z0-9]{2,8})*$")
704
+
705
+ # The name a player lists a track under, when the caller gives no --track-title. Data, not a
706
+ # translation: a code that is not in the table gets the code itself, never an invented name.
707
+ LANG_TITLES = {
708
+ "en": "English", "es": "Espanol", "pt": "Portugues", "fr": "Francais", "de": "Deutsch",
709
+ "it": "Italiano", "nl": "Nederlands", "pl": "Polski", "ru": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439",
710
+ "ja": "\u65e5\u672c\u8a9e", "zh": "\u4e2d\u6587", "ko": "\ud55c\uad6d\uc5b4",
711
+ "ar": "\u0627\u0644\u0639\u0631\u0628\u064a\u0629", "he": "\u05e2\u05d1\u05e8\u05d9\u05ea",
712
+ "hi": "\u0939\u093f\u0928\u094d\u0926\u0940", "th": "\u0e44\u0e17\u0e22",
713
+ "tr": "Turkce", "id": "Bahasa Indonesia", "vi": "Tieng Viet", "sv": "Svenska",
714
+ "da": "Dansk", "no": "Norsk", "fi": "Suomi", "cs": "Cestina", "uk": "\u0423\u043a\u0440\u0430\u0457\u043d\u0441\u044c\u043a\u0430",
715
+ }
716
+
717
+ # MP4/MOV store the language in an ISO-639-2/T box and silently drop anything that is not three
718
+ # letters -- verified against ffmpeg 6.1: `-metadata:s:s:0 language=en` on an .mp4 writes NO
719
+ # language tag at all, while `language=eng` writes one ffprobe reads back. Matroska stores the
720
+ # code verbatim, so `en` survives there. Only the codes this table knows are converted; an
721
+ # unknown one is passed through with a note rather than guessed at.
722
+ ISO639_1_TO_2 = {
723
+ "en": "eng", "es": "spa", "pt": "por", "fr": "fra", "de": "deu", "it": "ita", "nl": "nld",
724
+ "pl": "pol", "ru": "rus", "ja": "jpn", "zh": "zho", "ko": "kor", "ar": "ara", "he": "heb",
725
+ "hi": "hin", "th": "tha", "tr": "tur", "id": "ind", "vi": "vie", "sv": "swe", "da": "dan",
726
+ "no": "nor", "fi": "fin", "cs": "ces", "uk": "ukr", "el": "ell", "hu": "hun", "ro": "ron",
727
+ "bg": "bul", "ca": "cat", "fa": "fas", "ta": "tam", "bn": "ben", "ms": "msa", "fil": "fil",
728
+ }
729
+
730
+
731
+ def split_srt_lang(token: str) -> Tuple[str, Optional[str]]:
732
+ """`file.srt:ja` -> ("file.srt", "ja"); anything else -> (token, None).
733
+
734
+ The split is on the LAST colon and only when the suffix is BCP-47-shaped AND the whole token
735
+ is not itself a readable file -- so `C:\\subs\\en.srt` (a Windows path) and a file genuinely
736
+ named `a:b.srt` are never mangled.
737
+ """
738
+ token = str(token)
739
+ if ":" not in token or os.path.exists(token):
740
+ return token, None
741
+ head, _, tail = token.rpartition(":")
742
+ if head and LANG_TOKEN_RE.match(tail):
743
+ return head, tail
744
+ # A tail that is clearly meant as a language code but is not one is a language error, not a
745
+ # file called `en.srt:zzzz`: only say "file not found" when the whole token could be a path.
746
+ if head and tail and not os.path.exists(token) and os.path.exists(head) \
747
+ and re.match(r"^[A-Za-z][A-Za-z0-9-]*$", tail):
748
+ die(f"--srt {token}: '{tail}' is not a language code (two or three letters, optionally "
749
+ "with a region, e.g. en, ja, pt-BR)", kind="input")
750
+ return token, None
751
+
752
+
753
+ def container_language(code: str, output: str) -> str:
754
+ """The spelling of `code` this container actually stores (see ISO639_1_TO_2)."""
755
+ ext = Path(output).suffix.lower()
756
+ if ext not in (".mp4", ".m4v", ".mov"):
757
+ return code
758
+ primary = code.split("-")[0].lower()
759
+ return ISO639_1_TO_2.get(primary, code)
760
+
761
+
762
+ def track_title_for(code: Optional[str], given: Optional[str]) -> Optional[str]:
763
+ if given:
764
+ return given
765
+ if not code:
766
+ return None
767
+ return LANG_TITLES.get(code.split("-")[0].lower(), code)
768
+
769
+
845
770
  def mux_subtitle_codec(output: str) -> str:
846
771
  ext = Path(output).suffix.lower()
847
772
  if ext in (".mp4", ".m4v", ".mov"):
@@ -905,13 +830,26 @@ def main() -> int:
905
830
  "audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
906
831
  "the first track, same as leaving it unset always did")
907
832
  src = ap.add_argument_group("subtitle source")
908
- src.add_argument("--srt", help="SRT file to burn")
833
+ src.add_argument("--srt", action=AppendPath, metavar="FILE[:LANG]",
834
+ help="SRT file to burn, or (with --mode mux) to add as a soft subtitle track. Repeat it once "
835
+ "per language to build a multi-track deliverable, each with an optional `:lang` suffix: "
836
+ "`--srt en.srt:en --srt ja.srt:ja`. A single --srt with no suffix takes --language, as "
837
+ "it always did. NOTE: .mp4/.mov hold several mov_text tracks but many players show only "
838
+ "the first, and the format needs ISO-639-2 codes (`eng`, not `en`) -- this tool converts "
839
+ "them; .mkv is the honest multi-track container and stores the code you give verbatim")
909
840
  src.add_argument("--ass", help="ASS file to burn (styles inside the file are used)")
910
841
  src.add_argument("--text", help="plain text cue file to convert into SRT (see format above)")
911
842
  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")
912
843
  src.add_argument("--language", "--lang", help="language code (e.g. en, ja, zh, ko): the language for --transcribe (default auto), "
913
844
  "the tag on the subtitle stream with --mode mux, and the hint that says whether Han-only "
914
845
  "text is Chinese, Japanese or Korean when a font is picked by script")
846
+ src.add_argument("--track-title", action=AppendPath, metavar="TITLE",
847
+ help="--mode mux: the name a player lists a track under, repeated in the same order as --srt "
848
+ "(default: the language's display name from a frozen table, else the code itself -- the "
849
+ "table is data, never a guessed or translated name)")
850
+ src.add_argument("--default-track", metavar="LANG",
851
+ help="--mode mux: mark this language's track `default` so a player selects it by itself "
852
+ "(default: none, so no player burns in a language the viewer did not ask for)")
915
853
  src.add_argument("--offset", default="0", help="shift every cue by TIME (seconds, mm:ss, hh:mm:ss.ms or "
916
854
  "hh:mm:ss:ff; a leading - shifts earlier); works for --text, --srt and --ass")
917
855
  src.add_argument("--model", default="base", help="whisper model name/path for --transcribe (default base)")
@@ -953,6 +891,13 @@ def main() -> int:
953
891
  help="most emoji overlays one run may build (default 60)")
954
892
  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)")
955
893
  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)")
894
+ sty.add_argument("--wrap", choices=list(WRAP_MODES), default="phrase",
895
+ help="how a cue too wide for the safe area is broken into lines: 'phrase' (default, 1.16) never "
896
+ "breaks inside a word or on the wrong side of a hyphen, never leaves a lone digit, kana or "
897
+ "punctuation on a line, prefers Japanese sentence ends and particles over a mid-word break, "
898
+ "and never ends a line on an article or preposition; 'measured' is 1.15's width-only wrap, "
899
+ "kept so an older split can be reproduced. Neither ever changes the number of lines, "
900
+ "rewrites the text or shortens a cue")
956
901
  anim = ap.add_argument_group("animation (generates ASS; needs --text or --srt input)")
957
902
  anim.add_argument("--animate", choices=["none", "fade", "pop", "slide"], default=None, help="per-cue entrance animation (default none, or brand caption.animate)")
958
903
  anim.add_argument("--karaoke", action="store_true", help="word-by-word highlight (fills from --color to --highlight-color across each cue)")
@@ -1039,16 +984,46 @@ def main() -> int:
1039
984
  if meta["video"].get("rotation") in (90, -90, 270, -270):
1040
985
  play_w, play_h = play_h, play_w
1041
986
 
987
+ caption_stats: dict = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0,
988
+ "rebalanced": 0, "wrap": args.wrap, "phrase_breaks": 0}
989
+
1042
990
  def lay_out(cue_list):
1043
991
  """Wrap to the safe area, split past --max-lines, lengthen to --min-duration, shift by
1044
992
  --offset -- the one place every cue source goes through, so an SRT, a cue file and a
1045
993
  transcript all come out equally readable."""
1046
994
  out, stats = layout_cues(cue_list, max_em=max_line_em(args, play_w, play_h),
1047
995
  max_lines=args.max_lines, min_duration=args.min_duration,
1048
- offset=args.offset)
996
+ offset=args.offset, wrap=args.wrap, lang=args.language)
1049
997
  report_layout(stats)
1050
- return out, any(stats.values())
1051
-
998
+ caption_stats.clear()
999
+ caption_stats.update(stats)
1000
+ return out, any(v for k, v in stats.items() if k != "wrap")
1001
+
1002
+ # --srt is repeatable since 1.16 (one per language, each with an optional `:lang` suffix).
1003
+ # Every path below that burns, adjusts or transcribes works on the FIRST one, which is what
1004
+ # `--srt x.srt` has always meant; the extra tracks only exist for --mode mux.
1005
+ srt_tracks: List[Tuple[str, Optional[str]]] = []
1006
+ for token in (args.srt or []):
1007
+ path_part, lang_part = split_srt_lang(token)
1008
+ srt_tracks.append((path_part, lang_part))
1009
+ if len(srt_tracks) == 1 and srt_tracks[0][1] is None and args.language:
1010
+ srt_tracks[0] = (srt_tracks[0][0], args.language)
1011
+ if srt_tracks and args.mode != "mux" and len(srt_tracks) > 1:
1012
+ die("burning renders pixels; only one language can be in the picture -- burn one and mux "
1013
+ "the rest (caption.py OUT --mode mux --srt en.srt:en --srt ja.srt:ja)", kind="input")
1014
+ seen_langs = [lang for _p, lang in srt_tracks if lang]
1015
+ for lang in seen_langs:
1016
+ if not LANG_TOKEN_RE.match(lang):
1017
+ die(f"--srt: '{lang}' is not a language code (two or three letters, optionally with a "
1018
+ "region, e.g. en, ja, pt-BR)", kind="input")
1019
+ if len(set(seen_langs)) != len(seen_langs):
1020
+ dup = sorted({l for l in seen_langs if seen_langs.count(l) > 1})
1021
+ die(f"--srt: two tracks tagged '{', '.join(dup)}' -- a player cannot tell them apart; give "
1022
+ "each track its own code (and --track-title to name them)", kind="input")
1023
+ if args.track_title and len(args.track_title) > max(1, len(srt_tracks)):
1024
+ die(f"--track-title given {len(args.track_title)} times for {len(srt_tracks)} --srt file(s)",
1025
+ kind="input")
1026
+ args.srt = srt_tracks[0][0] if srt_tracks else None
1052
1027
  srt_path = args.srt
1053
1028
  if args.transcribe:
1054
1029
  if not args.input:
@@ -1139,26 +1114,102 @@ def main() -> int:
1139
1114
  codec = mux_subtitle_codec(output)
1140
1115
  # Keep any subtitle track(s) the input already has (e.g. chaining --mode mux once per
1141
1116
  # language to build a multi-language set) -- copied byte-identical, distinct from the
1142
- # newly-added SRT's own codec below.
1117
+ # newly-added SRTs' own codec below.
1143
1118
  existing_subs = meta.get("subtitle_streams") or 0
1119
+ # the first entry's path is srt_path, which --offset/--max-lines may have repointed at an
1120
+ # adjusted copy; the rest are taken as written
1121
+ added = [(srt_path, srt_tracks[0][1] if srt_tracks else args.language)] + \
1122
+ [(p, lang) for p, lang in srt_tracks[1:]]
1123
+ for path, _lang in added[1:]:
1124
+ if not os.path.exists(path) and not STATE.dry_run:
1125
+ die(f"SRT file not found: {path}")
1126
+ titles = list(args.track_title or [])
1127
+ mp4_family = Path(output).suffix.lower() in (".mp4", ".m4v", ".mov")
1128
+ dropped_titles: List[str] = []
1129
+ notes = list(side_notes)
1144
1130
  maps = ["-map", "0:v:0"]
1145
- cmd = ffmpeg_base() + ["-i", args.input, "-i", srt_path]
1131
+ cmd = ffmpeg_base() + ["-i", args.input]
1132
+ for path, _lang in added:
1133
+ cmd += ["-i", path]
1146
1134
  if meta.get("audio"):
1147
1135
  maps += ["-map", f"0:a:{args.audio_stream}"]
1148
1136
  if existing_subs:
1149
1137
  maps += ["-map", "0:s?"]
1150
- maps += ["-map", "1:0"]
1138
+ for n in range(len(added)):
1139
+ maps += ["-map", f"{n + 1}:0"]
1151
1140
  cmd += maps + ["-c:v", "copy"] + (["-c:a", "copy"] if meta.get("audio") else [])
1152
1141
  for i in range(existing_subs):
1153
1142
  cmd += [f"-c:s:{i}", "copy"]
1154
- cmd += [f"-c:s:{existing_subs}", codec]
1155
- if args.language:
1156
- cmd += [f"-metadata:s:s:{existing_subs}", f"language={args.language}"]
1143
+ tracks: List[Dict[str, Any]] = []
1144
+ for i in range(existing_subs):
1145
+ kept = (meta.get("subtitle_stream_details") or [])
1146
+ detail = kept[i] if i < len(kept) else {}
1147
+ tracks.append({"index": i, "file": None, "language": detail.get("language"),
1148
+ "title": detail.get("title"), "codec": detail.get("codec"),
1149
+ "default": False, "cues": None, "kept_from_input": True})
1150
+ for n, (path, lang) in enumerate(added):
1151
+ idx = existing_subs + n
1152
+ cmd += [f"-c:s:{idx}", codec]
1153
+ stored = container_language(lang, output) if lang else None
1154
+ if stored:
1155
+ cmd += [f"-metadata:s:s:{idx}", f"language={stored}"]
1156
+ title = track_title_for(lang, titles[n] if n < len(titles) else None)
1157
+ # `-metadata:s:s:N title=` is written for Matroska and silently dropped by the MPEG-4
1158
+ # muxer (verified on ffmpeg 6.1: ffprobe reads no title back), so an MP4 track is
1159
+ # reported with `title: null` rather than a name the file does not carry.
1160
+ if title and not mp4_family:
1161
+ cmd += [f"-metadata:s:s:{idx}", f"title={title}"]
1162
+ elif title:
1163
+ dropped_titles.append(title)
1164
+ title = None
1165
+ is_default = bool(args.default_track and lang
1166
+ and lang.lower() == args.default_track.lower())
1167
+ # ALWAYS stated, never only when it is "default": given two or more new subtitle
1168
+ # streams and nothing said, ffmpeg flags the first one `default` by itself -- which
1169
+ # is the opposite of what --default-track promises and made tracks[].default
1170
+ # disagree with the file it describes. An explicit 0 suppresses that.
1171
+ cmd += [f"-disposition:s:{idx}", "default" if is_default else "0"]
1172
+ cues_n = None
1173
+ if os.path.exists(path):
1174
+ try:
1175
+ cues_n = len(parse_srt(path))
1176
+ except SystemExit:
1177
+ cues_n = None
1178
+ tracks.append({"index": idx, "file": path, "language": stored, "title": title,
1179
+ "codec": codec, "default": is_default, "cues": cues_n,
1180
+ "kept_from_input": False})
1181
+ if args.default_track and not any(t["default"] for t in tracks):
1182
+ die(f"--default-track {args.default_track}: no --srt was tagged with that language",
1183
+ kind="input")
1184
+ # MPEG-4 has no way to say "no default subtitle track": the muxer sets the track-header
1185
+ # ENABLED flag on the first subtitle track whatever `-disposition:s:N 0` asks for
1186
+ # (verified on ffmpeg 6.1; `-disposition:s:N default` does move it to another track).
1187
+ # Matroska honours the explicit 0. Report what the file carries, not what was asked.
1188
+ if mp4_family and tracks and not any(t["default"] for t in tracks):
1189
+ tracks[0]["default"] = True
1190
+ notes.append("an MPEG-4 container always enables its first subtitle track, so "
1191
+ f"{tracks[0]['language'] or 'track 0'} is marked default even though none "
1192
+ "was asked for; .mkv is the container that can leave every track off")
1157
1193
  cmd += [output]
1194
+ total = existing_subs + len(added)
1195
+ if total > 2 and mp4_family:
1196
+ notes.append(f"{total} subtitle tracks in an MPEG-4 container: the tracks are all there, "
1197
+ "but many players only ever show the first -- write to .mkv for a "
1198
+ "deliverable a viewer can actually switch")
1199
+ if dropped_titles:
1200
+ notes.append("an MPEG-4 container has no per-track title this tool can write back "
1201
+ f"({', '.join(dropped_titles)} would be dropped), so the tracks are "
1202
+ "reported with no title; .mkv keeps the names")
1203
+ if mp4_family and any(
1204
+ t["language"] and len(t["language"]) != 3 for t in tracks if not t["kept_from_input"]):
1205
+ notes.append("MPEG-4 stores the language as a three-letter ISO-639-2 code and drops "
1206
+ "anything else; a code this tool has no conversion for was passed through "
1207
+ "as given and may not survive")
1158
1208
  run(cmd)
1159
1209
  result = probe(output, role="output")
1160
- info(f"wrote {output} ({fmt_secs(result.get('duration'))}, mux, subtitle codec {codec})")
1161
- emit(output)
1210
+ info(f"wrote {output} ({fmt_secs(result.get('duration'))}, mux, {len(added)} "
1211
+ f"subtitle track(s) added, codec {codec})")
1212
+ emit(output, tracks=tracks, subtitle_tracks=total, **({"notes": notes} if notes else {}))
1162
1213
  return 0
1163
1214
 
1164
1215
  # A font that covers the text, before anything is rendered: non-Latin cues in a Latin-only
@@ -1269,6 +1320,7 @@ def main() -> int:
1269
1320
  result = probe(output, role="output")
1270
1321
  info(f"wrote {output} ({fmt_secs(result.get('duration'))})")
1271
1322
  extra = {"notes": side_notes} if side_notes else {}
1323
+ extra["caption"] = dict(caption_stats)
1272
1324
  if emoji_plan:
1273
1325
  notes = list(extra.get("notes") or [])
1274
1326
  if emoji_plan["mode"] == "mono":