ffmpeg-skill 0.10.0 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +70 -10
  2. package/SKILL.md +89 -12
  3. package/bin/install.js +15 -1
  4. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  5. package/mcp/server.py +2 -0
  6. package/package.json +2 -2
  7. package/references/ci-platform-pitfalls.md +111 -0
  8. package/references/process-pitfalls.md +85 -0
  9. package/references/scripts.md +139 -12
  10. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  11. package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
  12. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  13. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  14. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  15. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  16. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  17. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  18. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  19. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  20. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  21. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  22. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  23. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  24. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  25. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  26. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  27. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  28. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  29. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  30. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  31. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  32. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
  33. package/scripts/_common.py +255 -14
  34. package/scripts/_contract.py +276 -13
  35. package/scripts/audio.py +1 -1
  36. package/scripts/background.py +73 -0
  37. package/scripts/caption.py +106 -24
  38. package/scripts/color.py +129 -30
  39. package/scripts/crop.py +79 -0
  40. package/scripts/cut.py +2 -2
  41. package/scripts/export.py +1 -1
  42. package/scripts/fit.py +73 -14
  43. package/scripts/graphics.py +18 -7
  44. package/scripts/insert.py +128 -0
  45. package/scripts/join.py +14 -5
  46. package/scripts/look.py +13 -8
  47. package/scripts/loudness.py +3 -3
  48. package/scripts/multicam.py +1 -1
  49. package/scripts/overlay.py +78 -12
  50. package/scripts/proxy.py +82 -0
  51. package/scripts/reverse.py +56 -0
  52. package/scripts/scenes.py +8 -2
  53. package/scripts/sequence.py +124 -0
  54. package/scripts/silence.py +2 -2
  55. package/scripts/stabilize.py +101 -0
  56. package/scripts/sync.py +1 -1
@@ -5,17 +5,26 @@ 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
16
25
  from typing import List, Optional
17
26
 
18
- 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, x264_args
19
28
 
20
29
  POS = {
21
30
  "top-left": ("{m}", "{m}"),
@@ -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 --logo")
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))
@@ -126,9 +144,16 @@ def main() -> int:
126
144
  args.font = brand.get("font", args.font)
127
145
  if not args.font_file and brand.get("font_file"):
128
146
  args.font_file = brand["font_file"]
147
+ if not args.font_file:
148
+ args.font_file = default_font_file(args.font)
129
149
  meta = probe(args.input)
130
150
  if not meta.get("video"):
131
151
  die("input has no video stream")
152
+ audio_streams = meta.get("audio_streams") or []
153
+ if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
154
+ die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
155
+ if args.audio_stream and not audio_streams:
156
+ die("--audio-stream needs an input with audio streams")
132
157
  vw = meta["video"]["width"]
133
158
  start = parse_time(args.start) if args.start else None
134
159
  end = parse_time(args.end) if args.end else None
@@ -136,6 +161,12 @@ def main() -> int:
136
161
  die("--end must be after --start")
137
162
  if not 0 <= args.opacity <= 1:
138
163
  die("--opacity must be within 0..1")
164
+ if args.chromakey and not args.video:
165
+ die("--chromakey needs --video")
166
+ if not 0 < args.chromakey_similarity <= 1:
167
+ die("--chromakey-similarity must be within (0, 1]")
168
+ if not 0 <= args.chromakey_blend <= 1:
169
+ die("--chromakey-blend must be within 0..1")
139
170
 
140
171
  output = args.output or default_output(args.input, "overlay")
141
172
  enable = enable_expr(start, end)
@@ -164,12 +195,46 @@ def main() -> int:
164
195
  # -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
165
196
  cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
166
197
  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:0?", "-shortest"]
168
- # -shortest alone is not exact on FFmpeg 7+: the muxer keeps up to shortest_buf_duration (10 s)
169
- # of the looped still after the video ended, and the file came out 2 s long on 8.1 / 9.0.
170
- # The output must be as long as the main input, so say so explicitly.
198
+ cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
171
199
  if meta.get("duration"):
200
+ # An explicit -t is exact and, unlike -shortest, only bounds the *main* input's
201
+ # streams -- a preserved subtitle/data stream that ends earlier (run_keeping_subtitles)
202
+ # must not be allowed to cut the whole output short via -shortest's "stop at whichever
203
+ # mapped stream finishes first" semantics.
172
204
  cmd += ["-t", f"{meta['duration']:.3f}"]
205
+ else:
206
+ # No known duration to bound by -t (e.g. probe found no video duration): -shortest is
207
+ # the only thing stopping the looped still from running forever. FFmpeg 7+'s
208
+ # shortest_buf_duration slack (up to 10s) is an accepted imprecision here since there is
209
+ # no better bound available.
210
+ cmd += ["-shortest"]
211
+ elif args.video:
212
+ pip_meta = probe(args.video)
213
+ if not pip_meta.get("video"):
214
+ die(f"--video {args.video} has no video stream")
215
+ chain = []
216
+ if args.scale_percent:
217
+ chain.append(f"scale={int(vw * args.scale_percent / 100)}:-2")
218
+ elif args.scale:
219
+ chain.append(f"scale={args.scale}:-2")
220
+ chain.append("format=yuva420p")
221
+ if args.chromakey:
222
+ chain.append(f"chromakey={args.chromakey}:{args.chromakey_similarity:g}:{args.chromakey_blend:g}")
223
+ if args.opacity < 1:
224
+ chain.append(f"colorchannelmixer=aa={args.opacity:g}")
225
+ x, y = position_exprs(args.position, args.margin, text_mode=False)
226
+ ov = f"overlay={x}:{y}:format=auto"
227
+ if enable:
228
+ ov += f":enable='{enable}'"
229
+ cmd = ffmpeg_base() + ["-i", args.input, "-i", args.video]
230
+ fc = f"[1:v]{','.join(chain)}[ov];[0:v][ov]{ov}[out]"
231
+ cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
232
+ if meta.get("duration"):
233
+ # See the --image branch above: -t (exact, bounds only the main input) instead of
234
+ # -shortest (would also stop at a preserved subtitle/data stream that ends earlier).
235
+ cmd += ["-t", f"{meta['duration']:.3f}"]
236
+ else:
237
+ cmd += ["-shortest"]
173
238
  else:
174
239
  x, y = position_exprs(args.position, args.margin, text_mode=True)
175
240
  opts = [f"text='{escape_drawtext(args.text)}'", f"fontsize={args.font_size}", f"x={x}", f"y={y}",
@@ -187,16 +252,17 @@ def main() -> int:
187
252
  opts += ["box=1", f"boxcolor={args.box_color}", "boxborderw=12"]
188
253
  if enable:
189
254
  opts.append(f"enable='{enable}'")
190
- cmd += ["-vf", "drawtext=" + ":".join(opts)]
255
+ cmd += ["-vf", "drawtext=" + ":".join(opts), "-map", "0:v:0"]
256
+ if meta.get("audio"):
257
+ cmd += ["-map", f"0:a:{args.audio_stream}"]
191
258
 
192
259
  cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
193
260
  cmd += aac_args() if meta.get("audio") else ["-an"]
194
- cmd.append(output)
195
- run(cmd)
261
+ dropped_streams = run_keeping_subtitles(cmd, output)
196
262
  if not STATE.dry_run:
197
- result = probe(output)
263
+ result = probe(output, role="output")
198
264
  info(f"wrote {output} ({result['duration']:.3f}s)")
199
- emit(output)
265
+ emit(output, dropped_non_av_streams=dropped_streams)
200
266
  return 0
201
267
 
202
268
 
@@ -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())
@@ -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
@@ -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
- vf = (f"select='{expr}',scale={tile_w}:-2,drawtext=text='%{{pts\\:hms}}':fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6,"
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}")
@@ -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())
@@ -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,101 @@
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
+ edges stabilizing can introduce (0 = keep the original framing and let edges
13
+ show). --crop chooses what happens to any edge vidstab reveals that --zoom
14
+ doesn't crop away: "keep" (default) stretches the border pixels, "black"
15
+ fills it in solid black instead. --tripod locks the frame fully still
16
+ against a single reference frame (e.g. a camera meant to be static but
17
+ nudged, or a shot you want dead-locked rather than merely smoothed) instead
18
+ of following the camera's intended motion.
19
+
20
+ Examples:
21
+ python3 stabilize.py shaky.mp4
22
+ python3 stabilize.py shaky.mp4 --shakiness 8 --smoothing 20 --zoom 5
23
+ python3 stabilize.py locked-off.mp4 --tripod --crop black
24
+ """
25
+ import argparse
26
+ import sys
27
+ import tempfile
28
+ from pathlib import Path
29
+
30
+ 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
31
+
32
+
33
+ def main() -> int:
34
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
35
+ ap.add_argument("input")
36
+ ap.add_argument("-o", "--output", help="output file (default: <name>_stab.<ext>)")
37
+ ap.add_argument("--shakiness", type=int, default=5, help="1 (barely shaky) .. 10 (very shaky), default 5")
38
+ ap.add_argument("--smoothing", type=int, default=15, help="frames of camera-path smoothing on each side, default 15")
39
+ ap.add_argument("--zoom", type=float, default=0.0, help="percent to zoom in to hide stabilization edges, 0..100 (default 0)")
40
+ ap.add_argument("--crop", choices=["keep", "black"], default="keep", help="edges --zoom doesn't crop away: keep (stretch border pixels, default) or black (fill solid black)")
41
+ ap.add_argument("--tripod", action="store_true", help="lock the frame fully still against a single reference frame instead of smoothing the camera's motion")
42
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
43
+ ap.add_argument("--preset", default="medium", help="x264 preset")
44
+ add_common(ap)
45
+ args = ap.parse_args()
46
+ apply_common(args)
47
+
48
+ if not 1 <= args.shakiness <= 10:
49
+ die(f"--shakiness must be 1..10, got {args.shakiness}")
50
+ if not 0 <= args.smoothing <= 1000:
51
+ die(f"--smoothing must be 0..1000, got {args.smoothing}")
52
+ if not 0 <= args.zoom <= 100:
53
+ die(f"--zoom must be 0..100, got {args.zoom}")
54
+
55
+ meta = probe(args.input)
56
+ if not meta.get("video"):
57
+ die("input has no video stream")
58
+ has_audio = bool(meta.get("audio"))
59
+ output = args.output or default_output(args.input, "stab")
60
+
61
+ with tempfile.TemporaryDirectory(prefix="ffmpeg-skill-vidstab-") as tmp:
62
+ trf = str(Path(tmp) / "transforms.trf")
63
+ trf_arg = escape_filter_path(trf)
64
+
65
+ if not STATE["dry_run"]:
66
+ ffmpeg = require_tool("ffmpeg")
67
+ detect_vf = f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
68
+ if args.tripod:
69
+ # A frame number, not a boolean: frame 1 is the standard reference for "lock to
70
+ # this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
71
+ detect_vf += ":tripod=1"
72
+ detect_cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", args.input,
73
+ "-vf", detect_vf, "-f", "null", "-"]
74
+ proc = run(detect_cmd, check=False)
75
+ if proc.returncode != 0:
76
+ die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}")
77
+
78
+ crop_mode = {"keep": 0, "black": 1}[args.crop]
79
+ transform_vf = f"vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
80
+ if args.tripod:
81
+ # Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
82
+ # camera path makes no sense once every frame is locked to one fixed reference.
83
+ transform_vf += ":tripod=1"
84
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", transform_vf]
85
+ cmd += video_args(meta, args.crf, args.preset)
86
+ cmd += cfr_args(meta)
87
+ if has_audio:
88
+ cmd += aac_args()
89
+ else:
90
+ cmd += ["-an"]
91
+ cmd.append(output)
92
+ run(cmd)
93
+
94
+ result = probe(output, role="output")
95
+ info(f"wrote {output} ({result['duration']:.3f}s, {result['video']['width']}x{result['video']['height']})")
96
+ emit(output)
97
+ return 0
98
+
99
+
100
+ if __name__ == "__main__":
101
+ sys.exit(main())
package/scripts/sync.py CHANGED
@@ -61,7 +61,7 @@ def decode_mono(path: str, seconds: float, start: float = 0.0) -> List[float]:
61
61
  "-vn", "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"]
62
62
  proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
63
63
  if proc.returncode != 0 or not proc.stdout:
64
- 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")
65
65
  n = len(proc.stdout) // 2
66
66
  return [v / 32768.0 for v in struct.unpack(f"<{n}h", proc.stdout[: n * 2])]
67
67