ffmpeg-skill 0.9.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +315 -122
- package/SKILL.md +115 -18
- package/bin/install.js +16 -2
- package/mcp/server.py +2 -0
- package/package.json +15 -3
- package/references/ci-platform-pitfalls.md +111 -0
- package/references/process-pitfalls.md +85 -0
- package/references/scripts.md +122 -11
- package/scripts/_common.py +247 -15
- package/scripts/_contract.py +420 -48
- package/scripts/audio.py +101 -8
- package/scripts/background.py +73 -0
- package/scripts/caption.py +97 -18
- package/scripts/check.py +21 -7
- package/scripts/color.py +104 -13
- package/scripts/crop.py +79 -0
- package/scripts/cut.py +85 -11
- package/scripts/export.py +16 -7
- package/scripts/fit.py +76 -12
- package/scripts/graphics.py +12 -3
- package/scripts/insert.py +128 -0
- package/scripts/join.py +88 -8
- package/scripts/loudness.py +3 -3
- package/scripts/multicam.py +11 -1
- package/scripts/overlay.py +64 -5
- package/scripts/proxy.py +82 -0
- package/scripts/render.py +13 -2
- package/scripts/reverse.py +56 -0
- package/scripts/scenes.py +15 -3
- package/scripts/sequence.py +124 -0
- package/scripts/silence.py +2 -2
- package/scripts/stabilize.py +83 -0
- package/scripts/sync.py +9 -1
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.cpython-311.pyc +0 -0
- package/scripts/__pycache__/report.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
package/scripts/overlay.py
CHANGED
|
@@ -5,11 +5,20 @@ opacity and fade in/out.
|
|
|
5
5
|
Positions: top-left, top, top-right, left, center, right, bottom-left, bottom,
|
|
6
6
|
bottom-right, or explicit "X,Y" pixels (negative counts from the far edge).
|
|
7
7
|
|
|
8
|
+
--video composites a second VIDEO as a picture-in-picture layer (position,
|
|
9
|
+
scale, opacity, time-range -- same knobs as --image), instead of a still
|
|
10
|
+
image or text. Only the main input's audio is kept; the PiP layer's own
|
|
11
|
+
audio track, if any, is dropped -- mixing two audio tracks is a job for
|
|
12
|
+
audio.py, not this tool. --chromakey COLOR (with --video) turns that colour
|
|
13
|
+
transparent first (green-screen removal) before compositing.
|
|
14
|
+
|
|
8
15
|
Examples:
|
|
9
16
|
python3 overlay.py input.mp4 --image logo.png --position top-right --scale 200 --opacity 0.8
|
|
10
17
|
python3 overlay.py input.mp4 --image lower_third.png --position bottom-left --start 2 --end 8 --fade 0.5
|
|
11
18
|
python3 overlay.py input.mp4 --text "Episode 12" --position bottom --font-size 48 --start 1 --end 5 --fade 0.3
|
|
12
19
|
python3 overlay.py input.mp4 --text "こんにちは" --font-file /path/NotoSansCJK-Bold.ttc --box
|
|
20
|
+
python3 overlay.py input.mp4 --video webcam.mp4 --position bottom-right --scale 480 --opacity 0.9
|
|
21
|
+
python3 overlay.py bg.mp4 --video greenscreen.mp4 --chromakey 0x00ff00 --chromakey-similarity 0.15
|
|
13
22
|
"""
|
|
14
23
|
import argparse
|
|
15
24
|
import sys
|
|
@@ -76,10 +85,19 @@ def main() -> int:
|
|
|
76
85
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
77
86
|
ap.add_argument("input")
|
|
78
87
|
ap.add_argument("-o", "--output", help="output file (default: <name>_overlay.<ext>)")
|
|
88
|
+
ap.add_argument("--audio-stream", type=int, default=0,
|
|
89
|
+
help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
|
|
90
|
+
"audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
|
|
91
|
+
"the first track, same as leaving it unset always did")
|
|
79
92
|
src = ap.add_mutually_exclusive_group()
|
|
80
93
|
src.add_argument("--image", help="PNG/JPG (alpha respected) to composite")
|
|
81
94
|
src.add_argument("--text", help="text to draw (drawtext)")
|
|
82
95
|
src.add_argument("--logo", action="store_true", help="composite the brand logo from --brand (position/scale/opacity from brand.json)")
|
|
96
|
+
src.add_argument("--video", help="a second video to composite as a picture-in-picture layer")
|
|
97
|
+
ck = ap.add_argument_group("chroma key (with --video)")
|
|
98
|
+
ck.add_argument("--chromakey", help="colour to key out (green-screen removal), e.g. 0x00ff00 or green")
|
|
99
|
+
ck.add_argument("--chromakey-similarity", type=float, default=0.15, help="how close a pixel must be to --chromakey to become transparent, 0..1 (default 0.15)")
|
|
100
|
+
ck.add_argument("--chromakey-blend", type=float, default=0.05, help="soften the key edge, 0..1 (default 0.05)")
|
|
83
101
|
ap.add_argument("--brand", help="brand.json (logo, font, colours, safe margin)")
|
|
84
102
|
ap.add_argument("--position", default="top-right", help="named position or X,Y (default top-right)")
|
|
85
103
|
ap.add_argument("--margin", type=int, default=24, help="margin from the edges in px (default 24)")
|
|
@@ -117,8 +135,8 @@ def main() -> int:
|
|
|
117
135
|
args.scale = int(brand.get("logo_scale", 160))
|
|
118
136
|
if args.opacity == 1.0:
|
|
119
137
|
args.opacity = float(brand.get("logo_opacity", 1.0))
|
|
120
|
-
if not (args.image or args.text):
|
|
121
|
-
die("give --image, --text or --
|
|
138
|
+
if not (args.image or args.text or args.video):
|
|
139
|
+
die("give --image, --text, --logo or --video")
|
|
122
140
|
if args.brand:
|
|
123
141
|
if args.margin == ap.get_default("margin"):
|
|
124
142
|
args.margin = int(brand.get("safe_margin", args.margin))
|
|
@@ -129,6 +147,11 @@ def main() -> int:
|
|
|
129
147
|
meta = probe(args.input)
|
|
130
148
|
if not meta.get("video"):
|
|
131
149
|
die("input has no video stream")
|
|
150
|
+
audio_streams = meta.get("audio_streams") or []
|
|
151
|
+
if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
|
|
152
|
+
die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
|
|
153
|
+
if args.audio_stream and not audio_streams:
|
|
154
|
+
die("--audio-stream needs an input with audio streams")
|
|
132
155
|
vw = meta["video"]["width"]
|
|
133
156
|
start = parse_time(args.start) if args.start else None
|
|
134
157
|
end = parse_time(args.end) if args.end else None
|
|
@@ -136,6 +159,12 @@ def main() -> int:
|
|
|
136
159
|
die("--end must be after --start")
|
|
137
160
|
if not 0 <= args.opacity <= 1:
|
|
138
161
|
die("--opacity must be within 0..1")
|
|
162
|
+
if args.chromakey and not args.video:
|
|
163
|
+
die("--chromakey needs --video")
|
|
164
|
+
if not 0 < args.chromakey_similarity <= 1:
|
|
165
|
+
die("--chromakey-similarity must be within (0, 1]")
|
|
166
|
+
if not 0 <= args.chromakey_blend <= 1:
|
|
167
|
+
die("--chromakey-blend must be within 0..1")
|
|
139
168
|
|
|
140
169
|
output = args.output or default_output(args.input, "overlay")
|
|
141
170
|
enable = enable_expr(start, end)
|
|
@@ -164,7 +193,35 @@ def main() -> int:
|
|
|
164
193
|
# -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
|
|
165
194
|
cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
|
|
166
195
|
fc = f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS[ov];[0:v][ov]{ov}[out]"
|
|
167
|
-
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", "0:a:
|
|
196
|
+
cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?", "-shortest"]
|
|
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.
|
|
200
|
+
if meta.get("duration"):
|
|
201
|
+
cmd += ["-t", f"{meta['duration']:.3f}"]
|
|
202
|
+
elif args.video:
|
|
203
|
+
pip_meta = probe(args.video)
|
|
204
|
+
if not pip_meta.get("video"):
|
|
205
|
+
die(f"--video {args.video} has no video stream")
|
|
206
|
+
chain = []
|
|
207
|
+
if args.scale_percent:
|
|
208
|
+
chain.append(f"scale={int(vw * args.scale_percent / 100)}:-2")
|
|
209
|
+
elif args.scale:
|
|
210
|
+
chain.append(f"scale={args.scale}:-2")
|
|
211
|
+
chain.append("format=yuva420p")
|
|
212
|
+
if args.chromakey:
|
|
213
|
+
chain.append(f"chromakey={args.chromakey}:{args.chromakey_similarity:g}:{args.chromakey_blend:g}")
|
|
214
|
+
if args.opacity < 1:
|
|
215
|
+
chain.append(f"colorchannelmixer=aa={args.opacity:g}")
|
|
216
|
+
x, y = position_exprs(args.position, args.margin, text_mode=False)
|
|
217
|
+
ov = f"overlay={x}:{y}:format=auto"
|
|
218
|
+
if enable:
|
|
219
|
+
ov += f":enable='{enable}'"
|
|
220
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-i", args.video]
|
|
221
|
+
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}?", "-shortest"]
|
|
223
|
+
if meta.get("duration"):
|
|
224
|
+
cmd += ["-t", f"{meta['duration']:.3f}"]
|
|
168
225
|
else:
|
|
169
226
|
x, y = position_exprs(args.position, args.margin, text_mode=True)
|
|
170
227
|
opts = [f"text='{escape_drawtext(args.text)}'", f"fontsize={args.font_size}", f"x={x}", f"y={y}",
|
|
@@ -182,14 +239,16 @@ def main() -> int:
|
|
|
182
239
|
opts += ["box=1", f"boxcolor={args.box_color}", "boxborderw=12"]
|
|
183
240
|
if enable:
|
|
184
241
|
opts.append(f"enable='{enable}'")
|
|
185
|
-
cmd += ["-vf", "drawtext=" + ":".join(opts)]
|
|
242
|
+
cmd += ["-vf", "drawtext=" + ":".join(opts), "-map", "0:v:0"]
|
|
243
|
+
if meta.get("audio"):
|
|
244
|
+
cmd += ["-map", f"0:a:{args.audio_stream}"]
|
|
186
245
|
|
|
187
246
|
cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
188
247
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
189
248
|
cmd.append(output)
|
|
190
249
|
run(cmd)
|
|
191
250
|
if not STATE.dry_run:
|
|
192
|
-
result = probe(output)
|
|
251
|
+
result = probe(output, role="output")
|
|
193
252
|
info(f"wrote {output} ({result['duration']:.3f}s)")
|
|
194
253
|
emit(output)
|
|
195
254
|
return 0
|
package/scripts/proxy.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate a small, low-bitrate proxy of a video: cheap for a machine to decode,
|
|
3
|
+
not meant for delivery. Intended for downstream AI analysis, preview or
|
|
4
|
+
editing-decision workflows that only need to look at (or feed a model) a
|
|
5
|
+
much smaller stand-in for the original.
|
|
6
|
+
|
|
7
|
+
Resizes to --width (default 640px, height follows the source aspect) or by
|
|
8
|
+
--scale factor, re-encodes at a proxy-grade --crf (default 30 - well above any
|
|
9
|
+
delivery preset's 18-24 in export.py, since a proxy trades visual quality for
|
|
10
|
+
size and speed), and always uses the fastest x264/x265 preset. Keeps the
|
|
11
|
+
source's own dynamic range (an HDR source proxies to HEVC10, same as every
|
|
12
|
+
other re-encoding tool here) rather than guessing whether SDR is wanted -
|
|
13
|
+
run color.py --to-sdr first if it is.
|
|
14
|
+
|
|
15
|
+
This tool only executes the spec it is given: it does not decide which asset
|
|
16
|
+
should be proxied, what resolution or bitrate is "right" for a given
|
|
17
|
+
downstream use, or what the proxy will be used for - those are the calling
|
|
18
|
+
agent's call.
|
|
19
|
+
|
|
20
|
+
Examples:
|
|
21
|
+
python3 proxy.py input.mov # 640px wide, CRF 30, keeps audio
|
|
22
|
+
python3 proxy.py input.mov --width 480 --no-audio # smaller, video-only
|
|
23
|
+
python3 proxy.py input.mov --scale 0.25 --fps 10 # quarter-size, 10fps (e.g. for a vision model)
|
|
24
|
+
"""
|
|
25
|
+
import argparse
|
|
26
|
+
import sys
|
|
27
|
+
|
|
28
|
+
from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def even(n: float) -> int:
|
|
32
|
+
v = int(round(n))
|
|
33
|
+
return v if v % 2 == 0 else v + 1
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main() -> int:
|
|
37
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
38
|
+
ap.add_argument("input")
|
|
39
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_proxy.<ext>)")
|
|
40
|
+
ap.add_argument("--width", type=int, default=640, help="output width in px, height follows the source aspect (default 640)")
|
|
41
|
+
ap.add_argument("--scale", type=float, help="scale factor applied to the source dimensions instead of --width (0 < scale <= 1)")
|
|
42
|
+
ap.add_argument("--crf", type=int, default=30, help="proxy-grade CRF, higher = smaller/lower quality (default 30)")
|
|
43
|
+
ap.add_argument("--fps", type=float, help="force a constant output frame rate")
|
|
44
|
+
ap.add_argument("--no-audio", action="store_true", help="drop audio entirely (default: keep it)")
|
|
45
|
+
add_common(ap)
|
|
46
|
+
args = ap.parse_args()
|
|
47
|
+
apply_common(args)
|
|
48
|
+
|
|
49
|
+
if args.scale is not None and not 0.0 < args.scale <= 1.0:
|
|
50
|
+
die(f"--scale must be > 0 and <= 1, got {args.scale}")
|
|
51
|
+
if args.width <= 0:
|
|
52
|
+
die(f"--width must be > 0, got {args.width}")
|
|
53
|
+
if args.fps is not None and args.fps <= 0:
|
|
54
|
+
die(f"--fps must be > 0, got {args.fps}")
|
|
55
|
+
|
|
56
|
+
meta = probe(args.input)
|
|
57
|
+
if not meta.get("video"):
|
|
58
|
+
die("input has no video stream")
|
|
59
|
+
sw, sh = meta["video"]["width"], meta["video"]["height"]
|
|
60
|
+
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
61
|
+
sw = sh
|
|
62
|
+
has_audio = bool(meta.get("audio")) and not args.no_audio
|
|
63
|
+
|
|
64
|
+
out_w = even(sw * args.scale) if args.scale is not None else even(args.width)
|
|
65
|
+
output = args.output or default_output(args.input, "proxy")
|
|
66
|
+
|
|
67
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", f"scale={out_w}:-2"]
|
|
68
|
+
cmd += video_args(meta, args.crf, "veryfast")
|
|
69
|
+
cmd += cfr_args(meta, args.fps)
|
|
70
|
+
cmd += ["-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"]
|
|
71
|
+
cmd.append(output)
|
|
72
|
+
run(cmd)
|
|
73
|
+
|
|
74
|
+
result = probe(output)
|
|
75
|
+
v = result["video"]
|
|
76
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['codec']}, crf {args.crf})")
|
|
77
|
+
emit(output)
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
if __name__ == "__main__":
|
|
82
|
+
sys.exit(main())
|
package/scripts/render.py
CHANGED
|
@@ -36,6 +36,12 @@ graphics → overlays → audio → loudness → export → check. Missing stage
|
|
|
36
36
|
skipped. "brand" points caption/graphics/overlay at a brand.json (fonts,
|
|
37
37
|
colours, logo, safe margin); {"logo": true} in overlays places the brand logo.
|
|
38
38
|
|
|
39
|
+
"check" mirrors check.py's own exit code: a delivery-spec FAIL (or check.py
|
|
40
|
+
itself failing to run) exits 1, same as running check.py directly would --
|
|
41
|
+
the render is not silently reported as successful just because every stage
|
|
42
|
+
up to it completed. The output file is still written and `--json`'s
|
|
43
|
+
`check` field still carries the full row-by-row result either way.
|
|
44
|
+
|
|
39
45
|
Examples:
|
|
40
46
|
python3 render.py --init project.json # write a commented starter project
|
|
41
47
|
python3 render.py project.json # render
|
|
@@ -338,14 +344,19 @@ def main() -> int:
|
|
|
338
344
|
# ---- check
|
|
339
345
|
ck = proj.get("check")
|
|
340
346
|
check_result = None
|
|
347
|
+
exit_code = 0
|
|
341
348
|
if ck and ck.get("platform") and not STATE["dry_run"]:
|
|
342
349
|
proc = subprocess.run([sys.executable, str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
343
350
|
try:
|
|
344
351
|
check_result = json.loads(proc.stdout)
|
|
345
352
|
except ValueError:
|
|
346
353
|
check_result = {"error": proc.stderr.strip()[-300:]}
|
|
347
|
-
if check_result.get("
|
|
354
|
+
if check_result.get("error"):
|
|
355
|
+
info(f"check: could not run check.py — {check_result['error']}")
|
|
356
|
+
exit_code = 1
|
|
357
|
+
elif check_result.get("failed"):
|
|
348
358
|
info(f"check: {check_result['failed']} FAIL — " + "; ".join(f"{r['check']}={r['value']} ({r['fix']})" for r in check_result["checks"] if r["status"] == "FAIL"))
|
|
359
|
+
exit_code = 1
|
|
349
360
|
else:
|
|
350
361
|
info(f"check: OK for {ck['platform']}")
|
|
351
362
|
stages_done.append("check")
|
|
@@ -355,7 +366,7 @@ def main() -> int:
|
|
|
355
366
|
shutil.rmtree(work, ignore_errors=True)
|
|
356
367
|
info(f"rendered {output} via {' → '.join(stages_done)}")
|
|
357
368
|
emit(output, stages=stages_done, check=check_result)
|
|
358
|
-
return
|
|
369
|
+
return exit_code
|
|
359
370
|
|
|
360
371
|
|
|
361
372
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Reverse a video (and its audio, unless dropped).
|
|
3
|
+
|
|
4
|
+
Uses ffmpeg's `reverse` (video) and `areverse` (audio) filters, which decode
|
|
5
|
+
and buffer the whole clip in memory -- long inputs cost real time and RAM,
|
|
6
|
+
which is why there is no length limit baked in here: it is the caller's job
|
|
7
|
+
to keep this to clips it makes sense to reverse (a few seconds to a couple of
|
|
8
|
+
minutes), not a workaround this tool applies for you.
|
|
9
|
+
|
|
10
|
+
Examples:
|
|
11
|
+
python3 reverse.py input.mp4
|
|
12
|
+
python3 reverse.py input.mp4 --no-audio -o backwards.mp4
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> int:
|
|
21
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
22
|
+
ap.add_argument("input")
|
|
23
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_reverse.<ext>)")
|
|
24
|
+
ap.add_argument("--no-audio", action="store_true", help="drop audio instead of reversing it")
|
|
25
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
26
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
27
|
+
add_common(ap)
|
|
28
|
+
args = ap.parse_args()
|
|
29
|
+
apply_common(args)
|
|
30
|
+
|
|
31
|
+
meta = probe(args.input)
|
|
32
|
+
if not meta.get("video"):
|
|
33
|
+
die("input has no video stream")
|
|
34
|
+
has_audio = bool(meta.get("audio")) and not args.no_audio
|
|
35
|
+
|
|
36
|
+
output = args.output or default_output(args.input, "reverse")
|
|
37
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", "reverse"]
|
|
38
|
+
if has_audio:
|
|
39
|
+
cmd += ["-af", "areverse"]
|
|
40
|
+
cmd += video_args(meta, args.crf, args.preset)
|
|
41
|
+
cmd += cfr_args(meta)
|
|
42
|
+
if has_audio:
|
|
43
|
+
cmd += aac_args()
|
|
44
|
+
else:
|
|
45
|
+
cmd += ["-an"]
|
|
46
|
+
cmd.append(output)
|
|
47
|
+
run(cmd)
|
|
48
|
+
|
|
49
|
+
result = probe(output, role="output")
|
|
50
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {result['video']['width']}x{result['video']['height']})")
|
|
51
|
+
emit(output)
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
sys.exit(main())
|
package/scripts/scenes.py
CHANGED
|
@@ -3,14 +3,19 @@
|
|
|
3
3
|
agent can plan an edit or a digest without watching the whole file.
|
|
4
4
|
|
|
5
5
|
Scene cuts come from ffmpeg's scdet; energy peaks from a 0.5 s RMS envelope
|
|
6
|
-
of the audio. Highlight candidates are
|
|
7
|
-
(
|
|
6
|
+
of the audio. Highlight candidates are scenes ranked by --rank-by: "audio"
|
|
7
|
+
(default, loudest first) or "duration" (longest first). Both are proxies,
|
|
8
|
+
not a judgement of what matters: "audio" misses a quiet but important
|
|
9
|
+
moment (a confession, a punchline landing in silence) and can surface pure
|
|
10
|
+
crowd noise; "duration" just finds long unbroken takes. Neither replaces
|
|
11
|
+
watching the contact sheet (--sheet) before committing to a cut.
|
|
8
12
|
|
|
9
13
|
Examples:
|
|
10
14
|
python3 scenes.py talk.mp4 # scenes + peaks, JSON
|
|
11
15
|
python3 scenes.py event.mp4 --highlights 5 --target 60 # 5 candidate ranges summing to ~60 s
|
|
12
16
|
python3 scenes.py event.mp4 --highlights 4 --edl picks.txt # cut.py --segments compatible list
|
|
13
17
|
python3 scenes.py event.mp4 --sheet scenes.png # one thumbnail per scene
|
|
18
|
+
python3 scenes.py talk.mp4 --highlights 5 --rank-by duration # longest unbroken scenes, not loudest
|
|
14
19
|
"""
|
|
15
20
|
import argparse
|
|
16
21
|
import math
|
|
@@ -96,6 +101,8 @@ def main() -> int:
|
|
|
96
101
|
ap.add_argument("--ratio", type=float, default=3.0, help="a cut must exceed this multiple of the neighbouring frames' median score (default 3; lower = more cuts)")
|
|
97
102
|
ap.add_argument("--min-scene", type=float, default=1.0, help="ignore cuts closer than this in seconds (default 1)")
|
|
98
103
|
ap.add_argument("--highlights", type=int, default=0, help="number of highlight ranges to propose")
|
|
104
|
+
ap.add_argument("--rank-by", choices=["audio", "duration"], default="audio",
|
|
105
|
+
help="how to rank scenes for --highlights: audio energy (default) or scene duration")
|
|
99
106
|
ap.add_argument("--target", type=float, help="with --highlights: total seconds the picks should add up to (trims long scenes)")
|
|
100
107
|
ap.add_argument("--max-scene", type=float, default=15.0, help="cap a highlight range at this many seconds (default 15)")
|
|
101
108
|
ap.add_argument("--edl", help="write highlight ranges as START-END lines (cut.py --segments format)")
|
|
@@ -136,7 +143,11 @@ def main() -> int:
|
|
|
136
143
|
info(f"{len(scenes)} scenes, {len(peaks)} audio peaks over {dur:.1f}s")
|
|
137
144
|
|
|
138
145
|
if args.highlights:
|
|
139
|
-
|
|
146
|
+
if args.rank_by == "duration":
|
|
147
|
+
rank_key = lambda sc: (-sc["duration"], sc["start"])
|
|
148
|
+
else:
|
|
149
|
+
rank_key = lambda sc: (-sc["audio_rms"], sc["start"])
|
|
150
|
+
ranked = sorted(scenes, key=rank_key)[: args.highlights]
|
|
140
151
|
picks: List[Tuple[float, float]] = []
|
|
141
152
|
budget = args.target if args.target else None
|
|
142
153
|
per = (budget / max(1, len(ranked))) if budget else args.max_scene
|
|
@@ -156,6 +167,7 @@ def main() -> int:
|
|
|
156
167
|
picks.sort()
|
|
157
168
|
result["highlights"] = [{"start": s, "end": e, "duration": round(e - s, 2)} for s, e in picks]
|
|
158
169
|
result["highlights_total"] = round(sum(e - s for s, e in picks), 2)
|
|
170
|
+
result["highlights_rank_by"] = args.rank_by
|
|
159
171
|
info(f"proposed {len(picks)} highlight ranges totalling {result['highlights_total']:.1f}s")
|
|
160
172
|
if args.edl:
|
|
161
173
|
with open(args.edl, "w", encoding="utf-8") as fh:
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Turn a numbered image sequence into a video.
|
|
3
|
+
|
|
4
|
+
--pattern accepts either a printf-style numbered pattern (`frame_%04d.png`,
|
|
5
|
+
resolved relative to --dir) or a glob (`*.png`, matched and sorted
|
|
6
|
+
alphabetically) -- detected by whether the pattern contains a `%`. Either
|
|
7
|
+
way, the actual frame list is resolved and checked on disk before ffmpeg
|
|
8
|
+
runs (an empty match or a missing first frame is refused here, not
|
|
9
|
+
discovered from an opaque ffmpeg error), then fed to ffmpeg as an explicit
|
|
10
|
+
concat list -- not `-pattern_type glob`, which several real ffmpeg builds
|
|
11
|
+
(the Windows Chocolatey package, for one) compile without.
|
|
12
|
+
|
|
13
|
+
Examples:
|
|
14
|
+
python3 sequence.py --dir frames --pattern "frame_%04d.png" --fps 24 -o out.mp4
|
|
15
|
+
python3 sequence.py --dir frames --pattern "*.png" --fps 30 --start-number 1
|
|
16
|
+
"""
|
|
17
|
+
import argparse
|
|
18
|
+
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def even(n: float) -> int:
|
|
26
|
+
v = int(round(n))
|
|
27
|
+
return v if v % 2 == 0 else v + 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _concat_list_line(path: Path) -> str:
|
|
31
|
+
# concat demuxer file paths: backslash and single-quote need escaping inside the quoted form.
|
|
32
|
+
escaped = str(path).replace("\\", "/").replace("'", "'\\''")
|
|
33
|
+
return f"file '{escaped}'"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def main() -> int:
|
|
37
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
38
|
+
ap.add_argument("--dir", required=True, help="directory containing the frames")
|
|
39
|
+
ap.add_argument("--pattern", required=True, help="printf pattern (frame_%%04d.png) or glob (*.png)")
|
|
40
|
+
ap.add_argument("-o", "--output", help="output file (default: <dir>_sequence.mp4)")
|
|
41
|
+
ap.add_argument("--fps", type=float, default=30.0, help="output frame rate (default 30)")
|
|
42
|
+
ap.add_argument("--start-number", type=int, default=0, help="first frame index, for a printf pattern (default 0)")
|
|
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("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
46
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
47
|
+
add_common(ap)
|
|
48
|
+
args = ap.parse_args()
|
|
49
|
+
apply_common(args)
|
|
50
|
+
|
|
51
|
+
if args.fps <= 0:
|
|
52
|
+
die("--fps must be > 0")
|
|
53
|
+
directory = Path(args.dir)
|
|
54
|
+
if not directory.is_dir():
|
|
55
|
+
die(f"--dir not found or not a directory: {args.dir}")
|
|
56
|
+
|
|
57
|
+
is_glob = "%" not in args.pattern
|
|
58
|
+
if is_glob:
|
|
59
|
+
frames = sorted(directory.glob(args.pattern))
|
|
60
|
+
if not frames:
|
|
61
|
+
die(f"no files in {args.dir} match glob '{args.pattern}'")
|
|
62
|
+
info(f"found {len(frames)} frames matching '{args.pattern}'")
|
|
63
|
+
else:
|
|
64
|
+
try:
|
|
65
|
+
args.pattern % args.start_number
|
|
66
|
+
except (TypeError, ValueError):
|
|
67
|
+
die(f"bad printf pattern '{args.pattern}'")
|
|
68
|
+
frames = []
|
|
69
|
+
i = args.start_number
|
|
70
|
+
while (directory / (args.pattern % i)).exists():
|
|
71
|
+
frames.append(directory / (args.pattern % i))
|
|
72
|
+
i += 1
|
|
73
|
+
if not frames:
|
|
74
|
+
die(f"first frame not found: {directory / (args.pattern % args.start_number)} (check --pattern / --start-number)")
|
|
75
|
+
info(f"found {len(frames)} consecutive frames from index {args.start_number}")
|
|
76
|
+
|
|
77
|
+
frame_meta = probe(str(frames[0]))
|
|
78
|
+
if not frame_meta.get("video"):
|
|
79
|
+
die(f"{frames[0]} is not a readable image")
|
|
80
|
+
sw, sh = frame_meta["video"]["width"], frame_meta["video"]["height"]
|
|
81
|
+
|
|
82
|
+
if args.width and args.height:
|
|
83
|
+
out_w, out_h = even(args.width), even(args.height)
|
|
84
|
+
elif args.width:
|
|
85
|
+
out_w = even(args.width)
|
|
86
|
+
out_h = even(out_w * sh / sw)
|
|
87
|
+
elif args.height:
|
|
88
|
+
out_h = even(args.height)
|
|
89
|
+
out_w = even(out_h * sw / sh)
|
|
90
|
+
else:
|
|
91
|
+
out_w, out_h = even(sw), even(sh)
|
|
92
|
+
|
|
93
|
+
output = args.output or default_output(str(directory).rstrip("/\\") or "sequence", "sequence", "mp4")
|
|
94
|
+
frame_duration = 1.0 / args.fps
|
|
95
|
+
|
|
96
|
+
with tempfile.TemporaryDirectory(prefix="ffmpeg-skill-sequence-") as tmp:
|
|
97
|
+
list_path = Path(tmp) / "frames.txt"
|
|
98
|
+
lines = []
|
|
99
|
+
for f in frames:
|
|
100
|
+
lines.append(_concat_list_line(f.resolve()))
|
|
101
|
+
lines.append(f"duration {frame_duration:.6f}")
|
|
102
|
+
lines.append(_concat_list_line(frames[-1].resolve())) # concat demuxer: last entry's duration is ignored, so repeat it
|
|
103
|
+
list_path.write_text("\n".join(lines), encoding="utf-8")
|
|
104
|
+
|
|
105
|
+
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", str(list_path)]
|
|
106
|
+
vf = [f"scale={out_w}:{out_h}", "setsar=1", f"fps={args.fps:g}"]
|
|
107
|
+
cmd += ["-vf", ",".join(vf)]
|
|
108
|
+
cmd += video_args(None, args.crf, args.preset)
|
|
109
|
+
# The concat demuxer's trailing repeated-last-file trick (needed so the last real file's
|
|
110
|
+
# duration line takes effect) has been observed to produce an extra frame's worth of
|
|
111
|
+
# duration on some ffmpeg builds -- force the exact intended length rather than trust it.
|
|
112
|
+
total_duration = len(frames) * frame_duration
|
|
113
|
+
cmd += ["-t", f"{total_duration:.6f}", "-an", output]
|
|
114
|
+
run(cmd)
|
|
115
|
+
|
|
116
|
+
result = probe(output, role="output")
|
|
117
|
+
v = result["video"]
|
|
118
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['fps']:g}fps)")
|
|
119
|
+
emit(output)
|
|
120
|
+
return 0
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
if __name__ == "__main__":
|
|
124
|
+
sys.exit(main())
|
package/scripts/silence.py
CHANGED
|
@@ -27,7 +27,7 @@ def detect(path: str, threshold: float, min_silence: float) -> List[Tuple[float,
|
|
|
27
27
|
f"silencedetect=noise={threshold}dB:d={min_silence}", "-f", "null", "-"]
|
|
28
28
|
proc = run(cmd, quiet=True, check=False)
|
|
29
29
|
if proc.returncode != 0:
|
|
30
|
-
die(f"silencedetect failed:\n{proc.stderr.strip()[-800:]}")
|
|
30
|
+
die(f"silencedetect failed:\n{proc.stderr.strip()[-800:]}", kind="ffmpeg")
|
|
31
31
|
silences: List[Tuple[float, float]] = []
|
|
32
32
|
start = None
|
|
33
33
|
for kind, val in SIL_RE.findall(proc.stderr):
|
|
@@ -113,7 +113,7 @@ def main() -> int:
|
|
|
113
113
|
cmd += ["-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
|
|
114
114
|
cmd += ["-af", af] + aac_args() + [output]
|
|
115
115
|
run(cmd)
|
|
116
|
-
r = probe(output)
|
|
116
|
+
r = probe(output, role="output")
|
|
117
117
|
info(f"wrote {output} ({r['duration']:.3f}s, expected ~{kept:.3f}s)")
|
|
118
118
|
emit(output, **summary)
|
|
119
119
|
return 0
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Stabilize shaky video (FFmpeg's vidstab, two-pass).
|
|
3
|
+
|
|
4
|
+
Pass 1 (vidstabdetect) analyses camera motion and writes the transforms to a
|
|
5
|
+
temporary file; pass 2 (vidstabtransform) smooths that motion and re-renders
|
|
6
|
+
the frames. The transforms file lives in a temp directory for the duration of
|
|
7
|
+
this run only -- it is not a caller-facing artifact.
|
|
8
|
+
|
|
9
|
+
--shakiness (1 = barely shaky, fast; 10 = very shaky, slow analysis) and
|
|
10
|
+
--smoothing (how many neighbouring frames to average the camera path over)
|
|
11
|
+
are the two knobs that matter most; --zoom crops in slightly to hide the
|
|
12
|
+
black edges stabilizing can introduce (0 = keep the original framing and let
|
|
13
|
+
edges show; ffmpeg's own --crop-mode is not exposed as a raw flag here).
|
|
14
|
+
|
|
15
|
+
Examples:
|
|
16
|
+
python3 stabilize.py shaky.mp4
|
|
17
|
+
python3 stabilize.py shaky.mp4 --shakiness 8 --smoothing 20 --zoom 5
|
|
18
|
+
"""
|
|
19
|
+
import argparse
|
|
20
|
+
import sys
|
|
21
|
+
import tempfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def main() -> int:
|
|
28
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
29
|
+
ap.add_argument("input")
|
|
30
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_stab.<ext>)")
|
|
31
|
+
ap.add_argument("--shakiness", type=int, default=5, help="1 (barely shaky) .. 10 (very shaky), default 5")
|
|
32
|
+
ap.add_argument("--smoothing", type=int, default=15, help="frames of camera-path smoothing on each side, default 15")
|
|
33
|
+
ap.add_argument("--zoom", type=float, default=0.0, help="percent to zoom in to hide stabilization edges, 0..100 (default 0)")
|
|
34
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
35
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
36
|
+
add_common(ap)
|
|
37
|
+
args = ap.parse_args()
|
|
38
|
+
apply_common(args)
|
|
39
|
+
|
|
40
|
+
if not 1 <= args.shakiness <= 10:
|
|
41
|
+
die(f"--shakiness must be 1..10, got {args.shakiness}")
|
|
42
|
+
if not 0 <= args.smoothing <= 1000:
|
|
43
|
+
die(f"--smoothing must be 0..1000, got {args.smoothing}")
|
|
44
|
+
if not 0 <= args.zoom <= 100:
|
|
45
|
+
die(f"--zoom must be 0..100, got {args.zoom}")
|
|
46
|
+
|
|
47
|
+
meta = probe(args.input)
|
|
48
|
+
if not meta.get("video"):
|
|
49
|
+
die("input has no video stream")
|
|
50
|
+
has_audio = bool(meta.get("audio"))
|
|
51
|
+
output = args.output or default_output(args.input, "stab")
|
|
52
|
+
|
|
53
|
+
with tempfile.TemporaryDirectory(prefix="ffmpeg-skill-vidstab-") as tmp:
|
|
54
|
+
trf = str(Path(tmp) / "transforms.trf")
|
|
55
|
+
trf_arg = escape_filter_path(trf)
|
|
56
|
+
|
|
57
|
+
if not STATE["dry_run"]:
|
|
58
|
+
ffmpeg = require_tool("ffmpeg")
|
|
59
|
+
detect_cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", args.input,
|
|
60
|
+
"-vf", f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}", "-f", "null", "-"]
|
|
61
|
+
proc = run(detect_cmd, check=False)
|
|
62
|
+
if proc.returncode != 0:
|
|
63
|
+
die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}")
|
|
64
|
+
|
|
65
|
+
transform_vf = f"vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:zoom={args.zoom:g}:optzoom=1"
|
|
66
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", transform_vf]
|
|
67
|
+
cmd += video_args(meta, args.crf, args.preset)
|
|
68
|
+
cmd += cfr_args(meta)
|
|
69
|
+
if has_audio:
|
|
70
|
+
cmd += aac_args()
|
|
71
|
+
else:
|
|
72
|
+
cmd += ["-an"]
|
|
73
|
+
cmd.append(output)
|
|
74
|
+
run(cmd)
|
|
75
|
+
|
|
76
|
+
result = probe(output, role="output")
|
|
77
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {result['video']['width']}x{result['video']['height']})")
|
|
78
|
+
emit(output)
|
|
79
|
+
return 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
sys.exit(main())
|
package/scripts/sync.py
CHANGED
|
@@ -9,6 +9,14 @@ Python (coarse, 20 ms), then refined by direct correlation at 1 ms.
|
|
|
9
9
|
Offset semantics: a positive offset means the SECOND input starts LATER
|
|
10
10
|
than the reference, i.e. `second` must be shifted earlier by that amount.
|
|
11
11
|
|
|
12
|
+
This aligns two AUDIO tracks to each other; it does not check or guarantee
|
|
13
|
+
lip sync (mouth movement matching the audio). It assumes each recording's
|
|
14
|
+
own audio is already correctly timed against its own picture, which holds
|
|
15
|
+
for ordinary cameras and phones (same device, same clock) but not for a
|
|
16
|
+
capture device with its own internal audio/video offset. There is no
|
|
17
|
+
face or mouth detection anywhere in this codebase to verify that; the only
|
|
18
|
+
way to confirm the final result actually looks in sync is to watch it.
|
|
19
|
+
|
|
12
20
|
Examples:
|
|
13
21
|
python3 sync.py camera.mp4 lavmic.wav # print offset only
|
|
14
22
|
python3 sync.py camera.mp4 lavmic.wav --replace-audio -o synced.mp4
|
|
@@ -53,7 +61,7 @@ def decode_mono(path: str, seconds: float, start: float = 0.0) -> List[float]:
|
|
|
53
61
|
"-vn", "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"]
|
|
54
62
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
55
63
|
if proc.returncode != 0 or not proc.stdout:
|
|
56
|
-
die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}")
|
|
64
|
+
die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
|
|
57
65
|
n = len(proc.stdout) // 2
|
|
58
66
|
return [v / 32768.0 for v in struct.unpack(f"<{n}h", proc.stdout[: n * 2])]
|
|
59
67
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|