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.
@@ -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
@@ -137,6 +137,20 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
137
137
  if role == "output":
138
138
  _output_failed(path, f"ffprobe cannot read it:\n{proc.stderr.strip()}")
139
139
  die(f"ffprobe failed on {path}:\n{proc.stderr.strip()}")
140
+ if not (proc.stdout or "").strip():
141
+ # ffprobe exited 0 and printed nothing we could read. Before 1.17.1 that produced a
142
+ # SUCCESS document of nulls -- "?s | no video | no audio", exit 0 -- which is how #234
143
+ # showed up on a Windows cp932 machine: the capture decoded ffprobe's UTF-8 JSON with the
144
+ # locale code page, the reader thread raised UnicodeDecodeError and stdout came back
145
+ # empty. Every child capture is decoded as UTF-8 with errors="replace" now; if a document
146
+ # still does not arrive, refuse rather than report an unmeasured file as measured.
147
+ msg = (f"ffprobe printed no output for {path}: its JSON could not be read (a decoding or "
148
+ "pipe failure, not a measurement)")
149
+ if proc.stderr.strip():
150
+ msg += f"\n{proc.stderr.strip()}"
151
+ if role == "output":
152
+ _output_failed(path, msg)
153
+ die(msg, kind="input")
140
154
  try:
141
155
  raw = json.loads(proc.stdout or "{}")
142
156
  except ValueError as e:
@@ -100,7 +100,7 @@ def ffmpeg_version() -> "Tuple[int, int]":
100
100
  if _FFMPEG_VERSION is None:
101
101
  _FFMPEG_VERSION = (0, 0)
102
102
  try:
103
- out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
103
+ out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
104
104
  timeout=PROBE_TIMEOUT).stdout
105
105
  m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)", out)
106
106
  if m:
@@ -708,7 +708,11 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, r
708
708
  STATE.commands.append(_cmdline(cmd))
709
709
  limit = _limit_for(cmd)
710
710
  try:
711
- proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, timeout=limit)
711
+ # #234: decode as UTF-8, never as the machine's locale code page -- ffmpeg echoes the
712
+ # input filename on stderr, and loudness/check parse the loudnorm JSON out of it. The
713
+ # encoding kwargs are rejected with text=False, so they are only passed for text mode.
714
+ text_kw = {"encoding": "utf-8", "errors": "replace"} if text else {}
715
+ proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, **text_kw, timeout=limit)
712
716
  except subprocess.TimeoutExpired:
713
717
  _timed_out(cmd, limit or 0)
714
718
  if check and proc.returncode != 0:
@@ -744,7 +748,7 @@ def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None) -> subpro
744
748
  document (kind timeout, exit 124), so callers that parse the child's --json see a timeout
745
749
  exactly as they would from the child itself."""
746
750
  limit = child_limit(per_call)
747
- child = subprocess.Popen([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
751
+ child = subprocess.Popen([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
748
752
  _watch(child, []) # a sibling script removes its own partial output; there is none of ours to clean
749
753
  try:
750
754
  out, err = child.communicate(timeout=limit)
@@ -804,7 +808,7 @@ def _limit_for(cmd: Sequence[str]) -> Optional[float]:
804
808
  def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
805
809
  """Plain run with stdout/stderr captured."""
806
810
  limit = _limit_for(cmd)
807
- child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
811
+ child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
808
812
  _watch(child, cmd)
809
813
  try:
810
814
  out, err = child.communicate(timeout=limit)
@@ -854,7 +858,7 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
854
858
  full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
855
859
  t0 = time.time()
856
860
  limit = _limit_for(cmd)
857
- proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
861
+ proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
858
862
  _watch(proc, cmd)
859
863
  assert proc.stdout is not None and proc.stderr is not None
860
864
  lines: "queue.Queue[Optional[str]]" = queue.Queue()
@@ -1022,7 +1026,7 @@ def ffmpeg_encoders() -> set:
1022
1026
  _ENCODERS = set()
1023
1027
  try:
1024
1028
  out = subprocess.run([shutil.which("ffmpeg") or "ffmpeg", "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
1025
- stderr=subprocess.DEVNULL, text=True, timeout=PROBE_TIMEOUT).stdout
1029
+ stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=PROBE_TIMEOUT).stdout
1026
1030
  _ENCODERS = set(re.findall(r"^\s*[VAS][.\w]{5}\s+(\S+)", out, re.M))
1027
1031
  except (OSError, subprocess.SubprocessError):
1028
1032
  pass
@@ -72,7 +72,7 @@ def default_font_file(font_name: str) -> Optional[str]:
72
72
  if not exe:
73
73
  return None
74
74
  try:
75
- proc = subprocess.run([exe, "--format=%{file}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5)
75
+ proc = subprocess.run([exe, "--format=%{file}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=5)
76
76
  except (subprocess.TimeoutExpired, OSError):
77
77
  return None
78
78
  if proc.returncode != 0:
@@ -319,7 +319,7 @@ def _emoji_color_font() -> "Tuple[Optional[str], Optional[str], bool]":
319
319
  for family in _EMOJI_COLOR_FAMILIES:
320
320
  try:
321
321
  proc = subprocess.run([exe, f":family={family}", "file"], stdout=subprocess.PIPE,
322
- stderr=subprocess.DEVNULL, text=True, timeout=10)
322
+ stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
323
323
  except (subprocess.TimeoutExpired, OSError):
324
324
  return None, None, False
325
325
  if proc.returncode != 0:
@@ -618,7 +618,7 @@ def drawtext_shaping() -> "Dict[str, bool]":
618
618
  for flag in ("-buildconf", "-version"):
619
619
  try:
620
620
  proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE,
621
- stderr=subprocess.STDOUT, text=True, timeout=10)
621
+ stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", timeout=10)
622
622
  except (subprocess.TimeoutExpired, OSError):
623
623
  break
624
624
  if proc.returncode == 0 and proc.stdout.strip():
@@ -645,7 +645,7 @@ def font_family_of_file(path: str) -> "Optional[str]":
645
645
  if exe:
646
646
  try:
647
647
  proc = subprocess.run([exe, "--format", "%{family[0]}", path], stdout=subprocess.PIPE,
648
- stderr=subprocess.DEVNULL, text=True, timeout=10)
648
+ stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
649
649
  if proc.returncode == 0 and proc.stdout.strip():
650
650
  return proc.stdout.strip().splitlines()[0].strip()
651
651
  except (subprocess.TimeoutExpired, OSError):
@@ -717,7 +717,7 @@ def _fc_list_fonts(fc_lang: str) -> "Optional[List[Tuple[str, List[str]]]]":
717
717
  return None
718
718
  try:
719
719
  proc = subprocess.run([exe, f":lang={fc_lang}", "file", "family"],
720
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=10)
720
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
721
721
  except (subprocess.TimeoutExpired, OSError):
722
722
  return None
723
723
  if proc.returncode != 0:
@@ -832,7 +832,7 @@ def font_covers_script(font_name: str, script: str) -> bool:
832
832
  return True
833
833
  try:
834
834
  proc = subprocess.run([exe, f":lang={FC_LANG[script]}:family={font_name}", "file"],
835
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=10)
835
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
836
836
  except (subprocess.TimeoutExpired, OSError):
837
837
  return True
838
838
  if proc.returncode != 0:
@@ -866,7 +866,7 @@ def fonts_dir_covers_script(fonts_dir: str, script: str) -> "Optional[bool]":
866
866
  return None
867
867
  try:
868
868
  proc = subprocess.run([exe, "--format", "%{lang}\n", fonts_dir],
869
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=10)
869
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
870
870
  except (subprocess.TimeoutExpired, OSError):
871
871
  return None
872
872
  if proc.returncode != 0:
@@ -1529,3 +1529,127 @@ def wrap_variants(text: str, max_em: float, *, mode: str = "phrase",
1529
1529
  if not greedy:
1530
1530
  greedy = [text]
1531
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))