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.
- package/README.md +5 -3
- package/SKILL.md +8 -7
- package/docs/contract.md +24 -9
- package/package.json +1 -1
- package/references/scripts.md +121 -8
- package/scripts/_common/__init__.py +21 -4
- package/scripts/_common/decision.py +106 -1
- package/scripts/_common/probe.py +92 -2
- package/scripts/_common/text.py +535 -0
- package/scripts/_contract.py +19 -4
- package/scripts/caption.py +252 -200
- package/scripts/check.py +19 -0
- package/scripts/graphics.py +47 -8
- package/scripts/metadata.py +130 -5
- package/scripts/render.py +33 -6
- package/scripts/scenes.py +3 -61
- package/scripts/silence.py +3 -25
- package/scripts/waveform.py +199 -12
- package/templates/audiogram.json +31 -0
package/scripts/_contract.py
CHANGED
|
@@ -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
|
|
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":
|
package/scripts/caption.py
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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 !=
|
|
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",
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
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,
|
|
1161
|
-
|
|
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":
|