ffmpeg-skill 0.8.5 → 0.10.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.
Files changed (44) hide show
  1. package/README.md +296 -118
  2. package/SKILL.md +39 -7
  3. package/bin/install.js +10 -2
  4. package/mcp/server.py +40 -44
  5. package/package.json +19 -5
  6. package/scripts/_common.py +55 -7
  7. package/scripts/_contract.py +757 -0
  8. package/scripts/audio.py +100 -7
  9. package/scripts/caption.py +10 -3
  10. package/scripts/check.py +21 -7
  11. package/scripts/color.py +68 -4
  12. package/scripts/cut.py +83 -9
  13. package/scripts/export.py +15 -6
  14. package/scripts/fit.py +17 -3
  15. package/scripts/join.py +74 -3
  16. package/scripts/multicam.py +10 -0
  17. package/scripts/overlay.py +5 -0
  18. package/scripts/render.py +13 -2
  19. package/scripts/report.py +6 -3
  20. package/scripts/scenes.py +15 -3
  21. package/scripts/sync.py +8 -0
  22. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  23. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  24. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  25. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  26. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  27. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  28. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  29. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  30. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  31. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  32. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  33. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  34. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  35. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  36. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  37. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  38. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  39. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  40. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  41. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  42. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  43. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  44. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
package/scripts/fit.py CHANGED
@@ -4,14 +4,22 @@
4
4
  Duration: --duration N with --method speed (retime video+audio, pitch-preserving
5
5
  via atempo chaining) or --method trim (keep the first N seconds, or a centred
6
6
  window with --from-center). Aspect: --aspect 16:9|9:16|1:1|4:5|W:H with
7
- --fit pad (letterbox/pillarbox with --pad-color, default black) or --fit crop
8
- (centre crop). --width sets the output width; height follows the aspect.
7
+ --fit pad (letterbox/pillarbox with --pad-color, default black) or --fit crop.
8
+ --width sets the output width; height follows the aspect.
9
+
10
+ Crop keeps the centre of the frame by default, which is a guess: going from
11
+ 16:9 to 9:16 throws away most of the width, and whatever isn't in the middle
12
+ third (a person at the edge, a product held to one side) is cut off. Say what
13
+ to keep with --crop-x / --crop-y (0=left/top, 0.5=centre, 1=right/bottom, or
14
+ a decimal in between) rather than accepting the default silently when the
15
+ subject isn't centred; --fit pad never loses anything if you don't know yet.
9
16
 
10
17
  Examples:
11
18
  python3 fit.py input.mp4 --duration 60 # speed up/down to exactly 60s
12
19
  python3 fit.py input.mp4 --duration 30 --method trim
13
20
  python3 fit.py input.mp4 --aspect 9:16 --fit pad --width 1080
14
21
  python3 fit.py input.mp4 --aspect 1:1 --fit crop --duration 15
22
+ python3 fit.py input.mp4 --aspect 9:16 --fit crop --crop-x 1 # keep the right edge (e.g. product held stage-right)
15
23
  """
16
24
  import argparse
17
25
  import math
@@ -70,6 +78,8 @@ def main() -> int:
70
78
  a.add_argument("--fit", choices=["pad", "crop"], default="pad", help="pad (letterbox) or crop to reach the aspect (default pad)")
71
79
  a.add_argument("--width", type=int, help="output width in px (default: keep source width or the width implied by the aspect)")
72
80
  a.add_argument("--pad-color", default="black", help="pad colour, e.g. black, white, 0x101010 (default black)")
81
+ a.add_argument("--crop-x", type=float, default=0.5, help="with --fit crop, horizontal anchor 0=left, 0.5=centre (default), 1=right")
82
+ a.add_argument("--crop-y", type=float, default=0.5, help="with --fit crop, vertical anchor 0=top, 0.5=centre (default), 1=bottom")
73
83
  e = ap.add_argument_group("encoding")
74
84
  e.add_argument("--crf", type=int, default=18)
75
85
  e.add_argument("--preset", default="medium")
@@ -80,6 +90,10 @@ def main() -> int:
80
90
 
81
91
  if not args.duration and not args.aspect and not args.width and not args.fps:
82
92
  die("nothing to do: give --duration, --aspect, --width and/or --fps")
93
+ if not 0.0 <= args.crop_x <= 1.0:
94
+ die(f"--crop-x must be 0..1, got {args.crop_x}")
95
+ if not 0.0 <= args.crop_y <= 1.0:
96
+ die(f"--crop-y must be 0..1, got {args.crop_y}")
83
97
 
84
98
  meta = probe(args.input)
85
99
  if not meta.get("video"):
@@ -138,7 +152,7 @@ def main() -> int:
138
152
  out_h = even(out_w / ratio)
139
153
  if args.fit == "crop":
140
154
  vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=increase")
141
- vf.append(f"crop={out_w}:{out_h}")
155
+ vf.append(f"crop={out_w}:{out_h}:(in_w-out_w)*{args.crop_x:g}:(in_h-out_h)*{args.crop_y:g}")
142
156
  else:
143
157
  vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease")
144
158
  vf.append(f"pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}")
package/scripts/join.py CHANGED
@@ -5,19 +5,81 @@ layout so mismatched sources (phone + camera + screen recording) cut together.
5
5
  Transitions (xfade): fade, dissolve, wipeleft, wiperight, wipeup, wipedown,
6
6
  slideleft, slideright, circleopen, fadeblack, fadewhite, smoothleft, none.
7
7
 
8
+ Audio-only inputs (WAV, FLAC, MP3, M4A, ...) are joined as audio: every clip is
9
+ resampled to one rate and channel layout (the first clip's rate, the widest
10
+ layout; --sample-rate / --channels override), crossfaded with acrossfade or
11
+ butted with concat, and written in the codec the output extension names. The
12
+ output of an audio join must be an audio extension; mixing audio and video
13
+ inputs is refused.
14
+
8
15
  Examples:
9
16
  python3 join.py a.mp4 b.mp4 c.mp4 -o final.mp4 # 0.5 s crossfade, size/fps from the first clip
10
17
  python3 join.py *.mp4 --transition fadeblack --duration 1 -o reel.mp4
11
18
  python3 join.py a.mov b.mp4 --transition none --width 1920 --height 1080 --fps 30
19
+ python3 join.py intro.wav talk.m4a outro.wav -o episode.flac # audio join, 0.5 s crossfade
20
+ python3 join.py part1.wav part2.wav --transition none -o full.wav # butt join, sample rate of part1
12
21
  """
13
22
  import argparse
14
23
  import sys
15
24
  from typing import List
16
25
 
17
- from _common import STATE, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, x264_args
26
+ from _common import STATE, video_args, aac_args, add_common, apply_common, audio_codec_for, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run
18
27
 
19
28
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
20
29
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
30
+ LAYOUTS = {1: "mono", 2: "stereo", 6: "5.1", 8: "7.1"}
31
+
32
+
33
+ def join_audio(args: argparse.Namespace, metas: List[dict]) -> int:
34
+ """Concatenate audio-only inputs: one sample rate, one channel layout, acrossfade or concat."""
35
+ n = len(args.inputs)
36
+ durs = [m.get("duration") or 0.0 for m in metas]
37
+ d = args.duration if args.transition != "none" else 0.0
38
+ for p, dur in zip(args.inputs, durs):
39
+ if d and dur <= d * 2 and not STATE["dry_run"]:
40
+ die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s crossfade; shorten --duration")
41
+ rates = [m["audio"].get("sample_rate") or 48000 for m in metas]
42
+ chans = [m["audio"].get("channels") or 2 for m in metas]
43
+ rate = args.sample_rate or rates[0]
44
+ channels = args.channels or max(chans)
45
+ layout = LAYOUTS.get(channels)
46
+ if layout is None:
47
+ die(f"{channels}-channel output has no standard layout here (1, 2, 6 or 8); pass --channels")
48
+ if len(set(rates)) > 1:
49
+ info(f"sample rates differ ({', '.join(str(r) for r in rates)} Hz); resampling every clip to {rate} Hz")
50
+ if len(set(chans)) > 1:
51
+ info(f"channel counts differ ({', '.join(str(c) for c in chans)}); every clip becomes {layout}")
52
+ output = args.output or default_output(args.inputs[0], "joined")
53
+ if not is_audio_output(output):
54
+ die(f"audio-only inputs cannot fill a video container: give -o an audio extension (.wav, .flac, .mp3, .m4a, .ogg, .opus), not {output}")
55
+
56
+ cmd = ffmpeg_base()
57
+ for p in args.inputs:
58
+ cmd += ["-i", p]
59
+ parts = [f"[{i}:a:0]aformat=sample_rates={rate}:channel_layouts={layout},asetpts=PTS-STARTPTS[a{i}]" for i in range(n)]
60
+ if args.transition == "none":
61
+ parts.append("".join(f"[a{i}]" for i in range(n)) + f"concat=n={n}:v=0:a=1[aout]")
62
+ else:
63
+ prev = "a0"
64
+ for i in range(1, n):
65
+ out = f"ax{i}" if i < n - 1 else "aout"
66
+ parts.append(f"[{prev}][a{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{out}]")
67
+ prev = out
68
+ cmd += ["-filter_complex", ";".join(parts), "-map", "[aout]", "-vn"] + audio_codec_for(output) + [output]
69
+ run(cmd)
70
+ expected = sum(durs) - d * (n - 1)
71
+ r = probe(output)
72
+ a = r.get("audio") or {}
73
+ if not STATE["dry_run"]:
74
+ if r.get("video"):
75
+ die(f"{output} unexpectedly contains a video stream")
76
+ if a.get("sample_rate") != rate or a.get("channels") != channels:
77
+ die(f"{output} is {a.get('sample_rate')} Hz {a.get('channels')} ch, expected {rate} Hz {channels} ch")
78
+ info(f"wrote {output} ({r['duration']:.3f}s, expected ~{expected:.3f}s, audio {a.get('codec')} {channels}ch {rate}Hz, {n} clips, "
79
+ + ("crossfade" if d else "butt join") + ")")
80
+ emit(output, mode="audio", clips=n, transition=args.transition if d else "none", expected_duration=round(expected, 3),
81
+ sample_rate=rate, channels=channels, video=False)
82
+ return 0
21
83
 
22
84
 
23
85
  def main() -> int:
@@ -33,6 +95,9 @@ def main() -> int:
33
95
  ap.add_argument("--pad-color", default="black")
34
96
  ap.add_argument("--crf", type=int, default=18)
35
97
  ap.add_argument("--preset", default="medium")
98
+ aud = ap.add_argument_group("audio-only inputs")
99
+ aud.add_argument("--sample-rate", type=int, help="output sample rate in Hz (default: first clip's)")
100
+ aud.add_argument("--channels", type=int, choices=[1, 2, 6, 8], help="output channel count (default: the widest clip)")
36
101
  add_common(ap)
37
102
  args = ap.parse_args()
38
103
  apply_common(args)
@@ -40,9 +105,15 @@ def main() -> int:
40
105
  if len(args.inputs) < 2:
41
106
  die("give at least two clips")
42
107
  metas = [probe(p) for p in args.inputs]
108
+ if all(not m.get("video") for m in metas):
109
+ for p, m in zip(args.inputs, metas):
110
+ if not m.get("audio"):
111
+ die(f"{p} has neither a video nor an audio stream")
112
+ return join_audio(args, metas)
43
113
  for p, m in zip(args.inputs, metas):
44
114
  if not m.get("video"):
45
- die(f"{p} has no video stream")
115
+ others = [q for q, mm in zip(args.inputs, metas) if mm.get("video")]
116
+ die(f"{p} has no video stream" + (f" while {others[0]} has one; join audio with audio or give every clip a picture" if others else ""))
46
117
  first = metas[0]["video"]
47
118
  fw, fh = first["width"], first["height"]
48
119
  if first.get("rotation") in (90, -90, 270, -270):
@@ -111,7 +182,7 @@ def main() -> int:
111
182
  expected = sum(durs) - d * (n - 1)
112
183
  r = probe(output)
113
184
  info(f"wrote {output} ({r['duration']:.3f}s, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
114
- emit(output, clips=n, transition=args.transition, expected_duration=round(expected, 3))
185
+ emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3))
115
186
  return 0
116
187
 
117
188
 
@@ -11,6 +11,14 @@ Switch list format: "START-END:CAM,START-END:CAM,..." with times on the
11
11
  reference timeline (seconds or mm:ss) and CAM = input index (0 = reference).
12
12
  Gaps fall back to camera 0.
13
13
 
14
+ Each camera's `confidence` (in the report, and warned on stderr below 0.1)
15
+ is how well its audio matched the reference's, not a guarantee the cut lands
16
+ in sync: a source with no shared audio event (music-only vs. a silent room,
17
+ or two rooms recording different conversations) can score low and still get
18
+ an offset applied. Check it before trusting a low-confidence multicam edit.
19
+ This aligns audio tracks to each other, the same as sync.py, and does not
20
+ check lip sync (mouth movement vs. audio) at all -- see sync.py's docstring.
21
+
14
22
  Examples:
15
23
  python3 multicam.py camA.mp4 camB.mp4 --offsets-only # just report the offsets
16
24
  python3 multicam.py camA.mp4 camB.mp4 --switch "0-12:0,12-30:1,30-45:0" -o edit.mp4
@@ -108,6 +116,8 @@ def main() -> int:
108
116
  ratios.append(ratio)
109
117
  conf.append(score)
110
118
  info(f"{p}: offset {off:+.3f}s (confidence {score:.2f})" + (f", drift {(ratio - 1) * 1e6:+.0f} ppm" if args.fix_drift else ""))
119
+ if score < 0.1:
120
+ info(f"warning: {p} has low correlation confidence ({score:.2f}); check that it shares an audio event with the reference before trusting this offset")
111
121
 
112
122
  report = {"inputs": args.inputs, "offsets_seconds": [round(o, 4) for o in offsets],
113
123
  "confidence": [round(c, 3) for c in conf]}
@@ -165,6 +165,11 @@ def main() -> int:
165
165
  cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
166
166
  fc = f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS[ov];[0:v][ov]{ov}[out]"
167
167
  cmd += ["-filter_complex", fc, "-map", "[out]", "-map", "0:a:0?", "-shortest"]
168
+ # -shortest alone is not exact on FFmpeg 7+: the muxer keeps up to shortest_buf_duration (10 s)
169
+ # of the looped still after the video ended, and the file came out 2 s long on 8.1 / 9.0.
170
+ # The output must be as long as the main input, so say so explicitly.
171
+ if meta.get("duration"):
172
+ cmd += ["-t", f"{meta['duration']:.3f}"]
168
173
  else:
169
174
  x, y = position_exprs(args.position, args.margin, text_mode=True)
170
175
  opts = [f"text='{escape_drawtext(args.text)}'", f"fontsize={args.font_size}", f"x={x}", f"y={y}",
package/scripts/render.py CHANGED
@@ -36,6 +36,12 @@ graphics → overlays → audio → loudness → export → check. Missing stage
36
36
  skipped. "brand" points caption/graphics/overlay at a brand.json (fonts,
37
37
  colours, logo, safe margin); {"logo": true} in overlays places the brand logo.
38
38
 
39
+ "check" mirrors check.py's own exit code: a delivery-spec FAIL (or check.py
40
+ itself failing to run) exits 1, same as running check.py directly would --
41
+ the render is not silently reported as successful just because every stage
42
+ up to it completed. The output file is still written and `--json`'s
43
+ `check` field still carries the full row-by-row result either way.
44
+
39
45
  Examples:
40
46
  python3 render.py --init project.json # write a commented starter project
41
47
  python3 render.py project.json # render
@@ -338,14 +344,19 @@ def main() -> int:
338
344
  # ---- check
339
345
  ck = proj.get("check")
340
346
  check_result = None
347
+ exit_code = 0
341
348
  if ck and ck.get("platform") and not STATE["dry_run"]:
342
349
  proc = subprocess.run([sys.executable, str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
343
350
  try:
344
351
  check_result = json.loads(proc.stdout)
345
352
  except ValueError:
346
353
  check_result = {"error": proc.stderr.strip()[-300:]}
347
- if check_result.get("failed"):
354
+ if check_result.get("error"):
355
+ info(f"check: could not run check.py — {check_result['error']}")
356
+ exit_code = 1
357
+ elif check_result.get("failed"):
348
358
  info(f"check: {check_result['failed']} FAIL — " + "; ".join(f"{r['check']}={r['value']} ({r['fix']})" for r in check_result["checks"] if r["status"] == "FAIL"))
359
+ exit_code = 1
349
360
  else:
350
361
  info(f"check: OK for {ck['platform']}")
351
362
  stages_done.append("check")
@@ -355,7 +366,7 @@ def main() -> int:
355
366
  shutil.rmtree(work, ignore_errors=True)
356
367
  info(f"rendered {output} via {' → '.join(stages_done)}")
357
368
  emit(output, stages=stages_done, check=check_result)
358
- return 0
369
+ return exit_code
359
370
 
360
371
 
361
372
  if __name__ == "__main__":
package/scripts/report.py CHANGED
@@ -19,7 +19,7 @@ import tempfile
19
19
  from pathlib import Path
20
20
  from typing import Any, Dict, List, Optional
21
21
 
22
- from _common import add_common, apply_common, die, emit, info, probe
22
+ from _common import STATE, add_common, apply_common, die, emit, info, probe
23
23
 
24
24
  HERE = Path(__file__).resolve().parent
25
25
 
@@ -150,8 +150,11 @@ def main() -> int:
150
150
  .foot{color:var(--ink2);font-size:12px;margin-top:36px;border-top:1px solid var(--line);padding-top:10px}
151
151
  """
152
152
  doc = f"<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>{html.escape(title)}</title><style>{css}</style></head><body><div class='wrap'>{''.join(parts)}</div></body></html>"
153
- Path(output).write_text(doc, encoding="utf-8")
154
- info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
153
+ if STATE.dry_run:
154
+ info(f"wrote {output}") # printed as "[dry-run] would write"; nothing is written
155
+ else:
156
+ Path(output).write_text(doc, encoding="utf-8")
157
+ info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
155
158
  emit(None, report=output, check=chk)
156
159
  if not args.json:
157
160
  print(output)
package/scripts/scenes.py CHANGED
@@ -3,14 +3,19 @@
3
3
  agent can plan an edit or a digest without watching the whole file.
4
4
 
5
5
  Scene cuts come from ffmpeg's scdet; energy peaks from a 0.5 s RMS envelope
6
- of the audio. Highlight candidates are the scenes ranked by audio energy
7
- (and, optionally, by motion).
6
+ of the audio. Highlight candidates are scenes ranked by --rank-by: "audio"
7
+ (default, loudest first) or "duration" (longest first). Both are proxies,
8
+ not a judgement of what matters: "audio" misses a quiet but important
9
+ moment (a confession, a punchline landing in silence) and can surface pure
10
+ crowd noise; "duration" just finds long unbroken takes. Neither replaces
11
+ watching the contact sheet (--sheet) before committing to a cut.
8
12
 
9
13
  Examples:
10
14
  python3 scenes.py talk.mp4 # scenes + peaks, JSON
11
15
  python3 scenes.py event.mp4 --highlights 5 --target 60 # 5 candidate ranges summing to ~60 s
12
16
  python3 scenes.py event.mp4 --highlights 4 --edl picks.txt # cut.py --segments compatible list
13
17
  python3 scenes.py event.mp4 --sheet scenes.png # one thumbnail per scene
18
+ python3 scenes.py talk.mp4 --highlights 5 --rank-by duration # longest unbroken scenes, not loudest
14
19
  """
15
20
  import argparse
16
21
  import math
@@ -96,6 +101,8 @@ def main() -> int:
96
101
  ap.add_argument("--ratio", type=float, default=3.0, help="a cut must exceed this multiple of the neighbouring frames' median score (default 3; lower = more cuts)")
97
102
  ap.add_argument("--min-scene", type=float, default=1.0, help="ignore cuts closer than this in seconds (default 1)")
98
103
  ap.add_argument("--highlights", type=int, default=0, help="number of highlight ranges to propose")
104
+ ap.add_argument("--rank-by", choices=["audio", "duration"], default="audio",
105
+ help="how to rank scenes for --highlights: audio energy (default) or scene duration")
99
106
  ap.add_argument("--target", type=float, help="with --highlights: total seconds the picks should add up to (trims long scenes)")
100
107
  ap.add_argument("--max-scene", type=float, default=15.0, help="cap a highlight range at this many seconds (default 15)")
101
108
  ap.add_argument("--edl", help="write highlight ranges as START-END lines (cut.py --segments format)")
@@ -136,7 +143,11 @@ def main() -> int:
136
143
  info(f"{len(scenes)} scenes, {len(peaks)} audio peaks over {dur:.1f}s")
137
144
 
138
145
  if args.highlights:
139
- ranked = sorted(scenes, key=lambda sc: (-sc["audio_rms"], sc["start"]))[: args.highlights]
146
+ if args.rank_by == "duration":
147
+ rank_key = lambda sc: (-sc["duration"], sc["start"])
148
+ else:
149
+ rank_key = lambda sc: (-sc["audio_rms"], sc["start"])
150
+ ranked = sorted(scenes, key=rank_key)[: args.highlights]
140
151
  picks: List[Tuple[float, float]] = []
141
152
  budget = args.target if args.target else None
142
153
  per = (budget / max(1, len(ranked))) if budget else args.max_scene
@@ -156,6 +167,7 @@ def main() -> int:
156
167
  picks.sort()
157
168
  result["highlights"] = [{"start": s, "end": e, "duration": round(e - s, 2)} for s, e in picks]
158
169
  result["highlights_total"] = round(sum(e - s for s, e in picks), 2)
170
+ result["highlights_rank_by"] = args.rank_by
159
171
  info(f"proposed {len(picks)} highlight ranges totalling {result['highlights_total']:.1f}s")
160
172
  if args.edl:
161
173
  with open(args.edl, "w", encoding="utf-8") as fh:
package/scripts/sync.py CHANGED
@@ -9,6 +9,14 @@ Python (coarse, 20 ms), then refined by direct correlation at 1 ms.
9
9
  Offset semantics: a positive offset means the SECOND input starts LATER
10
10
  than the reference, i.e. `second` must be shifted earlier by that amount.
11
11
 
12
+ This aligns two AUDIO tracks to each other; it does not check or guarantee
13
+ lip sync (mouth movement matching the audio). It assumes each recording's
14
+ own audio is already correctly timed against its own picture, which holds
15
+ for ordinary cameras and phones (same device, same clock) but not for a
16
+ capture device with its own internal audio/video offset. There is no
17
+ face or mouth detection anywhere in this codebase to verify that; the only
18
+ way to confirm the final result actually looks in sync is to watch it.
19
+
12
20
  Examples:
13
21
  python3 sync.py camera.mp4 lavmic.wav # print offset only
14
22
  python3 sync.py camera.mp4 lavmic.wav --replace-audio -o synced.mp4