ffmpeg-skill 0.10.0 → 0.12.5
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 +70 -10
- package/SKILL.md +89 -12
- package/bin/install.js +15 -1
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/mcp/server.py +2 -0
- package/package.json +2 -2
- package/references/ci-platform-pitfalls.md +111 -0
- package/references/process-pitfalls.md +85 -0
- package/references/scripts.md +139 -12
- 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/_common.py +255 -14
- package/scripts/_contract.py +276 -13
- package/scripts/audio.py +1 -1
- package/scripts/background.py +73 -0
- package/scripts/caption.py +106 -24
- package/scripts/color.py +129 -30
- package/scripts/crop.py +79 -0
- package/scripts/cut.py +2 -2
- package/scripts/export.py +1 -1
- package/scripts/fit.py +73 -14
- package/scripts/graphics.py +18 -7
- package/scripts/insert.py +128 -0
- package/scripts/join.py +14 -5
- package/scripts/look.py +13 -8
- package/scripts/loudness.py +3 -3
- package/scripts/multicam.py +1 -1
- package/scripts/overlay.py +78 -12
- package/scripts/proxy.py +82 -0
- package/scripts/reverse.py +56 -0
- package/scripts/scenes.py +8 -2
- package/scripts/sequence.py +124 -0
- package/scripts/silence.py +2 -2
- package/scripts/stabilize.py +101 -0
- package/scripts/sync.py +1 -1
package/scripts/fit.py
CHANGED
|
@@ -5,7 +5,13 @@ 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
7
|
--fit pad (letterbox/pillarbox with --pad-color, default black) or --fit crop.
|
|
8
|
-
--width
|
|
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.
|
|
9
15
|
|
|
10
16
|
Crop keeps the centre of the frame by default, which is a guess: going from
|
|
11
17
|
16:9 to 9:16 throws away most of the width, and whatever isn't in the middle
|
|
@@ -20,6 +26,10 @@ Examples:
|
|
|
20
26
|
python3 fit.py input.mp4 --aspect 9:16 --fit pad --width 1080
|
|
21
27
|
python3 fit.py input.mp4 --aspect 1:1 --fit crop --duration 15
|
|
22
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
|
|
23
33
|
"""
|
|
24
34
|
import argparse
|
|
25
35
|
import math
|
|
@@ -27,7 +37,7 @@ import sys
|
|
|
27
37
|
from fractions import Fraction
|
|
28
38
|
from typing import List
|
|
29
39
|
|
|
30
|
-
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
40
|
+
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, x264_args
|
|
31
41
|
|
|
32
42
|
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)}
|
|
33
43
|
|
|
@@ -66,6 +76,10 @@ def main() -> int:
|
|
|
66
76
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
67
77
|
ap.add_argument("input")
|
|
68
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")
|
|
69
83
|
d = ap.add_argument_group("duration")
|
|
70
84
|
d.add_argument("--duration", help="target duration (seconds or mm:ss)")
|
|
71
85
|
d.add_argument("--method", choices=["speed", "trim"], default="speed", help="how to reach the duration (default speed)")
|
|
@@ -76,10 +90,14 @@ def main() -> int:
|
|
|
76
90
|
a = ap.add_argument_group("aspect")
|
|
77
91
|
a.add_argument("--aspect", help="target aspect ratio, e.g. 16:9, 9:16, 1:1, 4:5")
|
|
78
92
|
a.add_argument("--fit", choices=["pad", "crop"], default="pad", help="pad (letterbox) or crop to reach the aspect (default pad)")
|
|
79
|
-
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")
|
|
80
95
|
a.add_argument("--pad-color", default="black", help="pad colour, e.g. black, white, 0x101010 (default black)")
|
|
81
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")
|
|
82
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)")
|
|
83
101
|
e = ap.add_argument_group("encoding")
|
|
84
102
|
e.add_argument("--crf", type=int, default=18)
|
|
85
103
|
e.add_argument("--preset", default="medium")
|
|
@@ -88,8 +106,10 @@ def main() -> int:
|
|
|
88
106
|
args = ap.parse_args()
|
|
89
107
|
apply_common(args)
|
|
90
108
|
|
|
91
|
-
if
|
|
92
|
-
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")
|
|
93
113
|
if not 0.0 <= args.crop_x <= 1.0:
|
|
94
114
|
die(f"--crop-x must be 0..1, got {args.crop_x}")
|
|
95
115
|
if not 0.0 <= args.crop_y <= 1.0:
|
|
@@ -98,10 +118,17 @@ def main() -> int:
|
|
|
98
118
|
meta = probe(args.input)
|
|
99
119
|
if not meta.get("video"):
|
|
100
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")
|
|
101
126
|
src_dur = meta["duration"] or 0.0
|
|
102
127
|
sw, sh = meta["video"]["width"], meta["video"]["height"]
|
|
103
128
|
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
104
129
|
sw, sh = sh, sw
|
|
130
|
+
if args.rotate in (90, 270):
|
|
131
|
+
sw, sh = sh, sw
|
|
105
132
|
has_audio = bool(meta.get("audio"))
|
|
106
133
|
|
|
107
134
|
vf: List[str] = []
|
|
@@ -110,6 +137,18 @@ def main() -> int:
|
|
|
110
137
|
post: List[str] = []
|
|
111
138
|
factor = 1.0
|
|
112
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
|
+
|
|
113
152
|
# ---- duration
|
|
114
153
|
if args.duration:
|
|
115
154
|
target = parse_time(args.duration)
|
|
@@ -142,14 +181,22 @@ def main() -> int:
|
|
|
142
181
|
info(f"source ({src_dur:.2f}s) is already shorter than {target:.2f}s; trim does nothing")
|
|
143
182
|
|
|
144
183
|
# ---- aspect / size
|
|
145
|
-
if args.aspect or args.width:
|
|
146
|
-
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
|
|
147
186
|
ratio = parse_aspect(args.aspect) if args.aspect else src_ratio
|
|
148
|
-
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:
|
|
149
190
|
out_w = even(args.width)
|
|
150
|
-
|
|
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:
|
|
151
196
|
out_w = even(sw if ratio <= src_ratio else sh * ratio)
|
|
152
|
-
|
|
197
|
+
out_h = even(out_w / ratio)
|
|
198
|
+
else:
|
|
199
|
+
out_w, out_h = even(sw), even(sh)
|
|
153
200
|
if args.fit == "crop":
|
|
154
201
|
vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=increase")
|
|
155
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}")
|
|
@@ -169,21 +216,33 @@ def main() -> int:
|
|
|
169
216
|
cmd += ["-vf", ",".join(vf)]
|
|
170
217
|
if af:
|
|
171
218
|
cmd += ["-af", ",".join(af)]
|
|
219
|
+
cmd += ["-map", "0:v:0"]
|
|
220
|
+
if has_audio:
|
|
221
|
+
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
172
222
|
cmd += video_args(meta, args.crf, args.preset)
|
|
173
223
|
cmd += cfr_args(meta, args.fps) if not args.fps else []
|
|
174
224
|
if has_audio:
|
|
175
225
|
cmd += aac_args()
|
|
176
226
|
else:
|
|
177
227
|
cmd += ["-an"]
|
|
178
|
-
cmd += post
|
|
179
|
-
|
|
228
|
+
cmd += post
|
|
229
|
+
if abs(factor - 1.0) > 1e-4:
|
|
230
|
+
# A subtitle/data stream stream-copied by run_keeping_subtitles keeps the source's
|
|
231
|
+
# original timestamps; --method speed retimes video (setpts) and audio (atempo) but has
|
|
232
|
+
# no equivalent way to retime a copied subtitle track, so it would desync from the
|
|
233
|
+
# now-faster/slower picture. Drop them here rather than ship a captions track that lies
|
|
234
|
+
# about when a line is spoken.
|
|
235
|
+
run(cmd + [output])
|
|
236
|
+
dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
|
|
237
|
+
else:
|
|
238
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
180
239
|
|
|
181
|
-
result = probe(output)
|
|
240
|
+
result = probe(output, role="output")
|
|
182
241
|
msg = f"wrote {output} ({result['duration']:.3f}s, {result['video']['width']}x{result['video']['height']})"
|
|
183
242
|
if abs(factor - 1.0) > 1e-4:
|
|
184
243
|
msg += f", speed {factor:.3f}x"
|
|
185
244
|
info(msg)
|
|
186
|
-
emit(output)
|
|
245
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
187
246
|
return 0
|
|
188
247
|
|
|
189
248
|
|
package/scripts/graphics.py
CHANGED
|
@@ -21,7 +21,7 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import List, Optional
|
|
23
23
|
|
|
24
|
-
from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, video_args
|
|
24
|
+
from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args
|
|
25
25
|
|
|
26
26
|
TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
|
|
27
27
|
|
|
@@ -33,6 +33,9 @@ def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
|
|
|
33
33
|
def font_opts(brand: dict, font: Optional[str], font_file: Optional[str]) -> str:
|
|
34
34
|
if font_file or brand.get("font_file"):
|
|
35
35
|
return f"fontfile={escape_filter_path(font_file or brand['font_file'])}"
|
|
36
|
+
resolved = default_font_file(font or brand.get("font", "DejaVu Sans"))
|
|
37
|
+
if resolved:
|
|
38
|
+
return f"fontfile={escape_filter_path(resolved)}"
|
|
36
39
|
return f"font='{font or brand.get('font', 'DejaVu Sans')}'"
|
|
37
40
|
|
|
38
41
|
|
|
@@ -40,6 +43,10 @@ def main() -> int:
|
|
|
40
43
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
41
44
|
ap.add_argument("input")
|
|
42
45
|
ap.add_argument("-o", "--output", help="output file (default: <name>_gfx.<ext>)")
|
|
46
|
+
ap.add_argument("--audio-stream", type=int, default=0,
|
|
47
|
+
help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
|
|
48
|
+
"audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
|
|
49
|
+
"the first track, same as leaving it unset always did")
|
|
43
50
|
ap.add_argument("--template", choices=TEMPLATES, required=True)
|
|
44
51
|
ap.add_argument("--brand", help="brand.json for colours, font, safe margin")
|
|
45
52
|
ap.add_argument("--name", help="lower-third: name line")
|
|
@@ -70,6 +77,11 @@ def main() -> int:
|
|
|
70
77
|
meta = probe(args.input)
|
|
71
78
|
if not meta.get("video"):
|
|
72
79
|
die("input has no video stream")
|
|
80
|
+
audio_streams = meta.get("audio_streams") or []
|
|
81
|
+
if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
|
|
82
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
83
|
+
if args.audio_stream and not audio_streams:
|
|
84
|
+
die("--audio-stream needs an input with audio streams")
|
|
73
85
|
W, H = meta["video"]["width"], meta["video"]["height"]
|
|
74
86
|
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
75
87
|
W, H = H, W
|
|
@@ -149,16 +161,15 @@ def main() -> int:
|
|
|
149
161
|
output = args.output or default_output(args.input, "gfx")
|
|
150
162
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
151
163
|
if fc:
|
|
152
|
-
cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", "0:a:
|
|
164
|
+
cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", f"0:a:{args.audio_stream}?"]
|
|
153
165
|
else:
|
|
154
|
-
cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", "0:a:
|
|
166
|
+
cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
155
167
|
cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
156
168
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
157
|
-
cmd
|
|
158
|
-
|
|
159
|
-
r = probe(output)
|
|
169
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
170
|
+
r = probe(output, role="output")
|
|
160
171
|
info(f"wrote {output} ({r['duration']:.3f}s, {args.template})")
|
|
161
|
-
emit(output, template=args.template)
|
|
172
|
+
emit(output, template=args.template, dropped_non_av_streams=dropped_streams)
|
|
162
173
|
return 0
|
|
163
174
|
|
|
164
175
|
|
|
@@ -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
|
@@ -121,9 +121,11 @@ def main() -> int:
|
|
|
121
121
|
if args.width and args.height:
|
|
122
122
|
w, h = args.width, args.height
|
|
123
123
|
elif args.width:
|
|
124
|
-
w
|
|
124
|
+
w = args.width
|
|
125
|
+
h = int(round(args.width * fh / fw)) if fw else args.width
|
|
125
126
|
elif args.height:
|
|
126
|
-
|
|
127
|
+
h = args.height
|
|
128
|
+
w = int(round(args.height * fw / fh)) if fh else args.height
|
|
127
129
|
else:
|
|
128
130
|
w, h = fw, fh
|
|
129
131
|
fps = args.fps or first.get("fps") or 30.0
|
|
@@ -141,15 +143,22 @@ def main() -> int:
|
|
|
141
143
|
n = len(args.inputs)
|
|
142
144
|
for i, (p, m) in enumerate(zip(args.inputs, metas)):
|
|
143
145
|
cmd += ["-i", p]
|
|
144
|
-
# 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.
|
|
145
152
|
audio_src: List[str] = []
|
|
153
|
+
added = 0
|
|
146
154
|
for i, m in enumerate(metas):
|
|
147
155
|
if m.get("audio"):
|
|
148
156
|
audio_src.append(f"{i}:a:0")
|
|
149
157
|
else:
|
|
150
|
-
idx = n +
|
|
158
|
+
idx = n + added
|
|
151
159
|
extra_inputs += ["-f", "lavfi", "-t", f"{durs[i]:.3f}", "-i", "anullsrc=r=48000:cl=stereo"]
|
|
152
160
|
audio_src.append(f"{idx}:a:0")
|
|
161
|
+
added += 1
|
|
153
162
|
cmd += extra_inputs
|
|
154
163
|
|
|
155
164
|
if args.fit == "crop":
|
|
@@ -180,7 +189,7 @@ def main() -> int:
|
|
|
180
189
|
cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + [output]
|
|
181
190
|
run(cmd)
|
|
182
191
|
expected = sum(durs) - d * (n - 1)
|
|
183
|
-
r = probe(output)
|
|
192
|
+
r = probe(output, role="output")
|
|
184
193
|
info(f"wrote {output} ({r['duration']:.3f}s, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
|
|
185
194
|
emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3))
|
|
186
195
|
return 0
|
package/scripts/look.py
CHANGED
|
@@ -15,7 +15,7 @@ import sys
|
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
from typing import List
|
|
17
17
|
|
|
18
|
-
from _common import add_common, apply_common, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run
|
|
18
|
+
from _common import add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run
|
|
19
19
|
|
|
20
20
|
FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
|
|
21
21
|
|
|
@@ -26,8 +26,8 @@ def fmt_hms(sec: float) -> str:
|
|
|
26
26
|
return f"{int(h):02d}:{int(m):02d}:{s_:06.3f}"
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def timecode_filter() -> str:
|
|
30
|
-
return f"drawtext=text='%{{pts\\:hms}}':{FONT}"
|
|
29
|
+
def timecode_filter(font_prefix: str) -> str:
|
|
30
|
+
return f"drawtext=text='%{{pts\\:hms}}':{font_prefix}{FONT}"
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def main() -> int:
|
|
@@ -49,7 +49,12 @@ def main() -> int:
|
|
|
49
49
|
dur = meta.get("duration") or 0.0
|
|
50
50
|
stem = Path(args.input).stem
|
|
51
51
|
outdir = str(Path(args.output).parent) if args.output else str(Path(args.input).parent)
|
|
52
|
-
|
|
52
|
+
# a resolvable font file, given as fontfile=, is the only form confirmed not to crash drawtext's
|
|
53
|
+
# own fontconfig resolution on some real Windows ffmpeg builds (#100); font= is the fallback when
|
|
54
|
+
# nothing can be resolved, unchanged from before this existed.
|
|
55
|
+
default_font = default_font_file("DejaVu Sans")
|
|
56
|
+
font_prefix = f"fontfile={escape_filter_path(default_font)}:" if default_font else ""
|
|
57
|
+
tc = "" if args.no_timecode else "," + timecode_filter(font_prefix)
|
|
53
58
|
# HDR sources: tone-map for the PNG so the agent judges representative colours, not raw HLG/PQ
|
|
54
59
|
if meta["video"].get("hdr"):
|
|
55
60
|
v = meta["video"]
|
|
@@ -67,8 +72,8 @@ def main() -> int:
|
|
|
67
72
|
sec = parse_time(t)
|
|
68
73
|
out = args.output or os.path.join(outdir, f"{stem}_vs_{Path(args.compare).stem}_{sec:.3f}s.png")
|
|
69
74
|
half = args.width // 2
|
|
70
|
-
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{FONT}"
|
|
71
|
-
tcs = tc.replace("," + timecode_filter(), "") + stamp
|
|
75
|
+
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
76
|
+
tcs = tc.replace("," + timecode_filter(font_prefix), "") + stamp
|
|
72
77
|
fc = (f"[0:v]scale={half}:-2{tcs}[a];[1:v]scale={half}:-2{tcs}[b];"
|
|
73
78
|
f"[a][b]scale2ref=w=iw:h=ih[a2][b2];[a2][b2]hstack=inputs=2[out]")
|
|
74
79
|
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-ss", f"{sec:.3f}", "-i", args.compare,
|
|
@@ -81,8 +86,8 @@ def main() -> int:
|
|
|
81
86
|
if dur and sec > dur:
|
|
82
87
|
die(f"--at {t} is beyond the duration ({dur:.2f}s)")
|
|
83
88
|
out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
|
|
84
|
-
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{FONT}"
|
|
85
|
-
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(), '')}{stamp}", "-frames:v", "1", out]
|
|
89
|
+
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
90
|
+
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", out]
|
|
86
91
|
run(cmd)
|
|
87
92
|
outputs.append(out)
|
|
88
93
|
else:
|
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
|
@@ -197,7 +197,7 @@ def main() -> int:
|
|
|
197
197
|
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
|
|
198
198
|
cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + ["-shortest", output]
|
|
199
199
|
run(cmd)
|
|
200
|
-
r = probe(output)
|
|
200
|
+
r = probe(output, role="output")
|
|
201
201
|
info(f"wrote {output} ({r['duration']:.3f}s, {len(filled)} cuts, audio from input {a})")
|
|
202
202
|
emit(output, cuts=[[round(s, 3), round(e, 3), c] for s, e, c in filled], **report)
|
|
203
203
|
return 0
|