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.
@@ -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())
@@ -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
- print(output)
81
+ emit(output)
80
82
  return 0
81
83
 
82
84
 
@@ -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
- print(output)
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 coarse loudness envelope, and cross-correlated with an FFT
7
- implemented in Python. Precision is roughly +/- one envelope step (default
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=5.0, help="envelope resolution in ms (default 5)")
121
- ap.add_argument("--json", action="store_true", help="print the result as JSON")
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
- step = max(1, int(SR * args.step_ms / 1000))
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
- max_lag = int(args.max_offset * SR / step)
139
- lag, score = cross_correlate(ref, oth, max_lag)
140
- # With prod = FFT(ref) * conj(FFT(other)), the peak sits at lag k where ref[i] ~ other[i - k]:
141
- # the same event happens k steps later in the reference than in the second file, which
142
- # means the second recording STARTED k steps later. Positive offset = second starts later.
143
- offset = lag * step / SR
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
- cmd += ["-af", f"adelay={delay_ms}:all=1"]
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
- af = f"adelay={delay_ms}:all=1"
187
- cmd = ffmpeg_base() + ["-i", args.second]
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
- cmd += ["-vf", f"tpad=start_duration={offset:.4f}"] + x264_args(args.crf)
190
- cmd += ["-af", af] + audio_codec_for(output) + [output]
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
- print(json.dumps(result, indent=2))
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