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
package/scripts/scenes.py
CHANGED
|
@@ -20,17 +20,38 @@ Examples:
|
|
|
20
20
|
import argparse
|
|
21
21
|
import math
|
|
22
22
|
import sys
|
|
23
|
-
from typing import Dict, List, Tuple
|
|
23
|
+
from typing import Dict, List, Optional, Tuple
|
|
24
24
|
|
|
25
25
|
# `detect_scenes` moved into _common/probe.py in 1.16.0 (see silence.py); the body is unchanged.
|
|
26
|
-
from _common import detect_scenes, STATE, add_common, apply_common, default_font_file, die, emit,
|
|
26
|
+
from _common import (detect_scenes, STATE, add_common, apply_common, beat_grid, default_font_file, die, emit,
|
|
27
|
+
escape_filter_path, ffmpeg_base, info, print_json, probe, run, decode_pcm_mono,
|
|
28
|
+
rms_envelope, BEAT_MIN_CONFIDENCE)
|
|
27
29
|
|
|
28
30
|
|
|
29
31
|
|
|
30
|
-
def audio_envelope(path: str, step_s: float
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
def audio_envelope(path: str, step_s: float, *, rate: int = 8000,
|
|
33
|
+
samples: "Optional[List[float]]" = None) -> List[float]:
|
|
34
|
+
"""RMS level per step_s window, absolute (a loud scene scores higher); [] when the audio
|
|
35
|
+
cannot be decoded (the cut scoring then runs on the picture alone).
|
|
36
|
+
|
|
37
|
+
`samples` reuses PCM a caller already decoded rather than decoding the same file twice --
|
|
38
|
+
--beats needs a 10 ms envelope and the scene scoring a 0.5 s one, and 0.5 s is an integer
|
|
39
|
+
multiple of 10 ms, so both come from one pass.
|
|
40
|
+
"""
|
|
41
|
+
if samples is None:
|
|
42
|
+
samples = decode_pcm_mono(path, rate, check=False)
|
|
43
|
+
return rms_envelope(samples, int(rate * step_s))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_beat_range(text: str) -> "tuple":
|
|
47
|
+
"""`--beat-range 60-200` as (lo, hi) BPM."""
|
|
48
|
+
try:
|
|
49
|
+
lo, hi = (float(p) for p in str(text).replace(" ", "").split("-", 1))
|
|
50
|
+
except ValueError:
|
|
51
|
+
die(f"--beat-range: expected LO-HI in BPM (e.g. 60-200), got {text!r}", kind="input")
|
|
52
|
+
if not (0 < lo < hi):
|
|
53
|
+
die(f"--beat-range {text}: LO must be above 0 and below HI", kind="input")
|
|
54
|
+
return (lo, hi)
|
|
34
55
|
|
|
35
56
|
|
|
36
57
|
def main() -> int:
|
|
@@ -45,6 +66,16 @@ def main() -> int:
|
|
|
45
66
|
ap.add_argument("--target", type=float, help="with --highlights: total seconds the picks should add up to (trims long scenes)")
|
|
46
67
|
ap.add_argument("--max-scene", type=float, default=15.0, help="cap a highlight range at this many seconds (default 15)")
|
|
47
68
|
ap.add_argument("--edl", help="write highlight ranges as START-END lines (cut.py --segments format)")
|
|
69
|
+
ap.add_argument("--beats", action="store_true",
|
|
70
|
+
help="measure the music's beat grid (tempo, beat times, confidence) and report it; "
|
|
71
|
+
"a measurement, not a proposal -- no cut is made and no beat is invented")
|
|
72
|
+
ap.add_argument("--beat-step", type=float, default=0.01,
|
|
73
|
+
help="envelope resolution in seconds for the onset pass with --beats (default 0.01)")
|
|
74
|
+
ap.add_argument("--beat-range", default="60-200",
|
|
75
|
+
help="tempo search range in BPM for --beats (default 60-200)")
|
|
76
|
+
ap.add_argument("--min-confidence", type=float, default=BEAT_MIN_CONFIDENCE,
|
|
77
|
+
help="with --beats: below this confidence the grid is still reported, marked "
|
|
78
|
+
f"usable: false (default {BEAT_MIN_CONFIDENCE})")
|
|
48
79
|
ap.add_argument("--sheet", help="write a contact sheet PNG with the first frame of every scene")
|
|
49
80
|
ap.add_argument("--no-timecode", action="store_true", help="--sheet without the burnt-in timecode stamp (a way out if drawtext itself is unusable, see doctor)")
|
|
50
81
|
add_common(ap)
|
|
@@ -54,11 +85,26 @@ def main() -> int:
|
|
|
54
85
|
meta = probe(args.input)
|
|
55
86
|
if not meta.get("video"):
|
|
56
87
|
die("input has no video stream")
|
|
88
|
+
# Only parsed when it is going to be used: --beat-range is a --beats flag, and a run that
|
|
89
|
+
# never asked for beats should not be able to die on one.
|
|
90
|
+
beat_range = parse_beat_range(args.beat_range) if args.beats else (60.0, 200.0)
|
|
91
|
+
if args.beats:
|
|
92
|
+
if not meta.get("audio"):
|
|
93
|
+
die("--beats needs an audio stream; this file has none", kind="input")
|
|
94
|
+
if args.beat_step <= 0:
|
|
95
|
+
die("--beat-step must be greater than 0", kind="input")
|
|
57
96
|
dur = meta.get("duration") or 0.0
|
|
58
97
|
cuts = detect_scenes(args.input, args.threshold, args.min_scene, dur, args.ratio)
|
|
59
98
|
bounds = cuts + [dur]
|
|
60
99
|
step_s = 0.5
|
|
61
|
-
|
|
100
|
+
# With --beats the file is decoded once, at the finer rate, and both envelopes come from that
|
|
101
|
+
# one pass: the 0.5 s scene blocks are an exact multiple of the 10 ms onset blocks.
|
|
102
|
+
beat_rate = 22050
|
|
103
|
+
fine_samples = decode_pcm_mono(args.input, beat_rate, check=False) if (args.beats and meta.get("audio")) else None
|
|
104
|
+
if fine_samples is not None:
|
|
105
|
+
env = audio_envelope(args.input, step_s, rate=beat_rate, samples=fine_samples)
|
|
106
|
+
else:
|
|
107
|
+
env = audio_envelope(args.input, step_s) if meta.get("audio") else []
|
|
62
108
|
|
|
63
109
|
scenes = []
|
|
64
110
|
for i in range(len(bounds) - 1):
|
|
@@ -82,6 +128,34 @@ def main() -> int:
|
|
|
82
128
|
result: Dict = {"file": args.input, "duration": round(dur, 3), "scene_count": len(scenes), "scenes": scenes, "audio_peaks": peaks}
|
|
83
129
|
info(f"{len(scenes)} scenes, {len(peaks)} audio peaks over {dur:.1f}s")
|
|
84
130
|
|
|
131
|
+
if args.beats:
|
|
132
|
+
# A beat grid is a measurement of the music's periodicity, not a statement about where a
|
|
133
|
+
# cut belongs. scenes.py reports what it measured, including a low confidence: reporting a
|
|
134
|
+
# weak measurement is honest, and only a tool that CHANGES a file refuses to act on one.
|
|
135
|
+
fine = rms_envelope(fine_samples or [], max(1, int(round(beat_rate * args.beat_step))))
|
|
136
|
+
grid = beat_grid(fine, args.beat_step, bpm_range=beat_range,
|
|
137
|
+
min_confidence=args.min_confidence, duration=dur)
|
|
138
|
+
result["beats"] = grid["beats"]
|
|
139
|
+
result["beat_grid"] = {
|
|
140
|
+
# The regular grid AND the subset a measured onset supports. A tool that MOVES
|
|
141
|
+
# something (cut.py --snap beats) may only use the subset; scenes.py reports both,
|
|
142
|
+
# because here the regular grid is the measurement being made.
|
|
143
|
+
"supported_beats": grid["supported_beats"],
|
|
144
|
+
"tempo_bpm": grid["tempo_bpm"], "interval": grid["interval"],
|
|
145
|
+
"confidence": grid["confidence"], "phase": grid["phase"],
|
|
146
|
+
"onsets": len(grid["onsets"]), "supported": grid["supported"],
|
|
147
|
+
"unsupported": grid["unsupported"], "method": grid["method"],
|
|
148
|
+
"step_s": grid["step_s"], "range_bpm": grid["range_bpm"], "usable": grid["usable"],
|
|
149
|
+
}
|
|
150
|
+
if grid["tempo_bpm"] is None:
|
|
151
|
+
info(f"--beats: no steady pulse in this audio (confidence {grid['confidence']:.2f}) -- "
|
|
152
|
+
"speech, ambience or rubato has no tempo to measure")
|
|
153
|
+
else:
|
|
154
|
+
info(f"--beats: {grid['tempo_bpm']:.1f} BPM, {len(grid['beats'])} beats, confidence "
|
|
155
|
+
f"{grid['confidence']:.2f} ({grid['supported']} of {len(grid['beats'])} grid points "
|
|
156
|
+
f"have a measured onset)"
|
|
157
|
+
+ ("" if grid["usable"] else f" -- below --min-confidence {args.min_confidence}, usable: false"))
|
|
158
|
+
|
|
85
159
|
if args.highlights:
|
|
86
160
|
if args.rank_by == "duration":
|
|
87
161
|
rank_key = lambda sc: (-sc["duration"], sc["start"])
|
package/scripts/silence.py
CHANGED
|
@@ -12,29 +12,168 @@ Examples:
|
|
|
12
12
|
python3 silence.py talk.mp4 --edl keep.txt # also save the kept ranges (START-END per line, cut.py --segments format)
|
|
13
13
|
"""
|
|
14
14
|
import argparse
|
|
15
|
+
import json
|
|
15
16
|
import os
|
|
16
17
|
import sys
|
|
17
18
|
from typing import List, Tuple
|
|
18
19
|
|
|
19
20
|
# `detect` moved into _common/probe.py in 1.16.0 so metadata.py --auto-chapters can measure the
|
|
20
21
|
# same silences without importing this tool; the body is unchanged and the name still lives here.
|
|
22
|
+
from _common import (filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOURSE_MARKERS,
|
|
23
|
+
FILLER_PAD, transcribe_words, read_text_or_die)
|
|
21
24
|
from _common import detect_silences as detect, STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, run, X264_PRESETS, measured_level_dbfs, fmt_secs
|
|
22
25
|
|
|
23
26
|
|
|
24
27
|
|
|
28
|
+
def merge_spans(spans: List[Tuple[float, float]]) -> List[Tuple[float, float]]:
|
|
29
|
+
"""`spans` sorted and coalesced: any pair that touches or overlaps becomes one.
|
|
30
|
+
|
|
31
|
+
keep_ranges() walks a single cursor forward, so it needs a removal list in which no span
|
|
32
|
+
starts before the previous one ended. Silences and filler spans are each merged only among
|
|
33
|
+
themselves -- and a mumbled "um" is very often quiet enough to sit INSIDE a detected silence --
|
|
34
|
+
so the union has to be taken before the two lists are handed over as one.
|
|
35
|
+
"""
|
|
36
|
+
out: List[Tuple[float, float]] = []
|
|
37
|
+
for s, e in sorted(spans):
|
|
38
|
+
if out and s <= out[-1][1]:
|
|
39
|
+
out[-1] = (out[-1][0], max(out[-1][1], e))
|
|
40
|
+
else:
|
|
41
|
+
out.append((s, e))
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
25
45
|
def keep_ranges(silences: List[Tuple[float, float]], duration: float, margin: float, min_keep: float) -> List[Tuple[float, float]]:
|
|
26
46
|
keeps: List[Tuple[float, float]] = []
|
|
27
47
|
cursor = 0.0
|
|
28
|
-
for s, e in silences:
|
|
48
|
+
for s, e in sorted(silences):
|
|
29
49
|
s_adj = max(cursor, s + margin)
|
|
30
50
|
if s_adj - cursor >= min_keep:
|
|
31
51
|
keeps.append((cursor, s_adj))
|
|
32
|
-
|
|
52
|
+
# max(cursor, ...) so the walk is monotone. A span nested inside the previous one used to
|
|
53
|
+
# rewind the cursor and hand back the very stretch that had just been removed: with a
|
|
54
|
+
# filler word inside a detected silence, adding --filler made the tool remove LESS.
|
|
55
|
+
# merge_spans() above is the caller-side fix; this keeps the function safe on its own.
|
|
56
|
+
cursor = max(cursor, min(duration, e - margin) if e != float("inf") else duration)
|
|
33
57
|
if duration - cursor >= min_keep:
|
|
34
58
|
keeps.append((cursor, duration))
|
|
35
59
|
return keeps
|
|
36
60
|
|
|
37
61
|
|
|
62
|
+
def _word_list(text: "str") -> "list":
|
|
63
|
+
return [w.strip() for w in str(text or "").replace(",", "\n").splitlines() if w.strip()]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def resolve_filler(args, meta):
|
|
67
|
+
"""(the `filler` result block, the spans to remove) for --filler. Refuses before any encode.
|
|
68
|
+
|
|
69
|
+
Never without measured word timings: no heuristic fallback, no guess from the filename. The
|
|
70
|
+
three refusals below are the whole safety story for this flag.
|
|
71
|
+
"""
|
|
72
|
+
if not args.words and not args.transcribe:
|
|
73
|
+
die("--filler needs word timings: pass --words transcript.json (a whisper JSON with word "
|
|
74
|
+
"timestamps) or --transcribe. There is no way to find a filler word without them -- "
|
|
75
|
+
"cutting the short quiet blips instead would remove real speech.", kind="input")
|
|
76
|
+
source, engine, raw = None, None, None
|
|
77
|
+
if args.words:
|
|
78
|
+
try:
|
|
79
|
+
raw = json.loads(read_text_or_die(args.words, "--words"))
|
|
80
|
+
except ValueError as exc:
|
|
81
|
+
die(f"--words {args.words}: not readable JSON ({exc})", kind="input")
|
|
82
|
+
source = f"whisper-json:{args.words}"
|
|
83
|
+
words, had_segments = _words_from_transcript(raw)
|
|
84
|
+
if not words:
|
|
85
|
+
if had_segments:
|
|
86
|
+
die(f"the transcript in {args.words} has segment timings but no word timings; "
|
|
87
|
+
"--filler removes words, and cutting on segment boundaries would remove whole "
|
|
88
|
+
"sentences. Re-run whisper with word timestamps (whisper.cpp "
|
|
89
|
+
"--output-json-full / faster-whisper word_timestamps=True), or use --list to "
|
|
90
|
+
"see the pauses instead.", kind="input")
|
|
91
|
+
die(f"no word timings in {args.words}: --filler needs "
|
|
92
|
+
'{"words": [{"word": ..., "start": ..., "end": ...}]} (or the same inside '
|
|
93
|
+
'"segments").', kind="input")
|
|
94
|
+
else:
|
|
95
|
+
# No pre-check for an installed engine here: transcribe_words() probes for one and
|
|
96
|
+
# raises the same die_no_engine() refusal when there is none. Two places deciding "is
|
|
97
|
+
# whisper here" is two places to disagree.
|
|
98
|
+
# The engine is driven with ITS word-timestamp option (whisper.cpp --output-json-full,
|
|
99
|
+
# faster-whisper word_timestamps=True, openai-whisper --word_timestamps True). An SRT
|
|
100
|
+
# cannot answer this question: a cue has a start and an end, a word does not.
|
|
101
|
+
words, engine = transcribe_words(args.input, args.filler_lang if args.filler_lang != "auto" else None)
|
|
102
|
+
source = f"whisper:{engine}" if engine else "whisper"
|
|
103
|
+
if not words:
|
|
104
|
+
die(f"{engine or 'the local engine'} ran but produced no word-level timings, so there "
|
|
105
|
+
"is nothing for --filler to cut on. Some builds do not support word timestamps. "
|
|
106
|
+
"Re-run that engine yourself with them (whisper.cpp --output-json-full / "
|
|
107
|
+
"faster-whisper word_timestamps=True / openai-whisper --word_timestamps True) and "
|
|
108
|
+
"pass the result with --words.", kind="input")
|
|
109
|
+
|
|
110
|
+
lang = args.filler_lang
|
|
111
|
+
if lang == "auto":
|
|
112
|
+
lang = str((raw or {}).get("language") or "").lower()[:2] if isinstance(raw, dict) else ""
|
|
113
|
+
lang = lang if lang in FILLER_WORDS else "en"
|
|
114
|
+
listname = "builtin"
|
|
115
|
+
if args.filler_words:
|
|
116
|
+
wordlist = set(_word_list(read_text_or_die(args.filler_words, "--filler-words")))
|
|
117
|
+
listname = args.filler_words
|
|
118
|
+
else:
|
|
119
|
+
if lang not in FILLER_WORDS:
|
|
120
|
+
die(f"--filler-lang {lang}: no built-in filler list for that language. The languages "
|
|
121
|
+
f"with one are {', '.join(sorted(FILLER_WORDS))}; pass --filler-words FILE with "
|
|
122
|
+
"your own list for anything else.", kind="input")
|
|
123
|
+
wordlist = set(FILLER_WORDS[lang])
|
|
124
|
+
wordlist |= set(_word_list(args.filler_extra))
|
|
125
|
+
wordlist -= set(_word_list(args.filler_keep))
|
|
126
|
+
|
|
127
|
+
spans = filler_spans(words, wordlist, pad=args.filler_pad)
|
|
128
|
+
removed_words = sorted({s["word"] for s in spans})
|
|
129
|
+
warnings = []
|
|
130
|
+
ambiguous = sorted(set(FILLER_AMBIGUOUS.get(lang, ())) & set(
|
|
131
|
+
t for s in spans for t in s["word"].split()))
|
|
132
|
+
if ambiguous:
|
|
133
|
+
warnings.append(
|
|
134
|
+
f"removed {', '.join(ambiguous)} -- in {lang} these are as often ordinary words as "
|
|
135
|
+
f"fillers. Keep one with --filler-keep {ambiguous[0]} and re-run if a sentence lost "
|
|
136
|
+
"its meaning.")
|
|
137
|
+
if FILLER_DISCOURSE_MARKERS.get(lang):
|
|
138
|
+
extra_markers = sorted(set(_word_list(args.filler_extra))
|
|
139
|
+
& set(FILLER_DISCOURSE_MARKERS[lang]))
|
|
140
|
+
if extra_markers:
|
|
141
|
+
warnings.append(f"--filler-extra {', '.join(extra_markers)}: a discourse marker, not a "
|
|
142
|
+
"disfluency -- this will cut real sentences.")
|
|
143
|
+
block = {
|
|
144
|
+
"lang": lang, "source": source, "engine": engine,
|
|
145
|
+
"words": sorted(wordlist), "removed": [dict(s) for s in spans],
|
|
146
|
+
"removed_count": len(spans),
|
|
147
|
+
"removed_seconds": round(sum(s["end"] - s["start"] for s in spans), 3),
|
|
148
|
+
"word_timings": len(words), "list": listname, "removed_words": removed_words,
|
|
149
|
+
"warnings": warnings,
|
|
150
|
+
}
|
|
151
|
+
return block, [(s["start"], s["end"]) for s in spans]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _words_from_transcript(data):
|
|
155
|
+
"""([{word,start,end}], whether the document had segments at all) from a whisper JSON."""
|
|
156
|
+
raw, had_segments = [], False
|
|
157
|
+
if isinstance(data, dict):
|
|
158
|
+
raw = list(data.get("words") or [])
|
|
159
|
+
segments = data.get("segments") or []
|
|
160
|
+
had_segments = bool(segments)
|
|
161
|
+
for seg in segments:
|
|
162
|
+
raw.extend((seg or {}).get("words") or [])
|
|
163
|
+
elif isinstance(data, list):
|
|
164
|
+
raw = list(data)
|
|
165
|
+
out = []
|
|
166
|
+
for w in raw:
|
|
167
|
+
if not isinstance(w, dict):
|
|
168
|
+
continue
|
|
169
|
+
try:
|
|
170
|
+
out.append({"word": str(w.get("word") or w.get("text") or ""),
|
|
171
|
+
"start": float(w["start"]), "end": float(w["end"])})
|
|
172
|
+
except (KeyError, TypeError, ValueError):
|
|
173
|
+
continue
|
|
174
|
+
return out, had_segments
|
|
175
|
+
|
|
176
|
+
|
|
38
177
|
def main() -> int:
|
|
39
178
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
40
179
|
ap.add_argument("input")
|
|
@@ -45,27 +184,90 @@ def main() -> int:
|
|
|
45
184
|
ap.add_argument("--min-keep", type=float, default=0.2, help="drop kept pieces shorter than this (default 0.2)")
|
|
46
185
|
ap.add_argument("--list", action="store_true", help="only print silences and the kept ranges")
|
|
47
186
|
ap.add_argument("--edl", help="write the kept ranges to this file, one START-END per line")
|
|
187
|
+
fil = ap.add_argument_group("filler words (1.17)")
|
|
188
|
+
fil.add_argument("--filler", action="store_true",
|
|
189
|
+
help="also remove filler words. Needs measured word timings: pass --words or "
|
|
190
|
+
"--transcribe. There is no heuristic fallback -- a word is cut only where "
|
|
191
|
+
"a speech engine timed it.")
|
|
192
|
+
fil.add_argument("--filler-lang", choices=["auto"] + sorted(FILLER_WORDS), default="auto",
|
|
193
|
+
help="which built-in list to use (default auto: the transcript's language field)")
|
|
194
|
+
fil.add_argument("--filler-words", metavar="FILE",
|
|
195
|
+
help="one word per line; replaces the built-in list for this run")
|
|
196
|
+
fil.add_argument("--filler-extra", metavar="W[,W...]",
|
|
197
|
+
help="add words to the list. 'like', 'tipo' and 'cio\u00e8' live here rather than in "
|
|
198
|
+
"the defaults: they are discourse markers, not disfluencies, and cutting "
|
|
199
|
+
"them cuts real sentences.")
|
|
200
|
+
fil.add_argument("--filler-keep", metavar="W[,W...]",
|
|
201
|
+
help="remove words from the built-in list (e.g. --filler-keep \u306a\u3093\u304b)")
|
|
202
|
+
fil.add_argument("--filler-pad", type=float, default=FILLER_PAD,
|
|
203
|
+
help=f"seconds trimmed either side of a filler word (default {FILLER_PAD})")
|
|
204
|
+
fil.add_argument("--words", metavar="FILE",
|
|
205
|
+
help="a whisper JSON with word-level timings, for --filler")
|
|
206
|
+
fil.add_argument("--transcribe", action="store_true",
|
|
207
|
+
help="produce the word timings with a local whisper (never required; the same "
|
|
208
|
+
"bridge caption.py uses)")
|
|
209
|
+
fil.add_argument("--filler-list", action="store_true",
|
|
210
|
+
help="report what --filler would remove and write nothing")
|
|
211
|
+
fil.add_argument("--max-cuts", type=int, default=400,
|
|
212
|
+
help="refuse above this many removal ranges: the filter graph grows with them "
|
|
213
|
+
"(default 400)")
|
|
48
214
|
ap.add_argument("--crf", type=int, default=18)
|
|
49
215
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
50
216
|
add_common(ap)
|
|
51
217
|
args = ap.parse_args()
|
|
52
218
|
apply_common(args)
|
|
53
219
|
|
|
220
|
+
if args.filler_pad < 0:
|
|
221
|
+
die("--filler-pad cannot be negative: a negative pad turns each word's span inside out "
|
|
222
|
+
"(end before start) and the span is then silently dropped, so nothing is removed",
|
|
223
|
+
kind="input")
|
|
224
|
+
if args.max_cuts < 1:
|
|
225
|
+
die("--max-cuts must be at least 1", kind="input")
|
|
226
|
+
if args.filler_list and not args.filler:
|
|
227
|
+
die("--filler-list reports what --filler would remove: pass --filler as well", kind="input")
|
|
54
228
|
meta = probe(args.input)
|
|
55
229
|
if not meta.get("audio"):
|
|
56
230
|
die("input has no audio stream to analyse")
|
|
57
231
|
duration = meta.get("duration") or 0.0
|
|
58
232
|
silences = detect(args.input, args.threshold, args.min_silence)
|
|
59
|
-
|
|
233
|
+
filler_info, filler_ranges = resolve_filler(args, meta) if args.filler else (None, [])
|
|
234
|
+
# One sorted, merged removal list through the graph the tool already has: filler removal IS
|
|
235
|
+
# time-range removal, so it reuses keep_ranges() and the same aselect/concat chain.
|
|
236
|
+
removals = merge_spans(list(silences) + list(filler_ranges))
|
|
237
|
+
keeps = keep_ranges(removals, duration, args.margin, args.min_keep)
|
|
60
238
|
kept = sum(e - s for s, e in keeps)
|
|
61
239
|
removed = max(0.0, duration - kept)
|
|
240
|
+
# `removed_seconds` keeps the meaning it has had since this tool existed: the seconds of
|
|
241
|
+
# SILENCE this run removes. It must not quietly start counting filler time as well, because a
|
|
242
|
+
# caller that has been reading it since 1.0 asked how much dead air went. The silence-only
|
|
243
|
+
# figure is the one the same run would have reported without --filler, so it is computed from
|
|
244
|
+
# the silences alone; everything removed is `removed_seconds_total`.
|
|
245
|
+
silence_only = removed
|
|
246
|
+
if args.filler:
|
|
247
|
+
silence_keeps = keep_ranges(merge_spans(list(silences)), duration, args.margin, args.min_keep)
|
|
248
|
+
silence_only = max(0.0, duration - sum(e - s for s, e in silence_keeps))
|
|
62
249
|
summary = {
|
|
63
250
|
"silences": [[round(s, 3), None if e == float("inf") else round(e, 3)] for s, e in silences],
|
|
64
251
|
"keep": [[round(s, 3), round(e, 3)] for s, e in keeps],
|
|
65
252
|
"input_duration": round(duration, 3),
|
|
66
253
|
"kept_duration": round(kept, 3),
|
|
67
|
-
"removed_seconds": round(
|
|
254
|
+
"removed_seconds": round(silence_only, 3),
|
|
68
255
|
}
|
|
256
|
+
if filler_info is not None:
|
|
257
|
+
# removed_seconds above is the silence-only figure, unchanged in meaning; the filler share
|
|
258
|
+
# is reported inside `filler`, and removed_seconds_total is the additive sibling that
|
|
259
|
+
# covers everything this run took out.
|
|
260
|
+
summary["filler"] = filler_info
|
|
261
|
+
summary["removed_seconds_total"] = round(removed, 3)
|
|
262
|
+
info(f"--filler: {filler_info['removed_count']} filler word(s), "
|
|
263
|
+
f"{filler_info['removed_seconds']:.2f}s, from {filler_info['word_timings']} word timings "
|
|
264
|
+
f"({filler_info['lang']}, list {filler_info['list']})")
|
|
265
|
+
for warning in filler_info.get("warnings") or []:
|
|
266
|
+
info("warning: " + warning)
|
|
267
|
+
if len(keeps) > args.max_cuts:
|
|
268
|
+
die(f"{len(keeps)} keep ranges is above --max-cuts {args.max_cuts}: the filter graph grows "
|
|
269
|
+
"with every range and a graph this size is slow and fragile. Raise --max-cuts if you "
|
|
270
|
+
"mean it, or use --min-silence/--filler-pad to merge the short ones.", kind="input")
|
|
69
271
|
info(f"{len(silences)} silences, keeping {len(keeps)} ranges: {kept:.2f}s of {duration:.2f}s (removing {removed:.2f}s)")
|
|
70
272
|
if not silences and not (STATE.dry_run and not os.path.exists(args.input)):
|
|
71
273
|
# Nothing under the threshold is a valid result, not a failure -- but an agent that only
|
|
@@ -88,7 +290,7 @@ def main() -> int:
|
|
|
88
290
|
fh.write(f"{s:.3f}-{e:.3f}\n")
|
|
89
291
|
info(f"wrote {args.edl}")
|
|
90
292
|
|
|
91
|
-
if args.list:
|
|
293
|
+
if args.list or args.filler_list:
|
|
92
294
|
if args.json:
|
|
93
295
|
emit(None, **summary)
|
|
94
296
|
else:
|
package/scripts/verify.py
CHANGED
|
@@ -81,7 +81,7 @@ def step(name: str, argv: List[str], timeout: float) -> Dict:
|
|
|
81
81
|
STATE.json = was_json
|
|
82
82
|
return {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": err}
|
|
83
83
|
try:
|
|
84
|
-
proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout)
|
|
84
|
+
proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=timeout)
|
|
85
85
|
ok = proc.returncode == 0
|
|
86
86
|
err = "" if ok else (proc.stderr.strip().splitlines() or ["?"])[-1][:200]
|
|
87
87
|
except subprocess.TimeoutExpired:
|
package/scripts/waveform.py
CHANGED
|
@@ -274,8 +274,7 @@ def _child(script_name: str, argv: "list") -> None:
|
|
|
274
274
|
if STATE.dry_run:
|
|
275
275
|
cmd.append("--dry-run")
|
|
276
276
|
info("-> " + " ".join(os.path.basename(c) if c.endswith(".py") else str(c) for c in cmd[1:]))
|
|
277
|
-
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
278
|
-
encoding="utf-8", errors="replace")
|
|
277
|
+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
|
|
279
278
|
if proc.returncode != 0:
|
|
280
279
|
die(f"{script_name} failed:\n{(proc.stderr or proc.stdout).strip()[-800:]}", kind="ffmpeg")
|
|
281
280
|
|