ffmpeg-skill 0.12.0 → 0.16.12
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 +56 -7
- package/SKILL.md +26 -2
- package/bin/install.js +12 -3
- package/mcp/server.py +8 -0
- package/package.json +2 -2
- package/references/scripts.md +177 -2
- package/scripts/_common.py +112 -5
- package/scripts/_contract.py +128 -6
- package/scripts/background.py +11 -2
- package/scripts/batch.py +58 -5
- package/scripts/caption.py +48 -5
- package/scripts/check.py +8 -0
- package/scripts/color.py +102 -22
- package/scripts/crop.py +2 -0
- package/scripts/cropdetect.py +106 -0
- package/scripts/cut.py +4 -0
- package/scripts/deinterlace.py +85 -0
- package/scripts/denoise.py +94 -0
- package/scripts/export.py +2 -1
- package/scripts/fit.py +26 -6
- package/scripts/freeze.py +108 -0
- package/scripts/graphics.py +7 -5
- package/scripts/grid.py +142 -0
- package/scripts/join.py +4 -1
- package/scripts/look.py +13 -8
- package/scripts/loop.py +80 -0
- package/scripts/multicam.py +10 -2
- package/scripts/overlay.py +27 -10
- package/scripts/pad.py +68 -0
- package/scripts/redact.py +100 -0
- package/scripts/render.py +17 -3
- package/scripts/scenes.py +8 -2
- package/scripts/silence.py +6 -3
- package/scripts/speedramp.py +123 -0
- package/scripts/sphere.py +126 -0
- package/scripts/stabilize.py +22 -4
- package/scripts/straighten.py +97 -0
- package/scripts/verify.py +25 -2
- package/scripts/waveform.py +92 -0
package/scripts/overlay.py
CHANGED
|
@@ -24,7 +24,7 @@ import argparse
|
|
|
24
24
|
import sys
|
|
25
25
|
from typing import List, Optional
|
|
26
26
|
|
|
27
|
-
from _common import STATE, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
27
|
+
from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args
|
|
28
28
|
|
|
29
29
|
POS = {
|
|
30
30
|
"top-left": ("{m}", "{m}"),
|
|
@@ -144,6 +144,8 @@ def main() -> int:
|
|
|
144
144
|
args.font = brand.get("font", args.font)
|
|
145
145
|
if not args.font_file and brand.get("font_file"):
|
|
146
146
|
args.font_file = brand["font_file"]
|
|
147
|
+
if not args.font_file:
|
|
148
|
+
args.font_file = default_font_file(args.font)
|
|
147
149
|
meta = probe(args.input)
|
|
148
150
|
if not meta.get("video"):
|
|
149
151
|
die("input has no video stream")
|
|
@@ -161,6 +163,11 @@ def main() -> int:
|
|
|
161
163
|
die("--opacity must be within 0..1")
|
|
162
164
|
if args.chromakey and not args.video:
|
|
163
165
|
die("--chromakey needs --video")
|
|
166
|
+
if args.chromakey:
|
|
167
|
+
validate_color(args.chromakey, "--chromakey")
|
|
168
|
+
validate_color(args.font_color, "--font-color")
|
|
169
|
+
validate_color(args.border_color, "--border-color")
|
|
170
|
+
validate_color(args.box_color, "--box-color")
|
|
164
171
|
if not 0 < args.chromakey_similarity <= 1:
|
|
165
172
|
die("--chromakey-similarity must be within (0, 1]")
|
|
166
173
|
if not 0 <= args.chromakey_blend <= 1:
|
|
@@ -193,12 +200,19 @@ def main() -> int:
|
|
|
193
200
|
# -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
|
|
194
201
|
cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
|
|
195
202
|
fc = f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS[ov];[0:v][ov]{ov}[out]"
|
|
196
|
-
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"
|
|
197
|
-
# -shortest alone is not exact on FFmpeg 7+: the muxer keeps up to shortest_buf_duration (10 s)
|
|
198
|
-
# of the looped still after the video ended, and the file came out 2 s long on 8.1 / 9.0.
|
|
199
|
-
# The output must be as long as the main input, so say so explicitly.
|
|
203
|
+
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
|
|
200
204
|
if meta.get("duration"):
|
|
205
|
+
# An explicit -t is exact and, unlike -shortest, only bounds the *main* input's
|
|
206
|
+
# streams -- a preserved subtitle/data stream that ends earlier (run_keeping_subtitles)
|
|
207
|
+
# must not be allowed to cut the whole output short via -shortest's "stop at whichever
|
|
208
|
+
# mapped stream finishes first" semantics.
|
|
201
209
|
cmd += ["-t", f"{meta['duration']:.3f}"]
|
|
210
|
+
else:
|
|
211
|
+
# No known duration to bound by -t (e.g. probe found no video duration): -shortest is
|
|
212
|
+
# the only thing stopping the looped still from running forever. FFmpeg 7+'s
|
|
213
|
+
# shortest_buf_duration slack (up to 10s) is an accepted imprecision here since there is
|
|
214
|
+
# no better bound available.
|
|
215
|
+
cmd += ["-shortest"]
|
|
202
216
|
elif args.video:
|
|
203
217
|
pip_meta = probe(args.video)
|
|
204
218
|
if not pip_meta.get("video"):
|
|
@@ -219,9 +233,13 @@ def main() -> int:
|
|
|
219
233
|
ov += f":enable='{enable}'"
|
|
220
234
|
cmd = ffmpeg_base() + ["-i", args.input, "-i", args.video]
|
|
221
235
|
fc = f"[1:v]{','.join(chain)}[ov];[0:v][ov]{ov}[out]"
|
|
222
|
-
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"
|
|
236
|
+
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
|
|
223
237
|
if meta.get("duration"):
|
|
238
|
+
# See the --image branch above: -t (exact, bounds only the main input) instead of
|
|
239
|
+
# -shortest (would also stop at a preserved subtitle/data stream that ends earlier).
|
|
224
240
|
cmd += ["-t", f"{meta['duration']:.3f}"]
|
|
241
|
+
else:
|
|
242
|
+
cmd += ["-shortest"]
|
|
225
243
|
else:
|
|
226
244
|
x, y = position_exprs(args.position, args.margin, text_mode=True)
|
|
227
245
|
opts = [f"text='{escape_drawtext(args.text)}'", f"fontsize={args.font_size}", f"x={x}", f"y={y}",
|
|
@@ -229,7 +247,7 @@ def main() -> int:
|
|
|
229
247
|
if args.font_file:
|
|
230
248
|
opts.append(f"fontfile={escape_filter_path(args.font_file)}")
|
|
231
249
|
else:
|
|
232
|
-
opts.append(f"font='{args.font}'")
|
|
250
|
+
opts.append(f"font='{escape_drawtext(args.font)}'")
|
|
233
251
|
alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None),
|
|
234
252
|
end if end is not None else ((meta.get("duration") or None) if args.fade > 0 else None), args.fade)
|
|
235
253
|
opts.append(f"fontcolor={args.font_color}")
|
|
@@ -245,12 +263,11 @@ def main() -> int:
|
|
|
245
263
|
|
|
246
264
|
cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
247
265
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
248
|
-
cmd
|
|
249
|
-
run(cmd)
|
|
266
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
250
267
|
if not STATE.dry_run:
|
|
251
268
|
result = probe(output, role="output")
|
|
252
269
|
info(f"wrote {output} ({result['duration']:.3f}s)")
|
|
253
|
-
emit(output)
|
|
270
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
254
271
|
return 0
|
|
255
272
|
|
|
256
273
|
|
package/scripts/pad.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Add black video / silent audio at the start and/or end of a clip.
|
|
3
|
+
|
|
4
|
+
Distinct from fit.py --fit pad, which pads the FRAME (letterbox/pillarbox
|
|
5
|
+
bars around each existing frame to reach a target aspect ratio) -- this
|
|
6
|
+
tool pads the TIMELINE (extra seconds of solid colour and silence before
|
|
7
|
+
and/or after the clip's existing content). Common uses: a beat of black
|
|
8
|
+
before a title card starts, room for a fade-in, aligning a clip to a fixed
|
|
9
|
+
slot length.
|
|
10
|
+
|
|
11
|
+
Examples:
|
|
12
|
+
python3 pad.py clip.mp4 --start 1.5 # 1.5s of black+silence before the clip
|
|
13
|
+
python3 pad.py clip.mp4 --end 2 # 2s of black+silence after the clip
|
|
14
|
+
python3 pad.py clip.mp4 --start 1 --end 1 --color 0x101010
|
|
15
|
+
"""
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def main() -> int:
|
|
23
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
24
|
+
ap.add_argument("input")
|
|
25
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_pad.<ext>)")
|
|
26
|
+
ap.add_argument("--start", type=float, default=0.0, help="seconds of padding to add before the clip (default 0)")
|
|
27
|
+
ap.add_argument("--end", type=float, default=0.0, help="seconds of padding to add after the clip (default 0)")
|
|
28
|
+
ap.add_argument("--color", default="black", help="padding colour (default black)")
|
|
29
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
30
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
31
|
+
add_common(ap)
|
|
32
|
+
args = ap.parse_args()
|
|
33
|
+
apply_common(args)
|
|
34
|
+
|
|
35
|
+
if args.start < 0 or args.end < 0:
|
|
36
|
+
die(f"--start/--end must be >= 0, got start={args.start:g} end={args.end:g}")
|
|
37
|
+
if args.start == 0 and args.end == 0:
|
|
38
|
+
die("--start and/or --end must be > 0 (nothing to pad)")
|
|
39
|
+
validate_color(args.color, "--color")
|
|
40
|
+
|
|
41
|
+
meta = probe(args.input)
|
|
42
|
+
if not meta.get("video"):
|
|
43
|
+
die("input has no video stream")
|
|
44
|
+
has_audio = bool(meta.get("audio"))
|
|
45
|
+
output = args.output or default_output(args.input, "pad")
|
|
46
|
+
|
|
47
|
+
vf = f"tpad=start_duration={args.start:.3f}:stop_duration={args.end:.3f}:color={args.color}"
|
|
48
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
|
|
49
|
+
if has_audio:
|
|
50
|
+
af = f"adelay={int(args.start * 1000)}:all=1,apad=pad_dur={args.end:.3f}"
|
|
51
|
+
cmd += ["-map", "0:a:0?", "-af", af]
|
|
52
|
+
cmd += video_args(meta, args.crf, args.preset)
|
|
53
|
+
cmd += cfr_args(meta)
|
|
54
|
+
if has_audio:
|
|
55
|
+
cmd += aac_args()
|
|
56
|
+
else:
|
|
57
|
+
cmd += ["-an"]
|
|
58
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
59
|
+
|
|
60
|
+
result = probe(output, role="output")
|
|
61
|
+
v = result["video"]
|
|
62
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, +{args.start:g}s start / +{args.end:g}s end)")
|
|
63
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
sys.exit(main())
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Blur or pixelate an exact pixel rectangle for the whole clip (privacy redaction, license plates, faces).
|
|
3
|
+
|
|
4
|
+
{x, y, width, height} are literal pixel offsets and dimensions in the SOURCE
|
|
5
|
+
frame, the same convention as crop.py -- this tool needs the rectangle
|
|
6
|
+
already known (a saved detection box, a hand-picked region); it does not
|
|
7
|
+
locate faces or plates itself. The rest of the frame is untouched.
|
|
8
|
+
|
|
9
|
+
--mode blur (default) applies a strong box blur inside the rectangle;
|
|
10
|
+
--mode pixelate mosaics it into large blocks -- the more recognisable,
|
|
11
|
+
unmistakably-redacted look often wanted for compliance/legal footage.
|
|
12
|
+
The region stays --mode blur/pixelate for the whole clip; for a region that
|
|
13
|
+
only needs covering part of the timeline, cut the clip into segments first
|
|
14
|
+
(cut.py) and redact only the relevant one.
|
|
15
|
+
|
|
16
|
+
Examples:
|
|
17
|
+
python3 redact.py interview.mp4 --x 820 --y 140 --width 240 --height 240
|
|
18
|
+
python3 redact.py dashcam.mp4 --x 0 --y 900 --width 400 --height 120 --mode pixelate --block-size 16
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main() -> int:
|
|
27
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
28
|
+
ap.add_argument("input")
|
|
29
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_redact.<ext>)")
|
|
30
|
+
ap.add_argument("--x", type=int, required=True, help="left edge of the rectangle, in source pixels")
|
|
31
|
+
ap.add_argument("--y", type=int, required=True, help="top edge of the rectangle, in source pixels")
|
|
32
|
+
ap.add_argument("--width", type=int, required=True, help="rectangle width in px (must be even)")
|
|
33
|
+
ap.add_argument("--height", type=int, required=True, help="rectangle height in px (must be even)")
|
|
34
|
+
ap.add_argument("--mode", choices=["blur", "pixelate"], default="blur", help="blur (default) or pixelate the rectangle")
|
|
35
|
+
ap.add_argument("--blur-strength", type=int, default=20, help="box-blur radius in px, --mode blur only (default 20)")
|
|
36
|
+
ap.add_argument("--block-size", type=int, default=12, help="mosaic block size in px, --mode pixelate only (default 12)")
|
|
37
|
+
ap.add_argument("--audio-stream", type=int, default=0,
|
|
38
|
+
help="which audio stream of the input to keep, 0-based in file order (default 0)")
|
|
39
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
40
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
41
|
+
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
42
|
+
add_common(ap)
|
|
43
|
+
args = ap.parse_args()
|
|
44
|
+
apply_common(args)
|
|
45
|
+
if args.fps is not None and args.fps <= 0:
|
|
46
|
+
die(f"--fps must be positive, got {args.fps:g}")
|
|
47
|
+
|
|
48
|
+
if args.x < 0 or args.y < 0:
|
|
49
|
+
die(f"--x/--y must be >= 0, got x={args.x} y={args.y}")
|
|
50
|
+
if args.width <= 0 or args.height <= 0:
|
|
51
|
+
die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
|
|
52
|
+
if args.width % 2 or args.height % 2:
|
|
53
|
+
die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
|
|
54
|
+
if args.blur_strength <= 0:
|
|
55
|
+
die(f"--blur-strength must be > 0, got {args.blur_strength}")
|
|
56
|
+
if args.block_size <= 1:
|
|
57
|
+
die(f"--block-size must be > 1, got {args.block_size}")
|
|
58
|
+
|
|
59
|
+
meta = probe(args.input)
|
|
60
|
+
if not meta.get("video"):
|
|
61
|
+
die("input has no video stream")
|
|
62
|
+
sw, sh = meta["video"]["width"], meta["video"]["height"]
|
|
63
|
+
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
64
|
+
sw, sh = sh, sw
|
|
65
|
+
if args.x + args.width > sw or args.y + args.height > sh:
|
|
66
|
+
die(f"redaction rectangle ({args.x},{args.y},{args.width}x{args.height}) exceeds the source frame ({sw}x{sh})")
|
|
67
|
+
has_audio = bool(meta.get("audio"))
|
|
68
|
+
audio_streams = meta.get("audio_streams") or []
|
|
69
|
+
if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
|
|
70
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
71
|
+
if args.audio_stream and not audio_streams:
|
|
72
|
+
die("--audio-stream needs an input with audio streams")
|
|
73
|
+
|
|
74
|
+
output = args.output or default_output(args.input, "redact")
|
|
75
|
+
crop = f"crop={args.width}:{args.height}:{args.x}:{args.y}"
|
|
76
|
+
if args.mode == "blur":
|
|
77
|
+
region = f"{crop},boxblur={args.blur_strength}:{args.blur_strength}"
|
|
78
|
+
else:
|
|
79
|
+
region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
|
|
80
|
+
fc = f"[0:v]split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}[out]"
|
|
81
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc, "-map", "[out]"]
|
|
82
|
+
if has_audio:
|
|
83
|
+
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
|
84
|
+
cmd += video_args(meta, args.crf, args.preset)
|
|
85
|
+
cmd += cfr_args(meta, args.fps)
|
|
86
|
+
if has_audio:
|
|
87
|
+
cmd += aac_args()
|
|
88
|
+
else:
|
|
89
|
+
cmd += ["-an"]
|
|
90
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
91
|
+
|
|
92
|
+
result = probe(output, role="output")
|
|
93
|
+
v = result["video"]
|
|
94
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {args.mode} at x={args.x} y={args.y} {args.width}x{args.height})")
|
|
95
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
sys.exit(main())
|
package/scripts/render.py
CHANGED
|
@@ -130,7 +130,14 @@ def main() -> int:
|
|
|
130
130
|
if not clips:
|
|
131
131
|
die("project.clips is empty")
|
|
132
132
|
output = rel(proj.get("output") or "final.mp4")
|
|
133
|
-
|
|
133
|
+
# The default work dir name comes only from the output path, with no PID or timestamp --
|
|
134
|
+
# two concurrent render.py runs targeting the same output (a batch.py "project" recipe
|
|
135
|
+
# processing several files in parallel, or simply running render.py twice by mistake) shared
|
|
136
|
+
# the same work directory and clobbered each other's same-named intermediates (clip00.mp4,
|
|
137
|
+
# fit.mp4, ...) mid-run. An explicit --work is left as given (the caller asked for that exact,
|
|
138
|
+
# shared path, e.g. to inspect intermediates across runs); only the auto-derived default is
|
|
139
|
+
# made unique per process, since it's the one that's also auto-deleted at the end.
|
|
140
|
+
work = Path(args.work) if args.work else Path(f"{Path(output).with_suffix('')}_work_{os.getpid()}")
|
|
134
141
|
work.mkdir(parents=True, exist_ok=True)
|
|
135
142
|
frame = proj.get("frame") or {}
|
|
136
143
|
trans = proj.get("transition") or {}
|
|
@@ -204,12 +211,14 @@ def main() -> int:
|
|
|
204
211
|
fit.setdefault("aspect", frame["aspect"])
|
|
205
212
|
if frame.get("width") and len(parts) == 1:
|
|
206
213
|
fit.setdefault("width", frame["width"])
|
|
214
|
+
if frame.get("height") and len(parts) == 1:
|
|
215
|
+
fit.setdefault("height", frame["height"])
|
|
207
216
|
if frame.get("fps") and len(parts) == 1:
|
|
208
217
|
fit.setdefault("fps", frame["fps"])
|
|
209
218
|
if fit:
|
|
210
219
|
nxt = str(work / "fit.mp4")
|
|
211
220
|
argv = [current, "-o", nxt]
|
|
212
|
-
for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("fps", "--fps"), ("smooth", "--smooth")):
|
|
221
|
+
for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("height", "--height"), ("fps", "--fps"), ("smooth", "--smooth")):
|
|
213
222
|
if fit.get(k) is not None:
|
|
214
223
|
argv += [flag, str(fit[k])]
|
|
215
224
|
sh("fit.py", *argv)
|
|
@@ -361,7 +370,12 @@ def main() -> int:
|
|
|
361
370
|
info(f"check: OK for {ck['platform']}")
|
|
362
371
|
stages_done.append("check")
|
|
363
372
|
|
|
364
|
-
if not args.keep and not args.work
|
|
373
|
+
if not args.keep and not args.work:
|
|
374
|
+
# Also clean up on --dry-run: a dry run still creates this directory (and some steps,
|
|
375
|
+
# e.g. caption.py's .ass sidecar, write into it even under --dry-run), and now that the
|
|
376
|
+
# default name carries this process's PID, nothing else will ever reuse -- and so
|
|
377
|
+
# implicitly clean up -- a leftover dry-run directory the way a same-named real run used
|
|
378
|
+
# to before the PID suffix was added.
|
|
365
379
|
import shutil
|
|
366
380
|
shutil.rmtree(work, ignore_errors=True)
|
|
367
381
|
info(f"rendered {output} via {' → '.join(stages_done)}")
|
package/scripts/scenes.py
CHANGED
|
@@ -26,7 +26,7 @@ import subprocess
|
|
|
26
26
|
import sys
|
|
27
27
|
from typing import Dict, List, Tuple
|
|
28
28
|
|
|
29
|
-
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run
|
|
29
|
+
from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run
|
|
30
30
|
|
|
31
31
|
SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
|
|
32
32
|
|
|
@@ -107,6 +107,7 @@ def main() -> int:
|
|
|
107
107
|
ap.add_argument("--max-scene", type=float, default=15.0, help="cap a highlight range at this many seconds (default 15)")
|
|
108
108
|
ap.add_argument("--edl", help="write highlight ranges as START-END lines (cut.py --segments format)")
|
|
109
109
|
ap.add_argument("--sheet", help="write a contact sheet PNG with the first frame of every scene")
|
|
110
|
+
ap.add_argument("--no-timecode", action="store_true", help="--sheet without the burnt-in timecode stamp (a way out if drawtext itself is unusable, see doctor)")
|
|
110
111
|
add_common(ap)
|
|
111
112
|
args = ap.parse_args()
|
|
112
113
|
apply_common(args)
|
|
@@ -183,7 +184,12 @@ def main() -> int:
|
|
|
183
184
|
# exactly one frame per scene: the frame index at the scene start
|
|
184
185
|
fps = meta["video"].get("fps") or 30.0
|
|
185
186
|
expr = "+".join(f"eq(n\\,{int(round(sc['start'] * fps))})" for sc in scenes)
|
|
186
|
-
|
|
187
|
+
stamp = ""
|
|
188
|
+
if not args.no_timecode:
|
|
189
|
+
default_font = default_font_file("DejaVu Sans")
|
|
190
|
+
font_prefix = f"fontfile={escape_filter_path(default_font)}:" if default_font else ""
|
|
191
|
+
stamp = f",drawtext=text='%{{pts\\:hms}}':{font_prefix}fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6"
|
|
192
|
+
vf = (f"select='{expr}',scale={tile_w}:-2{stamp},"
|
|
187
193
|
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
188
194
|
run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", args.sheet])
|
|
189
195
|
info(f"wrote {args.sheet}")
|
package/scripts/silence.py
CHANGED
|
@@ -16,7 +16,7 @@ import re
|
|
|
16
16
|
import sys
|
|
17
17
|
from typing import List, Tuple
|
|
18
18
|
|
|
19
|
-
from _common import video_args,
|
|
19
|
+
from _common import video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args
|
|
20
20
|
|
|
21
21
|
SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
|
|
22
22
|
|
|
@@ -109,9 +109,12 @@ def main() -> int:
|
|
|
109
109
|
vf = f"select='{expr}',setpts=N/FRAME_RATE/TB"
|
|
110
110
|
af = f"aselect='{expr}',asetpts=N/SR/TB"
|
|
111
111
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
112
|
-
|
|
112
|
+
audio_only = is_audio_output(output) or not meta.get("video")
|
|
113
|
+
if audio_only:
|
|
114
|
+
cmd += ["-vn"]
|
|
115
|
+
else:
|
|
113
116
|
cmd += ["-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
114
|
-
cmd += ["-af", af] +
|
|
117
|
+
cmd += ["-af", af] + audio_codec_for(output) + [output]
|
|
115
118
|
run(cmd)
|
|
116
119
|
r = probe(output, role="output")
|
|
117
120
|
info(f"wrote {output} ({r['duration']:.3f}s, expected ~{kept:.3f}s)")
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Step through different constant speeds across a clip's timeline (a speed ramp).
|
|
3
|
+
|
|
4
|
+
Distinct from fit.py --duration --method speed, which applies one constant
|
|
5
|
+
factor to the whole clip. This tool takes a list of --segment START-END:FACTOR
|
|
6
|
+
pieces covering the clip start to end with no gaps or overlaps, each played
|
|
7
|
+
at its own constant speed (pitch-preserving audio, matching fit.py), then
|
|
8
|
+
concatenates them back together -- the classic "speed up, then slow way
|
|
9
|
+
down for the punch, then speed back up" edit, built from a few constant
|
|
10
|
+
segments rather than a continuous curve (which this tool does not attempt:
|
|
11
|
+
picking exactly where a ramp should ease in or out is a judgement call for
|
|
12
|
+
the calling agent, made concrete here as segment boundaries it supplies).
|
|
13
|
+
|
|
14
|
+
Examples:
|
|
15
|
+
python3 speedramp.py action.mp4 --segment 0-3:1.0 --segment 3-4:0.25 --segment 4-8:2.0
|
|
16
|
+
python3 speedramp.py clip.mp4 --segment 0-2:2.0 --segment 2-6:1.0
|
|
17
|
+
"""
|
|
18
|
+
import argparse
|
|
19
|
+
import sys
|
|
20
|
+
from typing import List, Tuple
|
|
21
|
+
|
|
22
|
+
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
|
|
23
|
+
|
|
24
|
+
MAX_SPEED = 20.0
|
|
25
|
+
MIN_SPEED = 0.05
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def atempo_chain(factor: float) -> str:
|
|
29
|
+
"""atempo accepts 0.5..100 per instance; chain for factors outside that range."""
|
|
30
|
+
parts: List[str] = []
|
|
31
|
+
remaining = factor
|
|
32
|
+
while remaining < 0.5:
|
|
33
|
+
parts.append("atempo=0.5")
|
|
34
|
+
remaining /= 0.5
|
|
35
|
+
while remaining > 100.0:
|
|
36
|
+
parts.append("atempo=100.0")
|
|
37
|
+
remaining /= 100.0
|
|
38
|
+
parts.append(f"atempo={remaining:.6f}")
|
|
39
|
+
return ",".join(parts)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def parse_segment(raw: str) -> Tuple[float, float, float]:
|
|
43
|
+
try:
|
|
44
|
+
span, factor_s = raw.split(":")
|
|
45
|
+
start_s, end_s = span.split("-")
|
|
46
|
+
start, end, factor = float(start_s), float(end_s), float(factor_s)
|
|
47
|
+
except ValueError:
|
|
48
|
+
die(f"--segment must look like START-END:FACTOR, got '{raw}'")
|
|
49
|
+
if end <= start:
|
|
50
|
+
die(f"--segment {raw}: END must be after START")
|
|
51
|
+
if not MIN_SPEED <= factor <= MAX_SPEED:
|
|
52
|
+
die(f"--segment {raw}: FACTOR must be {MIN_SPEED}..{MAX_SPEED}")
|
|
53
|
+
return start, end, factor
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main() -> int:
|
|
57
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
58
|
+
ap.add_argument("input")
|
|
59
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_ramp.<ext>)")
|
|
60
|
+
ap.add_argument("--segment", action="append", required=True, dest="segments",
|
|
61
|
+
help=f"START-END:FACTOR, repeatable; segments must cover 0..duration with no gaps or overlaps, in order. FACTOR is {MIN_SPEED}..{MAX_SPEED} (2.0 = twice as fast, 0.5 = half speed)")
|
|
62
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
63
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
64
|
+
add_common(ap)
|
|
65
|
+
args = ap.parse_args()
|
|
66
|
+
apply_common(args)
|
|
67
|
+
|
|
68
|
+
segments = [parse_segment(s) for s in args.segments]
|
|
69
|
+
segments.sort(key=lambda s: s[0])
|
|
70
|
+
meta = probe(args.input)
|
|
71
|
+
if not meta.get("video"):
|
|
72
|
+
die("input has no video stream")
|
|
73
|
+
dur = meta.get("duration") or 0.0
|
|
74
|
+
if abs(segments[0][0] - 0.0) > 0.01:
|
|
75
|
+
die(f"segments must start at 0, first segment starts at {segments[0][0]:g}")
|
|
76
|
+
if abs(segments[-1][1] - dur) > 0.5:
|
|
77
|
+
die(f"segments must cover the whole clip (0..{dur:.3f}), last segment ends at {segments[-1][1]:g}")
|
|
78
|
+
for i in range(len(segments) - 1):
|
|
79
|
+
if abs(segments[i][1] - segments[i + 1][0]) > 0.01:
|
|
80
|
+
die(f"segments must be contiguous with no gap/overlap: segment {i} ends at {segments[i][1]:g}, "
|
|
81
|
+
f"segment {i + 1} starts at {segments[i + 1][0]:g}")
|
|
82
|
+
has_audio = bool(meta.get("audio"))
|
|
83
|
+
output = args.output or default_output(args.input, "ramp")
|
|
84
|
+
|
|
85
|
+
vparts, aparts, labels = [], [], []
|
|
86
|
+
for i, (start, end, factor) in enumerate(segments):
|
|
87
|
+
vlabel, alabel = f"v{i}", f"a{i}"
|
|
88
|
+
end_expr = f"{end:.3f}" if i < len(segments) - 1 else None
|
|
89
|
+
trim = f"trim=start={start:.3f}" + (f":end={end_expr}" if end_expr else "")
|
|
90
|
+
vparts.append(f"[0:v]{trim},setpts=(PTS-STARTPTS)/{factor:.6f}[{vlabel}]")
|
|
91
|
+
labels.append(f"[{vlabel}]")
|
|
92
|
+
if has_audio:
|
|
93
|
+
atrim = f"atrim=start={start:.3f}" + (f":end={end_expr}" if end_expr else "")
|
|
94
|
+
aparts.append(f"[0:a]{atrim},asetpts=PTS-STARTPTS,{atempo_chain(factor)}[{alabel}]")
|
|
95
|
+
|
|
96
|
+
if has_audio:
|
|
97
|
+
concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(len(segments)))
|
|
98
|
+
fc = ";".join(vparts + aparts) + f";{concat_inputs}concat=n={len(segments)}:v=1:a=1[outv][outa]"
|
|
99
|
+
maps = ["-map", "[outv]", "-map", "[outa]"]
|
|
100
|
+
else:
|
|
101
|
+
concat_inputs = "".join(f"[v{i}]" for i in range(len(segments)))
|
|
102
|
+
fc = ";".join(vparts) + f";{concat_inputs}concat=n={len(segments)}:v=1:a=0[outv]"
|
|
103
|
+
maps = ["-map", "[outv]"]
|
|
104
|
+
|
|
105
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc] + maps
|
|
106
|
+
cmd += video_args(meta, args.crf, args.preset)
|
|
107
|
+
cmd += ["-fps_mode", "cfr", "-r", f"{meta['video'].get('fps') or 30.0:g}"]
|
|
108
|
+
if has_audio:
|
|
109
|
+
cmd += aac_args()
|
|
110
|
+
else:
|
|
111
|
+
cmd += ["-an"]
|
|
112
|
+
cmd.append(output)
|
|
113
|
+
run(cmd)
|
|
114
|
+
|
|
115
|
+
result = probe(output, role="output")
|
|
116
|
+
v = result["video"]
|
|
117
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {len(segments)} speed segments)")
|
|
118
|
+
emit(output)
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
sys.exit(main())
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract a flat (rectilinear) viewport from a 360/spherical video.
|
|
3
|
+
|
|
4
|
+
This wraps FFmpeg's v360 filter for the one job it is most often needed for:
|
|
5
|
+
turning an equirectangular (or other spherical-projection) source into an
|
|
6
|
+
ordinary flat video pointed at a chosen direction -- the same "look this way"
|
|
7
|
+
operation a VR headset or a 360 video player's viewport does, baked into a
|
|
8
|
+
real file. --yaw/--pitch/--roll aim the camera; --h-fov/--v-fov set how wide
|
|
9
|
+
the view is.
|
|
10
|
+
|
|
11
|
+
This tool does not decide WHERE to point the camera -- there is no subject
|
|
12
|
+
detection or tracking here, only the typed rotation/FOV you give it (see this
|
|
13
|
+
skill's design principles: it measures and transforms, it does not judge
|
|
14
|
+
"what's interesting" in a frame). For a shot that follows a moving subject,
|
|
15
|
+
call this once per keyframe viewpoint (or render a short segment per angle)
|
|
16
|
+
from outside this tool.
|
|
17
|
+
|
|
18
|
+
Nor does it detect whether an input actually IS a 360/spherical video --
|
|
19
|
+
probe.py's frame dimensions don't distinguish a 2:1 equirectangular capture
|
|
20
|
+
from an ordinary flat clip that happens to be that aspect ratio. Point
|
|
21
|
+
--input-projection at what the source actually is; wrong information here
|
|
22
|
+
produces a distorted or garbled output, not an error (a real limit of what a
|
|
23
|
+
frame's own pixels can prove about how they were projected -- ffmpeg's own
|
|
24
|
+
v360 filter has the same limit).
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
python3 sphere.py insta360.mp4 --yaw 0 --pitch 0 -o front.mp4
|
|
28
|
+
python3 sphere.py insta360.mp4 --yaw 90 --h-fov 100 --v-fov 70 -o right_wide.mp4
|
|
29
|
+
python3 sphere.py gopro_max.mp4 --input-projection fisheye --yaw -45 --pitch 10 -o angle.mp4
|
|
30
|
+
"""
|
|
31
|
+
import argparse
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
|
|
35
|
+
|
|
36
|
+
# v360's own AVOption names for the input projections real 360 cameras/exports actually
|
|
37
|
+
# produce (ffmpeg -h filter=v360 documents 24 total; this is the subset a caller is likely
|
|
38
|
+
# to have on hand, not the full list -- narrower, typed choices over an open string).
|
|
39
|
+
INPUT_PROJECTIONS = ["equirect", "fisheye", "dfisheye", "c3x2", "c6x1", "barrel", "cylindrical", "hequirect"]
|
|
40
|
+
INTERP_METHODS = ["nearest", "linear", "cubic", "lanczos", "spline16", "gaussian", "mitchell"]
|
|
41
|
+
STEREO_MODES = {"mono": "2d", "sbs": "sbs", "tb": "tb"}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def main() -> int:
|
|
45
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
46
|
+
ap.add_argument("input")
|
|
47
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_view.<ext>)")
|
|
48
|
+
ap.add_argument("--audio-stream", type=int, default=0,
|
|
49
|
+
help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
|
|
50
|
+
"audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
|
|
51
|
+
"the first track")
|
|
52
|
+
ap.add_argument("--input-projection", choices=INPUT_PROJECTIONS, default="equirect",
|
|
53
|
+
help="the source's own 360 projection (default equirect, the most common capture/export format)")
|
|
54
|
+
ap.add_argument("--stereo", choices=list(STEREO_MODES), default="mono",
|
|
55
|
+
help="input stereo packing: mono (default), sbs (side-by-side), tb (top-bottom) -- always flattened to a mono output")
|
|
56
|
+
aim = ap.add_argument_group("camera aim")
|
|
57
|
+
aim.add_argument("--yaw", type=float, default=0.0, help="left/right rotation in degrees, -180..180 (default 0, straight ahead)")
|
|
58
|
+
aim.add_argument("--pitch", type=float, default=0.0, help="up/down rotation in degrees, -180..180 (default 0, level)")
|
|
59
|
+
aim.add_argument("--roll", type=float, default=0.0, help="tilt/roll rotation in degrees, -180..180 (default 0)")
|
|
60
|
+
fov = ap.add_argument_group("field of view")
|
|
61
|
+
fov.add_argument("--h-fov", type=float, default=90.0, help="output horizontal field of view in degrees, 1..170 (default 90)")
|
|
62
|
+
fov.add_argument("--v-fov", type=float, default=60.0, help="output vertical field of view in degrees, 1..170 (default 60)")
|
|
63
|
+
out = ap.add_argument_group("output frame")
|
|
64
|
+
out.add_argument("--width", type=int, default=1920, help="output width in px, must be even (default 1920)")
|
|
65
|
+
out.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
|
|
66
|
+
out.add_argument("--interp", choices=INTERP_METHODS, default="lanczos", help="resampling method (default lanczos)")
|
|
67
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
68
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
69
|
+
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
70
|
+
add_common(ap)
|
|
71
|
+
args = ap.parse_args()
|
|
72
|
+
apply_common(args)
|
|
73
|
+
if args.fps is not None and args.fps <= 0:
|
|
74
|
+
die(f"--fps must be positive, got {args.fps:g}")
|
|
75
|
+
|
|
76
|
+
if not -180 <= args.yaw <= 180:
|
|
77
|
+
die(f"--yaw must be -180..180, got {args.yaw}")
|
|
78
|
+
if not -180 <= args.pitch <= 180:
|
|
79
|
+
die(f"--pitch must be -180..180, got {args.pitch}")
|
|
80
|
+
if not -180 <= args.roll <= 180:
|
|
81
|
+
die(f"--roll must be -180..180, got {args.roll}")
|
|
82
|
+
if not 1 <= args.h_fov <= 170:
|
|
83
|
+
die(f"--h-fov must be 1..170, got {args.h_fov}")
|
|
84
|
+
if not 1 <= args.v_fov <= 170:
|
|
85
|
+
die(f"--v-fov must be 1..170, got {args.v_fov}")
|
|
86
|
+
if args.width <= 0 or args.height <= 0:
|
|
87
|
+
die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
|
|
88
|
+
if args.width % 2 or args.height % 2:
|
|
89
|
+
die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
|
|
90
|
+
|
|
91
|
+
meta = probe(args.input)
|
|
92
|
+
if not meta.get("video"):
|
|
93
|
+
die("input has no video stream")
|
|
94
|
+
has_audio = bool(meta.get("audio"))
|
|
95
|
+
audio_streams = meta.get("audio_streams") or []
|
|
96
|
+
if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
|
|
97
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
98
|
+
if args.audio_stream and not audio_streams:
|
|
99
|
+
die("--audio-stream needs an input with audio streams")
|
|
100
|
+
output = args.output or default_output(args.input, "view")
|
|
101
|
+
|
|
102
|
+
v360 = (f"v360=input={args.input_projection}:output=rectilinear:"
|
|
103
|
+
f"in_stereo={STEREO_MODES[args.stereo]}:out_stereo=2d:"
|
|
104
|
+
f"yaw={args.yaw:g}:pitch={args.pitch:g}:roll={args.roll:g}:"
|
|
105
|
+
f"h_fov={args.h_fov:g}:v_fov={args.v_fov:g}:"
|
|
106
|
+
f"w={args.width}:h={args.height}:interp={args.interp}")
|
|
107
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", v360, "-map", "0:v:0"]
|
|
108
|
+
if has_audio:
|
|
109
|
+
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
|
110
|
+
cmd += video_args(meta, args.crf, args.preset)
|
|
111
|
+
cmd += cfr_args(meta, args.fps)
|
|
112
|
+
if has_audio:
|
|
113
|
+
cmd += aac_args()
|
|
114
|
+
else:
|
|
115
|
+
cmd += ["-an"]
|
|
116
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
117
|
+
|
|
118
|
+
result = probe(output, role="output")
|
|
119
|
+
v = result["video"]
|
|
120
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, yaw={args.yaw:g} pitch={args.pitch:g})")
|
|
121
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
sys.exit(main())
|