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/export.py
CHANGED
|
@@ -26,7 +26,7 @@ import sys
|
|
|
26
26
|
from pathlib import Path
|
|
27
27
|
from typing import Dict, List
|
|
28
28
|
|
|
29
|
-
from _common import STATE, add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run
|
|
29
|
+
from _common import STATE, add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color
|
|
30
30
|
|
|
31
31
|
PRESETS: Dict[str, Dict] = {
|
|
32
32
|
"youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
|
|
@@ -63,6 +63,7 @@ def main() -> int:
|
|
|
63
63
|
return 0
|
|
64
64
|
if not args.input or not args.preset:
|
|
65
65
|
die("input and --preset are required (or use --list)")
|
|
66
|
+
validate_color(args.pad_color, "--pad-color")
|
|
66
67
|
|
|
67
68
|
p = PRESETS[args.preset]
|
|
68
69
|
meta = probe(args.input)
|
package/scripts/fit.py
CHANGED
|
@@ -37,7 +37,7 @@ import sys
|
|
|
37
37
|
from fractions import Fraction
|
|
38
38
|
from typing import List
|
|
39
39
|
|
|
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, 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, validate_color, x264_args
|
|
41
41
|
|
|
42
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)}
|
|
43
43
|
|
|
@@ -114,6 +114,7 @@ def main() -> int:
|
|
|
114
114
|
die(f"--crop-x must be 0..1, got {args.crop_x}")
|
|
115
115
|
if not 0.0 <= args.crop_y <= 1.0:
|
|
116
116
|
die(f"--crop-y must be 0..1, got {args.crop_y}")
|
|
117
|
+
validate_color(args.pad_color, "--pad-color")
|
|
117
118
|
|
|
118
119
|
meta = probe(args.input)
|
|
119
120
|
if not meta.get("video"):
|
|
@@ -193,8 +194,18 @@ def main() -> int:
|
|
|
193
194
|
out_h = even(args.height)
|
|
194
195
|
out_w = even(out_h * ratio) if ratio else args.height
|
|
195
196
|
elif ratio and src_ratio:
|
|
196
|
-
|
|
197
|
-
|
|
197
|
+
# No explicit --width/--height: size the canvas to the new aspect without exceeding
|
|
198
|
+
# the source's own resolution in either dimension. A narrower/taller target than the
|
|
199
|
+
# source (e.g. 9:16 from a 16:9 source) must be bounded by the source's HEIGHT, not
|
|
200
|
+
# its width -- bounding by width there multiplies the height by src_ratio/ratio (a
|
|
201
|
+
# 1920x1080 source asked for 9:16 used to come out 1920x3414, a ~3.16x upscale in
|
|
202
|
+
# both fit=pad and fit=crop, entirely unrequested).
|
|
203
|
+
if ratio <= src_ratio:
|
|
204
|
+
out_h = even(sh)
|
|
205
|
+
out_w = even(out_h * ratio)
|
|
206
|
+
else:
|
|
207
|
+
out_w = even(sw)
|
|
208
|
+
out_h = even(out_w / ratio)
|
|
198
209
|
else:
|
|
199
210
|
out_w, out_h = even(sw), even(sh)
|
|
200
211
|
if args.fit == "crop":
|
|
@@ -225,15 +236,24 @@ def main() -> int:
|
|
|
225
236
|
cmd += aac_args()
|
|
226
237
|
else:
|
|
227
238
|
cmd += ["-an"]
|
|
228
|
-
cmd += post
|
|
229
|
-
|
|
239
|
+
cmd += post
|
|
240
|
+
if abs(factor - 1.0) > 1e-4:
|
|
241
|
+
# A subtitle/data stream stream-copied by run_keeping_subtitles keeps the source's
|
|
242
|
+
# original timestamps; --method speed retimes video (setpts) and audio (atempo) but has
|
|
243
|
+
# no equivalent way to retime a copied subtitle track, so it would desync from the
|
|
244
|
+
# now-faster/slower picture. Drop them here rather than ship a captions track that lies
|
|
245
|
+
# about when a line is spoken.
|
|
246
|
+
run(cmd + [output])
|
|
247
|
+
dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
|
|
248
|
+
else:
|
|
249
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
230
250
|
|
|
231
251
|
result = probe(output, role="output")
|
|
232
252
|
msg = f"wrote {output} ({result['duration']:.3f}s, {result['video']['width']}x{result['video']['height']})"
|
|
233
253
|
if abs(factor - 1.0) > 1e-4:
|
|
234
254
|
msg += f", speed {factor:.3f}x"
|
|
235
255
|
info(msg)
|
|
236
|
-
emit(output)
|
|
256
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
237
257
|
return 0
|
|
238
258
|
|
|
239
259
|
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Freeze on one frame for a number of seconds -- an end-card hold, a comedic beat, a pause.
|
|
3
|
+
|
|
4
|
+
--at is the timestamp to freeze (default: the last frame). --hold is how
|
|
5
|
+
long the freeze lasts. --mode insert (default) inserts the hold into the
|
|
6
|
+
clip at --at, pushing everything after it later by --hold seconds; --mode
|
|
7
|
+
extend only works with --at at (or past) the end of the clip and simply
|
|
8
|
+
makes the last frame last --hold seconds longer, without touching anything
|
|
9
|
+
earlier. Audio is silent during the held frame in --mode insert (there is
|
|
10
|
+
no source audio for a frozen moment that didn't exist before); --mode
|
|
11
|
+
extend has no audio to extend either, since it only makes sense at the
|
|
12
|
+
clip's end.
|
|
13
|
+
|
|
14
|
+
Examples:
|
|
15
|
+
python3 freeze.py interview.mp4 --hold 2 # hold the last frame 2s longer
|
|
16
|
+
python3 freeze.py sketch.mp4 --at 12.5 --hold 1.5 # 1.5s freeze inserted at 12.5s
|
|
17
|
+
python3 freeze.py outro.mp4 --hold 3 --mode extend # extend only the very end by 3s
|
|
18
|
+
"""
|
|
19
|
+
import argparse
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main() -> int:
|
|
26
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
27
|
+
ap.add_argument("input")
|
|
28
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_freeze.<ext>)")
|
|
29
|
+
ap.add_argument("--at", type=float, help="timestamp to freeze, in seconds (default: the last frame)")
|
|
30
|
+
ap.add_argument("--hold", type=float, required=True, help="how long the freeze lasts, in seconds")
|
|
31
|
+
ap.add_argument("--mode", choices=["insert", "extend"], default="insert",
|
|
32
|
+
help="insert (default): hold pushes the rest of the clip later; extend: only valid at/after the clip's end, makes the last frame last longer with nothing pushed")
|
|
33
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
34
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
35
|
+
add_common(ap)
|
|
36
|
+
args = ap.parse_args()
|
|
37
|
+
apply_common(args)
|
|
38
|
+
|
|
39
|
+
if args.hold <= 0:
|
|
40
|
+
die(f"--hold must be > 0, got {args.hold:g}")
|
|
41
|
+
|
|
42
|
+
meta = probe(args.input)
|
|
43
|
+
if not meta.get("video"):
|
|
44
|
+
die("input has no video stream")
|
|
45
|
+
dur = meta.get("duration") or 0.0
|
|
46
|
+
fps = meta["video"].get("fps") or 30.0
|
|
47
|
+
at = args.at if args.at is not None else dur
|
|
48
|
+
if at < 0 or at > dur:
|
|
49
|
+
die(f"--at {at:g} is outside the clip (0..{dur:.3f})")
|
|
50
|
+
if args.mode == "extend" and at < dur - 0.01:
|
|
51
|
+
die(f"--mode extend needs --at at or after the clip's end ({dur:.3f}), got {at:g}")
|
|
52
|
+
has_audio = bool(meta.get("audio"))
|
|
53
|
+
output = args.output or default_output(args.input, "freeze")
|
|
54
|
+
|
|
55
|
+
if args.mode == "extend":
|
|
56
|
+
# tpad's stop_duration clones the last frame for the given duration; no PTS surgery
|
|
57
|
+
# needed since it only ever appends past the real end.
|
|
58
|
+
vf = f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}"
|
|
59
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
|
|
60
|
+
if has_audio:
|
|
61
|
+
cmd += ["-map", "0:a:0?", "-af", f"apad=pad_dur={args.hold:.3f}"]
|
|
62
|
+
elif at == 0:
|
|
63
|
+
# A freeze at the very start has no preceding "head" segment to hold on to (the
|
|
64
|
+
# split/trim/concat approach below needs a non-empty head, which trim=end=0 can't give
|
|
65
|
+
# it -- ffmpeg fails filtering an empty stream). Symmetric to --mode extend at the other
|
|
66
|
+
# end: tpad's start_duration clones the *first* frame backwards instead.
|
|
67
|
+
vf = f"tpad=start_mode=clone:start_duration={args.hold:.3f}"
|
|
68
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
|
|
69
|
+
if has_audio:
|
|
70
|
+
cmd += ["-map", "0:a:0?", "-af", f"adelay={int(args.hold * 1000)}:all=1"]
|
|
71
|
+
else:
|
|
72
|
+
# freeze N frames at `at` by holding on that one source frame for --hold seconds, then
|
|
73
|
+
# resuming the rest of the clip: split the timeline at `at`, freeze-frame the first
|
|
74
|
+
# part's last frame for --hold seconds via tpad, concat with the remainder.
|
|
75
|
+
n = max(1, round(args.hold * fps))
|
|
76
|
+
# The video hold length is rounded to a whole number of frames (n / fps), but the audio
|
|
77
|
+
# side used to pad by the raw --hold value -- up to half a frame duration off from what
|
|
78
|
+
# the video actually holds for, a permanent A/V drift from this point on. Pad audio by
|
|
79
|
+
# the same, frame-rounded duration the video actually gets.
|
|
80
|
+
actual_hold = n / fps
|
|
81
|
+
vf = (f"[0:v]split[a][b];[a]trim=end={at:.3f},setpts=PTS-STARTPTS[head];"
|
|
82
|
+
f"[b]trim=start={at:.3f},setpts=PTS-STARTPTS[tail];"
|
|
83
|
+
f"[head]tpad=stop_mode=clone:stop={n}[frozen];"
|
|
84
|
+
f"[frozen][tail]concat=n=2:v=1:a=0[outv]")
|
|
85
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", vf, "-map", "[outv]"]
|
|
86
|
+
if has_audio:
|
|
87
|
+
af = (f"[0:a]asplit[aa][ab];[aa]atrim=end={at:.3f},asetpts=PTS-STARTPTS[ahead];"
|
|
88
|
+
f"[ab]atrim=start={at:.3f},asetpts=PTS-STARTPTS[atail];"
|
|
89
|
+
f"[ahead]apad=pad_dur={actual_hold:.6f}[afrozen];"
|
|
90
|
+
f"[afrozen][atail]concat=n=2:v=0:a=1[outa]")
|
|
91
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", f"{vf};{af}", "-map", "[outv]", "-map", "[outa]"]
|
|
92
|
+
cmd += video_args(meta, args.crf, args.preset)
|
|
93
|
+
cmd += cfr_args(meta)
|
|
94
|
+
if has_audio:
|
|
95
|
+
cmd += aac_args()
|
|
96
|
+
else:
|
|
97
|
+
cmd += ["-an"]
|
|
98
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
99
|
+
|
|
100
|
+
result = probe(output, role="output")
|
|
101
|
+
v = result["video"]
|
|
102
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, froze {args.hold:g}s at {at:g}s, mode={args.mode})")
|
|
103
|
+
emit(output, dropped_non_av_streams=dropped_streams)
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
sys.exit(main())
|
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,7 +33,10 @@ 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
|
-
|
|
36
|
+
resolved = default_font_file(font or brand.get("font", "DejaVu Sans"))
|
|
37
|
+
if resolved:
|
|
38
|
+
return f"fontfile={escape_filter_path(resolved)}"
|
|
39
|
+
return f"font='{escape_drawtext(font or brand.get('font', 'DejaVu Sans'))}'"
|
|
37
40
|
|
|
38
41
|
|
|
39
42
|
def main() -> int:
|
|
@@ -163,11 +166,10 @@ def main() -> int:
|
|
|
163
166
|
cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
|
|
164
167
|
cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
165
168
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
166
|
-
cmd
|
|
167
|
-
run(cmd)
|
|
169
|
+
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
168
170
|
r = probe(output, role="output")
|
|
169
171
|
info(f"wrote {output} ({r['duration']:.3f}s, {args.template})")
|
|
170
|
-
emit(output, template=args.template)
|
|
172
|
+
emit(output, template=args.template, dropped_non_av_streams=dropped_streams)
|
|
171
173
|
return 0
|
|
172
174
|
|
|
173
175
|
|
package/scripts/grid.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Composite several video clips into one COLSxROWS grid, e.g. a 4x2 wall of
|
|
3
|
+
takes, angles, or A/B renders side by side.
|
|
4
|
+
|
|
5
|
+
Every cell is letterboxed (not stretched) to a common --cell-width/--cell-height
|
|
6
|
+
so clips of different aspect ratios and resolutions line up cleanly. By default
|
|
7
|
+
each cell gets its source filename (extension stripped) burnt into the bottom
|
|
8
|
+
right corner -- --label none turns that off. The grid has no audio unless
|
|
9
|
+
--audio-from picks one input's track to carry through; mixing every clip's
|
|
10
|
+
audio together is rarely what a comparison grid is for, so this tool never
|
|
11
|
+
does it silently.
|
|
12
|
+
|
|
13
|
+
The grid runs only as long as its shortest clip by default, or is padded to
|
|
14
|
+
the longest clip's duration with --pad (each shorter cell holds its last
|
|
15
|
+
frame, and --audio-from's track is padded with silence, out to that length);
|
|
16
|
+
a mismatched frame rate across sources is conformed to --fps first so cells
|
|
17
|
+
stay in sync.
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
python3 grid.py take1.mp4 take2.mp4 take3.mp4 take4.mp4 take5.mp4 take6.mp4 take7.mp4 take8.mp4 --cols 4 --rows 2
|
|
21
|
+
python3 grid.py a.mp4 b.mp4 c.mp4 d.mp4 --cols 2 --rows 2 --label none -o compare.mp4
|
|
22
|
+
python3 grid.py cam1.mp4 cam2.mp4 --cols 2 --rows 1 --audio-from 0 --pad
|
|
23
|
+
"""
|
|
24
|
+
import argparse
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_font_file, default_output, die, emit, \
|
|
29
|
+
escape_drawtext, escape_filter_path, ffmpeg_base, info, probe, run, validate_color, video_args
|
|
30
|
+
|
|
31
|
+
LABEL_MARGIN = 10
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main() -> int:
|
|
35
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
36
|
+
ap.add_argument("inputs", nargs="+", help="video clips, filled into the grid left-to-right, top-to-bottom")
|
|
37
|
+
ap.add_argument("-o", "--output", help="output file (default: <first name>_grid.<ext>)")
|
|
38
|
+
ap.add_argument("--cols", type=int, required=True, help="grid columns")
|
|
39
|
+
ap.add_argument("--rows", type=int, required=True, help="grid rows")
|
|
40
|
+
ap.add_argument("--cell-width", type=int, default=480, help="each cell's width in px, must be even (default 480)")
|
|
41
|
+
ap.add_argument("--cell-height", type=int, default=270, help="each cell's height in px, must be even (default 270)")
|
|
42
|
+
ap.add_argument("--fps", type=float, default=30.0, help="output frame rate every cell is conformed to (default 30)")
|
|
43
|
+
ap.add_argument("--label", choices=["auto", "none"], default="auto",
|
|
44
|
+
help="auto (default): burn each cell's filename (extension stripped) into its bottom-right corner; none: no label")
|
|
45
|
+
ap.add_argument("--font", default="DejaVu Sans", help="label font (fontconfig family name, default DejaVu Sans)")
|
|
46
|
+
ap.add_argument("--font-size", type=int, default=16, help="label font size (default 16)")
|
|
47
|
+
ap.add_argument("--font-color", default="white", help="label text colour (default white)")
|
|
48
|
+
ap.add_argument("--pad", action="store_true", help="hold each shorter cell's last frame (and pad --audio-from's track with silence) out to the longest clip's duration, instead of stopping at the shortest")
|
|
49
|
+
ap.add_argument("--audio-from", type=int, help="0-based index into inputs to take audio from (default: no audio)")
|
|
50
|
+
ap.add_argument("--gap", type=int, default=0, help="gap between cells in px, must be even (default 0, cells touch)")
|
|
51
|
+
ap.add_argument("--background", default="black", help="colour of the gap/pad borders (default black)")
|
|
52
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
53
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
54
|
+
add_common(ap)
|
|
55
|
+
args = ap.parse_args()
|
|
56
|
+
apply_common(args)
|
|
57
|
+
|
|
58
|
+
n = args.cols * args.rows
|
|
59
|
+
if args.cols <= 0 or args.rows <= 0:
|
|
60
|
+
die(f"--cols/--rows must be > 0, got cols={args.cols} rows={args.rows}")
|
|
61
|
+
if len(args.inputs) != n:
|
|
62
|
+
die(f"--cols {args.cols} --rows {args.rows} needs exactly {n} inputs, got {len(args.inputs)}")
|
|
63
|
+
if args.cell_width <= 0 or args.cell_height <= 0:
|
|
64
|
+
die(f"--cell-width/--cell-height must be > 0, got width={args.cell_width} height={args.cell_height}")
|
|
65
|
+
if args.cell_width % 2 or args.cell_height % 2:
|
|
66
|
+
die(f"--cell-width/--cell-height must be even (4:2:0 chroma), got width={args.cell_width} height={args.cell_height}")
|
|
67
|
+
if args.fps <= 0:
|
|
68
|
+
die(f"--fps must be > 0, got {args.fps:g}")
|
|
69
|
+
if args.gap < 0 or args.gap % 2:
|
|
70
|
+
die(f"--gap must be >= 0 and even, got {args.gap}")
|
|
71
|
+
validate_color(args.background, "--background")
|
|
72
|
+
if args.font_color:
|
|
73
|
+
validate_color(args.font_color, "--font-color")
|
|
74
|
+
if args.audio_from is not None and not 0 <= args.audio_from < n:
|
|
75
|
+
die(f"--audio-from {args.audio_from}: must be an input index 0..{n - 1}")
|
|
76
|
+
|
|
77
|
+
metas = [probe(p) for p in args.inputs]
|
|
78
|
+
for p, m in zip(args.inputs, metas):
|
|
79
|
+
if not m.get("video"):
|
|
80
|
+
die(f"{p}: input has no video stream")
|
|
81
|
+
if args.audio_from is not None and not metas[args.audio_from].get("audio"):
|
|
82
|
+
die(f"--audio-from {args.audio_from}: {args.inputs[args.audio_from]} has no audio stream")
|
|
83
|
+
|
|
84
|
+
durations = [m.get("duration") or 0.0 for m in metas]
|
|
85
|
+
target_duration = max(durations) if args.pad else min(durations)
|
|
86
|
+
|
|
87
|
+
font_file = default_font_file(args.font)
|
|
88
|
+
output = args.output or default_output(args.inputs[0], "grid")
|
|
89
|
+
|
|
90
|
+
cmd = ffmpeg_base()
|
|
91
|
+
for p in args.inputs:
|
|
92
|
+
cmd += ["-i", p]
|
|
93
|
+
|
|
94
|
+
parts = []
|
|
95
|
+
for i, p in enumerate(args.inputs):
|
|
96
|
+
chain = [
|
|
97
|
+
f"fps={args.fps:g}",
|
|
98
|
+
f"scale={args.cell_width}:{args.cell_height}:force_original_aspect_ratio=decrease",
|
|
99
|
+
f"pad={args.cell_width}:{args.cell_height}:(ow-iw)/2:(oh-ih)/2:color={args.background}",
|
|
100
|
+
"setsar=1",
|
|
101
|
+
]
|
|
102
|
+
if args.pad and durations[i] < target_duration:
|
|
103
|
+
chain.append(f"tpad=stop_mode=clone:stop_duration={target_duration - durations[i]:.3f}")
|
|
104
|
+
elif not args.pad:
|
|
105
|
+
chain.append(f"trim=duration={target_duration:.3f}")
|
|
106
|
+
if args.label == "auto":
|
|
107
|
+
stem = os.path.splitext(os.path.basename(p))[0]
|
|
108
|
+
font_opt = f"fontfile={escape_filter_path(font_file)}" if font_file else f"font='{escape_drawtext(args.font)}'"
|
|
109
|
+
chain.append(f"drawtext=text='{escape_drawtext(stem)}':{font_opt}:fontsize={args.font_size}:"
|
|
110
|
+
f"fontcolor={args.font_color}:x=w-tw-{LABEL_MARGIN}:y=h-th-{LABEL_MARGIN}:"
|
|
111
|
+
f"box=1:boxcolor=black@0.5:boxborderw=4")
|
|
112
|
+
parts.append(f"[{i}:v]{','.join(chain)}[v{i}]")
|
|
113
|
+
|
|
114
|
+
cw, ch = args.cell_width + args.gap, args.cell_height + args.gap
|
|
115
|
+
layout = "|".join(f"{c * cw}_{r * ch}" for r in range(args.rows) for c in range(args.cols))
|
|
116
|
+
parts.append("".join(f"[v{i}]" for i in range(n)) + f"xstack=inputs={n}:layout={layout}:fill={args.background}[out]")
|
|
117
|
+
if args.audio_from is not None and args.pad and durations[args.audio_from] < target_duration:
|
|
118
|
+
parts.append(f"[{args.audio_from}:a:0]apad,atrim=duration={target_duration:.3f}[aout]")
|
|
119
|
+
cmd += ["-filter_complex", ";".join(parts), "-map", "[out]"]
|
|
120
|
+
|
|
121
|
+
if args.audio_from is not None:
|
|
122
|
+
audio_source = "[aout]" if (args.pad and durations[args.audio_from] < target_duration) else f"{args.audio_from}:a:0"
|
|
123
|
+
cmd += ["-map", audio_source]
|
|
124
|
+
cmd += video_args(None, args.crf, args.preset)
|
|
125
|
+
cmd += cfr_args(None, args.fps)
|
|
126
|
+
if args.audio_from is not None:
|
|
127
|
+
cmd += aac_args()
|
|
128
|
+
else:
|
|
129
|
+
cmd += ["-an"]
|
|
130
|
+
cmd += ["-t", f"{target_duration:.3f}", output]
|
|
131
|
+
run(cmd)
|
|
132
|
+
|
|
133
|
+
result = probe(output, role="output")
|
|
134
|
+
v = result["video"]
|
|
135
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {args.cols}x{args.rows} grid, "
|
|
136
|
+
f"{n} clips, {'padded to longest' if args.pad else 'stopped at shortest'})")
|
|
137
|
+
emit(output, cols=args.cols, rows=args.rows, clips=n)
|
|
138
|
+
return 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
if __name__ == "__main__":
|
|
142
|
+
sys.exit(main())
|
package/scripts/join.py
CHANGED
|
@@ -23,7 +23,7 @@ import argparse
|
|
|
23
23
|
import sys
|
|
24
24
|
from typing import List
|
|
25
25
|
|
|
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
|
|
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, validate_color
|
|
27
27
|
|
|
28
28
|
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
29
29
|
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
@@ -101,9 +101,12 @@ def main() -> int:
|
|
|
101
101
|
add_common(ap)
|
|
102
102
|
args = ap.parse_args()
|
|
103
103
|
apply_common(args)
|
|
104
|
+
if args.fps is not None and args.fps <= 0:
|
|
105
|
+
die(f"--fps must be positive, got {args.fps:g}")
|
|
104
106
|
|
|
105
107
|
if len(args.inputs) < 2:
|
|
106
108
|
die("give at least two clips")
|
|
109
|
+
validate_color(args.pad_color, "--pad-color")
|
|
107
110
|
metas = [probe(p) for p in args.inputs]
|
|
108
111
|
if all(not m.get("video") for m in metas):
|
|
109
112
|
for p, m in zip(args.inputs, metas):
|
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/loop.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Loop a clip a number of times, or to a target duration.
|
|
3
|
+
|
|
4
|
+
For a background loop, an ambient bed, or filling a fixed slot length with
|
|
5
|
+
a short clip. --times repeats the whole clip that many times back to back;
|
|
6
|
+
--duration instead loops (and, on the last repeat, trims) to hit an exact
|
|
7
|
+
target length. Audio loops along with the video when present. This tool
|
|
8
|
+
does not smooth the loop point (no crossfade at the seam) -- a clip that
|
|
9
|
+
doesn't already loop cleanly will show a visible cut/pop at each repeat;
|
|
10
|
+
that's a judgement call about the source material, not something a --times
|
|
11
|
+
or --duration flag can fix.
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
python3 loop.py bg_loop.mp4 --times 3
|
|
15
|
+
python3 loop.py texture.mp4 --duration 30
|
|
16
|
+
"""
|
|
17
|
+
import argparse
|
|
18
|
+
import math
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main() -> int:
|
|
25
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
26
|
+
ap.add_argument("input")
|
|
27
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_loop.<ext>)")
|
|
28
|
+
group = ap.add_mutually_exclusive_group(required=True)
|
|
29
|
+
group.add_argument("--times", type=int, help="repeat the whole clip this many times (2 = original + 1 repeat)")
|
|
30
|
+
group.add_argument("--duration", help="loop (and trim the last repeat) to hit exactly this target duration (seconds or mm:ss)")
|
|
31
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
32
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
33
|
+
add_common(ap)
|
|
34
|
+
args = ap.parse_args()
|
|
35
|
+
apply_common(args)
|
|
36
|
+
|
|
37
|
+
meta = probe(args.input)
|
|
38
|
+
if not meta.get("video"):
|
|
39
|
+
die("input has no video stream")
|
|
40
|
+
src_dur = meta.get("duration") or 0.0
|
|
41
|
+
if src_dur <= 0:
|
|
42
|
+
die("input has no measurable duration to loop")
|
|
43
|
+
has_audio = bool(meta.get("audio"))
|
|
44
|
+
output = args.output or default_output(args.input, "loop")
|
|
45
|
+
|
|
46
|
+
if args.times is not None:
|
|
47
|
+
if args.times < 2:
|
|
48
|
+
die(f"--times must be >= 2 (1 is just the original clip), got {args.times}")
|
|
49
|
+
target = None
|
|
50
|
+
stream_loop = args.times - 1
|
|
51
|
+
else:
|
|
52
|
+
target = parse_time(args.duration)
|
|
53
|
+
if target <= src_dur:
|
|
54
|
+
die(f"--duration ({target:g}s) must be longer than the source ({src_dur:.3f}s) -- use cut.py to trim instead")
|
|
55
|
+
stream_loop = math.ceil(target / src_dur) - 1
|
|
56
|
+
|
|
57
|
+
# -stream_loop repeats the whole input read (video and audio together) at the demuxer level
|
|
58
|
+
# -- exact and lossless-in-intent for a re-encode target, unlike a filter-graph loop that
|
|
59
|
+
# would need separate video/audio filters kept in lockstep by hand.
|
|
60
|
+
cmd = ffmpeg_base() + ["-stream_loop", str(stream_loop), "-i", args.input]
|
|
61
|
+
if target is not None:
|
|
62
|
+
cmd += ["-t", f"{target:.3f}"]
|
|
63
|
+
cmd += video_args(meta, args.crf, args.preset)
|
|
64
|
+
cmd += cfr_args(meta)
|
|
65
|
+
if has_audio:
|
|
66
|
+
cmd += aac_args()
|
|
67
|
+
else:
|
|
68
|
+
cmd += ["-an"]
|
|
69
|
+
cmd.append(output)
|
|
70
|
+
run(cmd)
|
|
71
|
+
|
|
72
|
+
result = probe(output, role="output")
|
|
73
|
+
v = result["video"]
|
|
74
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, source {src_dur:.3f}s looped)")
|
|
75
|
+
emit(output)
|
|
76
|
+
return 0
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
if __name__ == "__main__":
|
|
80
|
+
sys.exit(main())
|
package/scripts/multicam.py
CHANGED
|
@@ -73,6 +73,8 @@ def main() -> int:
|
|
|
73
73
|
add_common(ap)
|
|
74
74
|
args = ap.parse_args()
|
|
75
75
|
apply_common(args)
|
|
76
|
+
if args.fps is not None and args.fps <= 0:
|
|
77
|
+
die(f"--fps must be positive, got {args.fps:g}")
|
|
76
78
|
|
|
77
79
|
n = len(args.inputs)
|
|
78
80
|
if n < 2:
|
|
@@ -134,6 +136,8 @@ def main() -> int:
|
|
|
134
136
|
if args.switch:
|
|
135
137
|
cuts = parse_switch(args.switch, n)
|
|
136
138
|
elif args.auto:
|
|
139
|
+
if args.auto <= 0:
|
|
140
|
+
die(f"--auto must be a positive number of seconds, got {args.auto:g}")
|
|
137
141
|
cams = [i for i, m in enumerate(metas) if m.get("video")]
|
|
138
142
|
cuts, t, k = [], 0.0, 0
|
|
139
143
|
while t < ref_dur:
|
|
@@ -184,13 +188,17 @@ def main() -> int:
|
|
|
184
188
|
parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
|
|
185
189
|
a = args.audio
|
|
186
190
|
a_start = -offsets[a] if offsets[a] < 0 else 0.0
|
|
187
|
-
|
|
191
|
+
# a_start is a trim point in the source's own, pre-drift-correction time axis, so it must be
|
|
192
|
+
# applied before asetrate/aresample rescale that axis -- otherwise the trim lands at the wrong
|
|
193
|
+
# point once the stream's timebase has already been stretched/compressed by the drift ratio
|
|
194
|
+
# (mirrors sync.py, which seeks with -ss, an input-level operation, before its drift_af filters).
|
|
195
|
+
afx = [f"atrim=start={a_start:.4f}", "asetpts=PTS-STARTPTS"]
|
|
188
196
|
if abs(ratios[a] - 1.0) > 1e-7:
|
|
189
197
|
sr = metas[a]["audio"].get("sample_rate") or 48000
|
|
190
198
|
afx += [f"asetrate={sr * ratios[a]:.6f}", f"aresample={sr}"]
|
|
191
199
|
if offsets[a] > 0:
|
|
192
200
|
afx.append(f"adelay={int(round(offsets[a] * 1000))}:all=1")
|
|
193
|
-
afx += [f"atrim=
|
|
201
|
+
afx += [f"atrim=0:{ref_dur:.3f}", "aformat=sample_rates=48000:channel_layouts=stereo"]
|
|
194
202
|
parts.append(f"[{a}:a]{','.join(afx)}[aout]")
|
|
195
203
|
|
|
196
204
|
output = args.output or default_output(args.inputs[0], "multicam", "mp4")
|