ffmpeg-skill 1.16.1 → 1.17.1

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,14 +335,14 @@ 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
464
- if any(text_width_em(l) > max_em for l in lines):
345
+ if any(text_width_em(l) > own_em for l in lines):
465
346
  # a run with no break point the wrapper may use (a long word, a Thai phrase
466
347
  # without spaces) stays long rather than chopped: say so, and name the fix
467
348
  stats["overlong"] = stats.get("overlong", 0) + 1
@@ -477,11 +358,13 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
477
358
  for chunk, weight in zip(chunks, weights):
478
359
  seg = (end - start) * weight / total_w
479
360
  staged.append((t, min(end, t + seg), "\n".join(chunk)))
361
+ staged_sizes.append(own_size)
480
362
  t += seg
481
363
  stats["split"] += len(chunks) - 1
482
364
  continue
483
365
  text = "\n".join(lines)
484
366
  staged.append((start, end, text))
367
+ staged_sizes.append(own_size)
485
368
  out: List[Tuple[float, float, str]] = []
486
369
  for i, (start, end, text) in enumerate(staged):
487
370
  if min_duration and end - start < min_duration:
@@ -491,6 +374,8 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
491
374
  stats["extended"] += 1
492
375
  end = new_end
493
376
  out.append((start, end, text))
377
+ if per_cue_size is not None:
378
+ stats["cue_sizes"] = staged_sizes
494
379
  return out, stats
495
380
 
496
381
 
@@ -511,12 +396,7 @@ def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[
511
396
  --size is in ASS points against a 288-line script (what libass's force_style uses), so the
512
397
  rendered pixel size is size * play_h / 288.
513
398
  """
514
- if not play_w or not play_h or not args.size:
515
- return None
516
- size_px = args.size * play_h / 288.0
517
- if size_px <= 0:
518
- return None
519
- return (play_w * SAFE_WIDTH_FRACTION) / size_px
399
+ return line_em_for_size(args.size, play_w, play_h)
520
400
 
521
401
 
522
402
  def parse_ass_dialogue(path: str) -> str:
@@ -561,44 +441,6 @@ def shift_ass_file(src: str, dst: str, offset: float) -> int:
561
441
  return n
562
442
 
563
443
 
564
- def whisper_word_timings(srt_path: Optional[str]) -> List[Tuple[float, float, str]]:
565
- """Word timings from a whisper JSON transcript sitting next to the SRT, if there is one.
566
-
567
- whisper (and faster-whisper, and whisper.cpp's --output-json) can emit per-word start/end
568
- times; when they are there, --karaoke should follow the real speech instead of splitting the
569
- cue evenly. Looked for as <stem>.json and <stem>.words.json next to the SRT, in either the
570
- {"segments": [{"words": [{"word": ..., "start": ..., "end": ...}]}]} or a bare
571
- {"words": [...]} shape. Anything unreadable is simply "no word timings".
572
- """
573
- if not srt_path:
574
- return []
575
- stem = os.path.splitext(srt_path)[0]
576
- for cand in (stem + ".words.json", stem + ".json"):
577
- if not os.path.exists(cand):
578
- continue
579
- try:
580
- data = json.loads(Path(cand).read_text(encoding="utf-8"))
581
- except (OSError, ValueError):
582
- continue
583
- raw = []
584
- if isinstance(data, dict):
585
- raw = list(data.get("words") or [])
586
- for seg in data.get("segments") or []:
587
- raw.extend((seg or {}).get("words") or [])
588
- words = []
589
- for w in raw:
590
- try:
591
- text = str(w.get("word") or w.get("text") or "").strip()
592
- if text:
593
- words.append((float(w["start"]), float(w["end"]), text))
594
- except (AttributeError, KeyError, TypeError, ValueError):
595
- continue
596
- if words:
597
- info(f"karaoke: word timings from {os.path.basename(cand)} ({len(words)} words)")
598
- return sorted(words)
599
- return []
600
-
601
-
602
444
  def word_durations_from_timings(words: List[Tuple[float, float, str]], start: float, end: float,
603
445
  n_words: int) -> Optional[List[int]]:
604
446
  """Centiseconds per word for one cue, from real word timings; None when they don't cover it."""
@@ -639,7 +481,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
639
481
  "", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
640
482
  ]
641
483
  lines = []
642
- for start, end, text in cues:
484
+ for cue_index, (start, end, text) in enumerate(cues):
643
485
  # ASS Dialogue text treats a literal `{...}` as an override block -- real style/animation
644
486
  # commands, not literal characters. Cue text (from --text, an SRT, or ASR transcription --
645
487
  # all effectively user-controlled) that happens to contain braces would otherwise be
@@ -657,6 +499,15 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
657
499
  elif args.animate == "slide":
658
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)
659
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))
660
511
  if args.karaoke:
661
512
  # split each line into words and give every word an equal share of the cue (\k is in centiseconds)
662
513
  dur_cs = max(1, int(round((end - start) * 100)))
@@ -898,6 +749,15 @@ def main() -> int:
898
749
  emo.add_argument("--emoji-max", type=int, default=60,
899
750
  help="most emoji overlays one run may build (default 60)")
900
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)")
901
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)")
902
762
  sty.add_argument("--wrap", choices=list(WRAP_MODES), default="phrase",
903
763
  help="how a cue too wide for the safe area is broken into lines: 'phrase' (default, 1.16) never "
@@ -930,6 +790,10 @@ def main() -> int:
930
790
  if args.brand and bcap.get("box") and not args.box:
931
791
  args.box = True
932
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)
933
797
  args.size = args.size if args.size is not None else (bcap.get("size", 24) if args.brand else 24)
934
798
  args.color = color_hex(args.color or (bcap.get("color") if args.brand else None) or bc.get("text", "FFFFFF"))
935
799
  args.outline_color = color_hex(args.outline_color or bc.get("outline", "000000"))
@@ -969,6 +833,11 @@ def main() -> int:
969
833
  args.offset = signed_time_arg(str(args.offset), "--offset")
970
834
  if args.max_lines < 1:
971
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")
972
841
  if args.min_duration < 0:
973
842
  die("--min-duration cannot be negative")
974
843
 
@@ -991,20 +860,95 @@ def main() -> int:
991
860
  play_w, play_h = meta["video"]["width"], meta["video"]["height"]
992
861
  if meta["video"].get("rotation") in (90, -90, 270, -270):
993
862
  play_w, play_h = play_h, play_w
994
-
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}
995
884
  caption_stats: dict = {"shifted": 0, "wrapped": 0, "split": 0, "extended": 0, "dropped": 0,
996
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))]
997
935
 
998
936
  def lay_out(cue_list):
999
937
  """Wrap to the safe area, split past --max-lines, lengthen to --min-duration, shift by
1000
938
  --offset -- the one place every cue source goes through, so an SRT, a cue file and a
1001
939
  transcript all come out equally readable."""
940
+ fit_the_size(cue_list)
1002
941
  out, stats = layout_cues(cue_list, max_em=max_line_em(args, play_w, play_h),
1003
942
  max_lines=args.max_lines, min_duration=args.min_duration,
1004
- 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)
1005
948
  report_layout(stats)
1006
949
  caption_stats.clear()
1007
950
  caption_stats.update(stats)
951
+ caption_stats.update(fit_stats)
1008
952
  return out, any(v for k, v in stats.items() if k != "wrap")
1009
953
 
1010
954
  # --srt is repeatable since 1.16 (one per language, each with an optional `:lang` suffix).
@@ -1328,6 +1272,21 @@ def main() -> int:
1328
1272
  result = probe(output, role="output")
1329
1273
  info(f"wrote {output} ({fmt_secs(result.get('duration'))})")
1330
1274
  extra = {"notes": side_notes} if side_notes else {}
1275
+ # 1.17.1: say so when the words on screen are the words that were handed in. This tool never
1276
+ # rewrites, shortens or translates a cue -- only line breaks, timing and type size move -- so
1277
+ # the honest sentence in a report ("the text is yours, unchanged") needs no extra judgement.
1278
+ # Review 17 finding 5: a cue SPLIT across two consecutive cues, a dropped cue, transcription
1279
+ # and `--emoji none` (which str.replace()s glyphs out of the drawn text) all change what the
1280
+ # viewer reads, so none of them may be reported as unchanged. Wrapping, line breaks and
1281
+ # timing do not count -- the words are the same.
1282
+ caption_stats["text_unchanged"] = bool(
1283
+ not args.transcribe
1284
+ and not caption_stats.get("dropped")
1285
+ and not caption_stats.get("split")
1286
+ and (emoji_plan or {}).get("mode") != "none")
1287
+ if caption_stats["text_unchanged"]:
1288
+ info("caption text unchanged: the cues were burned exactly as given (line breaks, timing "
1289
+ "and type size only)")
1331
1290
  extra["caption"] = dict(caption_stats)
1332
1291
  if emoji_plan:
1333
1292
  notes = list(extra.get("notes") or [])