ffmpeg-skill 0.9.0 → 0.12.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 (57) hide show
  1. package/README.md +315 -122
  2. package/SKILL.md +115 -18
  3. package/bin/install.js +16 -2
  4. package/mcp/server.py +2 -0
  5. package/package.json +15 -3
  6. package/references/ci-platform-pitfalls.md +111 -0
  7. package/references/process-pitfalls.md +85 -0
  8. package/references/scripts.md +122 -11
  9. package/scripts/_common.py +247 -15
  10. package/scripts/_contract.py +420 -48
  11. package/scripts/audio.py +101 -8
  12. package/scripts/background.py +73 -0
  13. package/scripts/caption.py +97 -18
  14. package/scripts/check.py +21 -7
  15. package/scripts/color.py +104 -13
  16. package/scripts/crop.py +79 -0
  17. package/scripts/cut.py +85 -11
  18. package/scripts/export.py +16 -7
  19. package/scripts/fit.py +76 -12
  20. package/scripts/graphics.py +12 -3
  21. package/scripts/insert.py +128 -0
  22. package/scripts/join.py +88 -8
  23. package/scripts/loudness.py +3 -3
  24. package/scripts/multicam.py +11 -1
  25. package/scripts/overlay.py +64 -5
  26. package/scripts/proxy.py +82 -0
  27. package/scripts/render.py +13 -2
  28. package/scripts/reverse.py +56 -0
  29. package/scripts/scenes.py +15 -3
  30. package/scripts/sequence.py +124 -0
  31. package/scripts/silence.py +2 -2
  32. package/scripts/stabilize.py +83 -0
  33. package/scripts/sync.py +9 -1
  34. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  35. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  36. package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
  37. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  38. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  39. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  40. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  41. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  42. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  43. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  44. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  45. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  46. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  47. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  48. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  49. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  50. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  51. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  52. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  53. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  54. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  55. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  56. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  57. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
package/scripts/fit.py CHANGED
@@ -4,14 +4,32 @@
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 and/or --height set the output size: give one and the other follows
9
+ the aspect (source aspect if --aspect is not also given); give both for an
10
+ exact frame. --rotate 90|180|270 (clockwise) and --flip h|v apply a new
11
+ rotation/mirror to the picture -- distinct from the rotation metadata a
12
+ source already carries (read automatically to compute the displayed size,
13
+ never altered by these flags unless asked). Both can be combined; rotate is
14
+ applied before flip.
15
+
16
+ Crop keeps the centre of the frame by default, which is a guess: going from
17
+ 16:9 to 9:16 throws away most of the width, and whatever isn't in the middle
18
+ third (a person at the edge, a product held to one side) is cut off. Say what
19
+ to keep with --crop-x / --crop-y (0=left/top, 0.5=centre, 1=right/bottom, or
20
+ a decimal in between) rather than accepting the default silently when the
21
+ subject isn't centred; --fit pad never loses anything if you don't know yet.
9
22
 
10
23
  Examples:
11
24
  python3 fit.py input.mp4 --duration 60 # speed up/down to exactly 60s
12
25
  python3 fit.py input.mp4 --duration 30 --method trim
13
26
  python3 fit.py input.mp4 --aspect 9:16 --fit pad --width 1080
14
27
  python3 fit.py input.mp4 --aspect 1:1 --fit crop --duration 15
28
+ python3 fit.py input.mp4 --aspect 9:16 --fit crop --crop-x 1 # keep the right edge (e.g. product held stage-right)
29
+ python3 fit.py input.mp4 --height 1080 # width follows the source aspect
30
+ python3 fit.py input.mp4 --width 1920 --height 1080 # exact frame, no aspect needed
31
+ python3 fit.py input.mp4 --rotate 90 # rotate 90 degrees clockwise
32
+ python3 fit.py input.mp4 --flip h # mirror horizontally
15
33
  """
16
34
  import argparse
17
35
  import math
@@ -58,6 +76,10 @@ def main() -> int:
58
76
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
59
77
  ap.add_argument("input")
60
78
  ap.add_argument("-o", "--output", help="output file (default: <name>_fit.<ext>)")
79
+ ap.add_argument("--audio-stream", type=int, default=0,
80
+ help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
81
+ "audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
82
+ "the first track, same as leaving it unset always did")
61
83
  d = ap.add_argument_group("duration")
62
84
  d.add_argument("--duration", help="target duration (seconds or mm:ss)")
63
85
  d.add_argument("--method", choices=["speed", "trim"], default="speed", help="how to reach the duration (default speed)")
@@ -68,8 +90,14 @@ def main() -> int:
68
90
  a = ap.add_argument_group("aspect")
69
91
  a.add_argument("--aspect", help="target aspect ratio, e.g. 16:9, 9:16, 1:1, 4:5")
70
92
  a.add_argument("--fit", choices=["pad", "crop"], default="pad", help="pad (letterbox) or crop to reach the aspect (default pad)")
71
- a.add_argument("--width", type=int, help="output width in px (default: keep source width or the width implied by the aspect)")
93
+ a.add_argument("--width", type=int, help="output width in px (default: keep source width or the width implied by the aspect); with --height also given, both are used directly")
94
+ a.add_argument("--height", type=int, help="output height in px (default: keep source height or the height implied by the aspect); with --width also given, both are used directly")
72
95
  a.add_argument("--pad-color", default="black", help="pad colour, e.g. black, white, 0x101010 (default black)")
96
+ a.add_argument("--crop-x", type=float, default=0.5, help="with --fit crop, horizontal anchor 0=left, 0.5=centre (default), 1=right")
97
+ a.add_argument("--crop-y", type=float, default=0.5, help="with --fit crop, vertical anchor 0=top, 0.5=centre (default), 1=bottom")
98
+ r = ap.add_argument_group("rotate / flip")
99
+ r.add_argument("--rotate", type=int, choices=[90, 180, 270], help="rotate the picture clockwise by this many degrees")
100
+ r.add_argument("--flip", choices=["h", "v"], help="mirror the picture horizontally (h) or vertically (v)")
73
101
  e = ap.add_argument_group("encoding")
74
102
  e.add_argument("--crf", type=int, default=18)
75
103
  e.add_argument("--preset", default="medium")
@@ -78,16 +106,29 @@ def main() -> int:
78
106
  args = ap.parse_args()
79
107
  apply_common(args)
80
108
 
81
- if not args.duration and not args.aspect and not args.width and not args.fps:
82
- die("nothing to do: give --duration, --aspect, --width and/or --fps")
109
+ if args.fps is not None and args.fps <= 0:
110
+ die(f"--fps must be positive, got {args.fps:g}")
111
+ if not args.duration and not args.aspect and not args.width and not args.height and not args.fps and not args.rotate and not args.flip:
112
+ die("nothing to do: give --duration, --aspect, --width/--height, --rotate/--flip and/or --fps")
113
+ if not 0.0 <= args.crop_x <= 1.0:
114
+ die(f"--crop-x must be 0..1, got {args.crop_x}")
115
+ if not 0.0 <= args.crop_y <= 1.0:
116
+ die(f"--crop-y must be 0..1, got {args.crop_y}")
83
117
 
84
118
  meta = probe(args.input)
85
119
  if not meta.get("video"):
86
120
  die("input has no video stream")
121
+ audio_streams = meta.get("audio_streams") or []
122
+ if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
123
+ die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
124
+ if args.audio_stream and not audio_streams:
125
+ die("--audio-stream needs an input with audio streams")
87
126
  src_dur = meta["duration"] or 0.0
88
127
  sw, sh = meta["video"]["width"], meta["video"]["height"]
89
128
  if meta["video"].get("rotation") in (90, -90, 270, -270):
90
129
  sw, sh = sh, sw
130
+ if args.rotate in (90, 270):
131
+ sw, sh = sh, sw
91
132
  has_audio = bool(meta.get("audio"))
92
133
 
93
134
  vf: List[str] = []
@@ -96,6 +137,18 @@ def main() -> int:
96
137
  post: List[str] = []
97
138
  factor = 1.0
98
139
 
140
+ # ---- rotate / flip
141
+ if args.rotate == 90:
142
+ vf.append("transpose=1")
143
+ elif args.rotate == 270:
144
+ vf.append("transpose=2")
145
+ elif args.rotate == 180:
146
+ vf.append("transpose=2,transpose=2")
147
+ if args.flip == "h":
148
+ vf.append("hflip")
149
+ elif args.flip == "v":
150
+ vf.append("vflip")
151
+
99
152
  # ---- duration
100
153
  if args.duration:
101
154
  target = parse_time(args.duration)
@@ -128,17 +181,25 @@ def main() -> int:
128
181
  info(f"source ({src_dur:.2f}s) is already shorter than {target:.2f}s; trim does nothing")
129
182
 
130
183
  # ---- aspect / size
131
- if args.aspect or args.width:
132
- src_ratio = Fraction(sw, sh)
184
+ if args.aspect or args.width or args.height:
185
+ src_ratio = Fraction(sw, sh) if sh else None
133
186
  ratio = parse_aspect(args.aspect) if args.aspect else src_ratio
134
- if args.width:
187
+ if args.width and args.height:
188
+ out_w, out_h = even(args.width), even(args.height)
189
+ elif args.width:
135
190
  out_w = even(args.width)
136
- else:
191
+ out_h = even(out_w / ratio) if ratio else args.width
192
+ elif args.height:
193
+ out_h = even(args.height)
194
+ out_w = even(out_h * ratio) if ratio else args.height
195
+ elif ratio and src_ratio:
137
196
  out_w = even(sw if ratio <= src_ratio else sh * ratio)
138
- out_h = even(out_w / ratio)
197
+ out_h = even(out_w / ratio)
198
+ else:
199
+ out_w, out_h = even(sw), even(sh)
139
200
  if args.fit == "crop":
140
201
  vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=increase")
141
- vf.append(f"crop={out_w}:{out_h}")
202
+ 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
203
  else:
143
204
  vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=decrease")
144
205
  vf.append(f"pad={out_w}:{out_h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}")
@@ -155,6 +216,9 @@ def main() -> int:
155
216
  cmd += ["-vf", ",".join(vf)]
156
217
  if af:
157
218
  cmd += ["-af", ",".join(af)]
219
+ cmd += ["-map", "0:v:0"]
220
+ if has_audio:
221
+ cmd += ["-map", f"0:a:{args.audio_stream}"]
158
222
  cmd += video_args(meta, args.crf, args.preset)
159
223
  cmd += cfr_args(meta, args.fps) if not args.fps else []
160
224
  if has_audio:
@@ -164,7 +228,7 @@ def main() -> int:
164
228
  cmd += post + [output]
165
229
  run(cmd)
166
230
 
167
- result = probe(output)
231
+ result = probe(output, role="output")
168
232
  msg = f"wrote {output} ({result['duration']:.3f}s, {result['video']['width']}x{result['video']['height']})"
169
233
  if abs(factor - 1.0) > 1e-4:
170
234
  msg += f", speed {factor:.3f}x"
@@ -40,6 +40,10 @@ def main() -> int:
40
40
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
41
41
  ap.add_argument("input")
42
42
  ap.add_argument("-o", "--output", help="output file (default: <name>_gfx.<ext>)")
43
+ ap.add_argument("--audio-stream", type=int, default=0,
44
+ help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
45
+ "audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
46
+ "the first track, same as leaving it unset always did")
43
47
  ap.add_argument("--template", choices=TEMPLATES, required=True)
44
48
  ap.add_argument("--brand", help="brand.json for colours, font, safe margin")
45
49
  ap.add_argument("--name", help="lower-third: name line")
@@ -70,6 +74,11 @@ def main() -> int:
70
74
  meta = probe(args.input)
71
75
  if not meta.get("video"):
72
76
  die("input has no video stream")
77
+ audio_streams = meta.get("audio_streams") or []
78
+ if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
79
+ die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
80
+ if args.audio_stream and not audio_streams:
81
+ die("--audio-stream needs an input with audio streams")
73
82
  W, H = meta["video"]["width"], meta["video"]["height"]
74
83
  if meta["video"].get("rotation") in (90, -90, 270, -270):
75
84
  W, H = H, W
@@ -149,14 +158,14 @@ def main() -> int:
149
158
  output = args.output or default_output(args.input, "gfx")
150
159
  cmd = ffmpeg_base() + ["-i", args.input]
151
160
  if fc:
152
- cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", "0:a:0?"]
161
+ cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", f"0:a:{args.audio_stream}?"]
153
162
  else:
154
- cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", "0:a:0?"]
163
+ cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
155
164
  cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
156
165
  cmd += aac_args() if meta.get("audio") else ["-an"]
157
166
  cmd.append(output)
158
167
  run(cmd)
159
- r = probe(output)
168
+ r = probe(output, role="output")
160
169
  info(f"wrote {output} ({r['duration']:.3f}s, {args.template})")
161
170
  emit(output, template=args.template)
162
171
  return 0
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ """Turn a still image into a silent, timed video clip.
3
+
4
+ Produces a fixed-duration, constant-frame-rate video from one image -- for
5
+ example a title card, an end slate, or a placeholder to slot into join.py
6
+ alongside real footage. The output has no audio track: pair it with audio.py
7
+ or export.py's own audio handling if the surrounding edit needs sound under
8
+ the still.
9
+
10
+ --width/--height set the output frame size the same way fit.py does: give
11
+ one and the other follows the image's own aspect; give both for an exact
12
+ frame (the image is scaled to fill it, centre-cropping any excess -- never
13
+ distorted). Omit both to keep the image's native size (evened for 4:2:0).
14
+
15
+ --zoom in|out applies a Ken Burns effect: a slow, linear zoom across the
16
+ clip's duration (--zoom-amount sets the end/start zoom factor, default 1.3 =
17
+ 30% zoomed in by the end). --pan left|right|up|down drifts the visible
18
+ window across the image while zoomed (ignored, with a warning, if --zoom is
19
+ not also given -- panning needs the extra image area a zoom exposes).
20
+
21
+ Examples:
22
+ python3 insert.py title.png --duration 3
23
+ python3 insert.py slate.jpg --duration 5 --width 1920 --height 1080 --fps 30 -o slate.mp4
24
+ python3 insert.py photo.jpg --duration 6 --zoom in --pan right --width 1920 --height 1080
25
+ """
26
+ import argparse
27
+ import math
28
+ import sys
29
+
30
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
31
+
32
+
33
+ def even(n: float) -> int:
34
+ v = int(round(n))
35
+ return v if v % 2 == 0 else v + 1
36
+
37
+
38
+ def main() -> int:
39
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
40
+ ap.add_argument("input", help="still image (PNG/JPG/...)")
41
+ ap.add_argument("-o", "--output", help="output file (default: <name>_insert.mp4)")
42
+ ap.add_argument("--duration", required=True, help="clip duration (seconds or mm:ss)")
43
+ ap.add_argument("--width", type=int, help="output width in px; with --height also given, both are used directly")
44
+ ap.add_argument("--height", type=int, help="output height in px; with --width also given, both are used directly")
45
+ ap.add_argument("--fps", type=float, default=30.0, help="output frame rate (default 30)")
46
+ ap.add_argument("--zoom", choices=["in", "out"], help="Ken Burns: slow linear zoom in or out across the clip")
47
+ ap.add_argument("--zoom-amount", type=float, default=1.3, help="end (zoom in) or start (zoom out) zoom factor, > 1.0 (default 1.3)")
48
+ ap.add_argument("--pan", choices=["left", "right", "up", "down"], help="drift the visible window this direction while zoomed (needs --zoom)")
49
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
50
+ ap.add_argument("--preset", default="medium", help="x264 preset")
51
+ add_common(ap)
52
+ args = ap.parse_args()
53
+ apply_common(args)
54
+
55
+ target = parse_time(args.duration)
56
+ if target <= 0:
57
+ die("--duration must be > 0")
58
+ if args.fps <= 0:
59
+ die("--fps must be > 0")
60
+ if args.zoom_amount <= 1.0:
61
+ die(f"--zoom-amount must be > 1.0, got {args.zoom_amount}")
62
+ if args.pan and not args.zoom:
63
+ die("--pan needs --zoom in|out")
64
+
65
+ meta = probe(args.input)
66
+ if not meta.get("video"):
67
+ die("input has no image/video stream")
68
+ sw, sh = meta["video"]["width"], meta["video"]["height"]
69
+ ratio = sw / sh
70
+
71
+ if args.width and args.height:
72
+ out_w, out_h = even(args.width), even(args.height)
73
+ elif args.width:
74
+ out_w = even(args.width)
75
+ out_h = even(out_w / ratio)
76
+ elif args.height:
77
+ out_h = even(args.height)
78
+ out_w = even(out_h * ratio)
79
+ else:
80
+ out_w, out_h = even(sw), even(sh)
81
+
82
+ if args.zoom:
83
+ frames = max(1, round(target * args.fps))
84
+ amount = args.zoom_amount
85
+ if args.zoom == "in":
86
+ zexpr = f"if(eq(on,0),1,min(zoom+{(amount - 1) / frames:.8f},{amount:g}))"
87
+ else:
88
+ zexpr = f"if(eq(on,0),{amount:g},max(zoom-{(amount - 1) / frames:.8f},1))"
89
+ pan_x = {
90
+ "left": f"(iw-iw/zoom)*(1-on/{frames})",
91
+ "right": f"(iw-iw/zoom)*on/{frames}",
92
+ }.get(args.pan, "iw/2-(iw/zoom/2)")
93
+ pan_y = {
94
+ "up": f"(ih-ih/zoom)*(1-on/{frames})",
95
+ "down": f"(ih-ih/zoom)*on/{frames}",
96
+ }.get(args.pan, "ih/2-(ih/zoom/2)")
97
+ # zoompan samples from the still at its native resolution; scale it up first so the
98
+ # zoomed-in crop still has real pixels to draw from instead of upscaling blur.
99
+ upscale = max(2, math.ceil(amount * 2))
100
+ vf = [
101
+ f"scale={out_w * upscale}:{out_h * upscale}:force_original_aspect_ratio=increase",
102
+ f"crop={out_w * upscale}:{out_h * upscale}",
103
+ f"zoompan=z='{zexpr}':x='{pan_x}':y='{pan_y}':d={frames}:s={out_w}x{out_h}:fps={args.fps:g}",
104
+ "setsar=1",
105
+ ]
106
+ else:
107
+ vf = [
108
+ f"scale={out_w}:{out_h}:force_original_aspect_ratio=increase",
109
+ f"crop={out_w}:{out_h}",
110
+ "setsar=1",
111
+ f"fps={args.fps:g}",
112
+ ]
113
+
114
+ output = args.output or default_output(args.input, "insert", "mp4")
115
+ cmd = ffmpeg_base() + ["-loop", "1", "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
116
+ cmd += video_args(None, args.crf, args.preset)
117
+ cmd += ["-an", output]
118
+ run(cmd)
119
+
120
+ result = probe(output, role="output")
121
+ v = result["video"]
122
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['fps']:g}fps)")
123
+ emit(output)
124
+ return 0
125
+
126
+
127
+ if __name__ == "__main__":
128
+ sys.exit(main())
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):
@@ -50,9 +121,11 @@ def main() -> int:
50
121
  if args.width and args.height:
51
122
  w, h = args.width, args.height
52
123
  elif args.width:
53
- w, h = args.width, int(round(args.width * fh / fw))
124
+ w = args.width
125
+ h = int(round(args.width * fh / fw)) if fw else args.width
54
126
  elif args.height:
55
- w, h = int(round(args.height * fw / fh)), args.height
127
+ h = args.height
128
+ w = int(round(args.height * fw / fh)) if fh else args.height
56
129
  else:
57
130
  w, h = fw, fh
58
131
  fps = args.fps or first.get("fps") or 30.0
@@ -70,15 +143,22 @@ def main() -> int:
70
143
  n = len(args.inputs)
71
144
  for i, (p, m) in enumerate(zip(args.inputs, metas)):
72
145
  cmd += ["-i", p]
73
- # silent audio for clips without an audio track
146
+ # silent audio for clips without an audio track. `idx` is this ffmpeg input's position, i.e. n +
147
+ # how many synthetic inputs were already added -- not len(extra_inputs), which counts the six
148
+ # argv tokens ("-f", "lavfi", "-t", duration, "-i", "anullsrc=...") each synthetic input adds, not
149
+ # the input itself. With one no-audio clip both counts coincide (n + 0); from the second no-audio
150
+ # clip onward they diverge, and the previous `n + len(extra_inputs)` named a nonexistent, far-out-of-
151
+ # range ffmpeg input index -- found via a real multi-camera join where every clip lacked audio.
74
152
  audio_src: List[str] = []
153
+ added = 0
75
154
  for i, m in enumerate(metas):
76
155
  if m.get("audio"):
77
156
  audio_src.append(f"{i}:a:0")
78
157
  else:
79
- idx = n + len(extra_inputs)
158
+ idx = n + added
80
159
  extra_inputs += ["-f", "lavfi", "-t", f"{durs[i]:.3f}", "-i", "anullsrc=r=48000:cl=stereo"]
81
160
  audio_src.append(f"{idx}:a:0")
161
+ added += 1
82
162
  cmd += extra_inputs
83
163
 
84
164
  if args.fit == "crop":
@@ -109,9 +189,9 @@ def main() -> int:
109
189
  cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + [output]
110
190
  run(cmd)
111
191
  expected = sum(durs) - d * (n - 1)
112
- r = probe(output)
192
+ r = probe(output, role="output")
113
193
  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))
194
+ emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3))
115
195
  return 0
116
196
 
117
197
 
@@ -24,13 +24,13 @@ from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_c
24
24
 
25
25
  def measure(path: str, I: float, tp: float, lra: float) -> dict:
26
26
  if STATE["dry_run"]:
27
- return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0"}
27
+ return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0", "silent": False}
28
28
  ffmpeg = require_tool("ffmpeg")
29
29
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
30
30
  proc = run(cmd, check=False)
31
31
  m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
32
32
  if proc.returncode != 0 or not m:
33
- die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}")
33
+ die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
34
34
  data = json.loads(m.group(0))
35
35
  for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset"):
36
36
  if data.get(k) in (None, "-inf", "inf", "nan"):
@@ -89,7 +89,7 @@ def main() -> int:
89
89
  after = measure(output, args.lufs, args.tp, args.lra)
90
90
  if not after.get("silent"):
91
91
  info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS)")
92
- emit(output)
92
+ emit(output, result={k: after[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")})
93
93
  return 0
94
94
 
95
95
 
@@ -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]}
@@ -187,7 +197,7 @@ def main() -> int:
187
197
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
188
198
  cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + ["-shortest", output]
189
199
  run(cmd)
190
- r = probe(output)
200
+ r = probe(output, role="output")
191
201
  info(f"wrote {output} ({r['duration']:.3f}s, {len(filled)} cuts, audio from input {a})")
192
202
  emit(output, cuts=[[round(s, 3), round(e, 3), c] for s, e, c in filled], **report)
193
203
  return 0