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/docs/contract.md +22 -10
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +1 -1
- package/references/scripts.md +85 -10
- package/scripts/_common/__init__.py +8 -5
- package/scripts/_common/decision.py +86 -0
- package/scripts/_common/drawtext.py +125 -0
- package/scripts/_common/emoji.py +350 -0
- package/scripts/_common/fonts.py +437 -0
- package/scripts/_common/probe.py +26 -0
- package/scripts/_common/text.py +59 -1642
- package/scripts/_common/wrap.py +778 -0
- package/scripts/_contract.py +8 -3
- package/scripts/cropdetect.py +59 -1
- package/scripts/multicam.py +85 -5
- package/scripts/scenes.py +87 -2
- package/scripts/silence.py +46 -1
- package/scripts/sync.py +100 -52
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
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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(
|
|
229
|
-
sec_dur = probe(
|
|
230
|
-
overlap_end = min(ref_dur, sec_dur + offset)
|
|
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:
|
|
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(
|
|
242
|
-
oth_s = decode_mono(
|
|
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
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
|