ffmpeg-skill 0.1.0 → 0.3.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/README.md +27 -15
- package/SKILL.md +118 -29
- package/package.json +2 -2
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/_common.py +64 -2
- package/scripts/audio.py +130 -0
- package/scripts/caption.py +98 -5
- package/scripts/color.py +119 -0
- package/scripts/cut.py +16 -10
- package/scripts/export.py +9 -3
- package/scripts/fit.py +15 -2
- package/scripts/join.py +111 -0
- package/scripts/look.py +101 -0
- package/scripts/loudness.py +4 -2
- package/scripts/overlay.py +5 -3
- package/scripts/silence.py +123 -0
- package/scripts/sync.py +124 -28
package/scripts/look.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Give the agent eyes: pull frames or a contact sheet out of a video as PNG so
|
|
3
|
+
the result can be inspected (caption placement, logo position, crop, colour).
|
|
4
|
+
|
|
5
|
+
Examples:
|
|
6
|
+
python3 look.py final.mp4 # 12-tile contact sheet with timecodes -> final_sheet.png
|
|
7
|
+
python3 look.py final.mp4 --tiles 4x5 --width 1600
|
|
8
|
+
python3 look.py final.mp4 --at 2.5 --at 7 # single frames -> final_2.500s.png, final_7.000s.png
|
|
9
|
+
python3 look.py before.mp4 --compare after.mp4 --at 4 # side-by-side frame
|
|
10
|
+
Then view the PNG (Read tool / image viewer) and verify before reporting.
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import List
|
|
17
|
+
|
|
18
|
+
from _common import add_common, apply_common, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run
|
|
19
|
+
|
|
20
|
+
FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def timecode_filter() -> str:
|
|
24
|
+
return f"drawtext=text='%{{pts\\:hms}}':{FONT}"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main() -> int:
|
|
28
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
29
|
+
ap.add_argument("input")
|
|
30
|
+
ap.add_argument("-o", "--output", help="output PNG (contact sheet / compare) or basename for --at frames")
|
|
31
|
+
ap.add_argument("--at", action="append", help="time of a frame to extract (repeatable)")
|
|
32
|
+
ap.add_argument("--tiles", default="4x3", help="contact sheet grid COLSxROWS (default 4x3)")
|
|
33
|
+
ap.add_argument("--width", type=int, default=1280, help="total width of the sheet / compare image (default 1280)")
|
|
34
|
+
ap.add_argument("--compare", help="second video: place its frame next to the first (needs --at)")
|
|
35
|
+
ap.add_argument("--no-timecode", action="store_true")
|
|
36
|
+
add_common(ap)
|
|
37
|
+
args = ap.parse_args()
|
|
38
|
+
apply_common(args)
|
|
39
|
+
|
|
40
|
+
meta = probe(args.input)
|
|
41
|
+
if not meta.get("video"):
|
|
42
|
+
die("input has no video stream")
|
|
43
|
+
dur = meta.get("duration") or 0.0
|
|
44
|
+
stem = Path(args.input).stem
|
|
45
|
+
outdir = str(Path(args.output).parent) if args.output else str(Path(args.input).parent)
|
|
46
|
+
tc = "" if args.no_timecode else "," + timecode_filter()
|
|
47
|
+
outputs: List[str] = []
|
|
48
|
+
|
|
49
|
+
if args.compare:
|
|
50
|
+
if not args.at:
|
|
51
|
+
die("--compare needs --at TIME")
|
|
52
|
+
probe(args.compare)
|
|
53
|
+
for t in args.at:
|
|
54
|
+
sec = parse_time(t)
|
|
55
|
+
out = args.output or os.path.join(outdir, f"{stem}_vs_{Path(args.compare).stem}_{sec:.3f}s.png")
|
|
56
|
+
half = args.width // 2
|
|
57
|
+
fc = (f"[0:v]scale={half}:-2{tc}[a];[1:v]scale={half}:-2{tc}[b];"
|
|
58
|
+
f"[a][b]scale2ref=w=iw:h=ih[a2][b2];[a2][b2]hstack=inputs=2[out]")
|
|
59
|
+
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-ss", f"{sec:.3f}", "-i", args.compare,
|
|
60
|
+
"-filter_complex", fc, "-map", "[out]", "-frames:v", "1", out]
|
|
61
|
+
run(cmd)
|
|
62
|
+
outputs.append(out)
|
|
63
|
+
elif args.at:
|
|
64
|
+
for t in args.at:
|
|
65
|
+
sec = parse_time(t)
|
|
66
|
+
if dur and sec > dur:
|
|
67
|
+
die(f"--at {t} is beyond the duration ({dur:.2f}s)")
|
|
68
|
+
out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
|
|
69
|
+
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc}", "-frames:v", "1", out]
|
|
70
|
+
run(cmd)
|
|
71
|
+
outputs.append(out)
|
|
72
|
+
else:
|
|
73
|
+
try:
|
|
74
|
+
cols, rows = (int(x) for x in args.tiles.lower().split("x"))
|
|
75
|
+
except ValueError:
|
|
76
|
+
die("--tiles must look like 4x3")
|
|
77
|
+
n = cols * rows
|
|
78
|
+
if not dur:
|
|
79
|
+
die("cannot build a contact sheet without a known duration")
|
|
80
|
+
step = dur / n
|
|
81
|
+
tile_w = max(2, (args.width // cols) // 2 * 2)
|
|
82
|
+
out = args.output or os.path.join(outdir, f"{stem}_sheet.png")
|
|
83
|
+
# sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
|
|
84
|
+
vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',scale={tile_w}:-2{tc},"
|
|
85
|
+
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
86
|
+
cmd = ffmpeg_base() + ["-ss", f"{step / 2:.6f}", "-i", args.input, "-vf", vf, "-frames:v", "1", out]
|
|
87
|
+
run(cmd)
|
|
88
|
+
outputs.append(out)
|
|
89
|
+
info(f"contact sheet: {n} frames every {step:.2f}s")
|
|
90
|
+
|
|
91
|
+
for o in outputs:
|
|
92
|
+
info(f"wrote {o}")
|
|
93
|
+
emit(outputs[0] if len(outputs) == 1 else None, outputs=outputs)
|
|
94
|
+
if len(outputs) > 1 and not args.json:
|
|
95
|
+
for o in outputs:
|
|
96
|
+
print(o)
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
if __name__ == "__main__":
|
|
101
|
+
sys.exit(main())
|
package/scripts/loudness.py
CHANGED
|
@@ -18,7 +18,7 @@ import os
|
|
|
18
18
|
import re
|
|
19
19
|
import sys
|
|
20
20
|
|
|
21
|
-
from _common import AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
|
|
21
|
+
from _common import add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
|
|
@@ -46,7 +46,9 @@ def main() -> int:
|
|
|
46
46
|
ap.add_argument("--measure-only", action="store_true", help="print the measured stats as JSON and exit")
|
|
47
47
|
ap.add_argument("--audio-bitrate", default="192k", help="AAC bitrate when the container is video (default 192k)")
|
|
48
48
|
ap.add_argument("--sample-rate", type=int, help="output sample rate (default: 48000; loudnorm upsamples internally to 192k)")
|
|
49
|
+
add_common(ap)
|
|
49
50
|
args = ap.parse_args()
|
|
51
|
+
apply_common(args)
|
|
50
52
|
|
|
51
53
|
meta = probe(args.input)
|
|
52
54
|
if not meta.get("audio"):
|
|
@@ -76,7 +78,7 @@ def main() -> int:
|
|
|
76
78
|
|
|
77
79
|
after = measure(output, args.lufs, args.tp, args.lra)
|
|
78
80
|
info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS)")
|
|
79
|
-
|
|
81
|
+
emit(output)
|
|
80
82
|
return 0
|
|
81
83
|
|
|
82
84
|
|
package/scripts/overlay.py
CHANGED
|
@@ -15,7 +15,7 @@ import argparse
|
|
|
15
15
|
import sys
|
|
16
16
|
from typing import List, Optional
|
|
17
17
|
|
|
18
|
-
from _common import aac_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
18
|
+
from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
19
19
|
|
|
20
20
|
POS = {
|
|
21
21
|
"top-left": ("{m}", "{m}"),
|
|
@@ -100,7 +100,9 @@ def main() -> int:
|
|
|
100
100
|
enc = ap.add_argument_group("encoding")
|
|
101
101
|
enc.add_argument("--crf", type=int, default=18)
|
|
102
102
|
enc.add_argument("--preset", default="medium")
|
|
103
|
+
add_common(ap)
|
|
103
104
|
args = ap.parse_args()
|
|
105
|
+
apply_common(args)
|
|
104
106
|
|
|
105
107
|
meta = probe(args.input)
|
|
106
108
|
if not meta.get("video"):
|
|
@@ -157,13 +159,13 @@ def main() -> int:
|
|
|
157
159
|
opts.append(f"enable='{enable}'")
|
|
158
160
|
cmd += ["-vf", "drawtext=" + ":".join(opts)]
|
|
159
161
|
|
|
160
|
-
cmd += x264_args(args.crf, args.preset)
|
|
162
|
+
cmd += x264_args(args.crf, args.preset) + cfr_args(meta)
|
|
161
163
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
162
164
|
cmd.append(output)
|
|
163
165
|
run(cmd)
|
|
164
166
|
result = probe(output)
|
|
165
167
|
info(f"wrote {output} ({result['duration']:.3f}s)")
|
|
166
|
-
|
|
168
|
+
emit(output)
|
|
167
169
|
return 0
|
|
168
170
|
|
|
169
171
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Remove silences / dead air (jump-cut editing) or just list them.
|
|
3
|
+
|
|
4
|
+
Detects quiet stretches with ffmpeg's silencedetect, keeps a margin on each
|
|
5
|
+
side so words are not clipped, drops gaps shorter than --min-silence, and
|
|
6
|
+
writes a frame-accurate re-encode in one pass (select/aselect filters).
|
|
7
|
+
|
|
8
|
+
Examples:
|
|
9
|
+
python3 silence.py talk.mp4 # -35 dB, gaps >= 0.6 s, 0.15 s margin
|
|
10
|
+
python3 silence.py talk.mp4 --threshold -40 --min-silence 1 --margin 0.25
|
|
11
|
+
python3 silence.py talk.mp4 --list # print the silences and the resulting cut list, no output
|
|
12
|
+
python3 silence.py talk.mp4 --edl keep.txt # also save the kept ranges (START-END per line, cut.py --segments format)
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from typing import List, Tuple
|
|
18
|
+
|
|
19
|
+
from _common import aac_args, add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run, x264_args
|
|
20
|
+
|
|
21
|
+
SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def detect(path: str, threshold: float, min_silence: float) -> List[Tuple[float, float]]:
|
|
25
|
+
ffmpeg = require_tool("ffmpeg")
|
|
26
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af",
|
|
27
|
+
f"silencedetect=noise={threshold}dB:d={min_silence}", "-f", "null", "-"]
|
|
28
|
+
proc = run(cmd, quiet=True, check=False)
|
|
29
|
+
if proc.returncode != 0:
|
|
30
|
+
die(f"silencedetect failed:\n{proc.stderr.strip()[-800:]}")
|
|
31
|
+
silences: List[Tuple[float, float]] = []
|
|
32
|
+
start = None
|
|
33
|
+
for kind, val in SIL_RE.findall(proc.stderr):
|
|
34
|
+
if kind == "start":
|
|
35
|
+
start = float(val)
|
|
36
|
+
elif start is not None:
|
|
37
|
+
silences.append((start, float(val)))
|
|
38
|
+
start = None
|
|
39
|
+
if start is not None: # silence runs to the end
|
|
40
|
+
silences.append((start, float("inf")))
|
|
41
|
+
return silences
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def keep_ranges(silences: List[Tuple[float, float]], duration: float, margin: float, min_keep: float) -> List[Tuple[float, float]]:
|
|
45
|
+
keeps: List[Tuple[float, float]] = []
|
|
46
|
+
cursor = 0.0
|
|
47
|
+
for s, e in silences:
|
|
48
|
+
s_adj = max(cursor, s + margin)
|
|
49
|
+
if s_adj - cursor >= min_keep:
|
|
50
|
+
keeps.append((cursor, s_adj))
|
|
51
|
+
cursor = min(duration, e - margin) if e != float("inf") else duration
|
|
52
|
+
if duration - cursor >= min_keep:
|
|
53
|
+
keeps.append((cursor, duration))
|
|
54
|
+
return keeps
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main() -> int:
|
|
58
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
59
|
+
ap.add_argument("input")
|
|
60
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_tight.<ext>)")
|
|
61
|
+
ap.add_argument("--threshold", type=float, default=-35.0, help="silence level in dBFS (default -35; use -40..-45 for quiet rooms)")
|
|
62
|
+
ap.add_argument("--min-silence", type=float, default=0.6, help="only remove gaps at least this long in seconds (default 0.6)")
|
|
63
|
+
ap.add_argument("--margin", type=float, default=0.15, help="seconds of silence to keep on each side of speech (default 0.15)")
|
|
64
|
+
ap.add_argument("--min-keep", type=float, default=0.2, help="drop kept pieces shorter than this (default 0.2)")
|
|
65
|
+
ap.add_argument("--list", action="store_true", help="only print silences and the kept ranges")
|
|
66
|
+
ap.add_argument("--edl", help="write the kept ranges to this file, one START-END per line")
|
|
67
|
+
ap.add_argument("--crf", type=int, default=18)
|
|
68
|
+
ap.add_argument("--preset", default="medium")
|
|
69
|
+
add_common(ap)
|
|
70
|
+
args = ap.parse_args()
|
|
71
|
+
apply_common(args)
|
|
72
|
+
|
|
73
|
+
meta = probe(args.input)
|
|
74
|
+
if not meta.get("audio"):
|
|
75
|
+
die("input has no audio stream to analyse")
|
|
76
|
+
duration = meta.get("duration") or 0.0
|
|
77
|
+
silences = detect(args.input, args.threshold, args.min_silence)
|
|
78
|
+
keeps = keep_ranges(silences, duration, args.margin, args.min_keep)
|
|
79
|
+
kept = sum(e - s for s, e in keeps)
|
|
80
|
+
removed = max(0.0, duration - kept)
|
|
81
|
+
summary = {
|
|
82
|
+
"silences": [[round(s, 3), None if e == float("inf") else round(e, 3)] for s, e in silences],
|
|
83
|
+
"keep": [[round(s, 3), round(e, 3)] for s, e in keeps],
|
|
84
|
+
"input_duration": round(duration, 3),
|
|
85
|
+
"kept_duration": round(kept, 3),
|
|
86
|
+
"removed_seconds": round(removed, 3),
|
|
87
|
+
}
|
|
88
|
+
info(f"{len(silences)} silences, keeping {len(keeps)} ranges: {kept:.2f}s of {duration:.2f}s (removing {removed:.2f}s)")
|
|
89
|
+
|
|
90
|
+
if args.edl:
|
|
91
|
+
with open(args.edl, "w", encoding="utf-8") as fh:
|
|
92
|
+
for s, e in keeps:
|
|
93
|
+
fh.write(f"{s:.3f}-{e:.3f}\n")
|
|
94
|
+
info(f"wrote {args.edl}")
|
|
95
|
+
|
|
96
|
+
if args.list:
|
|
97
|
+
if args.json:
|
|
98
|
+
emit(None, **summary)
|
|
99
|
+
else:
|
|
100
|
+
print_json(summary)
|
|
101
|
+
return 0
|
|
102
|
+
if not keeps:
|
|
103
|
+
die("nothing would be kept; raise --threshold (e.g. -45) or check the audio")
|
|
104
|
+
if not silences or removed < 0.05:
|
|
105
|
+
info("no removable silence found; output would equal the input")
|
|
106
|
+
|
|
107
|
+
output = args.output or default_output(args.input, "tight")
|
|
108
|
+
expr = "+".join(f"between(t,{s:.3f},{e:.3f})" for s, e in keeps)
|
|
109
|
+
vf = f"select='{expr}',setpts=N/FRAME_RATE/TB"
|
|
110
|
+
af = f"aselect='{expr}',asetpts=N/SR/TB"
|
|
111
|
+
cmd = ffmpeg_base() + ["-i", args.input]
|
|
112
|
+
if meta.get("video"):
|
|
113
|
+
cmd += ["-vf", vf] + x264_args(args.crf, args.preset) + cfr_args(meta)
|
|
114
|
+
cmd += ["-af", af] + aac_args() + [output]
|
|
115
|
+
run(cmd)
|
|
116
|
+
r = probe(output)
|
|
117
|
+
info(f"wrote {output} ({r['duration']:.3f}s, expected ~{kept:.3f}s)")
|
|
118
|
+
emit(output, **summary)
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
sys.exit(main())
|
package/scripts/sync.py
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Detect the time offset between two recordings by audio cross-correlation
|
|
3
|
-
and (optionally) write a synced output.
|
|
3
|
+
and (optionally) write a synced output, with optional clock-drift correction.
|
|
4
4
|
|
|
5
5
|
Pure standard library: both tracks are decoded by ffmpeg to mono 8 kHz PCM,
|
|
6
|
-
reduced to a
|
|
7
|
-
|
|
8
|
-
5 ms), which is plenty for lining up a lav mic or a second camera.
|
|
6
|
+
reduced to a loudness envelope, cross-correlated with an FFT implemented in
|
|
7
|
+
Python (coarse, 20 ms), then refined by direct correlation at 1 ms.
|
|
9
8
|
|
|
10
9
|
Offset semantics: a positive offset means the SECOND input starts LATER
|
|
11
10
|
than the reference, i.e. `second` must be shifted earlier by that amount.
|
|
@@ -15,6 +14,7 @@ Examples:
|
|
|
15
14
|
python3 sync.py camera.mp4 lavmic.wav --replace-audio -o synced.mp4
|
|
16
15
|
python3 sync.py camA.mp4 camB.mp4 --trim-second -o camB_synced.mp4
|
|
17
16
|
python3 sync.py cam.mp4 mic.wav --max-offset 60 --json
|
|
17
|
+
python3 sync.py cam.mp4 recorder.wav --fix-drift --replace-audio # long takes: fix clock drift too
|
|
18
18
|
"""
|
|
19
19
|
import argparse
|
|
20
20
|
import cmath
|
|
@@ -26,14 +26,14 @@ import subprocess
|
|
|
26
26
|
import sys
|
|
27
27
|
from typing import List
|
|
28
28
|
|
|
29
|
-
from _common import aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
|
|
29
|
+
from _common import add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
|
|
30
30
|
|
|
31
31
|
SR = 8000 # decode sample rate
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def decode_mono(path: str, seconds: float) -> List[float]:
|
|
34
|
+
def decode_mono(path: str, seconds: float, start: float = 0.0) -> List[float]:
|
|
35
35
|
ffmpeg = require_tool("ffmpeg")
|
|
36
|
-
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-t", f"{seconds:.3f}",
|
|
36
|
+
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{seconds:.3f}",
|
|
37
37
|
"-vn", "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"]
|
|
38
38
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
39
39
|
if proc.returncode != 0 or not proc.stdout:
|
|
@@ -110,6 +110,45 @@ def cross_correlate(ref: List[float], other: List[float], max_lag: int):
|
|
|
110
110
|
return best_lag, best_val / energy
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
def refine(ref_s: List[float], oth_s: List[float], coarse_offset: float, fine_step: int, window_s: float) -> float:
|
|
114
|
+
"""Direct correlation at fine resolution around a coarse estimate (+/- window_s)."""
|
|
115
|
+
ref_e = envelope(ref_s, fine_step)
|
|
116
|
+
oth_e = envelope(oth_s, fine_step)
|
|
117
|
+
centre = int(round(coarse_offset * SR / fine_step))
|
|
118
|
+
span = int(window_s * SR / fine_step)
|
|
119
|
+
best_lag, best_val = centre, -float("inf")
|
|
120
|
+
n = min(len(ref_e), len(oth_e))
|
|
121
|
+
for lag in range(centre - span, centre + span + 1):
|
|
122
|
+
# ref[i] ~ oth[i - lag]
|
|
123
|
+
lo, hi = max(0, lag), min(n, n + lag)
|
|
124
|
+
if hi - lo < 10:
|
|
125
|
+
continue
|
|
126
|
+
val = 0.0
|
|
127
|
+
for i in range(lo, hi):
|
|
128
|
+
val += ref_e[i] * oth_e[i - lag]
|
|
129
|
+
val /= (hi - lo)
|
|
130
|
+
if val > best_val:
|
|
131
|
+
best_val, best_lag = val, lag
|
|
132
|
+
return best_lag * fine_step / SR
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def measure_offset(ref_path: str, oth_path: str, start: float, seconds: float, step_ms: float, max_offset: float, fine_ms: float):
|
|
136
|
+
"""Return (offset_seconds, confidence) for a window starting at `start` in both files."""
|
|
137
|
+
ref_s = decode_mono(ref_path, seconds, start)
|
|
138
|
+
oth_s = decode_mono(oth_path, seconds, start)
|
|
139
|
+
step = max(1, int(SR * step_ms / 1000))
|
|
140
|
+
ref = envelope(ref_s, step)
|
|
141
|
+
oth = envelope(oth_s, step)
|
|
142
|
+
if len(ref) < 10 or len(oth) < 10:
|
|
143
|
+
die("not enough audio to analyse")
|
|
144
|
+
max_lag = int(max_offset * SR / step)
|
|
145
|
+
lag, score = cross_correlate(ref, oth, max_lag)
|
|
146
|
+
offset = lag * step / SR
|
|
147
|
+
if fine_ms and fine_ms < step_ms:
|
|
148
|
+
offset = refine(ref_s, oth_s, offset, max(1, int(SR * fine_ms / 1000)), step_ms / 1000 * 2)
|
|
149
|
+
return offset, max(0.0, min(1.0, score))
|
|
150
|
+
|
|
151
|
+
|
|
113
152
|
def main() -> int:
|
|
114
153
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
115
154
|
ap.add_argument("reference", help="reference recording (usually the camera video)")
|
|
@@ -117,30 +156,63 @@ def main() -> int:
|
|
|
117
156
|
ap.add_argument("-o", "--output", help="output file when writing a synced result")
|
|
118
157
|
ap.add_argument("--max-offset", type=float, default=30.0, help="largest offset to search in seconds (default 30)")
|
|
119
158
|
ap.add_argument("--analyze-seconds", type=float, default=120.0, help="how much audio to analyse from each file (default 120)")
|
|
120
|
-
ap.add_argument("--step-ms", type=float, default=
|
|
121
|
-
ap.add_argument("--
|
|
159
|
+
ap.add_argument("--step-ms", type=float, default=20.0, help="coarse envelope resolution in ms for the FFT search (default 20)")
|
|
160
|
+
ap.add_argument("--fine-ms", type=float, default=1.0, help="fine resolution in ms for the refinement pass, 0 to skip (default 1)")
|
|
161
|
+
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")
|
|
162
|
+
ap.add_argument("--drift-window", type=float, default=60.0, help="seconds of audio analysed at each end for drift (default 60)")
|
|
122
163
|
mode = ap.add_mutually_exclusive_group()
|
|
123
164
|
mode.add_argument("--replace-audio", action="store_true", help="write reference video with the second file's audio, aligned")
|
|
124
165
|
mode.add_argument("--trim-second", action="store_true", help="write the second file shifted so it lines up with the reference")
|
|
125
166
|
ap.add_argument("--crf", type=int, default=18)
|
|
167
|
+
add_common(ap)
|
|
126
168
|
args = ap.parse_args()
|
|
169
|
+
apply_common(args)
|
|
127
170
|
|
|
128
171
|
for p in (args.reference, args.second):
|
|
129
172
|
if not probe(p).get("audio"):
|
|
130
173
|
die(f"{p} has no audio stream to correlate")
|
|
131
174
|
|
|
132
|
-
|
|
133
|
-
ref = envelope(decode_mono(args.reference, args.analyze_seconds), step)
|
|
134
|
-
oth = envelope(decode_mono(args.second, args.analyze_seconds), step)
|
|
135
|
-
if len(ref) < 10 or len(oth) < 10:
|
|
136
|
-
die("not enough audio to analyse")
|
|
175
|
+
offset, score = measure_offset(args.reference, args.second, 0.0, args.analyze_seconds, args.step_ms, args.max_offset, args.fine_ms)
|
|
137
176
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
177
|
+
drift_ratio = 1.0
|
|
178
|
+
drift_info = None
|
|
179
|
+
if args.fix_drift:
|
|
180
|
+
ref_dur = probe(args.reference)["duration"] or 0.0
|
|
181
|
+
sec_dur = probe(args.second)["duration"] or 0.0
|
|
182
|
+
overlap_end = min(ref_dur, sec_dur + offset) # last reference time both files cover
|
|
183
|
+
head_len = min(args.analyze_seconds, overlap_end)
|
|
184
|
+
tail_start = overlap_end - args.drift_window
|
|
185
|
+
if tail_start <= head_len / 2 + 5:
|
|
186
|
+
info("warning: files too short to measure drift reliably; skipping drift correction")
|
|
187
|
+
else:
|
|
188
|
+
ref_start = tail_start
|
|
189
|
+
sec_start = tail_start - offset
|
|
190
|
+
if sec_start < 0:
|
|
191
|
+
ref_start -= sec_start
|
|
192
|
+
sec_start = 0.0
|
|
193
|
+
ref_s = decode_mono(args.reference, args.drift_window, ref_start)
|
|
194
|
+
oth_s = decode_mono(args.second, args.drift_window, sec_start)
|
|
195
|
+
step = max(1, int(SR * args.step_ms / 1000))
|
|
196
|
+
lag, end_score = cross_correlate(envelope(ref_s, step), envelope(oth_s, step), int(2.0 * SR / step))
|
|
197
|
+
residual = lag * step / SR
|
|
198
|
+
if args.fine_ms:
|
|
199
|
+
residual = refine(ref_s, oth_s, residual, max(1, int(SR * args.fine_ms / 1000)), args.step_ms / 1000 * 2)
|
|
200
|
+
# both measurements represent the offset at the centre of their windows
|
|
201
|
+
head_mid = head_len / 2
|
|
202
|
+
tail_mid = ref_start + args.drift_window / 2
|
|
203
|
+
elapsed = tail_mid - head_mid
|
|
204
|
+
if elapsed > 0 and end_score > 0.1:
|
|
205
|
+
# offset(T) = offset0 - (ratio - 1) * T, where ratio is how fast the second file's clock
|
|
206
|
+
# runs relative to the reference (ratio > 1 = the second file is too long / plays slow)
|
|
207
|
+
drift_ratio = 1.0 - residual / elapsed
|
|
208
|
+
offset = offset + (drift_ratio - 1.0) * head_mid # extrapolate back to T = 0
|
|
209
|
+
drift_info = {"residual_at_end_seconds": round(residual, 4), "measured_over_seconds": round(elapsed, 2),
|
|
210
|
+
"drift_ppm": round((drift_ratio - 1) * 1e6, 1),
|
|
211
|
+
"meaning": "second file runs %.1f ppm %s (%.3fs over %.0fs); it will be resampled to match" % (
|
|
212
|
+
abs(drift_ratio - 1) * 1e6, "long/slow" if drift_ratio > 1 else "short/fast", abs(residual), elapsed),
|
|
213
|
+
"confidence": round(end_score, 3)}
|
|
214
|
+
else:
|
|
215
|
+
info("warning: could not measure drift with confidence; skipping drift correction")
|
|
144
216
|
|
|
145
217
|
result = {
|
|
146
218
|
"reference": args.reference,
|
|
@@ -149,6 +221,8 @@ def main() -> int:
|
|
|
149
221
|
"confidence": round(max(0.0, min(1.0, score)), 3),
|
|
150
222
|
"meaning": ("second starts %.3fs %s than reference" % (abs(offset), "later" if offset > 0 else "earlier")),
|
|
151
223
|
}
|
|
224
|
+
if drift_info:
|
|
225
|
+
result["drift"] = drift_info
|
|
152
226
|
if result["confidence"] < 0.1:
|
|
153
227
|
info("warning: low correlation confidence; check that both files contain the same audio event")
|
|
154
228
|
|
|
@@ -160,14 +234,20 @@ def main() -> int:
|
|
|
160
234
|
head_trim = -offset if offset < 0 else 0.0
|
|
161
235
|
second_meta = probe(args.second)
|
|
162
236
|
has_video = bool(second_meta.get("video"))
|
|
237
|
+
sec_sr = (second_meta.get("audio") or {}).get("sample_rate") or 48000
|
|
238
|
+
drift_af: List[str] = []
|
|
239
|
+
if abs(drift_ratio - 1.0) > 1e-7:
|
|
240
|
+
# the second file runs long by drift_ratio -> play it faster by that ratio (pitch shift is ~ppm, inaudible)
|
|
241
|
+
drift_af = [f"asetrate={sec_sr * drift_ratio:.6f}", f"aresample={sec_sr}"]
|
|
163
242
|
|
|
164
243
|
if args.replace_audio:
|
|
165
244
|
cmd = ffmpeg_base() + ["-i", args.reference]
|
|
166
245
|
if head_trim > 0:
|
|
167
246
|
cmd += ["-ss", f"{head_trim:.4f}"]
|
|
168
247
|
cmd += ["-i", args.second, "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy"]
|
|
169
|
-
if delay_ms > 0
|
|
170
|
-
|
|
248
|
+
af_parts = drift_af + ([f"adelay={delay_ms}:all=1"] if delay_ms > 0 else [])
|
|
249
|
+
if af_parts:
|
|
250
|
+
cmd += ["-af", ",".join(af_parts)]
|
|
171
251
|
cmd += aac_args() + ["-shortest", output]
|
|
172
252
|
proc = run(cmd, check=False)
|
|
173
253
|
if proc.returncode != 0:
|
|
@@ -176,26 +256,42 @@ def main() -> int:
|
|
|
176
256
|
cmd = cmd[:-1] + x264_args(args.crf) + [output]
|
|
177
257
|
run(cmd)
|
|
178
258
|
else:
|
|
179
|
-
if head_trim > 0:
|
|
259
|
+
if head_trim > 0 and not drift_af:
|
|
180
260
|
cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second, "-c", "copy", "-avoid_negative_ts", "make_zero", output]
|
|
181
261
|
proc = run(cmd, check=False)
|
|
182
262
|
if proc.returncode != 0:
|
|
183
263
|
cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (x264_args(args.crf) if has_video else []) + audio_codec_for(output) + [output]
|
|
184
264
|
run(cmd)
|
|
185
265
|
else:
|
|
186
|
-
|
|
187
|
-
|
|
266
|
+
cmd = ffmpeg_base()
|
|
267
|
+
if head_trim > 0:
|
|
268
|
+
cmd += ["-ss", f"{head_trim:.4f}"]
|
|
269
|
+
cmd += ["-i", args.second]
|
|
270
|
+
af_parts = list(drift_af)
|
|
271
|
+
if delay_ms > 0:
|
|
272
|
+
af_parts.append(f"adelay={delay_ms}:all=1")
|
|
188
273
|
if has_video:
|
|
189
|
-
|
|
190
|
-
|
|
274
|
+
vf = []
|
|
275
|
+
if delay_ms > 0:
|
|
276
|
+
vf.append(f"tpad=start_duration={offset:.4f}")
|
|
277
|
+
if drift_af:
|
|
278
|
+
vf.append(f"setpts=PTS/{drift_ratio:.9f}")
|
|
279
|
+
if vf:
|
|
280
|
+
cmd += ["-vf", ",".join(vf)]
|
|
281
|
+
cmd += x264_args(args.crf)
|
|
282
|
+
if af_parts:
|
|
283
|
+
cmd += ["-af", ",".join(af_parts)]
|
|
284
|
+
cmd += audio_codec_for(output) + [output]
|
|
191
285
|
run(cmd)
|
|
192
286
|
result["output"] = output
|
|
193
287
|
info(f"wrote {output}")
|
|
194
288
|
|
|
195
289
|
if args.json:
|
|
196
|
-
|
|
290
|
+
emit(result.get("output"), **{k: v for k, v in result.items() if k != "output"})
|
|
197
291
|
else:
|
|
198
292
|
print(f"offset: {result['offset_seconds']:+.3f}s ({result['meaning']}), confidence {result['confidence']:.2f}")
|
|
293
|
+
if drift_info:
|
|
294
|
+
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}")
|
|
199
295
|
if "output" in result:
|
|
200
296
|
print(result["output"])
|
|
201
297
|
return 0
|