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/cut.py
CHANGED
|
@@ -25,11 +25,13 @@ Examples:
|
|
|
25
25
|
python3 cut.py talk.mp4 --start 1:00 --end 2:00 -o part.wav # audio extraction
|
|
26
26
|
"""
|
|
27
27
|
import argparse
|
|
28
|
+
import json
|
|
28
29
|
import os
|
|
29
30
|
import sys
|
|
30
31
|
import tempfile
|
|
31
32
|
from typing import List, Tuple
|
|
32
33
|
|
|
34
|
+
from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
|
|
33
35
|
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input, fmt_secs
|
|
34
36
|
|
|
35
37
|
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
|
|
@@ -148,6 +150,123 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
|
|
|
148
150
|
return reencode
|
|
149
151
|
|
|
150
152
|
|
|
153
|
+
BEAT_RATE = 22050 # the decode rate the onset pass uses, matching scenes.py --beats
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _grid_from_source(path: str, min_confidence: float) -> "dict":
|
|
157
|
+
"""The beat grid of `path`: a scenes.py --json document if that is what it is, otherwise a
|
|
158
|
+
media file to measure. Reading a document is how a caller avoids a second decode."""
|
|
159
|
+
try:
|
|
160
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
161
|
+
doc = json.load(fh)
|
|
162
|
+
except (OSError, ValueError):
|
|
163
|
+
doc = None
|
|
164
|
+
if isinstance(doc, dict) and doc.get("beat_grid"):
|
|
165
|
+
grid = dict(doc["beat_grid"])
|
|
166
|
+
grid["beats"] = doc.get("beats") or []
|
|
167
|
+
# A scenes.py document carries the supported subset since 1.17; one written by an older
|
|
168
|
+
# build does not, and a grid whose supported points are unknown is not one this tool may
|
|
169
|
+
# move a cut onto -- an unknown subset is not an empty one, but it is not a measurement
|
|
170
|
+
# either, so it is refused rather than silently treated as "all of them".
|
|
171
|
+
grid["supported_beats"] = doc.get("beat_grid", {}).get("supported_beats")
|
|
172
|
+
if grid["supported_beats"] is None:
|
|
173
|
+
grid["supported_beats"] = doc.get("supported_beats")
|
|
174
|
+
try:
|
|
175
|
+
tempo = grid.get("tempo_bpm")
|
|
176
|
+
grid["tempo_bpm"] = float(tempo) if tempo is not None else None
|
|
177
|
+
grid["confidence"] = float(grid.get("confidence") or 0.0)
|
|
178
|
+
except (TypeError, ValueError):
|
|
179
|
+
die(f"--snap-source {path}: beat_grid.tempo_bpm and .confidence must be numbers "
|
|
180
|
+
"(regenerate it with `scenes.py MUSIC --beats --json`)", kind="input")
|
|
181
|
+
if grid["beats"] and grid["tempo_bpm"] is None:
|
|
182
|
+
die(f"--snap-source {path}: this document lists beats but no tempo_bpm, so no grid "
|
|
183
|
+
"was actually measured in it. Regenerate it with "
|
|
184
|
+
"`scenes.py MUSIC --beats --json`.", kind="input")
|
|
185
|
+
grid["usable"] = grid["confidence"] >= min_confidence
|
|
186
|
+
return grid
|
|
187
|
+
if isinstance(doc, dict):
|
|
188
|
+
die(f"--snap-source {path}: this JSON has no beat_grid -- produce one with "
|
|
189
|
+
"`scenes.py MUSIC --beats --json`", kind="input")
|
|
190
|
+
samples = decode_pcm_mono(path, BEAT_RATE, check=False)
|
|
191
|
+
env = rms_envelope(samples, max(1, int(round(BEAT_RATE * 0.01))))
|
|
192
|
+
return beat_grid(env, 0.01, min_confidence=min_confidence)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def snap_segments(args, segments, meta, total):
|
|
196
|
+
"""Move every in/out point to the nearest measured beat. Returns (result dict, segments).
|
|
197
|
+
|
|
198
|
+
A cut point may move to a measured, onset-supported grid point and may not appear from one:
|
|
199
|
+
the number of segments is unchanged, and nothing is ever proposed. The keyframe/tolerance
|
|
200
|
+
decision downstream then runs on the snapped values, which is the right order -- whether a cut
|
|
201
|
+
can be lossless depends on where it actually lands.
|
|
202
|
+
"""
|
|
203
|
+
source = args.snap_source or args.input
|
|
204
|
+
if not args.snap_source and not meta.get("audio"):
|
|
205
|
+
die("--snap beats needs audio to measure a beat in; this file has none. Cut without it "
|
|
206
|
+
"(--snap none), or pass --snap-source with the music bed.", kind="input")
|
|
207
|
+
# Compare the PARSED segments against the whole file, not the raw --start string: "0:00",
|
|
208
|
+
# "0.0" and "00:00:00" are all a zero start that a string comparison lets through, and the
|
|
209
|
+
# run would then snap the implicit end point and silently shorten a whole-file copy.
|
|
210
|
+
whole_file = (len(segments) == 1 and abs(segments[0][0]) < 1e-6
|
|
211
|
+
and (not total or abs(segments[0][1] - total) < 1e-6))
|
|
212
|
+
if whole_file:
|
|
213
|
+
die("--snap beats has no in or out point to move: this run copies the whole file. Give "
|
|
214
|
+
"--start/--end (or --segments), or drop --snap.", kind="input")
|
|
215
|
+
# A floor of zero would make the confidence check vacuous -- a grid measured from noise scores
|
|
216
|
+
# above 0.0 and would pass -- and the whole point of the flag is that a cut only moves onto a
|
|
217
|
+
# pulse somebody can hear. The number is a floor on belief, so it must be a positive one.
|
|
218
|
+
if args.min_confidence <= 0:
|
|
219
|
+
die("--min-confidence must be greater than 0: at 0 every grid is 'reliable', including "
|
|
220
|
+
"one measured from noise, which is exactly what --snap beats must not cut to. Use "
|
|
221
|
+
"--snap none if you do not want the points moved at all.", kind="input")
|
|
222
|
+
grid = _grid_from_source(source, args.min_confidence)
|
|
223
|
+
confidence = float(grid.get("confidence") or 0.0)
|
|
224
|
+
tempo = grid.get("tempo_bpm")
|
|
225
|
+
if confidence < args.min_confidence or not grid.get("beats"):
|
|
226
|
+
die(f"no reliable beat grid in this audio (confidence {confidence:.2f}, needs "
|
|
227
|
+
f"{args.min_confidence:.2f}): cutting to invented beats would move your in/out points "
|
|
228
|
+
"to times nothing in the audio supports. Re-run with --snap none, or pass "
|
|
229
|
+
"--snap-source from a music bed.", kind="input")
|
|
230
|
+
# THE grid a cut may move onto is the onset-supported subset, never the full regular grid.
|
|
231
|
+
# beat_grid() reports a regular grid over the whole duration by design -- a grid has to be
|
|
232
|
+
# regular -- so it runs on through a passage with no music in it. Snapping to one of those
|
|
233
|
+
# points moves a cut to a time nothing in the audio marks, which is the fabrication this
|
|
234
|
+
# release forbids and which this tool's own refusal text promises it does not do.
|
|
235
|
+
supported = grid.get("supported_beats")
|
|
236
|
+
if supported is None:
|
|
237
|
+
die(f"--snap-source {source}: this document does not say which grid points a measured "
|
|
238
|
+
"onset supports, so there is no way to tell a beat from a gap in it. Regenerate it "
|
|
239
|
+
"with `scenes.py MUSIC --beats --json`.", kind="input")
|
|
240
|
+
if not supported:
|
|
241
|
+
die(f"no measured onset supports any point of this beat grid (confidence "
|
|
242
|
+
f"{confidence:.2f}): the grid is regular but nothing in the audio marks it, so every "
|
|
243
|
+
"move would be to an invented time. Re-run with --snap none, or pass --snap-source "
|
|
244
|
+
"from a music bed.", kind="input")
|
|
245
|
+
points = [t for seg in segments for t in seg]
|
|
246
|
+
moved = snap_points(points, supported, args.snap_tolerance)
|
|
247
|
+
out_segments = []
|
|
248
|
+
for i in range(0, len(moved), 2):
|
|
249
|
+
s, e = moved[i]["to"], moved[i + 1]["to"]
|
|
250
|
+
if e <= s: # a snap that would collapse the segment is not applied to it
|
|
251
|
+
s, e = moved[i]["from"], moved[i + 1]["from"]
|
|
252
|
+
moved[i].update({"to": s, "delta": 0.0, "snapped": False, "beat_index": None})
|
|
253
|
+
moved[i + 1].update({"to": e, "delta": 0.0, "snapped": False, "beat_index": None})
|
|
254
|
+
out_segments.append((s, e))
|
|
255
|
+
snapped = sum(1 for m in moved if m["snapped"])
|
|
256
|
+
for m in moved:
|
|
257
|
+
if m["snapped"]:
|
|
258
|
+
info(f"--snap beats: {m['from']:.3f}s -> {m['to']:.3f}s ({m['delta'] * 1000:+.0f} ms)")
|
|
259
|
+
info(f"--snap beats: {tempo:.1f} BPM, confidence {confidence:.2f}; {snapped} of {len(moved)} "
|
|
260
|
+
f"point(s) moved, within {args.snap_tolerance:.3f}s, onto {len(supported)} of "
|
|
261
|
+
f"{len(grid['beats'])} grid point(s) a measured onset supports")
|
|
262
|
+
return ({"mode": "beats", "tolerance": args.snap_tolerance, "confidence": confidence,
|
|
263
|
+
"tempo_bpm": tempo, "grid": "supported", "grid_points": len(supported),
|
|
264
|
+
"moved": [dict(m) for m in moved], "snapped": snapped,
|
|
265
|
+
"unchanged": len(moved) - snapped,
|
|
266
|
+
"source": "measured" if not args.snap_source else args.snap_source},
|
|
267
|
+
out_segments)
|
|
268
|
+
|
|
269
|
+
|
|
151
270
|
def main() -> int:
|
|
152
271
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
153
272
|
ap.add_argument("input")
|
|
@@ -159,6 +278,17 @@ def main() -> int:
|
|
|
159
278
|
ap.add_argument("--segments", help="comma separated START-END list, e.g. '0:05-0:12,1:00-1:20' (joined in order)")
|
|
160
279
|
ap.add_argument("--accurate", action="store_true", help="always re-encode for frame-accurate (video) / sample-accurate (audio) cuts (default: lossless -c copy, re-encoding only when the keyframe snap exceeds --tolerance)")
|
|
161
280
|
ap.add_argument("--tolerance", type=float, default=0.5, help="max seconds a lossless cut may deviate before re-encoding kicks in (default 0.5, -1 = never)")
|
|
281
|
+
snap = ap.add_argument_group("beat snapping")
|
|
282
|
+
snap.add_argument("--snap", choices=["none", "beats"], default="none",
|
|
283
|
+
help="move each in/out point to the nearest measured beat (default none)")
|
|
284
|
+
snap.add_argument("--snap-tolerance", type=float, default=0.12,
|
|
285
|
+
help="most seconds a point may move with --snap beats (default 0.12, about a "
|
|
286
|
+
"quarter of a beat at 120 BPM)")
|
|
287
|
+
snap.add_argument("--snap-source", metavar="FILE",
|
|
288
|
+
help="take the beat grid from this scenes.py --beats --json document (or from "
|
|
289
|
+
"this media file) instead of measuring the input again")
|
|
290
|
+
snap.add_argument("--min-confidence", type=float, default=BEAT_MIN_CONFIDENCE,
|
|
291
|
+
help=f"refuse to snap below this measured beat confidence (default {BEAT_MIN_CONFIDENCE})")
|
|
162
292
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF when re-encoding (default 18)")
|
|
163
293
|
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset when re-encoding")
|
|
164
294
|
add_common(ap)
|
|
@@ -196,6 +326,10 @@ def main() -> int:
|
|
|
196
326
|
die("end must be after start")
|
|
197
327
|
segments = [(start, end)]
|
|
198
328
|
|
|
329
|
+
snap_result = None
|
|
330
|
+
if args.snap == "beats":
|
|
331
|
+
snap_result, segments = snap_segments(args, segments, meta, total)
|
|
332
|
+
|
|
199
333
|
for s, e in segments:
|
|
200
334
|
if total and s >= total:
|
|
201
335
|
die(f"segment start {s:.3f}s is beyond the media duration {total:.3f}s")
|
|
@@ -249,7 +383,8 @@ def main() -> int:
|
|
|
249
383
|
# the trade the caller can offer instead of a re-encode (eval e02: "without losing quality")
|
|
250
384
|
lossless_alternative=(f"--start {min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])):.3f} lands on a keyframe: "
|
|
251
385
|
f"stream copy with no re-encode, {abs(min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])) - segments[0][0]):.2f}s off the requested start")
|
|
252
|
-
if mode == "hybrid" and NEAREST_KEYFRAMES and len(segments) == 1 else None
|
|
386
|
+
if mode == "hybrid" and NEAREST_KEYFRAMES and len(segments) == 1 else None,
|
|
387
|
+
snap=snap_result)
|
|
253
388
|
return 0
|
|
254
389
|
|
|
255
390
|
|