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.
- package/README.md +315 -122
- package/SKILL.md +115 -18
- package/bin/install.js +16 -2
- package/mcp/server.py +2 -0
- package/package.json +15 -3
- package/references/ci-platform-pitfalls.md +111 -0
- package/references/process-pitfalls.md +85 -0
- package/references/scripts.md +122 -11
- package/scripts/_common.py +247 -15
- package/scripts/_contract.py +420 -48
- package/scripts/audio.py +101 -8
- package/scripts/background.py +73 -0
- package/scripts/caption.py +97 -18
- package/scripts/check.py +21 -7
- package/scripts/color.py +104 -13
- package/scripts/crop.py +79 -0
- package/scripts/cut.py +85 -11
- package/scripts/export.py +16 -7
- package/scripts/fit.py +76 -12
- package/scripts/graphics.py +12 -3
- package/scripts/insert.py +128 -0
- package/scripts/join.py +88 -8
- package/scripts/loudness.py +3 -3
- package/scripts/multicam.py +11 -1
- package/scripts/overlay.py +64 -5
- package/scripts/proxy.py +82 -0
- package/scripts/render.py +13 -2
- package/scripts/reverse.py +56 -0
- package/scripts/scenes.py +15 -3
- package/scripts/sequence.py +124 -0
- package/scripts/silence.py +2 -2
- package/scripts/stabilize.py +83 -0
- package/scripts/sync.py +9 -1
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.cpython-311.pyc +0 -0
- package/scripts/__pycache__/report.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/__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
|
-
|
|
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
|
|
82
|
-
die("
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
package/scripts/graphics.py
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
124
|
+
w = args.width
|
|
125
|
+
h = int(round(args.width * fh / fw)) if fw else args.width
|
|
54
126
|
elif args.height:
|
|
55
|
-
|
|
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 +
|
|
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
|
|
package/scripts/loudness.py
CHANGED
|
@@ -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
|
|
package/scripts/multicam.py
CHANGED
|
@@ -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
|