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.
@@ -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
- cursor = min(duration, e - margin) if e != float("inf") else duration
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
- keeps = keep_ranges(silences, duration, args.margin, args.min_keep)
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(removed, 3),
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: