ffmpeg-skill 1.17.3 → 1.18.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/scripts/sync.py CHANGED
@@ -30,7 +30,7 @@ import json
30
30
  import math
31
31
  import os
32
32
  import sys
33
- from typing import List
33
+ from typing import Dict, List
34
34
 
35
35
  from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, x264_args, decode_pcm_mono, rms_envelope
36
36
 
@@ -195,84 +195,126 @@ def measure_offset(ref_path: str, oth_path: str, start: float, seconds: float, s
195
195
  return offset, max(0.0, min(1.0, score))
196
196
 
197
197
 
198
- def main() -> int:
199
- ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
200
- ap.add_argument("reference", help="reference recording (usually the camera video)")
201
- ap.add_argument("second", help="recording to align (external audio or second camera)")
202
- ap.add_argument("-o", "--output", help="output file when writing a synced result")
203
- ap.add_argument("--max-offset", type=float, default=30.0, help="largest offset to search in seconds (default 30)")
204
- ap.add_argument("--analyze-seconds", type=float, default=120.0, help="how much audio to analyse from each file (default 120)")
205
- ap.add_argument("--step-ms", type=float, default=20.0, help="coarse envelope resolution in ms for the FFT search (default 20)")
206
- ap.add_argument("--fine-ms", type=float, default=1.0, help="fine resolution in ms for the refinement pass, 0 to skip (default 1)")
207
- ap.add_argument("--fix-drift", action="store_true", help="also measure the offset near the END and correct clock drift by resampling the second file")
208
- ap.add_argument("--drift-window", type=float, default=60.0, help="seconds of audio analysed at each end for drift (default 60)")
209
- mode = ap.add_mutually_exclusive_group()
210
- mode.add_argument("--replace-audio", action="store_true", help="write reference video with the second file's audio, aligned")
211
- mode.add_argument("--trim-second", action="store_true", help="write the second file shifted so it lines up with the reference")
212
- ap.add_argument("--crf", type=int, default=18)
213
- add_common(ap)
214
- args = ap.parse_args()
215
- apply_common(args)
216
- if args.analyze_seconds > 900:
217
- die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
218
-
219
- for p in (args.reference, args.second):
220
- if not probe(p).get("audio"):
221
- die(f"{p} has no audio stream to correlate")
222
-
223
- offset, score = measure_offset(args.reference, args.second, 0.0, args.analyze_seconds, args.step_ms, args.max_offset, args.fine_ms)
224
-
198
+ def measure_one(reference: str, path: str, args) -> Dict:
199
+ """Everything sync.py measures for ONE other source against the reference: offset,
200
+ confidence and (with --fix-drift) the same end-of-file drift measurement main() has always
201
+ made for a single pair. Used for every source when N>1, and for the historical single-pair
202
+ CLI shape when N==1 -- one measurement, so the two shapes cannot disagree."""
203
+ offset, score = measure_offset(reference, path, 0.0, args.analyze_seconds, args.step_ms, args.max_offset, args.fine_ms)
225
204
  drift_ratio = 1.0
226
205
  drift_info = None
227
206
  if args.fix_drift:
228
- ref_dur = probe(args.reference)["duration"] or 0.0
229
- sec_dur = probe(args.second)["duration"] or 0.0
230
- overlap_end = min(ref_dur, sec_dur + offset) # last reference time both files cover
207
+ ref_dur = probe(reference)["duration"] or 0.0
208
+ sec_dur = probe(path)["duration"] or 0.0
209
+ overlap_end = min(ref_dur, sec_dur + offset)
231
210
  head_len = min(args.analyze_seconds, overlap_end)
232
211
  tail_start = overlap_end - args.drift_window
233
212
  if tail_start <= head_len / 2 + 5:
234
- info("warning: files too short to measure drift reliably; skipping drift correction")
213
+ info(f"warning: {path} too short to measure drift reliably; skipping drift correction")
235
214
  else:
236
215
  ref_start = tail_start
237
216
  sec_start = tail_start - offset
238
217
  if sec_start < 0:
239
218
  ref_start -= sec_start
240
219
  sec_start = 0.0
241
- ref_s = decode_mono(args.reference, args.drift_window, ref_start)
242
- oth_s = decode_mono(args.second, args.drift_window, sec_start)
220
+ ref_s = decode_mono(reference, args.drift_window, ref_start)
221
+ oth_s = decode_mono(path, args.drift_window, sec_start)
243
222
  step = max(1, int(SR * args.step_ms / 1000))
244
223
  lag, end_score = cross_correlate(envelope(ref_s, step), envelope(oth_s, step), int(2.0 * SR / step))
245
224
  residual = lag * step / SR
246
225
  if args.fine_ms:
247
226
  residual = refine(ref_s, oth_s, residual, max(1, int(SR * args.fine_ms / 1000)), args.step_ms / 1000 * 2)
248
- # both measurements represent the offset at the centre of their windows
249
227
  head_mid = head_len / 2
250
228
  tail_mid = ref_start + args.drift_window / 2
251
229
  elapsed = tail_mid - head_mid
252
230
  if elapsed > 0 and end_score > 0.1:
253
- # offset(T) = offset0 - (ratio - 1) * T, where ratio is how fast the second file's clock
254
- # runs relative to the reference (ratio > 1 = the second file is too long / plays slow)
255
231
  drift_ratio = 1.0 - residual / elapsed
256
- offset = offset + (drift_ratio - 1.0) * head_mid # extrapolate back to T = 0
232
+ offset = offset + (drift_ratio - 1.0) * head_mid
257
233
  drift_info = {"residual_at_end_seconds": round(residual, 4), "measured_over_seconds": round(elapsed, 2),
258
234
  "drift_ppm": round((drift_ratio - 1) * 1e6, 1),
259
235
  "meaning": "second file runs %.1f ppm %s (%.3fs over %.0fs); it will be resampled to match" % (
260
236
  abs(drift_ratio - 1) * 1e6, "long/slow" if drift_ratio > 1 else "short/fast", abs(residual), elapsed),
261
237
  "confidence": round(end_score, 3)}
262
238
  else:
263
- info("warning: could not measure drift with confidence; skipping drift correction")
264
-
265
- result = {
266
- "reference": args.reference,
267
- "second": args.second,
268
- "offset_seconds": round(offset, 4),
269
- "confidence": round(max(0.0, min(1.0, score)), 3),
270
- "meaning": ("second starts %.3fs %s than reference" % (abs(offset), "later" if offset > 0 else "earlier")),
271
- }
272
- if drift_info:
273
- result["drift"] = drift_info
274
- if result["confidence"] < 0.1:
275
- info("warning: low correlation confidence; check that both files contain the same audio event")
239
+ info(f"warning: could not measure drift with confidence for {path}; skipping drift correction")
240
+ return {"path": path, "offset_seconds": round(offset, 4), "offset_s": round(offset, 4),
241
+ "confidence": round(max(0.0, min(1.0, score)), 3), "drift_ratio": drift_ratio, "drift": drift_info,
242
+ "drift_ppm": (drift_info["drift_ppm"] if drift_info else None)}
243
+
244
+
245
+ def main() -> int:
246
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
247
+ ap.add_argument("reference", help="reference recording (usually the camera video)")
248
+ ap.add_argument("second", help="recording to align (external audio or second camera)")
249
+ ap.add_argument("more_sources", nargs="*", metavar="SOURCE3...", default=[],
250
+ help="(1.18) additional recordings beyond `second` to align to the same "
251
+ "reference -- a third camera, a second external recorder. With none of "
252
+ "these, sync.py keeps its original 2-source shape (offset_seconds/"
253
+ "confidence/drift at the top level, --replace-audio/--trim-second "
254
+ "available). With 1+, all sources (second plus these) are measured and "
255
+ "reported as one offsets JSON {reference, sources: [{path, offset_s, "
256
+ "confidence, drift_ppm}]}, and --replace-audio/--trim-second refuse: "
257
+ "each writes ONE synced output and there is more than one source")
258
+ ap.add_argument("-o", "--output", help="output file when writing a synced result (one-source runs only)")
259
+ ap.add_argument("--max-offset", type=float, default=30.0, help="largest offset to search in seconds (default 30)")
260
+ ap.add_argument("--analyze-seconds", type=float, default=120.0, help="how much audio to analyse from each file (default 120)")
261
+ ap.add_argument("--step-ms", type=float, default=20.0, help="coarse envelope resolution in ms for the FFT search (default 20)")
262
+ ap.add_argument("--fine-ms", type=float, default=1.0, help="fine resolution in ms for the refinement pass, 0 to skip (default 1)")
263
+ ap.add_argument("--fix-drift", action="store_true", help="also measure the offset near the END and correct clock drift by resampling the second file")
264
+ ap.add_argument("--drift-window", type=float, default=60.0, help="seconds of audio analysed at each end for drift (default 60)")
265
+ mode = ap.add_mutually_exclusive_group()
266
+ mode.add_argument("--replace-audio", action="store_true", help="write reference video with the second file's audio, aligned")
267
+ mode.add_argument("--trim-second", action="store_true", help="write the second file shifted so it lines up with the reference")
268
+ ap.add_argument("--crf", type=int, default=18)
269
+ add_common(ap)
270
+ args = ap.parse_args()
271
+ apply_common(args)
272
+ if args.analyze_seconds > 900:
273
+ die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
274
+ args.sources = [args.second] + list(args.more_sources)
275
+
276
+ for p in (args.reference,) + tuple(args.sources):
277
+ if not probe(p).get("audio"):
278
+ die(f"{p} has no audio stream to correlate")
279
+
280
+ n_sources = len(args.sources)
281
+ if (args.replace_audio or args.trim_second) and n_sources > 1:
282
+ die("--replace-audio/--trim-second write ONE synced output and need exactly one SOURCE; "
283
+ f"{n_sources} were given. Run sync.py once per source, or drop the flag and read the "
284
+ "offsets JSON.", kind="input")
285
+
286
+ measured = [measure_one(args.reference, p, args) for p in args.sources]
287
+ for p, m in zip(args.sources, measured):
288
+ if m["confidence"] < 0.1:
289
+ info(f"warning: {p}: low correlation confidence; check that it shares an audio event with the reference")
290
+
291
+ sources_block = [{"path": p, "offset_s": m["offset_s"], "confidence": m["confidence"],
292
+ **({"drift_ppm": m["drift_ppm"]} if args.fix_drift else {})}
293
+ for p, m in zip(args.sources, measured)]
294
+
295
+ if n_sources == 1:
296
+ # The original 2-source shape, unchanged, plus (additively) the same measurement under
297
+ # `sources` so a caller reading the new key gets the same numbers for a single source.
298
+ args.second = args.sources[0]
299
+ m = measured[0]
300
+ offset, score, drift_ratio, drift_info = m["offset_seconds"], m["confidence"], m["drift_ratio"], m["drift"]
301
+ result = {
302
+ "reference": args.reference,
303
+ "second": args.second,
304
+ "offset_seconds": offset,
305
+ "confidence": score,
306
+ "meaning": ("second starts %.3fs %s than reference" % (abs(offset), "later" if offset > 0 else "earlier")),
307
+ "sources": sources_block,
308
+ }
309
+ if drift_info:
310
+ result["drift"] = drift_info
311
+ if score < 0.1:
312
+ info("warning: low correlation confidence; check that both files contain the same audio event")
313
+ else:
314
+ offset = drift_ratio = drift_info = None # not used below; output writing is 1-source only
315
+ result = {"reference": args.reference, "sources": sources_block}
316
+ info(f"measured {n_sources} sources against {args.reference}: " +
317
+ ", ".join(f"{s['path']}: {s['offset_s']:+.3f}s (confidence {s['confidence']:.2f})" for s in sources_block))
276
318
 
277
319
  if args.replace_audio or args.trim_second:
278
320
  output = args.output or default_output(args.reference if args.replace_audio else args.second, "synced", "mp4")
@@ -339,12 +381,18 @@ def main() -> int:
339
381
 
340
382
  if args.json:
341
383
  emit(result.get("output"), **{k: v for k, v in result.items() if k != "output"})
342
- else:
384
+ elif n_sources == 1:
343
385
  print(f"offset: {result['offset_seconds']:+.3f}s ({result['meaning']}), confidence {result['confidence']:.2f}")
344
386
  if drift_info:
345
387
  print(f"drift: {drift_info['drift_ppm']:+.1f} ppm ({drift_info['residual_at_end_seconds']:+.3f}s over {drift_info['measured_over_seconds']:.0f}s), confidence {drift_info['confidence']:.2f}")
346
388
  if "output" in result:
347
389
  print(result["output"])
390
+ else:
391
+ for s in sources_block:
392
+ line = f"{s['path']}: {s['offset_s']:+.3f}s, confidence {s['confidence']:.2f}"
393
+ if args.fix_drift:
394
+ line += f", drift {s['drift_ppm']:+.1f} ppm" if s["drift_ppm"] is not None else ""
395
+ print(line)
348
396
  return 0
349
397
 
350
398