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.
- package/README.md +19 -9
- package/SKILL.md +27 -22
- package/docs/contract.md +35 -10
- package/package.json +1 -1
- package/references/gotchas.md +4 -0
- package/references/scripts.md +281 -1
- package/scripts/_common/__init__.py +25 -3
- package/scripts/_common/asr.py +369 -0
- package/scripts/_common/decision.py +346 -0
- package/scripts/_common/probe.py +14 -0
- package/scripts/_common/runner.py +10 -6
- package/scripts/_common/text.py +131 -7
- package/scripts/_contract.py +8 -6
- package/scripts/batch.py +287 -22
- package/scripts/caption.py +157 -198
- package/scripts/cut.py +136 -1
- package/scripts/render.py +326 -26
- package/scripts/scenes.py +81 -7
- package/scripts/silence.py +207 -5
- package/scripts/verify.py +1 -1
- package/scripts/waveform.py +1 -2
|
@@ -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
|
package/scripts/_common/probe.py
CHANGED
|
@@ -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
|
-
|
|
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
|
package/scripts/_common/text.py
CHANGED
|
@@ -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))
|