ffmpeg-skill 1.16.1 → 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 +11 -7
- 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 +257 -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/text.py +124 -0
- package/scripts/_contract.py +4 -2
- package/scripts/batch.py +287 -22
- package/scripts/caption.py +142 -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
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:
|