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/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