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.
@@ -7,6 +7,7 @@ and encoder-selection rules testable on their own.
7
7
  from __future__ import annotations
8
8
 
9
9
  import json
10
+ import math
10
11
  import os
11
12
  import argparse
12
13
  from pathlib import Path
@@ -518,3 +519,348 @@ def propose_chapters(duration: float, silences: "Sequence", scene_cuts: "Sequenc
518
519
  def description_block(chapters: "Sequence") -> str:
519
520
  """The YouTube description form of a chapter list: `00:00 Chapter 1` per line."""
520
521
  return "\n".join(f"{fmt_chapter_time(c['at'])} {c['title']}" for c in chapters)
522
+
523
+
524
+ # ------------------------------------------------------------------ the beat grid (1.17)
525
+ #
526
+ # A beat grid is a measurement of the music's periodicity -- not a statement about where a cut
527
+ # belongs. Everything here is arithmetic on an RMS envelope somebody else decoded; nothing in this
528
+ # module decides to cut anything, and nothing invents a beat the audio does not support.
529
+
530
+ BEAT_ONSET_K = 1.5 # peak threshold: median + k * MAD over the local window
531
+ BEAT_WINDOW_S = 1.0 # +/- this many seconds is "local" for the threshold
532
+ BEAT_REFRACTORY_S = 0.06 # two onsets closer than this are one onset
533
+ BEAT_SUPPORT_DIVISOR = 4 # a grid point with no onset within interval/4 is "unsupported"
534
+ BEAT_ALIGN_DIVISOR = 8 # an onset within interval/8 of a grid point counts as aligned
535
+ BEAT_MIN_CONFIDENCE = 0.5 # the default below which a tool that CHANGES a file refuses to snap
536
+ BEAT_OCTAVE_MARGIN = 1.2 # a half/double-tempo grid must explain this much more onset strength
537
+ BEAT_Z_FLOOR = 2.0 # autocorrelation z-score at which periodicity starts counting
538
+ BEAT_Z_SPAN = 4.0 # ... and the span over which it reaches 1.0
539
+
540
+
541
+ def _onset_strength(envelope: "Sequence[float]") -> "List[float]":
542
+ """Half-wave-rectified first difference of log(env), i.e. a compression-domain spectral-flux
543
+ analogue. The log matters: the level-domain difference over-weights the loud sections, so a
544
+ quiet verse contributes no onsets at all and the tempo is measured on the chorus alone."""
545
+ import math as _math
546
+ log_env = [_math.log(max(0.0, float(e)) + 1e-9) for e in envelope]
547
+ return [0.0] + [max(0.0, log_env[i] - log_env[i - 1]) for i in range(1, len(log_env))]
548
+
549
+
550
+ def _pick_onsets(strength: "Sequence[float]", step_s: float) -> "List[int]":
551
+ """Indices of local maxima above median + k*MAD over a +/-BEAT_WINDOW_S window, with a
552
+ refractory gap. A median/MAD threshold rather than a mean/stdev one because a handful of very
553
+ strong hits would drag a mean-based threshold above every other onset in the piece."""
554
+ n = len(strength)
555
+ if n < 3:
556
+ return []
557
+ half = max(1, int(round(BEAT_WINDOW_S / max(step_s, 1e-9))))
558
+ refractory = max(1, int(round(BEAT_REFRACTORY_S / max(step_s, 1e-9))))
559
+ picked: "List[int]" = []
560
+ for i in range(1, n - 1):
561
+ s = strength[i]
562
+ if s <= 0 or s < strength[i - 1] or s < strength[i + 1]:
563
+ continue
564
+ window = sorted(strength[max(0, i - half):min(n, i + half + 1)])
565
+ if not window:
566
+ continue
567
+ med = window[len(window) // 2]
568
+ devs = sorted(abs(x - med) for x in window)
569
+ mad = devs[len(devs) // 2]
570
+ if s < med + BEAT_ONSET_K * mad or s <= med:
571
+ continue
572
+ if picked and i - picked[-1] < refractory:
573
+ if s > strength[picked[-1]]:
574
+ picked[-1] = i
575
+ continue
576
+ picked.append(i)
577
+ return picked
578
+
579
+
580
+ def _autocorrelation_peak(strength: "Sequence[float]", step_s: float,
581
+ bpm_range: "Sequence[float]") -> "tuple":
582
+ """(best lag in samples, peak, mean, standard deviation) of the onset signal's
583
+ autocorrelation over the lags `bpm_range` allows, or (None, 0.0, 0.0, 0.0).
584
+
585
+ The spread matters as much as the peak: every signal's autocorrelation has a maximum
586
+ somewhere, so "the peak is above the mean" says nothing. How far above it stands relative to
587
+ the spread of the other lags is what separates a pulse from noise.
588
+ """
589
+ n = len(strength)
590
+ hi_bpm, lo_bpm = max(bpm_range), min(bpm_range)
591
+ lag_min = max(1, int(round(60.0 / hi_bpm / max(step_s, 1e-9))))
592
+ lag_max = int(round(60.0 / lo_bpm / max(step_s, 1e-9)))
593
+ lag_max = min(lag_max, n - 1)
594
+ if lag_max < lag_min:
595
+ return (None, 0.0, 0.0, 0.0)
596
+ mean = sum(strength) / n if n else 0.0
597
+ centred = [s - mean for s in strength]
598
+ best_lag, best = None, 0.0
599
+ values = []
600
+ for lag in range(lag_min, lag_max + 1):
601
+ acc = sum(centred[i] * centred[i + lag] for i in range(n - lag))
602
+ acc /= (n - lag)
603
+ values.append(acc)
604
+ if best_lag is None or acc > best:
605
+ best_lag, best = lag, acc
606
+ mean_acc = sum(values) / len(values) if values else 0.0
607
+ var = sum((v - mean_acc) ** 2 for v in values) / len(values) if values else 0.0
608
+ return (best_lag, best, mean_acc, math.sqrt(var))
609
+
610
+
611
+ def _grid_score(onset_times: "Sequence[float]", strength_at: "Dict[int, float]",
612
+ interval: float, phase: float) -> float:
613
+ """Total onset strength landing within interval/BEAT_ALIGN_DIVISOR of the grid."""
614
+ if interval <= 0:
615
+ return 0.0
616
+ tol = interval / BEAT_ALIGN_DIVISOR
617
+ total = 0.0
618
+ for i, t in enumerate(onset_times):
619
+ off = (t - phase) % interval
620
+ if min(off, interval - off) <= tol:
621
+ total += strength_at.get(i, 1.0)
622
+ return total
623
+
624
+
625
+ def beat_grid(envelope: "Sequence[float]", step_s: float, *,
626
+ bpm_range: "Sequence[float]" = (60, 200),
627
+ min_confidence: float = BEAT_MIN_CONFIDENCE,
628
+ duration: "Optional[float]" = None) -> "Dict[str, Any]":
629
+ """A beat grid from an RMS envelope. Pure: numbers in, a dict out -- no ffmpeg, no I/O.
630
+
631
+ Returns {"beats": [t, ...], "tempo_bpm": float|None, "interval": float|None,
632
+ "confidence": 0..1, "onsets": [t, ...], "phase": float,
633
+ "supported": int, "unsupported": int, "usable": bool,
634
+ "method": "rms-flux-autocorrelation", "step_s": step_s, "range_bpm": [lo, hi]}
635
+
636
+ The method, in full, so a report can quote it:
637
+ 1. onset strength = half-wave-rectified first difference of log(env + 1e-9);
638
+ 2. onsets = local maxima above median + 1.5 * MAD over a +/-1 s window, 60 ms refractory;
639
+ 3. tempo = the best autocorrelation lag of the onset signal inside `bpm_range`, with its
640
+ half and double checked and the one whose onsets align better preferred (octave
641
+ disambiguation: 60, 120 and 240 BPM all autocorrelate on a 120 BPM track);
642
+ 4. phase = the offset in [0, interval) at which the grid catches the most onset strength;
643
+ 5. beats = phase + n * interval across the duration. A grid must be regular, so a grid
644
+ point with no measured onset within interval/4 is still reported -- and counted in
645
+ `unsupported`, so a caller can see how much of the grid the audio actually supports.
646
+ `supported_beats` is the subset that a measured onset does support: it is what a tool
647
+ that MOVES something must snap to, because a regular grid runs on through a passage with
648
+ no music in it and a point moved there was moved to a time nothing in the audio marks;
649
+ 6. confidence = 0.5 * clip((z - 2) / 4, 0, 1)
650
+ + 0.5 * (fraction of onsets within interval/8 of a grid point),
651
+ where z is how many standard deviations the winning autocorrelation lag stands above
652
+ the mean of the others. The plain peak/mean ratio is not used: every signal's
653
+ autocorrelation has a maximum somewhere, so a peak above the mean says nothing -- noise
654
+ scores 2.9 on it, which would read as full confidence.
655
+
656
+ A flat or empty envelope has no pulse: confidence 0.0, tempo None, beats []. That is a
657
+ measurement, not a failure -- the caller decides whether 0.0 is enough to act on.
658
+ """
659
+ step_s = float(step_s)
660
+ env = list(envelope or [])
661
+ lo_bpm, hi_bpm = float(min(bpm_range)), float(max(bpm_range))
662
+ out: "Dict[str, Any]" = {
663
+ "beats": [], "supported_beats": [], "tempo_bpm": None, "interval": None,
664
+ "confidence": 0.0, "onsets": [],
665
+ "phase": 0.0, "supported": 0, "unsupported": 0, "usable": False,
666
+ "method": "rms-flux-autocorrelation", "step_s": step_s, "range_bpm": [lo_bpm, hi_bpm],
667
+ }
668
+ if len(env) < 4 or step_s <= 0:
669
+ return out
670
+ total_s = float(duration) if duration else len(env) * step_s
671
+
672
+ strength = _onset_strength(env)
673
+ if not any(strength):
674
+ return out
675
+ idx = _pick_onsets(strength, step_s)
676
+ onset_times = [i * step_s for i in idx]
677
+ out["onsets"] = [round(t, 4) for t in onset_times]
678
+ if len(idx) < 2:
679
+ return out
680
+
681
+ best_lag, peak, mean_acc, sd_acc = _autocorrelation_peak(strength, step_s, (lo_bpm, hi_bpm))
682
+ if not best_lag or peak <= 0:
683
+ return out
684
+ interval = best_lag * step_s
685
+ strength_at = {i: strength[j] for i, j in enumerate(idx)}
686
+
687
+ # Octave disambiguation: try half and double the candidate interval and keep the one whose
688
+ # grid catches the most onset strength per grid point (per point, or a denser grid always wins).
689
+ candidates = [interval]
690
+ for factor in (0.5, 2.0):
691
+ alt = interval * factor
692
+ if 60.0 / hi_bpm <= alt <= 60.0 / lo_bpm:
693
+ candidates.append(alt)
694
+
695
+ def best_phase(iv: float) -> "tuple":
696
+ steps = max(4, int(round(iv / step_s)))
697
+ best_ph, best_sc = 0.0, -1.0
698
+ for k in range(steps):
699
+ ph = k * iv / steps
700
+ sc = _grid_score(onset_times, strength_at, iv, ph)
701
+ if sc > best_sc:
702
+ best_ph, best_sc = ph, sc
703
+ return best_ph, best_sc
704
+
705
+ # The winner is the grid that explains the most measured onset strength -- "more onsets fall
706
+ # on it", the standard octave rule. An alternative must explain appreciably more (a fifth
707
+ # again) to displace the autocorrelation's own answer: a half-tempo grid catches a subset of
708
+ # the same onsets and a double-tempo grid catches the same set plus empty points, so a bare
709
+ # ">" would flip the answer on noise.
710
+ base_phase, base_score = best_phase(interval)
711
+ interval, phase = interval, base_phase
712
+ for alt in candidates[1:]:
713
+ alt_phase, alt_score = best_phase(alt)
714
+ if alt_score > base_score * BEAT_OCTAVE_MARGIN:
715
+ interval, phase, base_score = alt, alt_phase, alt_score
716
+
717
+ beats = []
718
+ t = phase
719
+ while t <= total_s + 1e-9:
720
+ beats.append(round(t, 4))
721
+ t += interval
722
+ support_tol = interval / BEAT_SUPPORT_DIVISOR
723
+ supported_beats = [b for b in beats
724
+ if any(abs(b - o) <= support_tol for o in onset_times)]
725
+ supported = len(supported_beats)
726
+ align_tol = interval / BEAT_ALIGN_DIVISOR
727
+ aligned = sum(1 for o in onset_times
728
+ if min((o - phase) % interval, interval - (o - phase) % interval) <= align_tol)
729
+
730
+ # How many spreads the best lag stands above the rest of them, mapped onto [0, 1]: a click
731
+ # track measures z ~= 6, a jittery human performance ~= 5, pseudo-random levels ~= 2.4, so
732
+ # the band [BEAT_Z_FLOOR, BEAT_Z_FLOOR + BEAT_Z_SPAN] = [2, 6] is where the answer changes.
733
+ z = ((peak - mean_acc) / sd_acc) if sd_acc > 0 else 0.0
734
+ periodicity = max(0.0, min(1.0, (z - BEAT_Z_FLOOR) / BEAT_Z_SPAN))
735
+ alignment = aligned / len(onset_times) if onset_times else 0.0
736
+ confidence = round(0.5 * periodicity + 0.5 * alignment, 3)
737
+
738
+ out.update({
739
+ "beats": beats, "supported_beats": supported_beats,
740
+ "interval": round(interval, 6), "tempo_bpm": round(60.0 / interval, 2),
741
+ "phase": round(phase, 4), "confidence": confidence, "supported": supported,
742
+ "unsupported": len(beats) - supported,
743
+ "usable": confidence >= float(min_confidence),
744
+ })
745
+ return out
746
+
747
+
748
+ def snap_points(points: "Sequence[float]", beats: "Sequence[float]",
749
+ tolerance: float) -> "List[Dict[str, Any]]":
750
+ """Move each given point to the nearest beat within `tolerance` seconds.
751
+
752
+ Pure. Returns [{"from": t, "to": t2, "delta": d, "snapped": bool, "beat_index": i|None}].
753
+ A point with no beat inside `tolerance` is returned unchanged with snapped=False.
754
+
755
+ NEVER invents a point: len(out) == len(points), always, and every `to` is either a value that
756
+ was in `beats` or the caller's own `from`. This is the whole no-fabrication rule for beat
757
+ snapping -- a cut point may move to a measured grid point, and may not appear from one.
758
+ """
759
+ grid = sorted(float(b) for b in (beats or []))
760
+ out: "List[Dict[str, Any]]" = []
761
+ for p in points:
762
+ p = float(p)
763
+ best_i, best_d = None, None
764
+ for i, b in enumerate(grid):
765
+ d = abs(b - p)
766
+ if best_d is None or d < best_d:
767
+ best_i, best_d = i, d
768
+ if best_i is not None and best_d is not None and best_d <= float(tolerance):
769
+ out.append({"from": p, "to": grid[best_i], "delta": round(grid[best_i] - p, 6),
770
+ "snapped": True, "beat_index": best_i})
771
+ else:
772
+ out.append({"from": p, "to": p, "delta": 0.0, "snapped": False, "beat_index": None})
773
+ return out
774
+
775
+
776
+ # ------------------------------------------------------------- filler words (1.17)
777
+ #
778
+ # A filler word is removed only when a speech engine measured a start/end pair for it. There is no
779
+ # heuristic fallback -- no "cut the 0.3 s blips that look like an 'um'", no language guess from the
780
+ # filename. Without timings there is nothing to cut, and the tool says so.
781
+ #
782
+ # What is NOT in these lists is the substance of the decision. "like", "tipo" and "cioè" are
783
+ # discourse markers, not disfluencies: they are grammatical words in most sentences, and removing
784
+ # them cuts meaning rather than noise. That is a judgement about content, which this skill does not
785
+ # make. They are reachable with --filler-extra, and documented as what they are.
786
+ FILLER_WORDS = {
787
+ "en": frozenset({"um", "uh", "erm", "hmm", "mm", "mhm", "er", "ah"}),
788
+ # なんか is the most common Japanese filler AND a pronoun/adverb spelled identically. It is in the
789
+ # default list because leaving it out makes --filler useless for Japanese, and every run that
790
+ # removes one warns that it is often a content word (--filler-keep なんか takes it out).
791
+ "ja": frozenset({"えー", "えーと", "えっと", "あの", "あのー", "その", "そのー", "まあ", "なんか"}),
792
+ "es": frozenset({"eh", "este", "esto", "mmm"}),
793
+ "de": frozenset({"äh", "ähm", "hm"}),
794
+ "fr": frozenset({"euh", "hein"}),
795
+ "pt": frozenset({"é", "hum"}),
796
+ "it": frozenset({"ehm"}),
797
+ }
798
+
799
+ # Words in a default list that are also ordinary vocabulary: every run that removes one says so.
800
+ FILLER_AMBIGUOUS = {
801
+ "ja": frozenset({"なんか", "あの", "その", "まあ"}),
802
+ "es": frozenset({"este", "esto"}),
803
+ "pt": frozenset({"é"}),
804
+ }
805
+
806
+ # Discourse markers people ask for by name. Not defaults; named here so --help and the docs can
807
+ # say what adding one costs.
808
+ FILLER_DISCOURSE_MARKERS = {
809
+ "en": ("like", "you know"),
810
+ "pt": ("tipo",),
811
+ "it": ("cioè",),
812
+ }
813
+
814
+ FILLER_MAX_WORD = 1.2 # a longer "uhhh" is a held vowel someone meant
815
+ FILLER_MIN_GAP = 0.05 # spans closer than this become one span
816
+ FILLER_PAD = 0.02 # trimmed either side of the word
817
+
818
+
819
+ def normalise_filler_token(word: str) -> str:
820
+ """A spoken token stripped to what a word list can be compared against: case-folded, with
821
+ surrounding punctuation and whitespace removed. Never a substring match -- "umbrella" must
822
+ survive a list containing "um"."""
823
+ import unicodedata as _ud
824
+ text = str(word or "").strip()
825
+ text = "".join(ch for ch in text
826
+ if not _ud.category(ch).startswith("P") or ch in "-'’")
827
+ return text.strip("-'’").casefold()
828
+
829
+
830
+ def filler_spans(words, wordlist, *, pad: float = FILLER_PAD, min_gap: float = FILLER_MIN_GAP,
831
+ max_word: float = FILLER_MAX_WORD) -> "List[Dict[str, Any]]":
832
+ """Time spans to remove, from measured word timings. Pure: no subprocess, no I/O.
833
+
834
+ `words` is [{"word", "start", "end"}] as whisper emits. A word is removed only when its
835
+ normalised form is in `wordlist` AND it carries a real start < end pair AND its length is
836
+ <= max_word. Adjacent spans closer than min_gap merge. Returns [{"start", "end", "word"}]
837
+ sorted and non-overlapping.
838
+ """
839
+ listed = {normalise_filler_token(w) for w in (wordlist or set())}
840
+ listed.discard("")
841
+ hits = []
842
+ for entry in words or []:
843
+ if not isinstance(entry, dict):
844
+ continue
845
+ token = normalise_filler_token(entry.get("word") or entry.get("text") or "")
846
+ if not token or token not in listed:
847
+ continue
848
+ try:
849
+ start, end = float(entry["start"]), float(entry["end"])
850
+ except (KeyError, TypeError, ValueError):
851
+ continue # no measured timing: nothing to cut
852
+ if not (end > start) or (end - start) > max_word:
853
+ continue
854
+ hits.append({"start": max(0.0, start - pad), "end": end + pad, "word": token})
855
+ hits.sort(key=lambda h: (h["start"], h["end"]))
856
+ merged: "List[Dict[str, Any]]" = []
857
+ for h in hits:
858
+ if merged and h["start"] - merged[-1]["end"] <= min_gap:
859
+ merged[-1]["end"] = max(merged[-1]["end"], h["end"])
860
+ merged[-1]["word"] = merged[-1]["word"] + " " + h["word"]
861
+ else:
862
+ merged.append(dict(h))
863
+ for m in merged:
864
+ m["start"] = round(m["start"], 4)
865
+ m["end"] = round(m["end"], 4)
866
+ return merged
@@ -440,7 +440,14 @@ ADVANCE_EM = {"ja": 1.0, "zh": 1.0, "ko": 1.0, "th": 1.0, "hi": 0.7, "ar": 0.6,
440
440
 
441
441
 
442
442
  # Scripts written without spaces: a line breaks between any two characters.
443
- NO_SPACE_SCRIPTS = ("ja", "zh", "ko", "th")
443
+ # Scripts a line may break inside a run of, one character at a time. Thai is deliberately NOT
444
+ # here since 1.16.1: it writes no space inside a phrase, and without a dictionary the wrapper
445
+ # cannot see where one word ends -- every character-level break it took in eval 17 landed inside
446
+ # a word. A Thai run is therefore one atom, broken only at the spaces (or the manual `|`) the
447
+ # writer put there; an over-long run stays long on its own line, the rule long Latin words
448
+ # already follow.
449
+ NO_SPACE_SCRIPTS = ("ja", "zh", "ko")
450
+ NO_BOUNDARY_SCRIPTS = ("th",) # per-character breaking would chop words: keep the run whole
444
451
 
445
452
 
446
453
  # Per-character Latin advances in em, read off DejaVu Sans (the default caption family, and close
@@ -1085,7 +1092,10 @@ def _atoms(line: str) -> "List[Tuple[str, bool]]":
1085
1092
  if word:
1086
1093
  out.append((word, spaced))
1087
1094
  word = ""
1088
- if out and (attach_next or _is_mark(ch)):
1095
+ if out and not pending and _is_katakana_run(ch) and _is_katakana_run(out[-1][0][-1]):
1096
+ # a katakana word (タイミング, コンピューター) is one atom: eval 17 saw タイ|ミング
1097
+ out[-1] = (out[-1][0] + ch, out[-1][1])
1098
+ elif out and (attach_next or _is_mark(ch)):
1089
1099
  # never break between a base and the mark (or the leading vowel) that belongs to
1090
1100
  # it: the line would start with an orphaned tone mark or vowel sign
1091
1101
  out[-1] = (out[-1][0] + ch, out[-1][1])
@@ -1153,6 +1163,12 @@ def _is_kana(ch: str) -> bool:
1153
1163
  return 0x3040 <= ord(ch) <= 0x30FF
1154
1164
 
1155
1165
 
1166
+ def _is_katakana_run(ch: str) -> bool:
1167
+ """Katakana proper plus the prolonged-sound mark: the characters one loan word is made of."""
1168
+ cp = ord(ch)
1169
+ return (0x30A1 <= cp <= 0x30FA) or cp == 0x30FC or (0x31F0 <= cp <= 0x31FF) or (0xFF66 <= cp <= 0xFF9F)
1170
+
1171
+
1156
1172
  def _is_hiragana(ch: str) -> bool:
1157
1173
  return 0x3040 <= ord(ch) <= 0x309F
1158
1174
 
@@ -1513,3 +1529,127 @@ def wrap_variants(text: str, max_em: float, *, mode: str = "phrase",
1513
1529
  if not greedy:
1514
1530
  greedy = [text]
1515
1531
  return (wrapped or [text], greedy, measured or [text])
1532
+
1533
+
1534
+ # --- caption size that fits the cue (1.17) -------------------------------------------------
1535
+ # The legibility floor: 4.5 % of the frame height, ass_units(0.045) = 13 against the 288-line
1536
+ # ASS script grid. One floor for every destination -- 87 px of type on a 1920-tall frame, above
1537
+ # the ~3.5 % where mobile legibility bottoms out and where the platforms' own caption UIs sit.
1538
+ # Nothing per-platform is measured, so nothing per-platform is claimed. (The eval-17 cues happen
1539
+ # to land exactly on it: 13 is the smallest size at which every one of them fits two lines.)
1540
+ MIN_CAPTION_FRACTION = 0.045
1541
+ ASS_SCRIPT_HEIGHT = 288 # caption.py's --size/--margin reference grid; mirrors _platforms
1542
+
1543
+
1544
+ def line_em_for_size(size: float, play_w: "Optional[int]", play_h: "Optional[int]", *,
1545
+ safe_fraction: float = SAFE_WIDTH_FRACTION,
1546
+ script_height: int = ASS_SCRIPT_HEIGHT) -> "Optional[float]":
1547
+ """How many em fit on one caption line at `size`, or None without geometry.
1548
+
1549
+ `size` is in ASS points against a `script_height`-line script (what libass's force_style
1550
+ uses), so the rendered pixel size is size * play_h / script_height. This is the one width
1551
+ formula: caption.py::max_line_em and fit_size() both call it.
1552
+ """
1553
+ if not play_w or not play_h or not size:
1554
+ return None
1555
+ size_px = size * play_h / float(script_height)
1556
+ if size_px <= 0:
1557
+ return None
1558
+ return (play_w * safe_fraction) / size_px
1559
+
1560
+
1561
+ def fit_size(cues, *, size: int, min_size: "Optional[int]" = None, max_lines: int = 2,
1562
+ play_w: "Optional[int]" = None, play_h: "Optional[int]" = None,
1563
+ safe_fraction: float = SAFE_WIDTH_FRACTION, mode: str = "phrase",
1564
+ lang: "Optional[str]" = None, script_height: int = ASS_SCRIPT_HEIGHT,
1565
+ step: int = 1, scope: str = "file") -> "Dict[str, Any]":
1566
+ """The largest size in [min_size, size] at which every cue wraps to <= max_lines lines.
1567
+
1568
+ Pure: strings and integers in, a dict out. No ffmpeg, no ffprobe, no I/O -- the caption size
1569
+ is a text-measurement decision, and measuring it must not need a subprocess.
1570
+
1571
+ `cues` is an iterable of cue texts (or of (start, end, text) tuples, as caption.py holds
1572
+ them before layout). Returns
1573
+ {"size", "floor", "requested", "scope", "shrunk", "fits", "per_cue", "max_em", "steps"}.
1574
+
1575
+ The search is a linear walk downwards, not a bisection, and deliberately so:
1576
+ len(wrap_text(t, max_em)) is NOT guaranteed monotone in max_em under the phrase rules -- a
1577
+ rebalance that is discarded at one width can be applied at the next -- and a non-monotone
1578
+ predicate breaks bisection. 24 -> 13 is at most twelve iterations of pure string work.
1579
+
1580
+ `scope="cue"` returns one size per cue index in `per_cue`, with `size` the minimum of them;
1581
+ the caller writes a per-cue {\\fsN} override. The default is `scope="file"`: a caption track
1582
+ whose type size changes from cue to cue reads as a mistake, and one measured line width per
1583
+ file is what makes the wrap behaviour reproducible.
1584
+ """
1585
+ # `texts` stays parallel to `cues`: a blank cue becomes None rather than being dropped, so
1586
+ # per_cue[i] always refers to the caller's cue i. caption.py indexes layout by these keys.
1587
+ texts: "List[Optional[str]]" = []
1588
+ for cue in cues or []:
1589
+ if isinstance(cue, (tuple, list)):
1590
+ raw = cue[2] if len(cue) > 2 else cue[-1]
1591
+ else:
1592
+ raw = cue
1593
+ texts.append(raw if raw and str(raw).strip() else None)
1594
+ measurable = [t for t in texts if t is not None]
1595
+ requested = int(size)
1596
+ floor = int(min_size) if min_size is not None else ass_units_local(MIN_CAPTION_FRACTION,
1597
+ script_height)
1598
+ floor = max(1, min(floor, requested))
1599
+ step = max(1, int(step))
1600
+ result: "Dict[str, Any]" = {"size": requested, "floor": floor, "requested": requested,
1601
+ "scope": scope, "shrunk": 0, "fits": True, "per_cue": {},
1602
+ "max_em": None, "steps": 0}
1603
+ em_at = lambda sz: line_em_for_size(sz, play_w, play_h, safe_fraction=safe_fraction,
1604
+ script_height=script_height)
1605
+ base_em = em_at(requested)
1606
+ result["max_em"] = base_em
1607
+ if not measurable or base_em is None or max_lines < 1:
1608
+ # No geometry means no measurable width: leave the size exactly as asked.
1609
+ return result
1610
+
1611
+ def lines_at(text: str, sz: int) -> int:
1612
+ em = em_at(sz)
1613
+ if em is None:
1614
+ return 1
1615
+ return len(wrap_text(text, em, mode=mode, lang=lang))
1616
+
1617
+ over_at_requested = [t for t in measurable if lines_at(t, requested) > max_lines]
1618
+ result["shrunk"] = len(over_at_requested)
1619
+
1620
+ def best_for(subset) -> "Tuple[int, bool]":
1621
+ """(largest size in [floor, requested] fitting every text in `subset`, did it fit)."""
1622
+ sz = requested
1623
+ while sz >= floor:
1624
+ result["steps"] += 1
1625
+ if all(lines_at(t, sz) <= max_lines for t in subset):
1626
+ return sz, True
1627
+ sz -= step
1628
+ return floor, all(lines_at(t, floor) <= max_lines for t in subset)
1629
+
1630
+ if scope == "cue":
1631
+ per_cue = {}
1632
+ fits_all = True
1633
+ for i, t in enumerate(texts):
1634
+ if t is None:
1635
+ per_cue[i] = requested # a blank cue draws nothing; it constrains nothing
1636
+ continue
1637
+ sz, ok = best_for([t])
1638
+ per_cue[i] = sz
1639
+ fits_all = fits_all and ok
1640
+ result["per_cue"] = per_cue
1641
+ sized = [v for i, v in per_cue.items() if texts[i] is not None]
1642
+ result["size"] = min(sized) if sized else requested
1643
+ result["fits"] = fits_all
1644
+ else:
1645
+ sz, ok = best_for(measurable)
1646
+ result["size"] = sz
1647
+ result["fits"] = ok
1648
+ result["max_em"] = em_at(result["size"])
1649
+ return result
1650
+
1651
+
1652
+ def ass_units_local(fraction: float, script_height: int = ASS_SCRIPT_HEIGHT) -> int:
1653
+ """`fraction` of the frame height in ASS units. Mirrors _platforms.ass_units, kept here so
1654
+ _common.text stays importable without the scripts/ top level on sys.path."""
1655
+ return int(round(fraction * script_height))
@@ -170,7 +170,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
170
170
  required=FF + ["filter:loudnorm"], optional=[{"capability": AAC, "when": "output extension isn't .mp3/.opus/.ogg/.flac (audio_codec_for()'s default)"}] + AUDIO_OUT,
171
171
  video_required=False, audio_only=True, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
172
172
  "silence": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["silence list JSON (--list)", "artifact with silences removed", "EDL text (--edl)"],
173
- required=FF + ["filter:silencedetect"], optional=[{"capability": X264, "when": "removing silences from a video"}, HDR_X265, {"capability": AAC, "when": "removing silences from a video"}] + AUDIO_OUT,
173
+ required=FF + ["filter:silencedetect"], optional=[{"capability": X264, "when": "removing silences from a video"}, HDR_X265, {"capability": AAC, "when": "removing silences from a video"},
174
+ {"capability": "external:whisper", "when": "--filler --transcribe"}] + AUDIO_OUT,
174
175
  video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
175
176
  "join": dict(role="execution", inputs=["two or more video assets, or two or more audio-only assets"], outputs=["concatenated video artifact", "concatenated audio artifact (audio-only inputs, audio output extension)"],
176
177
  required=FF + ["filter:xfade", "filter:acrossfade"],
@@ -197,7 +198,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
197
198
  required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "loudness rows (default)"}, {"capability": "filter:loudnorm", "when": "loudness rows (default)"}],
198
199
  video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
199
200
  "scenes": dict(role="analysis", inputs=["video asset"], outputs=["scene / audio-peak / highlight JSON on stdout", "EDL text (--edl)", "per-scene contact sheet PNG (--sheet)"],
200
- required=FF + ["filter:scdet"], optional=[{"capability": "filter:drawtext", "when": "--sheet"}, {"capability": "filter:tile", "when": "--sheet"}],
201
+ required=FF + ["filter:scdet"], optional=[{"capability": "filter:drawtext", "when": "--sheet"}, {"capability": "filter:tile", "when": "--sheet"},
202
+ {"capability": "ffmpeg", "when": "--beats (one audio decode for the onset pass)"}],
201
203
  video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
202
204
  "look": dict(role="verification", inputs=["video artifact"], outputs=["PNG contact sheet / frames / side-by-side"],
203
205
  required=FF + ["filter:tile"], optional=[{"capability": "filter:drawtext", "when": "timecode stamps (default; --no-timecode to skip)"},