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.
- package/README.md +13 -8
- package/SKILL.md +5 -5
- package/docs/contract.md +28 -10
- package/package.json +1 -1
- package/references/gotchas.md +4 -0
- package/references/scripts.md +264 -4
- package/scripts/_common/__init__.py +25 -3
- package/scripts/_common/asr.py +369 -0
- package/scripts/_common/decision.py +346 -0
- package/scripts/_common/text.py +142 -2
- package/scripts/_contract.py +4 -2
- package/scripts/batch.py +287 -22
- package/scripts/caption.py +150 -198
- package/scripts/cut.py +136 -1
- package/scripts/render.py +289 -24
- package/scripts/scenes.py +81 -7
- package/scripts/silence.py +207 -5
|
@@ -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/text.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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))
|
package/scripts/_contract.py
CHANGED
|
@@ -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"}
|
|
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)"},
|