ffmpeg-skill 0.2.0 → 0.4.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 (35) hide show
  1. package/README.md +15 -4
  2. package/SKILL.md +80 -6
  3. package/package.json +2 -2
  4. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  5. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  6. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  7. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  8. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  9. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  10. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  11. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  12. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  13. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  14. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  15. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  16. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  17. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  18. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  19. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
  20. package/scripts/_common.py +137 -3
  21. package/scripts/audio.py +4 -2
  22. package/scripts/caption.py +73 -6
  23. package/scripts/color.py +24 -3
  24. package/scripts/cut.py +5 -3
  25. package/scripts/export.py +5 -3
  26. package/scripts/fit.py +5 -2
  27. package/scripts/join.py +111 -0
  28. package/scripts/look.py +101 -0
  29. package/scripts/loudness.py +4 -2
  30. package/scripts/multicam.py +196 -0
  31. package/scripts/overlay.py +4 -2
  32. package/scripts/probe.py +10 -1
  33. package/scripts/silence.py +123 -0
  34. package/scripts/sync.py +4 -3
  35. package/scripts/verify.py +157 -0
package/scripts/color.py CHANGED
@@ -9,13 +9,15 @@ Examples:
9
9
  python3 color.py slog3.mp4 --lut SLog3_to_Rec709.cube # apply LUT (any Log -> 709 or a look)
10
10
  python3 color.py clip.mp4 --lut look.cube --lut-strength 0.6
11
11
  python3 color.py wrongly_tagged.mp4 --retag bt709 # metadata only, stream copy
12
+ python3 color.py iphone_dv.mov --strip-dovi # drop Dolby Vision RPU, keep HLG base layer
13
+ python3 color.py iphone_dv.mov --to-sdr # DV 8.4 = HLG base layer -> tone-mapped SDR
12
14
  """
13
15
  import argparse
14
16
  import os
15
17
  import sys
16
18
  from typing import List
17
19
 
18
- from _common import aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, x264_args
20
+ from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, x264_args
19
21
 
20
22
  TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
21
23
 
@@ -45,6 +47,7 @@ def main() -> int:
45
47
  mode.add_argument("--to-sdr", action="store_true", help="tone-map HDR (PQ/HLG/BT.2020) to SDR BT.709")
46
48
  mode.add_argument("--lut", help=".cube LUT to apply (3D)")
47
49
  mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
50
+ mode.add_argument("--strip-dovi", action="store_true", help="remove the Dolby Vision RPU (profile 8.4 iPhone clips) so players use the plain HLG/HDR10 base layer; stream copy")
48
51
  ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
49
52
  ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
50
53
  ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
@@ -52,7 +55,9 @@ def main() -> int:
52
55
  ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is not tagged as HDR (treat as PQ)")
53
56
  ap.add_argument("--crf", type=int, default=18)
54
57
  ap.add_argument("--preset", default="medium")
58
+ add_common(ap)
55
59
  args = ap.parse_args()
60
+ apply_common(args)
56
61
 
57
62
  meta = probe(args.input)
58
63
  if not meta.get("video"):
@@ -60,6 +65,22 @@ def main() -> int:
60
65
  v = meta["video"]
61
66
  has_audio = bool(meta.get("audio"))
62
67
 
68
+ if args.strip_dovi:
69
+ output = args.output or default_output(args.input, "nodv")
70
+ if v.get("codec") != "hevc":
71
+ die("--strip-dovi only applies to HEVC (Dolby Vision) streams")
72
+ if not v.get("dolby_vision"):
73
+ info("note: no Dolby Vision metadata detected; removing unregistered SEI anyway")
74
+ cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy", "-bsf:v", "filter_units=remove_types=62", "-tag:v", "hvc1"]
75
+ if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
76
+ cmd += ["-movflags", "+faststart"]
77
+ cmd.append(output)
78
+ run(cmd)
79
+ r = probe(output)
80
+ info(f"wrote {output} (dolby_vision={r['video'].get('dolby_vision')})")
81
+ emit(output)
82
+ return 0
83
+
63
84
  if args.retag:
64
85
  tags = {
65
86
  "bt709": ["bt709", "bt709", "bt709"],
@@ -82,7 +103,7 @@ def main() -> int:
82
103
  cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
83
104
  run(cmd)
84
105
  info(f"wrote {output} (tags -> {args.retag})")
85
- print(output)
106
+ emit(output)
86
107
  return 0
87
108
 
88
109
  if args.to_sdr:
@@ -109,7 +130,7 @@ def main() -> int:
109
130
  r = probe(output)
110
131
  info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
111
132
  f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
112
- print(output)
133
+ emit(output)
113
134
  return 0
114
135
 
115
136
 
package/scripts/cut.py CHANGED
@@ -16,7 +16,7 @@ import sys
16
16
  import tempfile
17
17
  from typing import List, Tuple
18
18
 
19
- from _common import aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
19
+ from _common import STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
20
20
 
21
21
 
22
22
  def parse_segments(spec: str) -> List[Tuple[float, float]]:
@@ -52,7 +52,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
52
52
  info("stream copy failed, falling back to re-encode")
53
53
  return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
54
54
  die(f"ffmpeg failed:\n{proc.stderr.strip()}")
55
- if not reencode and tolerance >= 0:
55
+ if not reencode and tolerance >= 0 and not STATE["dry_run"]:
56
56
  got = probe(dst).get("duration") or 0.0
57
57
  if abs(got - dur) > tolerance:
58
58
  info(f"stream copy landed on a keyframe {abs(got - dur):.2f}s away from the requested cut "
@@ -74,7 +74,9 @@ def main() -> int:
74
74
  ap.add_argument("--tolerance", type=float, default=0.5, help="max seconds a lossless cut may deviate before re-encoding kicks in (default 0.5, -1 = never)")
75
75
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF when re-encoding (default 18)")
76
76
  ap.add_argument("--preset", default="medium", help="x264 preset when re-encoding")
77
+ add_common(ap)
77
78
  args = ap.parse_args()
79
+ apply_common(args)
78
80
 
79
81
  meta = probe(args.input)
80
82
  total = meta.get("duration") or 0.0
@@ -131,7 +133,7 @@ def main() -> int:
131
133
  expected = sum(e - s for s, e in segments)
132
134
  info(f"wrote {output} ({result.get('duration'):.3f}s, expected ~{expected:.3f}s, "
133
135
  + ("re-encoded" if reencoded else "lossless stream copy") + ")")
134
- print(output)
136
+ emit(output)
135
137
  return 0
136
138
 
137
139
 
package/scripts/export.py CHANGED
@@ -22,7 +22,7 @@ import argparse
22
22
  import sys
23
23
  from typing import Dict, List
24
24
 
25
- from _common import cfr_args, default_output, die, ffmpeg_base, info, probe, run
25
+ from _common import add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run
26
26
 
27
27
  PRESETS: Dict[str, Dict] = {
28
28
  "youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
@@ -48,7 +48,9 @@ def main() -> int:
48
48
  ap.add_argument("--allow-long", action="store_true", help="do not trim to the platform's max duration")
49
49
  ap.add_argument("--crf", type=int, help="override CRF")
50
50
  ap.add_argument("--list", action="store_true", help="list presets and exit")
51
+ add_common(ap)
51
52
  args = ap.parse_args()
53
+ apply_common(args)
52
54
 
53
55
  if args.list:
54
56
  for name, p in PRESETS.items():
@@ -84,7 +86,7 @@ def main() -> int:
84
86
  cmd += ["-filter_complex", fc, "-loop", "0", output]
85
87
  run(cmd)
86
88
  info(f"wrote {output}")
87
- print(output)
89
+ emit(output)
88
90
  return 0
89
91
 
90
92
  if vf:
@@ -108,7 +110,7 @@ def main() -> int:
108
110
  result = probe(output)
109
111
  v = result["video"]
110
112
  info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['codec']})")
111
- print(output)
113
+ emit(output)
112
114
  return 0
113
115
 
114
116
 
package/scripts/fit.py CHANGED
@@ -19,7 +19,7 @@ import sys
19
19
  from fractions import Fraction
20
20
  from typing import List
21
21
 
22
- from _common import aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
22
+ from _common import STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
23
23
 
24
24
  ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
25
25
 
@@ -74,7 +74,9 @@ def main() -> int:
74
74
  e.add_argument("--crf", type=int, default=18)
75
75
  e.add_argument("--preset", default="medium")
76
76
  e.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
77
+ add_common(ap)
77
78
  args = ap.parse_args()
79
+ apply_common(args)
78
80
 
79
81
  if not args.duration and not args.aspect and not args.width and not args.fps:
80
82
  die("nothing to do: give --duration, --aspect, --width and/or --fps")
@@ -114,6 +116,7 @@ def main() -> int:
114
116
  if has_audio:
115
117
  af.append(atempo_chain(factor))
116
118
  post += ["-t", f"{target:.3f}"]
119
+ STATE["duration_hint"] = target
117
120
  else:
118
121
  if target < src_dur:
119
122
  start = (src_dur - target) / 2 if args.from_center else 0.0
@@ -164,7 +167,7 @@ def main() -> int:
164
167
  if abs(factor - 1.0) > 1e-4:
165
168
  msg += f", speed {factor:.3f}x"
166
169
  info(msg)
167
- print(output)
170
+ emit(output)
168
171
  return 0
169
172
 
170
173
 
@@ -0,0 +1,111 @@
1
+ #!/usr/bin/env python3
2
+ """Join clips with transitions, normalising resolution, frame rate and audio
3
+ layout so mismatched sources (phone + camera + screen recording) cut together.
4
+
5
+ Transitions (xfade): fade, dissolve, wipeleft, wiperight, wipeup, wipedown,
6
+ slideleft, slideright, circleopen, fadeblack, fadewhite, smoothleft, none.
7
+
8
+ Examples:
9
+ python3 join.py a.mp4 b.mp4 c.mp4 -o final.mp4 # 0.5 s crossfade, size/fps from the first clip
10
+ python3 join.py *.mp4 --transition fadeblack --duration 1 -o reel.mp4
11
+ python3 join.py a.mov b.mp4 --transition none --width 1920 --height 1080 --fps 30
12
+ """
13
+ import argparse
14
+ import sys
15
+ from typing import List
16
+
17
+ from _common import aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, x264_args
18
+
19
+ TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
20
+ "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
21
+
22
+
23
+ def main() -> int:
24
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
25
+ ap.add_argument("inputs", nargs="+", help="two or more clips in order")
26
+ ap.add_argument("-o", "--output", help="output file (default: <first>_joined.mp4)")
27
+ ap.add_argument("--transition", choices=TRANSITIONS, default="fade", help="transition between clips (default fade)")
28
+ ap.add_argument("--duration", type=float, default=0.5, help="transition length in seconds (default 0.5)")
29
+ ap.add_argument("--width", type=int, help="output width (default: first clip)")
30
+ ap.add_argument("--height", type=int, help="output height (default: first clip)")
31
+ ap.add_argument("--fps", type=float, help="output frame rate (default: first clip)")
32
+ ap.add_argument("--fit", choices=["pad", "crop"], default="pad", help="how clips of another aspect reach the frame (default pad)")
33
+ ap.add_argument("--pad-color", default="black")
34
+ ap.add_argument("--crf", type=int, default=18)
35
+ ap.add_argument("--preset", default="medium")
36
+ add_common(ap)
37
+ args = ap.parse_args()
38
+ apply_common(args)
39
+
40
+ if len(args.inputs) < 2:
41
+ die("give at least two clips")
42
+ metas = [probe(p) for p in args.inputs]
43
+ for p, m in zip(args.inputs, metas):
44
+ if not m.get("video"):
45
+ die(f"{p} has no video stream")
46
+ first = metas[0]["video"]
47
+ w = args.width or first["width"]
48
+ h = args.height or first["height"]
49
+ if first.get("rotation") in (90, -90, 270, -270) and not (args.width or args.height):
50
+ w, h = h, w
51
+ fps = args.fps or first.get("fps") or 30.0
52
+ fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
53
+ w, h = w - (w % 2), h - (h % 2)
54
+ durs = [m.get("duration") or 0.0 for m in metas]
55
+ d = args.duration if args.transition != "none" else 0.0
56
+ for p, dur in zip(args.inputs, durs):
57
+ if d and dur <= d * 2:
58
+ die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
59
+
60
+ cmd = ffmpeg_base()
61
+ extra_inputs: List[str] = []
62
+ parts: List[str] = []
63
+ n = len(args.inputs)
64
+ for i, (p, m) in enumerate(zip(args.inputs, metas)):
65
+ cmd += ["-i", p]
66
+ # silent audio for clips without an audio track
67
+ audio_src: List[str] = []
68
+ for i, m in enumerate(metas):
69
+ if m.get("audio"):
70
+ audio_src.append(f"{i}:a:0")
71
+ else:
72
+ idx = n + len(extra_inputs)
73
+ extra_inputs += ["-f", "lavfi", "-t", f"{durs[i]:.3f}", "-i", "anullsrc=r=48000:cl=stereo"]
74
+ audio_src.append(f"{idx}:a:0")
75
+ cmd += extra_inputs
76
+
77
+ if args.fit == "crop":
78
+ geo = f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
79
+ else:
80
+ geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
81
+ for i in range(n):
82
+ parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format=yuv420p,settb=AVTB[v{i}]")
83
+ parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts=stereo,asetpts=PTS-STARTPTS[a{i}]")
84
+
85
+ if args.transition == "none":
86
+ chain = "".join(f"[v{i}][a{i}]" for i in range(n))
87
+ parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
88
+ else:
89
+ vprev, aprev = "v0", "a0"
90
+ offset = 0.0
91
+ for i in range(1, n):
92
+ offset += durs[i - 1] - d
93
+ vout = f"vx{i}" if i < n - 1 else "vout"
94
+ aout = f"ax{i}" if i < n - 1 else "aout"
95
+ parts.append(f"[{vprev}][v{i}]xfade=transition={args.transition}:duration={d:g}:offset={offset:.3f}[{vout}]")
96
+ parts.append(f"[{aprev}][a{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
97
+ vprev, aprev = vout, aout
98
+
99
+ output = args.output or default_output(args.inputs[0], "joined", "mp4")
100
+ cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
101
+ cmd += x264_args(args.crf, args.preset) + aac_args() + [output]
102
+ run(cmd)
103
+ expected = sum(durs) - d * (n - 1)
104
+ r = probe(output)
105
+ info(f"wrote {output} ({r['duration']:.3f}s, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
106
+ emit(output, clips=n, transition=args.transition, expected_duration=round(expected, 3))
107
+ return 0
108
+
109
+
110
+ if __name__ == "__main__":
111
+ sys.exit(main())
@@ -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
 
@@ -0,0 +1,196 @@
1
+ #!/usr/bin/env python3
2
+ """Multicam: align two or more cameras (and an optional external recorder) by
3
+ audio, then cut between them from a simple switch list.
4
+
5
+ All sources are aligned to the FIRST input (the reference) using the same
6
+ cross-correlation as sync.py. The output takes video from whichever camera the
7
+ switch list names for each time range (reference timeline), and audio from the
8
+ reference unless --audio picks another source.
9
+
10
+ Switch list format: "START-END:CAM,START-END:CAM,..." with times on the
11
+ reference timeline (seconds or mm:ss) and CAM = input index (0 = reference).
12
+ Gaps fall back to camera 0.
13
+
14
+ Examples:
15
+ python3 multicam.py camA.mp4 camB.mp4 --offsets-only # just report the offsets
16
+ python3 multicam.py camA.mp4 camB.mp4 --switch "0-12:0,12-30:1,30-45:0" -o edit.mp4
17
+ python3 multicam.py camA.mp4 camB.mp4 recorder.wav --audio 2 --switch "0-20:0,20-40:1" --fix-drift
18
+ python3 multicam.py camA.mp4 camB.mp4 --auto 8 -o edit.mp4 # alternate cameras every 8 s
19
+ """
20
+ import argparse
21
+ import sys
22
+ from typing import List, Tuple
23
+
24
+ from _common import aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args
25
+ from sync import measure_offset
26
+
27
+
28
+ def parse_switch(spec: str, n: int) -> List[Tuple[float, float, int]]:
29
+ out = []
30
+ for raw in spec.split(","):
31
+ raw = raw.strip()
32
+ if not raw:
33
+ continue
34
+ try:
35
+ rng, cam = raw.rsplit(":", 1)
36
+ a, b = rng.rsplit("-", 1)
37
+ s, e, c = parse_time(a), parse_time(b), int(cam)
38
+ except ValueError:
39
+ die(f"bad switch entry '{raw}' (want START-END:CAM)")
40
+ if not 0 <= c < n:
41
+ die(f"camera {c} does not exist (inputs are 0..{n - 1})")
42
+ if e <= s:
43
+ die(f"switch entry '{raw}': end must be after start")
44
+ out.append((s, e, c))
45
+ out.sort()
46
+ return out
47
+
48
+
49
+ def main() -> int:
50
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
51
+ ap.add_argument("inputs", nargs="+", help="reference camera first, then other cameras / recorders")
52
+ ap.add_argument("-o", "--output", help="output file (default: <reference>_multicam.mp4)")
53
+ ap.add_argument("--switch", help="switch list START-END:CAM,... on the reference timeline")
54
+ ap.add_argument("--auto", type=float, help="no switch list: alternate through the cameras every N seconds")
55
+ ap.add_argument("--audio", type=int, default=0, help="input index to take audio from (default 0 = reference)")
56
+ ap.add_argument("--offsets-only", action="store_true", help="print the measured offsets and exit")
57
+ ap.add_argument("--max-offset", type=float, default=30.0)
58
+ ap.add_argument("--analyze-seconds", type=float, default=120.0)
59
+ ap.add_argument("--fix-drift", action="store_true", help="also correct clock drift of each source (long recordings)")
60
+ ap.add_argument("--width", type=int, help="output width (default: reference)")
61
+ ap.add_argument("--height", type=int, help="output height (default: reference)")
62
+ ap.add_argument("--fps", type=float, help="output fps (default: reference)")
63
+ ap.add_argument("--crf", type=int, default=18)
64
+ ap.add_argument("--preset", default="medium")
65
+ add_common(ap)
66
+ args = ap.parse_args()
67
+ apply_common(args)
68
+
69
+ n = len(args.inputs)
70
+ if n < 2:
71
+ die("give at least two inputs")
72
+ metas = [probe(p) for p in args.inputs]
73
+ for p, m in zip(args.inputs, metas):
74
+ if not m.get("audio"):
75
+ die(f"{p} has no audio to align with")
76
+ if not metas[0].get("video"):
77
+ die("the reference (first input) must have video")
78
+
79
+ offsets = [0.0]
80
+ ratios = [1.0]
81
+ conf = [1.0]
82
+ for p in args.inputs[1:]:
83
+ off, score = measure_offset(args.inputs[0], p, 0.0, args.analyze_seconds, 20.0, args.max_offset, 1.0)
84
+ ratio = 1.0
85
+ if args.fix_drift:
86
+ ref_dur = metas[0]["duration"] or 0.0
87
+ sec_dur = probe(p)["duration"] or 0.0
88
+ overlap_end = min(ref_dur, sec_dur + off)
89
+ window = 60.0
90
+ head_len = min(args.analyze_seconds, overlap_end)
91
+ tail_start = overlap_end - window
92
+ if tail_start > head_len / 2 + 5:
93
+ ref_start, sec_start = tail_start, tail_start - off
94
+ if sec_start < 0:
95
+ ref_start -= sec_start
96
+ sec_start = 0.0
97
+ from sync import decode_mono, envelope, cross_correlate, refine, SR
98
+ ref_s = decode_mono(args.inputs[0], window, ref_start)
99
+ oth_s = decode_mono(p, window, sec_start)
100
+ step = int(SR * 0.02)
101
+ lag, sc = cross_correlate(envelope(ref_s, step), envelope(oth_s, step), int(2.0 * SR / step))
102
+ residual = refine(ref_s, oth_s, lag * step / SR, int(SR * 0.001), 0.04)
103
+ elapsed = (ref_start + window / 2) - head_len / 2
104
+ if elapsed > 0 and sc > 0.1:
105
+ ratio = 1.0 - residual / elapsed
106
+ off = off + (ratio - 1.0) * (head_len / 2)
107
+ offsets.append(off)
108
+ ratios.append(ratio)
109
+ conf.append(score)
110
+ info(f"{p}: offset {off:+.3f}s (confidence {score:.2f})" + (f", drift {(ratio - 1) * 1e6:+.0f} ppm" if args.fix_drift else ""))
111
+
112
+ report = {"inputs": args.inputs, "offsets_seconds": [round(o, 4) for o in offsets],
113
+ "confidence": [round(c, 3) for c in conf]}
114
+ if args.fix_drift:
115
+ report["drift_ppm"] = [round((r - 1) * 1e6, 1) for r in ratios]
116
+ if args.offsets_only:
117
+ emit(None, **report)
118
+ if not args.json:
119
+ for p, o, c in zip(args.inputs, offsets, conf):
120
+ print(f"{p}: {o:+.3f}s (confidence {c:.2f})")
121
+ return 0
122
+
123
+ ref_dur = metas[0]["duration"] or 0.0
124
+ if args.switch:
125
+ cuts = parse_switch(args.switch, n)
126
+ elif args.auto:
127
+ cams = [i for i, m in enumerate(metas) if m.get("video")]
128
+ cuts, t, k = [], 0.0, 0
129
+ while t < ref_dur:
130
+ cuts.append((t, min(ref_dur, t + args.auto), cams[k % len(cams)]))
131
+ t += args.auto
132
+ k += 1
133
+ else:
134
+ die("give --switch or --auto (or --offsets-only)")
135
+ # fill gaps with camera 0 and clip to the reference length
136
+ filled: List[Tuple[float, float, int]] = []
137
+ cursor = 0.0
138
+ for s, e, c in cuts:
139
+ s, e = max(0.0, s), min(ref_dur, e)
140
+ if s > cursor:
141
+ filled.append((cursor, s, 0))
142
+ if e > s:
143
+ filled.append((s, e, c))
144
+ cursor = max(cursor, e)
145
+ if cursor < ref_dur:
146
+ filled.append((cursor, ref_dur, 0))
147
+ for s, e, c in filled:
148
+ if not metas[c].get("video"):
149
+ die(f"camera {c} ({args.inputs[c]}) has no video; it can only be used with --audio")
150
+
151
+ v0 = metas[0]["video"]
152
+ w, h = args.width or v0["width"], args.height or v0["height"]
153
+ if v0.get("rotation") in (90, -90, 270, -270) and not (args.width or args.height):
154
+ w, h = h, w
155
+ fps = args.fps or v0.get("fps") or 30.0
156
+ fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
157
+ geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps:g},format=yuv420p"
158
+
159
+ cmd = ffmpeg_base()
160
+ for p in args.inputs:
161
+ cmd += ["-i", p]
162
+ parts: List[str] = []
163
+ labels: List[str] = []
164
+ for i, (s, e, c) in enumerate(filled):
165
+ # reference time t maps to source time (t - offset_c) * ratio_c
166
+ src_s = (s - offsets[c]) * ratios[c]
167
+ src_e = (e - offsets[c]) * ratios[c]
168
+ if src_s < 0:
169
+ info(f"warning: camera {c} has not started at reference {s:.2f}s; using camera 0 for that range")
170
+ c, src_s, src_e = 0, s, e
171
+ parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{geo}[v{i}]")
172
+ labels.append(f"[v{i}]")
173
+ parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
174
+ a = args.audio
175
+ a_start = -offsets[a] if offsets[a] < 0 else 0.0
176
+ afx = []
177
+ if abs(ratios[a] - 1.0) > 1e-7:
178
+ sr = metas[a]["audio"].get("sample_rate") or 48000
179
+ afx += [f"asetrate={sr * ratios[a]:.6f}", f"aresample={sr}"]
180
+ if offsets[a] > 0:
181
+ afx.append(f"adelay={int(round(offsets[a] * 1000))}:all=1")
182
+ afx += [f"atrim=start={a_start:.4f}", "asetpts=PTS-STARTPTS", f"atrim=0:{ref_dur:.3f}", "aformat=sample_rates=48000:channel_layouts=stereo"]
183
+ parts.append(f"[{a}:a]{','.join(afx)}[aout]")
184
+
185
+ output = args.output or default_output(args.inputs[0], "multicam", "mp4")
186
+ cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
187
+ cmd += x264_args(args.crf, args.preset) + aac_args() + ["-shortest", output]
188
+ run(cmd)
189
+ r = probe(output)
190
+ info(f"wrote {output} ({r['duration']:.3f}s, {len(filled)} cuts, audio from input {a})")
191
+ emit(output, cuts=[[round(s, 3), round(e, 3), c] for s, e, c in filled], **report)
192
+ return 0
193
+
194
+
195
+ if __name__ == "__main__":
196
+ sys.exit(main())
@@ -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, cfr_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"):
@@ -163,7 +165,7 @@ def main() -> int:
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