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
package/scripts/fit.py CHANGED
@@ -5,7 +5,13 @@ Duration: --duration N with --method speed (retime video+audio, pitch-preserving
5
5
  via atempo chaining) or --method trim (keep the first N seconds, or a centred
6
6
  window with --from-center). Aspect: --aspect 16:9|9:16|1:1|4:5|W:H with
7
7
  --fit pad (letterbox/pillarbox with --pad-color, default black) or --fit crop.
8
- --width sets the output width; height follows the aspect.
8
+ --width and/or --height set the output size: give one and the other follows
9
+ the aspect (source aspect if --aspect is not also given); give both for an
10
+ exact frame. --rotate 90|180|270 (clockwise) and --flip h|v apply a new
11
+ rotation/mirror to the picture -- distinct from the rotation metadata a
12
+ source already carries (read automatically to compute the displayed size,
13
+ never altered by these flags unless asked). Both can be combined; rotate is
14
+ applied before flip.
9
15
 
10
16
  Crop keeps the centre of the frame by default, which is a guess: going from
11
17
  16:9 to 9:16 throws away most of the width, and whatever isn't in the middle
@@ -20,6 +26,10 @@ Examples:
20
26
  python3 fit.py input.mp4 --aspect 9:16 --fit pad --width 1080
21
27
  python3 fit.py input.mp4 --aspect 1:1 --fit crop --duration 15
22
28
  python3 fit.py input.mp4 --aspect 9:16 --fit crop --crop-x 1 # keep the right edge (e.g. product held stage-right)
29
+ python3 fit.py input.mp4 --height 1080 # width follows the source aspect
30
+ python3 fit.py input.mp4 --width 1920 --height 1080 # exact frame, no aspect needed
31
+ python3 fit.py input.mp4 --rotate 90 # rotate 90 degrees clockwise
32
+ python3 fit.py input.mp4 --flip h # mirror horizontally
23
33
  """
24
34
  import argparse
25
35
  import math
@@ -27,7 +37,7 @@ import sys
27
37
  from fractions import Fraction
28
38
  from typing import List
29
39
 
30
- from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
40
+ from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, x264_args
31
41
 
32
42
  ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
33
43
 
@@ -66,6 +76,10 @@ def main() -> int:
66
76
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
67
77
  ap.add_argument("input")
68
78
  ap.add_argument("-o", "--output", help="output file (default: <name>_fit.<ext>)")
79
+ ap.add_argument("--audio-stream", type=int, default=0,
80
+ help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
81
+ "audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
82
+ "the first track, same as leaving it unset always did")
69
83
  d = ap.add_argument_group("duration")
70
84
  d.add_argument("--duration", help="target duration (seconds or mm:ss)")
71
85
  d.add_argument("--method", choices=["speed", "trim"], default="speed", help="how to reach the duration (default speed)")
@@ -76,10 +90,14 @@ def main() -> int:
76
90
  a = ap.add_argument_group("aspect")
77
91
  a.add_argument("--aspect", help="target aspect ratio, e.g. 16:9, 9:16, 1:1, 4:5")
78
92
  a.add_argument("--fit", choices=["pad", "crop"], default="pad", help="pad (letterbox) or crop to reach the aspect (default pad)")
79
- a.add_argument("--width", type=int, help="output width in px (default: keep source width or the width implied by the aspect)")
93
+ a.add_argument("--width", type=int, help="output width in px (default: keep source width or the width implied by the aspect); with --height also given, both are used directly")
94
+ a.add_argument("--height", type=int, help="output height in px (default: keep source height or the height implied by the aspect); with --width also given, both are used directly")
80
95
  a.add_argument("--pad-color", default="black", help="pad colour, e.g. black, white, 0x101010 (default black)")
81
96
  a.add_argument("--crop-x", type=float, default=0.5, help="with --fit crop, horizontal anchor 0=left, 0.5=centre (default), 1=right")
82
97
  a.add_argument("--crop-y", type=float, default=0.5, help="with --fit crop, vertical anchor 0=top, 0.5=centre (default), 1=bottom")
98
+ r = ap.add_argument_group("rotate / flip")
99
+ r.add_argument("--rotate", type=int, choices=[90, 180, 270], help="rotate the picture clockwise by this many degrees")
100
+ r.add_argument("--flip", choices=["h", "v"], help="mirror the picture horizontally (h) or vertically (v)")
83
101
  e = ap.add_argument_group("encoding")
84
102
  e.add_argument("--crf", type=int, default=18)
85
103
  e.add_argument("--preset", default="medium")
@@ -88,8 +106,10 @@ def main() -> int:
88
106
  args = ap.parse_args()
89
107
  apply_common(args)
90
108
 
91
- if not args.duration and not args.aspect and not args.width and not args.fps:
92
- die("nothing to do: give --duration, --aspect, --width and/or --fps")
109
+ if args.fps is not None and args.fps <= 0:
110
+ die(f"--fps must be positive, got {args.fps:g}")
111
+ if not args.duration and not args.aspect and not args.width and not args.height and not args.fps and not args.rotate and not args.flip:
112
+ die("nothing to do: give --duration, --aspect, --width/--height, --rotate/--flip and/or --fps")
93
113
  if not 0.0 <= args.crop_x <= 1.0:
94
114
  die(f"--crop-x must be 0..1, got {args.crop_x}")
95
115
  if not 0.0 <= args.crop_y <= 1.0:
@@ -98,10 +118,17 @@ def main() -> int:
98
118
  meta = probe(args.input)
99
119
  if not meta.get("video"):
100
120
  die("input has no video stream")
121
+ audio_streams = meta.get("audio_streams") or []
122
+ if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
123
+ die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
124
+ if args.audio_stream and not audio_streams:
125
+ die("--audio-stream needs an input with audio streams")
101
126
  src_dur = meta["duration"] or 0.0
102
127
  sw, sh = meta["video"]["width"], meta["video"]["height"]
103
128
  if meta["video"].get("rotation") in (90, -90, 270, -270):
104
129
  sw, sh = sh, sw
130
+ if args.rotate in (90, 270):
131
+ sw, sh = sh, sw
105
132
  has_audio = bool(meta.get("audio"))
106
133
 
107
134
  vf: List[str] = []
@@ -110,6 +137,18 @@ def main() -> int:
110
137
  post: List[str] = []
111
138
  factor = 1.0
112
139
 
140
+ # ---- rotate / flip
141
+ if args.rotate == 90:
142
+ vf.append("transpose=1")
143
+ elif args.rotate == 270:
144
+ vf.append("transpose=2")
145
+ elif args.rotate == 180:
146
+ vf.append("transpose=2,transpose=2")
147
+ if args.flip == "h":
148
+ vf.append("hflip")
149
+ elif args.flip == "v":
150
+ vf.append("vflip")
151
+
113
152
  # ---- duration
114
153
  if args.duration:
115
154
  target = parse_time(args.duration)
@@ -142,14 +181,22 @@ def main() -> int:
142
181
  info(f"source ({src_dur:.2f}s) is already shorter than {target:.2f}s; trim does nothing")
143
182
 
144
183
  # ---- aspect / size
145
- if args.aspect or args.width:
146
- src_ratio = Fraction(sw, sh)
184
+ if args.aspect or args.width or args.height:
185
+ src_ratio = Fraction(sw, sh) if sh else None
147
186
  ratio = parse_aspect(args.aspect) if args.aspect else src_ratio
148
- if args.width:
187
+ if args.width and args.height:
188
+ out_w, out_h = even(args.width), even(args.height)
189
+ elif args.width:
149
190
  out_w = even(args.width)
150
- else:
191
+ out_h = even(out_w / ratio) if ratio else args.width
192
+ elif args.height:
193
+ out_h = even(args.height)
194
+ out_w = even(out_h * ratio) if ratio else args.height
195
+ elif ratio and src_ratio:
151
196
  out_w = even(sw if ratio <= src_ratio else sh * ratio)
152
- out_h = even(out_w / ratio)
197
+ out_h = even(out_w / ratio)
198
+ else:
199
+ out_w, out_h = even(sw), even(sh)
153
200
  if args.fit == "crop":
154
201
  vf.append(f"scale={out_w}:{out_h}:force_original_aspect_ratio=increase")
155
202
  vf.append(f"crop={out_w}:{out_h}:(in_w-out_w)*{args.crop_x:g}:(in_h-out_h)*{args.crop_y:g}")
@@ -169,21 +216,33 @@ def main() -> int:
169
216
  cmd += ["-vf", ",".join(vf)]
170
217
  if af:
171
218
  cmd += ["-af", ",".join(af)]
219
+ cmd += ["-map", "0:v:0"]
220
+ if has_audio:
221
+ cmd += ["-map", f"0:a:{args.audio_stream}"]
172
222
  cmd += video_args(meta, args.crf, args.preset)
173
223
  cmd += cfr_args(meta, args.fps) if not args.fps else []
174
224
  if has_audio:
175
225
  cmd += aac_args()
176
226
  else:
177
227
  cmd += ["-an"]
178
- cmd += post + [output]
179
- run(cmd)
228
+ cmd += post
229
+ if abs(factor - 1.0) > 1e-4:
230
+ # A subtitle/data stream stream-copied by run_keeping_subtitles keeps the source's
231
+ # original timestamps; --method speed retimes video (setpts) and audio (atempo) but has
232
+ # no equivalent way to retime a copied subtitle track, so it would desync from the
233
+ # now-faster/slower picture. Drop them here rather than ship a captions track that lies
234
+ # about when a line is spoken.
235
+ run(cmd + [output])
236
+ dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
237
+ else:
238
+ dropped_streams = run_keeping_subtitles(cmd, output)
180
239
 
181
- result = probe(output)
240
+ result = probe(output, role="output")
182
241
  msg = f"wrote {output} ({result['duration']:.3f}s, {result['video']['width']}x{result['video']['height']})"
183
242
  if abs(factor - 1.0) > 1e-4:
184
243
  msg += f", speed {factor:.3f}x"
185
244
  info(msg)
186
- emit(output)
245
+ emit(output, dropped_non_av_streams=dropped_streams)
187
246
  return 0
188
247
 
189
248
 
@@ -21,7 +21,7 @@ import argparse
21
21
  import sys
22
22
  from typing import List, Optional
23
23
 
24
- from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, video_args
24
+ from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args
25
25
 
26
26
  TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
27
27
 
@@ -33,6 +33,9 @@ def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
33
33
  def font_opts(brand: dict, font: Optional[str], font_file: Optional[str]) -> str:
34
34
  if font_file or brand.get("font_file"):
35
35
  return f"fontfile={escape_filter_path(font_file or brand['font_file'])}"
36
+ resolved = default_font_file(font or brand.get("font", "DejaVu Sans"))
37
+ if resolved:
38
+ return f"fontfile={escape_filter_path(resolved)}"
36
39
  return f"font='{font or brand.get('font', 'DejaVu Sans')}'"
37
40
 
38
41
 
@@ -40,6 +43,10 @@ def main() -> int:
40
43
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
41
44
  ap.add_argument("input")
42
45
  ap.add_argument("-o", "--output", help="output file (default: <name>_gfx.<ext>)")
46
+ ap.add_argument("--audio-stream", type=int, default=0,
47
+ help="which audio stream of the input to keep, 0-based in file order (probe.py lists them under "
48
+ "audio_streams) -- matters on a multi-track input (dubbed languages, M&E stems); default 0, "
49
+ "the first track, same as leaving it unset always did")
43
50
  ap.add_argument("--template", choices=TEMPLATES, required=True)
44
51
  ap.add_argument("--brand", help="brand.json for colours, font, safe margin")
45
52
  ap.add_argument("--name", help="lower-third: name line")
@@ -70,6 +77,11 @@ def main() -> int:
70
77
  meta = probe(args.input)
71
78
  if not meta.get("video"):
72
79
  die("input has no video stream")
80
+ audio_streams = meta.get("audio_streams") or []
81
+ if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
82
+ die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
83
+ if args.audio_stream and not audio_streams:
84
+ die("--audio-stream needs an input with audio streams")
73
85
  W, H = meta["video"]["width"], meta["video"]["height"]
74
86
  if meta["video"].get("rotation") in (90, -90, 270, -270):
75
87
  W, H = H, W
@@ -149,16 +161,15 @@ def main() -> int:
149
161
  output = args.output or default_output(args.input, "gfx")
150
162
  cmd = ffmpeg_base() + ["-i", args.input]
151
163
  if fc:
152
- cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", "0:a:0?"]
164
+ cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", f"0:a:{args.audio_stream}?"]
153
165
  else:
154
- cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", "0:a:0?"]
166
+ cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
155
167
  cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
156
168
  cmd += aac_args() if meta.get("audio") else ["-an"]
157
- cmd.append(output)
158
- run(cmd)
159
- r = probe(output)
169
+ dropped_streams = run_keeping_subtitles(cmd, output)
170
+ r = probe(output, role="output")
160
171
  info(f"wrote {output} ({r['duration']:.3f}s, {args.template})")
161
- emit(output, template=args.template)
172
+ emit(output, template=args.template, dropped_non_av_streams=dropped_streams)
162
173
  return 0
163
174
 
164
175
 
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ """Turn a still image into a silent, timed video clip.
3
+
4
+ Produces a fixed-duration, constant-frame-rate video from one image -- for
5
+ example a title card, an end slate, or a placeholder to slot into join.py
6
+ alongside real footage. The output has no audio track: pair it with audio.py
7
+ or export.py's own audio handling if the surrounding edit needs sound under
8
+ the still.
9
+
10
+ --width/--height set the output frame size the same way fit.py does: give
11
+ one and the other follows the image's own aspect; give both for an exact
12
+ frame (the image is scaled to fill it, centre-cropping any excess -- never
13
+ distorted). Omit both to keep the image's native size (evened for 4:2:0).
14
+
15
+ --zoom in|out applies a Ken Burns effect: a slow, linear zoom across the
16
+ clip's duration (--zoom-amount sets the end/start zoom factor, default 1.3 =
17
+ 30% zoomed in by the end). --pan left|right|up|down drifts the visible
18
+ window across the image while zoomed (ignored, with a warning, if --zoom is
19
+ not also given -- panning needs the extra image area a zoom exposes).
20
+
21
+ Examples:
22
+ python3 insert.py title.png --duration 3
23
+ python3 insert.py slate.jpg --duration 5 --width 1920 --height 1080 --fps 30 -o slate.mp4
24
+ python3 insert.py photo.jpg --duration 6 --zoom in --pan right --width 1920 --height 1080
25
+ """
26
+ import argparse
27
+ import math
28
+ import sys
29
+
30
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
31
+
32
+
33
+ def even(n: float) -> int:
34
+ v = int(round(n))
35
+ return v if v % 2 == 0 else v + 1
36
+
37
+
38
+ def main() -> int:
39
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
40
+ ap.add_argument("input", help="still image (PNG/JPG/...)")
41
+ ap.add_argument("-o", "--output", help="output file (default: <name>_insert.mp4)")
42
+ ap.add_argument("--duration", required=True, help="clip duration (seconds or mm:ss)")
43
+ ap.add_argument("--width", type=int, help="output width in px; with --height also given, both are used directly")
44
+ ap.add_argument("--height", type=int, help="output height in px; with --width also given, both are used directly")
45
+ ap.add_argument("--fps", type=float, default=30.0, help="output frame rate (default 30)")
46
+ ap.add_argument("--zoom", choices=["in", "out"], help="Ken Burns: slow linear zoom in or out across the clip")
47
+ ap.add_argument("--zoom-amount", type=float, default=1.3, help="end (zoom in) or start (zoom out) zoom factor, > 1.0 (default 1.3)")
48
+ ap.add_argument("--pan", choices=["left", "right", "up", "down"], help="drift the visible window this direction while zoomed (needs --zoom)")
49
+ ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
50
+ ap.add_argument("--preset", default="medium", help="x264 preset")
51
+ add_common(ap)
52
+ args = ap.parse_args()
53
+ apply_common(args)
54
+
55
+ target = parse_time(args.duration)
56
+ if target <= 0:
57
+ die("--duration must be > 0")
58
+ if args.fps <= 0:
59
+ die("--fps must be > 0")
60
+ if args.zoom_amount <= 1.0:
61
+ die(f"--zoom-amount must be > 1.0, got {args.zoom_amount}")
62
+ if args.pan and not args.zoom:
63
+ die("--pan needs --zoom in|out")
64
+
65
+ meta = probe(args.input)
66
+ if not meta.get("video"):
67
+ die("input has no image/video stream")
68
+ sw, sh = meta["video"]["width"], meta["video"]["height"]
69
+ ratio = sw / sh
70
+
71
+ if args.width and args.height:
72
+ out_w, out_h = even(args.width), even(args.height)
73
+ elif args.width:
74
+ out_w = even(args.width)
75
+ out_h = even(out_w / ratio)
76
+ elif args.height:
77
+ out_h = even(args.height)
78
+ out_w = even(out_h * ratio)
79
+ else:
80
+ out_w, out_h = even(sw), even(sh)
81
+
82
+ if args.zoom:
83
+ frames = max(1, round(target * args.fps))
84
+ amount = args.zoom_amount
85
+ if args.zoom == "in":
86
+ zexpr = f"if(eq(on,0),1,min(zoom+{(amount - 1) / frames:.8f},{amount:g}))"
87
+ else:
88
+ zexpr = f"if(eq(on,0),{amount:g},max(zoom-{(amount - 1) / frames:.8f},1))"
89
+ pan_x = {
90
+ "left": f"(iw-iw/zoom)*(1-on/{frames})",
91
+ "right": f"(iw-iw/zoom)*on/{frames}",
92
+ }.get(args.pan, "iw/2-(iw/zoom/2)")
93
+ pan_y = {
94
+ "up": f"(ih-ih/zoom)*(1-on/{frames})",
95
+ "down": f"(ih-ih/zoom)*on/{frames}",
96
+ }.get(args.pan, "ih/2-(ih/zoom/2)")
97
+ # zoompan samples from the still at its native resolution; scale it up first so the
98
+ # zoomed-in crop still has real pixels to draw from instead of upscaling blur.
99
+ upscale = max(2, math.ceil(amount * 2))
100
+ vf = [
101
+ f"scale={out_w * upscale}:{out_h * upscale}:force_original_aspect_ratio=increase",
102
+ f"crop={out_w * upscale}:{out_h * upscale}",
103
+ f"zoompan=z='{zexpr}':x='{pan_x}':y='{pan_y}':d={frames}:s={out_w}x{out_h}:fps={args.fps:g}",
104
+ "setsar=1",
105
+ ]
106
+ else:
107
+ vf = [
108
+ f"scale={out_w}:{out_h}:force_original_aspect_ratio=increase",
109
+ f"crop={out_w}:{out_h}",
110
+ "setsar=1",
111
+ f"fps={args.fps:g}",
112
+ ]
113
+
114
+ output = args.output or default_output(args.input, "insert", "mp4")
115
+ cmd = ffmpeg_base() + ["-loop", "1", "-i", args.input, "-t", f"{target:.3f}", "-vf", ",".join(vf)]
116
+ cmd += video_args(None, args.crf, args.preset)
117
+ cmd += ["-an", output]
118
+ run(cmd)
119
+
120
+ result = probe(output, role="output")
121
+ v = result["video"]
122
+ info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['fps']:g}fps)")
123
+ emit(output)
124
+ return 0
125
+
126
+
127
+ if __name__ == "__main__":
128
+ sys.exit(main())
package/scripts/join.py CHANGED
@@ -121,9 +121,11 @@ def main() -> int:
121
121
  if args.width and args.height:
122
122
  w, h = args.width, args.height
123
123
  elif args.width:
124
- w, h = args.width, int(round(args.width * fh / fw))
124
+ w = args.width
125
+ h = int(round(args.width * fh / fw)) if fw else args.width
125
126
  elif args.height:
126
- w, h = int(round(args.height * fw / fh)), args.height
127
+ h = args.height
128
+ w = int(round(args.height * fw / fh)) if fh else args.height
127
129
  else:
128
130
  w, h = fw, fh
129
131
  fps = args.fps or first.get("fps") or 30.0
@@ -141,15 +143,22 @@ def main() -> int:
141
143
  n = len(args.inputs)
142
144
  for i, (p, m) in enumerate(zip(args.inputs, metas)):
143
145
  cmd += ["-i", p]
144
- # silent audio for clips without an audio track
146
+ # silent audio for clips without an audio track. `idx` is this ffmpeg input's position, i.e. n +
147
+ # how many synthetic inputs were already added -- not len(extra_inputs), which counts the six
148
+ # argv tokens ("-f", "lavfi", "-t", duration, "-i", "anullsrc=...") each synthetic input adds, not
149
+ # the input itself. With one no-audio clip both counts coincide (n + 0); from the second no-audio
150
+ # clip onward they diverge, and the previous `n + len(extra_inputs)` named a nonexistent, far-out-of-
151
+ # range ffmpeg input index -- found via a real multi-camera join where every clip lacked audio.
145
152
  audio_src: List[str] = []
153
+ added = 0
146
154
  for i, m in enumerate(metas):
147
155
  if m.get("audio"):
148
156
  audio_src.append(f"{i}:a:0")
149
157
  else:
150
- idx = n + len(extra_inputs)
158
+ idx = n + added
151
159
  extra_inputs += ["-f", "lavfi", "-t", f"{durs[i]:.3f}", "-i", "anullsrc=r=48000:cl=stereo"]
152
160
  audio_src.append(f"{idx}:a:0")
161
+ added += 1
153
162
  cmd += extra_inputs
154
163
 
155
164
  if args.fit == "crop":
@@ -180,7 +189,7 @@ def main() -> int:
180
189
  cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + [output]
181
190
  run(cmd)
182
191
  expected = sum(durs) - d * (n - 1)
183
- r = probe(output)
192
+ r = probe(output, role="output")
184
193
  info(f"wrote {output} ({r['duration']:.3f}s, expected ~{expected:.3f}s, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
185
194
  emit(output, mode="video", clips=n, transition=args.transition, expected_duration=round(expected, 3))
186
195
  return 0
package/scripts/look.py CHANGED
@@ -15,7 +15,7 @@ import sys
15
15
  from pathlib import Path
16
16
  from typing import List
17
17
 
18
- from _common import add_common, apply_common, die, emit, escape_drawtext, ffmpeg_base, info, parse_time, probe, run
18
+ from _common import add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run
19
19
 
20
20
  FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
21
21
 
@@ -26,8 +26,8 @@ def fmt_hms(sec: float) -> str:
26
26
  return f"{int(h):02d}:{int(m):02d}:{s_:06.3f}"
27
27
 
28
28
 
29
- def timecode_filter() -> str:
30
- return f"drawtext=text='%{{pts\\:hms}}':{FONT}"
29
+ def timecode_filter(font_prefix: str) -> str:
30
+ return f"drawtext=text='%{{pts\\:hms}}':{font_prefix}{FONT}"
31
31
 
32
32
 
33
33
  def main() -> int:
@@ -49,7 +49,12 @@ def main() -> int:
49
49
  dur = meta.get("duration") or 0.0
50
50
  stem = Path(args.input).stem
51
51
  outdir = str(Path(args.output).parent) if args.output else str(Path(args.input).parent)
52
- tc = "" if args.no_timecode else "," + timecode_filter()
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:
@@ -24,13 +24,13 @@ from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_c
24
24
 
25
25
  def measure(path: str, I: float, tp: float, lra: float) -> dict:
26
26
  if STATE["dry_run"]:
27
- return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0"}
27
+ return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0", "silent": False}
28
28
  ffmpeg = require_tool("ffmpeg")
29
29
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
30
30
  proc = run(cmd, check=False)
31
31
  m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
32
32
  if proc.returncode != 0 or not m:
33
- die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}")
33
+ die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
34
34
  data = json.loads(m.group(0))
35
35
  for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset"):
36
36
  if data.get(k) in (None, "-inf", "inf", "nan"):
@@ -89,7 +89,7 @@ def main() -> int:
89
89
  after = measure(output, args.lufs, args.tp, args.lra)
90
90
  if not after.get("silent"):
91
91
  info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS)")
92
- emit(output)
92
+ emit(output, result={k: after[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")})
93
93
  return 0
94
94
 
95
95
 
@@ -197,7 +197,7 @@ def main() -> int:
197
197
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
198
198
  cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + ["-shortest", output]
199
199
  run(cmd)
200
- r = probe(output)
200
+ r = probe(output, role="output")
201
201
  info(f"wrote {output} ({r['duration']:.3f}s, {len(filled)} cuts, audio from input {a})")
202
202
  emit(output, cuts=[[round(s, 3), round(e, 3), c] for s, e, c in filled], **report)
203
203
  return 0