ffmpeg-skill 1.16.0 → 1.17.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.
@@ -40,17 +40,27 @@ import unicodedata
40
40
  from pathlib import Path
41
41
  from typing import Any, Dict, List, Optional, Tuple
42
42
 
43
- from _platforms import PLATFORMS, PLATFORM_CHOICES, ass_units, resolve as resolve_platform
43
+ from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
44
+ resolve as resolve_platform)
44
45
  from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
45
46
  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
47
  # The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
48
+ # The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
49
+ # shares them). They stay caption.py's public names -- every caller and test that reached for
50
+ # caption.parse_srt / caption.transcribe / caption.whisper_word_timings before 1.17 still does.
51
+ from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
52
+ write_srt)
47
53
  from _common import (SAFE_WIDTH_FRACTION, ORPHAN_MIN_EM, WRAP_MODES, wrap_text, wrap_variants, best_break,
54
+ fit_size, line_em_for_size, MIN_CAPTION_FRACTION,
48
55
  break_penalty, _is_weak_line, _atoms, _join, _break_spaced, _bare_word, _function_words,
49
56
  _split_hyphens, FUNCTION_WORDS, JA_PARTICLES, JA_SENTENCE_END, _fix_orphans, _rebalance)
50
57
 
51
58
  # The breaker's names are caption.py's public surface as much as _common's: every caller and test
52
59
  # 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",
60
+ __all__ = ["parse_srt", "write_srt", "transcribe", "whisper_word_timings", "die_no_engine",
61
+ "ASR_INSTALL_HINT",
62
+ "SAFE_WIDTH_FRACTION", "ORPHAN_MIN_EM", "WRAP_MODES", "wrap_text", "wrap_variants",
63
+ "fit_size", "line_em_for_size", "MIN_CAPTION_FRACTION",
54
64
  "best_break", "break_penalty", "_is_weak_line", "_atoms", "_join", "_break_spaced",
55
65
  "_bare_word", "_function_words", "_split_hyphens", "FUNCTION_WORDS", "JA_PARTICLES",
56
66
  "JA_SENTENCE_END", "_fix_orphans", "_rebalance", "char_script", "NO_SPACE_SCRIPTS",
@@ -97,150 +107,6 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[fl
97
107
  return cues
98
108
 
99
109
 
100
- def transcribe(video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int = 0) -> List[Tuple[float, float, str]]:
101
- """Optional local ASR bridge. Tries, in order: whisper-cli / main (whisper.cpp), faster-whisper (python),
102
- whisper (openai-whisper CLI). Produces an SRT with word timings where the engine supports it.
103
- No engine installed -> clear error with install hints; the skill never depends on one."""
104
- import shutil
105
- import subprocess
106
- import tempfile
107
- from _common import require_tool, run_analysis, STATE
108
- ffmpeg = require_tool("ffmpeg")
109
- tmpdir = tempfile.mkdtemp(prefix="ffskill_asr_")
110
- try:
111
- return _transcribe_in(tmpdir, video, out_srt, language, model, audio_stream, ffmpeg, shutil, subprocess)
112
- finally:
113
- shutil.rmtree(tmpdir, ignore_errors=True)
114
-
115
-
116
- def _asr_run(cmd: List[str], subprocess, name: str) -> "subprocess.CompletedProcess":
117
- """Run a speech-to-text engine under the same wall-clock limit as an ffmpeg call."""
118
- from _common import STATE, die
119
- limit = STATE.timeout or None
120
- try:
121
- return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=limit)
122
- except subprocess.TimeoutExpired:
123
- die(f"{name} exceeded the {limit:.0f} s time limit and was killed; raise --timeout for a long recording",
124
- code=124, kind="timeout")
125
- return None # unreachable
126
-
127
-
128
- def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int,
129
- ffmpeg: str, shutil, subprocess) -> List[Tuple[float, float, str]]:
130
- from _common import run_analysis, STATE, die
131
- wav = os.path.join(tmpdir, "audio.wav")
132
- # A wav in our own temp dir: a measurement input for the engine, not a deliverable, so it
133
- # is not a run() call (no --dry-run gate, not recorded), but it keeps the time limit and
134
- # reports an unreadable input as kind ffmpeg instead of a CalledProcessError traceback.
135
- run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
136
- "-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav])
137
- # 1. whisper.cpp
138
- cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp")
139
- if not cli:
140
- # older whisper.cpp builds ship the binary as plain `main`; accept it only when it lives
141
- # in a directory that names whisper, so an unrelated /usr/bin/main is never run
142
- main_bin = shutil.which("main")
143
- if main_bin and "whisper" in os.path.dirname(os.path.realpath(main_bin)).lower():
144
- cli = main_bin
145
- if cli:
146
- model_path = model
147
- if not os.path.exists(model_path):
148
- for cand in (os.path.expanduser(f"~/.cache/whisper.cpp/ggml-{model}.bin"), f"models/ggml-{model}.bin", f"/usr/local/share/whisper/ggml-{model}.bin"):
149
- if os.path.exists(cand):
150
- model_path = cand
151
- break
152
- base = os.path.join(tmpdir, "out")
153
- cmd = [cli, "-m", model_path, "-f", wav, "-osrt", "-of", base]
154
- if language:
155
- cmd += ["-l", language]
156
- proc = _asr_run(cmd, subprocess, "whisper.cpp")
157
- if proc.returncode == 0 and os.path.exists(base + ".srt"):
158
- info(f"transcribed with whisper.cpp ({os.path.basename(cli)}, model {os.path.basename(model_path)})")
159
- cues = parse_srt(base + ".srt")
160
- write_srt(cues, out_srt)
161
- return cues
162
- info("whisper.cpp found but failed: " + (proc.stderr.strip().splitlines() or ["?"])[-1][:200])
163
- # 2. faster-whisper (python package)
164
- try:
165
- from faster_whisper import WhisperModel # type: ignore
166
- import threading
167
- result: list = []
168
-
169
- def work() -> None:
170
- m = WhisperModel(model, device="cpu", compute_type="int8")
171
- segments, _ = m.transcribe(wav, language=language, word_timestamps=False)
172
- result.extend((seg.start, seg.end, seg.text.strip()) for seg in segments if seg.text.strip())
173
-
174
- # An in-process engine gets the same wall-clock limit as the CLI engines and ffmpeg.
175
- t = threading.Thread(target=work, daemon=True)
176
- t.start()
177
- t.join(STATE.timeout or None)
178
- if t.is_alive():
179
- die(f"faster-whisper exceeded the {STATE.timeout:.0f} s time limit; raise --timeout for a long recording", code=124, kind="timeout")
180
- cues = list(result)
181
- if cues:
182
- info("transcribed with faster-whisper")
183
- write_srt(cues, out_srt)
184
- return cues
185
- except ImportError:
186
- pass
187
- # 3. openai-whisper CLI
188
- if shutil.which("whisper"):
189
- cmd = ["whisper", wav, "--model", model, "--output_format", "srt", "--output_dir", tmpdir]
190
- if language:
191
- cmd += ["--language", language]
192
- proc = _asr_run(cmd, subprocess, "openai-whisper")
193
- srt = os.path.join(tmpdir, "audio.srt")
194
- if proc.returncode == 0 and os.path.exists(srt):
195
- info("transcribed with openai-whisper")
196
- cues = parse_srt(srt)
197
- write_srt(cues, out_srt)
198
- return cues
199
- die("no local speech-to-text engine found for --transcribe.\n"
200
- "Install one (all run offline):\n"
201
- " whisper.cpp: brew install whisper-cpp (then download a model: ggml-base.bin)\n"
202
- " faster-whisper: pip install faster-whisper\n"
203
- " openai-whisper: pip install openai-whisper\n"
204
- "Or write the cues by hand with --text cues.txt (see format above).")
205
- return []
206
-
207
-
208
- def parse_srt(path: str) -> List[Tuple[float, float, str]]:
209
- cues: List[Tuple[float, float, str]] = []
210
- block: List[str] = []
211
- content = read_text_or_die(path, "--srt").lstrip("\ufeff").replace("\r\n", "\n") + "\n\n"
212
- for line in content.split("\n"):
213
- if line.strip():
214
- block.append(line)
215
- continue
216
- if block:
217
- times = next((b for b in block if "-->" in b), None)
218
- if times:
219
- a, b = times.split("-->")
220
- text = "\n".join(block[block.index(times) + 1:]).strip()
221
- try:
222
- cues.append((parse_time(a), parse_time(b), text))
223
- except ValueError as e: # includes MissingFpsError: SRT timings are hh:mm:ss,ms, never frames
224
- die(f"{path}: cannot read the timing line {times.strip()!r}: {e}")
225
- block = []
226
- if not cues:
227
- die(f"no cues found in {path}")
228
- return cues
229
-
230
-
231
- def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
232
- with open(path, "w", encoding="utf-8") as fh:
233
- for i, (s, e, t) in enumerate(cues, 1):
234
- # A blank line is SRT's own block separator (index/timecode/text, blank, next block).
235
- # Cue text can contain one -- parse_text_cues() turns a bare "|" into "\n", so a source
236
- # line with two adjacent pipes ("a||b") becomes "a\n\nb" -- and writing that blank line
237
- # raw would split one cue into two malformed half-blocks (the second missing its own
238
- # index/timecode). Collapse any run of blank lines within the cue text to a single
239
- # newline so the cue's own text can never fake the format's block boundary.
240
- t = re.sub(r"\n{2,}", "\n", t).strip("\n")
241
- fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
242
-
243
-
244
110
  def word_durations_from_audio(video: str, start: float, end: float, n_words: int, audio_stream: int = 0) -> List[int]:
245
111
  """Split a cue's time across n_words in proportion to speech energy (centiseconds each).
246
112
 
@@ -434,7 +300,8 @@ def plan_emoji(cues, args, play_w, play_h, brand=None):
434
300
 
435
301
  def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float], max_lines: int,
436
302
  min_duration: float, offset: float, wrap: str = "phrase",
437
- lang: Optional[str] = None) -> Tuple[List[Tuple[float, float, str]], dict]:
303
+ lang: Optional[str] = None, per_cue_em: Optional[List[Optional[float]]] = None,
304
+ per_cue_size: Optional[List[int]] = None) -> Tuple[List[Tuple[float, float, str]], dict]:
438
305
  """Shift, wrap, split and lengthen cues so they can actually be read.
439
306
 
440
307
  `offset` moves every cue (a transcript that runs early/late); `max_em` wraps each cue to the
@@ -442,11 +309,25 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
442
309
  a cue needing more than `max_lines` lines is split into consecutive cues sharing its time in
443
310
  proportion to their text; a cue shorter than `min_duration` is lengthened, never past the next
444
311
  cue's start. Returns the new cues and a count of what changed.
312
+
313
+ `per_cue_em` (--fit-size-scope cue) gives cue i its OWN line budget instead of the file's:
314
+ a cue drawn at a larger size has a narrower line in em, and wrapping it to the file-wide
315
+ budget -- which is the budget of the SMALLEST size -- produced lines that overflowed the
316
+ frame when they were then drawn large. `per_cue_size` rides along so the caller knows which
317
+ size each OUTPUT cue belongs to after splits have renumbered them; it comes back as
318
+ `stats["cue_sizes"]`, one entry per returned cue.
445
319
  """
446
320
  stats = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0, "rebalanced": 0,
447
321
  "wrap": wrap, "phrase_breaks": 0}
448
322
  staged: List[Tuple[float, float, str]] = []
449
- for start, end, text in cues:
323
+ staged_sizes: List[Optional[int]] = []
324
+ for cue_index, (start, end, text) in enumerate(cues):
325
+ own_em = max_em
326
+ own_size = None
327
+ if per_cue_em is not None and cue_index < len(per_cue_em):
328
+ own_em = per_cue_em[cue_index]
329
+ if per_cue_size is not None and cue_index < len(per_cue_size):
330
+ own_size = per_cue_size[cue_index]
450
331
  if offset:
451
332
  start, end = start + offset, end + offset
452
333
  if end <= 0:
@@ -454,13 +335,17 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
454
335
  continue
455
336
  start = max(0.0, start)
456
337
  stats["shifted"] += 1
457
- if max_em and max_em > 0:
338
+ if own_em and own_em > 0:
458
339
  # one greedy fill per cue, three answers off it: what gets burnt in, what the
459
340
  # greedy wrap would have given (`rebalanced`) and what 1.15's wrap would have
460
341
  # 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)
342
+ lines, greedy, measured = wrap_variants(text, own_em, mode=wrap, lang=lang)
462
343
  if lines != [l for l in text.split("\n") if l.strip()]:
463
344
  stats["wrapped"] += 1
345
+ if any(text_width_em(l) > own_em for l in lines):
346
+ # a run with no break point the wrapper may use (a long word, a Thai phrase
347
+ # without spaces) stays long rather than chopped: say so, and name the fix
348
+ stats["overlong"] = stats.get("overlong", 0) + 1
464
349
  if lines != greedy:
465
350
  stats["rebalanced"] += 1
466
351
  if wrap != "measured" and lines != measured:
@@ -473,11 +358,13 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
473
358
  for chunk, weight in zip(chunks, weights):
474
359
  seg = (end - start) * weight / total_w
475
360
  staged.append((t, min(end, t + seg), "\n".join(chunk)))
361
+ staged_sizes.append(own_size)
476
362
  t += seg
477
363
  stats["split"] += len(chunks) - 1
478
364
  continue
479
365
  text = "\n".join(lines)
480
366
  staged.append((start, end, text))
367
+ staged_sizes.append(own_size)
481
368
  out: List[Tuple[float, float, str]] = []
482
369
  for i, (start, end, text) in enumerate(staged):
483
370
  if min_duration and end - start < min_duration:
@@ -487,14 +374,20 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
487
374
  stats["extended"] += 1
488
375
  end = new_end
489
376
  out.append((start, end, text))
377
+ if per_cue_size is not None:
378
+ stats["cue_sizes"] = staged_sizes
490
379
  return out, stats
491
380
 
492
381
 
493
382
  def report_layout(stats: dict) -> None:
494
383
  """One info line, only when a cue actually changed."""
495
- parts = [f"{stats[k]} {k}" for k in ("shifted", "wrapped", "rebalanced", "phrase_breaks", "split", "extended", "dropped") if stats.get(k)]
384
+ parts = [f"{stats[k]} {k}" for k in ("shifted", "wrapped", "rebalanced", "phrase_breaks", "split", "extended", "dropped", "overlong") if stats.get(k)]
496
385
  if parts:
497
386
  info("cues: " + ", ".join(parts))
387
+ if stats.get("overlong"):
388
+ info(f"{stats['overlong']} cue line(s) wider than the safe width: a word or a run with no break "
389
+ "point (Thai writes none inside a phrase) was kept whole rather than chopped -- put a "
390
+ "space or `|` where the line may break, or use a smaller --size")
498
391
 
499
392
 
500
393
  def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[float]:
@@ -503,12 +396,7 @@ def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[
503
396
  --size is in ASS points against a 288-line script (what libass's force_style uses), so the
504
397
  rendered pixel size is size * play_h / 288.
505
398
  """
506
- if not play_w or not play_h or not args.size:
507
- return None
508
- size_px = args.size * play_h / 288.0
509
- if size_px <= 0:
510
- return None
511
- return (play_w * SAFE_WIDTH_FRACTION) / size_px
399
+ return line_em_for_size(args.size, play_w, play_h)
512
400
 
513
401
 
514
402
  def parse_ass_dialogue(path: str) -> str:
@@ -553,44 +441,6 @@ def shift_ass_file(src: str, dst: str, offset: float) -> int:
553
441
  return n
554
442
 
555
443
 
556
- def whisper_word_timings(srt_path: Optional[str]) -> List[Tuple[float, float, str]]:
557
- """Word timings from a whisper JSON transcript sitting next to the SRT, if there is one.
558
-
559
- whisper (and faster-whisper, and whisper.cpp's --output-json) can emit per-word start/end
560
- times; when they are there, --karaoke should follow the real speech instead of splitting the
561
- cue evenly. Looked for as <stem>.json and <stem>.words.json next to the SRT, in either the
562
- {"segments": [{"words": [{"word": ..., "start": ..., "end": ...}]}]} or a bare
563
- {"words": [...]} shape. Anything unreadable is simply "no word timings".
564
- """
565
- if not srt_path:
566
- return []
567
- stem = os.path.splitext(srt_path)[0]
568
- for cand in (stem + ".words.json", stem + ".json"):
569
- if not os.path.exists(cand):
570
- continue
571
- try:
572
- data = json.loads(Path(cand).read_text(encoding="utf-8"))
573
- except (OSError, ValueError):
574
- continue
575
- raw = []
576
- if isinstance(data, dict):
577
- raw = list(data.get("words") or [])
578
- for seg in data.get("segments") or []:
579
- raw.extend((seg or {}).get("words") or [])
580
- words = []
581
- for w in raw:
582
- try:
583
- text = str(w.get("word") or w.get("text") or "").strip()
584
- if text:
585
- words.append((float(w["start"]), float(w["end"]), text))
586
- except (AttributeError, KeyError, TypeError, ValueError):
587
- continue
588
- if words:
589
- info(f"karaoke: word timings from {os.path.basename(cand)} ({len(words)} words)")
590
- return sorted(words)
591
- return []
592
-
593
-
594
444
  def word_durations_from_timings(words: List[Tuple[float, float, str]], start: float, end: float,
595
445
  n_words: int) -> Optional[List[int]]:
596
446
  """Centiseconds per word for one cue, from real word timings; None when they don't cover it."""
@@ -631,7 +481,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
631
481
  "", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
632
482
  ]
633
483
  lines = []
634
- for start, end, text in cues:
484
+ for cue_index, (start, end, text) in enumerate(cues):
635
485
  # ASS Dialogue text treats a literal `{...}` as an override block -- real style/animation
636
486
  # commands, not literal characters. Cue text (from --text, an SRT, or ASR transcription --
637
487
  # all effectively user-controlled) that happens to contain braces would otherwise be
@@ -649,6 +499,15 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
649
499
  elif args.animate == "slide":
650
500
  fx = "{\\fad(150,150)\\move(%d,%d,%d,%d,0,250)}" % (play_w // 2, play_h - margin + int(30 * scale), play_w // 2, play_h - margin)
651
501
  body = text
502
+ # --fit-size-scope cue: one size per cue, as a leading {\fsN} override. Opt-in only --
503
+ # see fit_size()'s docstring for why a size that changes cue to cue is not the default.
504
+ # The size is the one THIS cue was laid out at, carried through layout_cues; re-measuring
505
+ # here would measure the post-layout text (already carrying the wrap's newlines), which
506
+ # is a different string from the one the fit was computed on.
507
+ own_sizes = getattr(args, "_fit_sizes_out", None) or []
508
+ own = own_sizes[cue_index] if cue_index < len(own_sizes) else None
509
+ if own and own != args.size:
510
+ fx += "{\\fs%d}" % int(round(own * scale))
652
511
  if args.karaoke:
653
512
  # split each line into words and give every word an equal share of the cue (\k is in centiseconds)
654
513
  dur_cs = max(1, int(round((end - start) * 100)))
@@ -890,6 +749,15 @@ def main() -> int:
890
749
  emo.add_argument("--emoji-max", type=int, default=60,
891
750
  help="most emoji overlays one run may build (default 60)")
892
751
  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)")
752
+ sty.add_argument("--fit-size", choices=["auto", "on", "off"], default="auto",
753
+ help="shrink the caption size until the cue fits --max-lines, BEFORE splitting it: "
754
+ "'auto' (default) only when no --size was given, 'on' always, 'off' for 1.16 behaviour")
755
+ sty.add_argument("--min-size", type=int, default=None,
756
+ help="smallest size --fit-size may use, in ASS points (default 13 = 4.5%% of the frame height, "
757
+ "the legibility floor)")
758
+ sty.add_argument("--fit-size-scope", choices=["file", "cue"], default="file",
759
+ help="one fitted size for the whole file (default) or one per cue (a size that changes "
760
+ "cue to cue reads as a mistake, so it is opt-in)")
893
761
  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
762
  sty.add_argument("--wrap", choices=list(WRAP_MODES), default="phrase",
895
763
  help="how a cue too wide for the safe area is broken into lines: 'phrase' (default, 1.16) never "
@@ -922,6 +790,10 @@ def main() -> int:
922
790
  if args.brand and bcap.get("box") and not args.box:
923
791
  args.box = True
924
792
  args.font = args.font or (bcap.get("font") if args.brand else None) or brand.get("font") or "DejaVu Sans"
793
+ # --fit-size auto shrinks only a size the skill itself chose. An explicit --size is a
794
+ # statement about the look and is never quietly overridden; a brand's caption size is the
795
+ # same kind of statement, so it counts as explicit too.
796
+ args._size_explicit = args.size is not None or bool(args.brand and bcap.get("size") is not None)
925
797
  args.size = args.size if args.size is not None else (bcap.get("size", 24) if args.brand else 24)
926
798
  args.color = color_hex(args.color or (bcap.get("color") if args.brand else None) or bc.get("text", "FFFFFF"))
927
799
  args.outline_color = color_hex(args.outline_color or bc.get("outline", "000000"))
@@ -961,6 +833,11 @@ def main() -> int:
961
833
  args.offset = signed_time_arg(str(args.offset), "--offset")
962
834
  if args.max_lines < 1:
963
835
  die("--max-lines must be at least 1")
836
+ if args.min_size is not None and args.min_size < 1:
837
+ die("--min-size must be at least 1", kind="input")
838
+ if args.min_size is not None and args.min_size > args.size:
839
+ die(f"--min-size {args.min_size} is larger than --size {args.size}: the floor cannot be "
840
+ "above the size it is a floor for", kind="input")
964
841
  if args.min_duration < 0:
965
842
  die("--min-duration cannot be negative")
966
843
 
@@ -983,20 +860,95 @@ def main() -> int:
983
860
  play_w, play_h = meta["video"]["width"], meta["video"]["height"]
984
861
  if meta["video"].get("rotation") in (90, -90, 270, -270):
985
862
  play_w, play_h = play_h, play_w
986
-
863
+ # A --plan or --dry-run written before the input exists has no geometry to fit against, and a
864
+ # plan that describes a different FontSize from the run that executes it is not a plan. When
865
+ # --platform names a destination, that destination's frame IS the geometry the real run will
866
+ # have, so the fit is computed against it; with no platform there is nothing to stand in for
867
+ # the frame, and size_used is reported as null rather than presenting the requested size as
868
+ # the size that was used.
869
+ planned_frame = False
870
+ if not (play_w and play_h) and args.platform and PLATFORMS[args.platform].get("frame"):
871
+ frame = PLATFORMS[args.platform]["frame"]
872
+ play_w, play_h = frame["w"], frame["h"]
873
+ planned_frame = True
874
+ info(f"[plan] no geometry to measure yet; fitting the caption size against the "
875
+ f"--platform {args.platform} frame ({play_w}x{play_h})")
876
+
877
+ args._fit_floor = args.min_size if args.min_size is not None else ass_units(MIN_CAPTION_FRACTION)
878
+ fit_unmeasurable = not (play_w and play_h)
879
+ fit_stats: dict = {"fit_size": args.fit_size, "size_requested": args.size,
880
+ "size_used": None if fit_unmeasurable else args.size,
881
+ "size_floor": args._fit_floor,
882
+ "size_pct_height": round(args.size * 100.0 / ASS_SCRIPT_HEIGHT, 2),
883
+ "shrunk": 0, "fit_scope": args.fit_size_scope, "fit_exhausted": False}
987
884
  caption_stats: dict = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0,
988
885
  "rebalanced": 0, "wrap": args.wrap, "phrase_breaks": 0}
886
+ caption_stats.update(fit_stats)
887
+
888
+ def fit_params():
889
+ return dict(size=fit_stats["size_requested"], min_size=args._fit_floor,
890
+ max_lines=args.max_lines, play_w=play_w, play_h=play_h,
891
+ mode=args.wrap, lang=args.language, scope=args.fit_size_scope)
892
+
893
+ def fit_the_size(cue_list):
894
+ """Shrink --size until every cue fits --max-lines, BEFORE the cue is split.
895
+
896
+ This is the whole point of the ordering: at a size the cue cannot fit, layout_cues splits
897
+ the sentence into consecutive cues and half of it arrives late (eval 17). The text is never
898
+ touched -- only the type size, and never below the legibility floor.
899
+ """
900
+ if args.fit_size == "off" or (args.fit_size == "auto" and args._size_explicit):
901
+ fit_stats["size_used"] = args.size # a stated size IS the size used
902
+ return
903
+ if fit_unmeasurable:
904
+ # Nothing to measure against: size_used stays null rather than presenting the
905
+ # requested size as one that was fitted.
906
+ return
907
+ if args.mode == "mux":
908
+ # Soft subtitles carry no size: the player picks it. Shrinking would change nothing a
909
+ # viewer sees and would silently change the SRT this run writes, so the mux path is
910
+ # left exactly as 1.16 wrote it.
911
+ return
912
+ fit = fit_size(cue_list, **fit_params())
913
+ fit_stats["size_used"] = fit["size"]
914
+ fit_stats["size_source"] = "platform-frame" if planned_frame else "input"
915
+ fit_stats["shrunk"] = fit["shrunk"]
916
+ fit_stats["fit_exhausted"] = bool(fit["shrunk"]) and not fit["fits"]
917
+ fit_stats["size_pct_height"] = round(fit["size"] * 100.0 / ASS_SCRIPT_HEIGHT, 2)
918
+ if fit["size"] != args.size:
919
+ info(f"caption size {args.size} -> {fit['size']} ASS units "
920
+ f"({fit_stats['size_pct_height']:.1f} % of frame height) so {fit['shrunk']} cue(s) "
921
+ f"fit --max-lines {args.max_lines}; floor {args._fit_floor}")
922
+ args.size = fit["size"]
923
+ elif fit_stats["fit_exhausted"]:
924
+ info(f"caption size stays {args.size} ASS units: {fit['shrunk']} cue(s) still need more "
925
+ f"than {args.max_lines} line(s) at the floor {args._fit_floor} and are split "
926
+ "(--min-size goes smaller; `|` sets the break yourself)")
927
+ if args.fit_size_scope == "cue":
928
+ # Each cue is laid out at ITS OWN size, not at the file minimum. A cue drawn larger
929
+ # has a NARROWER line in em, so wrapping everything to the minimum size's (widest)
930
+ # budget and then drawing some cues large put lines off the side of the frame.
931
+ args._fit_cue_em = [line_em_for_size(fit["per_cue"].get(i, fit["size"]),
932
+ play_w, play_h)
933
+ for i in range(len(cue_list))]
934
+ args._fit_cue_size = [fit["per_cue"].get(i, fit["size"]) for i in range(len(cue_list))]
989
935
 
990
936
  def lay_out(cue_list):
991
937
  """Wrap to the safe area, split past --max-lines, lengthen to --min-duration, shift by
992
938
  --offset -- the one place every cue source goes through, so an SRT, a cue file and a
993
939
  transcript all come out equally readable."""
940
+ fit_the_size(cue_list)
994
941
  out, stats = layout_cues(cue_list, max_em=max_line_em(args, play_w, play_h),
995
942
  max_lines=args.max_lines, min_duration=args.min_duration,
996
- offset=args.offset, wrap=args.wrap, lang=args.language)
943
+ offset=args.offset, wrap=args.wrap, lang=args.language,
944
+ per_cue_em=getattr(args, "_fit_cue_em", None),
945
+ per_cue_size=getattr(args, "_fit_cue_size", None))
946
+ # which size each OUTPUT cue belongs to, after splits have renumbered them
947
+ args._fit_sizes_out = stats.pop("cue_sizes", None)
997
948
  report_layout(stats)
998
949
  caption_stats.clear()
999
950
  caption_stats.update(stats)
951
+ caption_stats.update(fit_stats)
1000
952
  return out, any(v for k, v in stats.items() if k != "wrap")
1001
953
 
1002
954
  # --srt is repeatable since 1.16 (one per language, each with an optional `:lang` suffix).