@topy-ai/maggie 0.8.10 → 0.8.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env python3
2
+ """maggie_image - generate correct image-processing commands for marketing assets.
3
+
4
+ A recipe generator distilled from ImageMagick, sharp, svgo, resvg, squoosh and
5
+ imagemin (see references/image-recipes.md). Each subcommand prints the exact command
6
+ (reviewable/loggable) and runs it with --run only when the tool is on PATH. Raster
7
+ ops prefer ImageMagick (`magick` on IM7, else `convert`) since it is almost always
8
+ present and covers nearly everything; vector ops use the dedicated tool (svgo, resvg)
9
+ and are generate-only until it is installed. Web-delivery quality defaults follow
10
+ sharp/squoosh (JPEG/WebP ~80, AVIF ~50); OG/social are cover-cropped, logos pad.
11
+
12
+ Subcommands: resize, social, webp, avif, favicon, strip, compress, svgo, svg2png,
13
+ montage, presets.
14
+ """
15
+ import argparse
16
+ import shlex
17
+ import shutil
18
+ import subprocess
19
+ import sys
20
+
21
+ # OG/social use cover-crop; name -> (W, H)
22
+ SOCIAL = {"og": (1200, 630), "twitter": (1600, 900), "square": (1080, 1080),
23
+ "story": (1080, 1920), "reel": (1080, 1920), "landscape": (1920, 1080),
24
+ "linkedin": (1200, 627), "pinterest": (1000, 1500)}
25
+ FAVICON_PNG = [16, 32, 48, 180, 192, 512] # 180 = apple-touch
26
+
27
+
28
+ def _im():
29
+ return ["magick"] if shutil.which("magick") else ["convert"]
30
+
31
+
32
+ def _im_tool():
33
+ return _im()[0]
34
+
35
+
36
+ def _montage():
37
+ return ["magick", "montage"] if shutil.which("magick") else ["montage"]
38
+
39
+
40
+ def emit(args, run, tool):
41
+ print(" ".join(shlex.quote(a) for a in args))
42
+ if not run:
43
+ return 0
44
+ if not shutil.which(tool):
45
+ sys.stderr.write(f"{tool} not found on PATH; printed the command only.\n")
46
+ return 3
47
+ return subprocess.run(args).returncode
48
+
49
+
50
+ def _resize_ops(w, h, mode, pad):
51
+ if mode == "cover":
52
+ return ["-resize", f"{w}x{h}^", "-gravity", "center", "-extent", f"{w}x{h}"]
53
+ if mode == "contain":
54
+ return ["-resize", f"{w}x{h}", "-background", pad, "-gravity", "center", "-extent", f"{w}x{h}"]
55
+ if mode == "shrink":
56
+ return ["-resize", f"{w}x{h}>"]
57
+ return ["-resize", f"{w}x{h}"] # fit
58
+
59
+
60
+ def c_resize(a):
61
+ w, h = a.size.lower().split("x")
62
+ cmd = _im() + [a.input, "-strip"] + _resize_ops(w, h, a.mode, a.pad) + \
63
+ ["-quality", str(a.quality), a.output]
64
+ return emit(cmd, a.run, _im_tool())
65
+
66
+
67
+ def c_social(a):
68
+ w, h = SOCIAL[a.preset]
69
+ cmd = _im() + [a.input, "-strip"] + _resize_ops(w, h, a.mode, a.pad) + \
70
+ ["-quality", str(a.quality), a.output]
71
+ return emit(cmd, a.run, _im_tool())
72
+
73
+
74
+ def c_webp(a):
75
+ if shutil.which("cwebp") and not a.force_im:
76
+ return emit(["cwebp", "-q", str(a.quality), "-m", "6", a.input, "-o", a.output], a.run, "cwebp")
77
+ cmd = _im() + [a.input, "-strip", "-quality", str(a.quality), "-define", "webp:method=6", a.output]
78
+ return emit(cmd, a.run, _im_tool())
79
+
80
+
81
+ def c_avif(a):
82
+ if shutil.which("avifenc") and not a.force_im:
83
+ return emit(["avifenc", "-s", "6", "--min", "20", "--max", "40", a.input, a.output], a.run, "avifenc")
84
+ cmd = _im() + [a.input, "-strip", "-quality", str(a.quality), a.output]
85
+ return emit(cmd, a.run, _im_tool())
86
+
87
+
88
+ def c_favicon(a):
89
+ import os
90
+ os.makedirs(a.out_dir, exist_ok=True)
91
+ rc = 0
92
+ ico = os.path.join(a.out_dir, "favicon.ico")
93
+ rc |= emit(_im() + [a.input, "-define", "icon:auto-resize=64,48,32,16", ico], a.run, _im_tool())
94
+ for s in FAVICON_PNG:
95
+ name = "apple-touch-icon.png" if s == 180 else f"favicon-{s}.png"
96
+ out = os.path.join(a.out_dir, name)
97
+ rc |= emit(_im() + [a.input, "-strip", "-resize", f"{s}x{s}", out], a.run, _im_tool())
98
+ return rc
99
+
100
+
101
+ def c_strip(a):
102
+ return emit(_im() + [a.input, "-strip", a.output], a.run, _im_tool())
103
+
104
+
105
+ def c_compress(a):
106
+ out = a.output.lower()
107
+ if out.endswith((".jpg", ".jpeg")):
108
+ cmd = _im() + [a.input, "-strip", "-interlace", "Plane", "-quality", str(a.quality), a.output]
109
+ else: # png: palette-reduce + max zlib (pngquant is the better alt)
110
+ if shutil.which("pngquant") and not a.force_im:
111
+ return emit(["pngquant", "--quality", "60-82", "--strip", "--force",
112
+ "--output", a.output, a.input], a.run, "pngquant")
113
+ cmd = _im() + [a.input, "-strip", "-colors", "256",
114
+ "-define", "png:compression-level=9", a.output]
115
+ return emit(cmd, a.run, _im_tool())
116
+
117
+
118
+ def c_svgo(a):
119
+ args = ["svgo", "--multipass"] if a.multipass else ["svgo"]
120
+ args += ["-i", a.input, "-o", a.output]
121
+ print("# svgo keeps viewBox and title by default (responsive + accessible).")
122
+ if not shutil.which("svgo"):
123
+ print("# svgo not installed (npm i -g svgo). Do NOT rasterize an SVG as a substitute.")
124
+ return emit(args, a.run, "svgo")
125
+
126
+
127
+ def c_svg2png(a):
128
+ if shutil.which("resvg") and not a.force_im:
129
+ args = ["resvg"]
130
+ if a.width:
131
+ args += ["-w", str(a.width)]
132
+ if a.background:
133
+ args += ["--background", a.background]
134
+ args += [a.input, a.output]
135
+ return emit(args, a.run, "resvg")
136
+ if shutil.which("rsvg-convert") and not a.force_im:
137
+ args = ["rsvg-convert"]
138
+ if a.width:
139
+ args += ["-w", str(a.width)]
140
+ return emit(args + [a.input, "-o", a.output], a.run, "rsvg-convert")
141
+ # ImageMagick fallback: drive size via -density (it rasterizes at DPI); lower fidelity
142
+ dens = 384
143
+ bg = ["-background", a.background] if a.background else ["-background", "none"]
144
+ size = ["-resize", f"{a.width}x"] if a.width else []
145
+ print("# ImageMagick SVG fallback is lower fidelity; install resvg for correct rendering.")
146
+ return emit(_im() + bg + ["-density", str(dens), a.input] + size + [a.output], a.run, _im_tool())
147
+
148
+
149
+ def c_montage(a):
150
+ cmd = _montage() + list(a.inputs) + ["-tile", a.tile, "-geometry",
151
+ f"{a.cell}+{a.pad}+{a.pad}", "-background", a.background, a.output]
152
+ return emit(cmd, a.run, _montage()[0])
153
+
154
+
155
+ def c_presets(a):
156
+ print("Social/OG size presets (use with `social --preset <name>`; cover-cropped):")
157
+ for name, (w, h) in SOCIAL.items():
158
+ print(f" {name:<10} {w}x{h}")
159
+ print("\nDefaults: strip metadata; JPEG/WebP quality 80, AVIF 50; favicon .ico "
160
+ "(64/48/32/16) + PNG set (16/32/48/180-apple/192/512).")
161
+ print(f"ImageMagick binary detected: {_im_tool()}")
162
+ return 0
163
+
164
+
165
+ def main(argv=None):
166
+ ap = argparse.ArgumentParser(prog="maggie_image", description=__doc__,
167
+ formatter_class=argparse.RawDescriptionHelpFormatter)
168
+ ap.add_argument("--run", action="store_true", help="execute (needs the tool on PATH); default prints only")
169
+ ap.add_argument("--force-im", action="store_true", dest="force_im",
170
+ help="force the ImageMagick path even if a dedicated tool exists")
171
+ sub = ap.add_subparsers(dest="cmd", required=True)
172
+
173
+ def io(p, out=True):
174
+ p.add_argument("--input", required=True)
175
+ if out:
176
+ p.add_argument("--output", required=True)
177
+
178
+ r = sub.add_parser("resize", help="resize+fit to WxH")
179
+ io(r); r.add_argument("--size", required=True, help="WxH, e.g. 1200x630")
180
+ r.add_argument("--mode", choices=["cover", "contain", "fit", "shrink"], default="cover")
181
+ r.add_argument("--pad", default="#ffffff"); r.add_argument("--quality", type=int, default=82)
182
+ r.set_defaults(fn=c_resize)
183
+
184
+ s = sub.add_parser("social", help="resize to a channel/OG preset")
185
+ io(s); s.add_argument("--preset", choices=list(SOCIAL), required=True)
186
+ s.add_argument("--mode", choices=["cover", "contain", "fit", "shrink"], default="cover")
187
+ s.add_argument("--pad", default="#ffffff"); s.add_argument("--quality", type=int, default=82)
188
+ s.set_defaults(fn=c_social)
189
+
190
+ w = sub.add_parser("webp", help="encode WebP"); io(w)
191
+ w.add_argument("--quality", type=int, default=80); w.set_defaults(fn=c_webp)
192
+ av = sub.add_parser("avif", help="encode AVIF"); io(av)
193
+ av.add_argument("--quality", type=int, default=50); av.set_defaults(fn=c_avif)
194
+
195
+ fv = sub.add_parser("favicon", help="favicon .ico + PNG set")
196
+ fv.add_argument("--input", required=True); fv.add_argument("--out-dir", dest="out_dir", required=True)
197
+ fv.set_defaults(fn=c_favicon)
198
+
199
+ st = sub.add_parser("strip", help="strip metadata (EXIF/GPS/ICC)"); io(st); st.set_defaults(fn=c_strip)
200
+ co = sub.add_parser("compress", help="compress JPEG/PNG"); io(co)
201
+ co.add_argument("--quality", type=int, default=82); co.set_defaults(fn=c_compress)
202
+
203
+ so = sub.add_parser("svgo", help="optimize an SVG (svgo)"); io(so)
204
+ so.add_argument("--multipass", action="store_true"); so.set_defaults(fn=c_svgo)
205
+ sp = sub.add_parser("svg2png", help="render SVG -> PNG (resvg)"); io(sp)
206
+ sp.add_argument("--width", type=int); sp.add_argument("--background"); sp.set_defaults(fn=c_svg2png)
207
+
208
+ mo = sub.add_parser("montage", help="contact sheet / grid")
209
+ mo.add_argument("--inputs", nargs="+", required=True); mo.add_argument("--output", required=True)
210
+ mo.add_argument("--tile", default="3x"); mo.add_argument("--cell", default="400x400")
211
+ mo.add_argument("--pad", type=int, default=10); mo.add_argument("--background", default="#ffffff")
212
+ mo.set_defaults(fn=c_montage)
213
+
214
+ sub.add_parser("presets", help="list size presets").set_defaults(fn=c_presets)
215
+
216
+ a = ap.parse_args(argv)
217
+ raise SystemExit(a.fn(a))
218
+
219
+
220
+ if __name__ == "__main__":
221
+ main()
@@ -9,7 +9,7 @@ LGPL/GPL use); it links no libav and vendors no GPL code. Output is always force
9
9
  to a web-safe, platform-correct profile.
10
10
 
11
11
  Subcommands: encode, gif, trim, concat, social, overlay, subtitle, loudnorm,
12
- thumb, compile, captions, presets.
12
+ thumb, compile, autocut, captions, presets.
13
13
  """
14
14
  import argparse
15
15
  import json
@@ -161,28 +161,36 @@ def _clip_dur(clip, default):
161
161
  return float(default)
162
162
 
163
163
 
164
- def _kenburns(w, h, fps, dur, direction="in", amount=0.12):
165
- """A ken-burns (slow zoom) filter chain for an image clip, fit to WxH.
166
- Prescales with headroom so the zoom stays sharp; animates over the clip."""
164
+ def _kenburns(w, h, fps, dur, direction="in", amount=0.12, normalize_fps=False):
165
+ """A ken-burns (slow zoom) filter chain, fit to WxH. Prescales with headroom so
166
+ the zoom stays sharp and animates over the clip (d=1 keeps it smooth on video
167
+ too). normalize_fps prepends an fps filter so a video clip's input frame count
168
+ matches dur*fps (image inputs are already pinned via -framerate)."""
167
169
  bw, bh = int(w * 1.6), int(h * 1.6)
168
170
  n = max(1, round(dur * fps))
169
171
  if direction == "out":
170
172
  z = f"(1+{amount})-{amount}*in/{n}"
171
173
  else:
172
174
  z = f"1+{amount}*in/{n}"
173
- return (f"scale={bw}:{bh}:force_original_aspect_ratio=increase,crop={bw}:{bh},"
175
+ lead = f"fps={fps}," if normalize_fps else ""
176
+ return (f"{lead}scale={bw}:{bh}:force_original_aspect_ratio=increase,crop={bw}:{bh},"
174
177
  f"zoompan=z='{z}':d=1:x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':"
175
178
  f"s={w}x{h}:fps={fps},setsar=1,format=yuv420p")
176
179
 
177
180
 
178
- def _kf_alpha_expr(keyframes, offset):
179
- """A piecewise-linear ffmpeg expression (in t, seconds) for an opacity track."""
180
- pts = [(offset + float(k["t"]), float(k["v"])) for k in keyframes]
181
- expr = f"{pts[-1][1]}"
182
- for (ta, va), (tb, vb) in reversed(list(zip(pts, pts[1:]))):
181
+ def _kf_expr(points):
182
+ """A piecewise-linear ffmpeg expression in t (seconds) from (t_abs, value) points.
183
+ Holds the first/last value outside the keyframed range."""
184
+ expr = f"{points[-1][1]}"
185
+ for (ta, va), (tb, vb) in reversed(list(zip(points, points[1:]))):
183
186
  seg = f"({va}+({vb}-{va})*(t-{ta})/({tb - ta}))"
184
187
  expr = f"if(lt(t,{tb}),{seg},{expr})"
185
- return f"if(lt(t,{pts[0][0]}),{pts[0][1]},{expr})"
188
+ return f"if(lt(t,{points[0][0]}),{points[0][1]},{expr})"
189
+
190
+
191
+ def _kf_alpha_expr(keyframes, offset):
192
+ """Opacity (alpha) expression in t from keyframes relative to the clip offset."""
193
+ return _kf_expr([(offset + float(k["t"]), float(k["v"])) for k in keyframes])
186
194
 
187
195
 
188
196
  def _opacity_kfs(clip):
@@ -192,6 +200,29 @@ def _opacity_kfs(clip):
192
200
  return None
193
201
 
194
202
 
203
+ def _move_exprs(clip, offset, W, H, base_x, base_y):
204
+ """x/y ffmpeg expressions (in t) for a move/slide effect, else (None, None).
205
+ Effect: {type: move|slide, x:[{t,v}...], y:[{t,v}...]} with v as %/px/0..1."""
206
+ for e in clip.get("effects", []) or []:
207
+ if e.get("type") in ("move", "slide", "position"):
208
+ xe = ye = None
209
+ if e.get("x"):
210
+ xe = _kf_expr([(offset + float(k["t"]), _px(k["v"], W)) for k in e["x"]])
211
+ if e.get("y"):
212
+ ye = _kf_expr([(offset + float(k["t"]), _px(k["v"], H)) for k in e["y"]])
213
+ return (xe if xe is not None else base_x, ye if ye is not None else base_y)
214
+ return (None, None)
215
+
216
+
217
+ def _overlay_zoom_cfg(clip):
218
+ """direction, amount for an image-overlay zoom effect, else None."""
219
+ for e in clip.get("effects", []) or []:
220
+ if e.get("type") == "zoom" and e.get("keyframes"):
221
+ vs = [float(k["v"]) for k in e["keyframes"]]
222
+ return ("out" if vs[-1] < vs[0] else "in"), (abs(vs[-1] - vs[0]) or 0.15)
223
+ return None
224
+
225
+
195
226
  def _kenburns_cfg(clip):
196
227
  kb = clip.get("kenBurns")
197
228
  if isinstance(kb, dict):
@@ -204,6 +235,13 @@ def _kenburns_cfg(clip):
204
235
  return None
205
236
 
206
237
 
238
+ def _color(c):
239
+ """Normalize a CSS-ish color for ffmpeg: expand #abc -> #aabbcc; pass the rest."""
240
+ if isinstance(c, str) and c.startswith("#") and len(c) == 4:
241
+ return "#" + "".join(ch * 2 for ch in c[1:])
242
+ return c
243
+
244
+
207
245
  def _px(val, total):
208
246
  """Resolve '80%' / 0.8 / 120 against a total dimension to an int pixel value."""
209
247
  if isinstance(val, str) and val.strip().endswith("%"):
@@ -260,9 +298,11 @@ def c_compile(a):
260
298
  idx = add_input([], asset.get("src", ""))
261
299
  s = clip.get("start", 0)
262
300
  trim = f"trim=start={s}:end={s + dur},setpts=PTS-STARTPTS,"
263
- filters.append(f"[{idx}:v]{trim}{_fit(W, H, fit, FPS)}[v{i}]")
264
301
  if kb:
265
- notes.append(f"clip {i}: ken-burns on a video clip not compiled (image clips only); shown as plain fit")
302
+ chain = _kenburns(W, H, FPS, dur, kb[0], kb[1], normalize_fps=True)
303
+ else:
304
+ chain = _fit(W, H, fit, FPS)
305
+ filters.append(f"[{idx}:v]{trim}{chain}[v{i}]")
266
306
  else:
267
307
  notes.append(f"clip {i}: unsupported type (need asset of type image/video)")
268
308
  continue
@@ -300,19 +340,19 @@ def c_compile(a):
300
340
  dur = _clip_dur(clip, def_dur)
301
341
  en = f"enable='between(t,{pos},{pos + dur})'"
302
342
  op_kfs = _opacity_kfs(clip)
303
- if clip.get("effects") and not op_kfs:
304
- notes.append("overlay effect other than opacity (e.g. zoom) not compiled on this overlay")
305
343
  ctype = clip.get("type")
306
344
  if ctype == "text":
307
345
  st = clip.get("style", {})
308
346
  rect = clip.get("rect", {})
309
- x = _px(rect.get("x", "10%"), W)
310
- y = _px(rect.get("y", "80%"), H)
347
+ bx, by = _px(rect.get("x", "10%"), W), _px(rect.get("y", "80%"), H)
348
+ mx, my = _move_exprs(clip, pos, W, H, bx, by)
349
+ xv = f"'{mx}'" if mx is not None else f"{bx}"
350
+ yv = f"'{my}'" if my is not None else f"{by}"
311
351
  txt = str(clip.get("text", "")).replace(":", r"\:").replace("'", "’")
312
- box = f":box=1:boxcolor={st['box']}:boxborderw=12" if st.get("box") else ""
352
+ box = f":box=1:boxcolor={_color(st['box'])}:boxborderw=12" if st.get("box") else ""
313
353
  alpha = f":alpha='{_kf_alpha_expr(op_kfs, pos)}'" if op_kfs else ""
314
- dt = (f"drawtext=text='{txt}':x={x}:y={y}:fontsize={st.get('size',48)}:"
315
- f"fontcolor={st.get('color','#FFFFFF')}{box}{alpha}:{en}")
354
+ dt = (f"drawtext=text='{txt}':x={xv}:y={yv}:fontsize={st.get('size',48)}:"
355
+ f"fontcolor={_color(st.get('color','#FFFFFF'))}{box}{alpha}:{en}")
316
356
  filters.append(f"{cur}{dt}{nxt}")
317
357
  elif ctype == "captions":
318
358
  src = clip.get("src", "")
@@ -321,46 +361,75 @@ def c_compile(a):
321
361
  asset = assets[clip["asset"]]
322
362
  rect = clip.get("rect", {})
323
363
  ow = _px(rect.get("w", "15%"), W)
324
- idx = add_input(["-loop", "1"], asset.get("src", ""))
325
- filters.append(f"[{idx}:v]scale={ow}:-1[ov{step}]")
326
- x = _px(rect.get("x", "80%"), W)
327
- y = _px(rect.get("y", "5%"), H)
328
- filters.append(f"{cur}[ov{step}]overlay={x}:{y}:{en}:eof_action=pass{nxt}")
364
+ bx, by = _px(rect.get("x", "80%"), W), _px(rect.get("y", "5%"), H)
365
+ zoom = _overlay_zoom_cfg(clip)
366
+ if zoom and rect.get("h") not in (None, "auto"):
367
+ oh = _px(rect.get("h"), H)
368
+ idx = add_input(["-loop", "1", "-framerate", f"{FPS}", "-t", f"{dur}"],
369
+ asset.get("src", ""))
370
+ filters.append(f"[{idx}:v]{_kenburns(ow, oh, FPS, dur, zoom[0], zoom[1])}[ov{step}]")
371
+ else:
372
+ if zoom:
373
+ notes.append("image-overlay zoom needs rect w+h; placed at a static size "
374
+ "(use a programmatic engine for free-form overlay scaling)")
375
+ idx = add_input(["-loop", "1"], asset.get("src", ""))
376
+ filters.append(f"[{idx}:v]scale={ow}:-1[ov{step}]")
377
+ mx, my = _move_exprs(clip, pos, W, H, bx, by)
378
+ xv = f"x='{mx}'" if mx is not None else f"x={bx}"
379
+ yv = f"y='{my}'" if my is not None else f"y={by}"
380
+ filters.append(f"{cur}[ov{step}]overlay={xv}:{yv}:{en}:eof_action=pass{nxt}")
329
381
  else:
330
382
  notes.append(f"overlay clip of type {ctype!r} not compiled")
331
383
  continue
384
+ if clip.get("effects") and not (op_kfs or _move_exprs(clip, pos, W, H, 0, 0) != (None, None) or _overlay_zoom_cfg(clip)):
385
+ notes.append("an overlay effect was not recognised (supported: opacity, move/slide, zoom on sized images)")
332
386
  cur = nxt
333
387
  step += 1
334
388
  filters.append(f"{cur}format=yuv420p[vout]")
335
389
 
336
- # --- audio: mix audio[] tracks with gain + optional loudnorm ---
337
- aud = spec.get("audio", [])
338
- alabels = []
390
+ # --- audio: mix audio[] tracks with gain, sidechain ducking, loudnorm ---
391
+ aud = [t for t in spec.get("audio", []) if assets.get(t.get("asset"), {}).get("src")]
392
+ # which assets are used as a ducking key (the VO under which music ducks)
393
+ keys = {t["ducking"]["under"] for t in aud if t.get("ducking") and t["ducking"].get("under")}
394
+ asset_track = {}
395
+ raw = {} # track index -> its raw [araw{i}] label
339
396
  for i, tr in enumerate(aud):
340
- asset = assets.get(tr.get("asset"), {})
341
- if not asset.get("src"):
342
- continue
343
- idx = add_input([], asset["src"])
344
- chain = f"[{idx}:a]"
397
+ idx = add_input([], assets[tr["asset"]]["src"])
398
+ parts = []
345
399
  pos = float(tr.get("position", 0))
346
400
  if pos > 0:
347
401
  ms = int(pos * 1000)
348
- chain += f"adelay={ms}|{ms},"
349
- gain = tr.get("gain", 0)
350
- if gain:
351
- chain += f"volume={gain}dB,"
352
- if tr.get("ducking"):
353
- notes.append("audio ducking (sidechain) not compiled; set music gain lower instead")
354
- chain = chain.rstrip(",") + f"[a{i}]"
355
- filters.append(chain)
356
- alabels.append(f"[a{i}]")
402
+ parts.append(f"adelay={ms}|{ms}")
403
+ if tr.get("gain"):
404
+ parts.append(f"volume={tr['gain']}dB")
405
+ chain = ",".join(parts) if parts else "anull"
406
+ filters.append(f"[{idx}:a]{chain}[araw{i}]")
407
+ raw[i] = f"[araw{i}]"
408
+ asset_track.setdefault(tr["asset"], i)
409
+ # split any track that serves as a ducking key into a mix copy and a key copy
410
+ mix = dict(raw)
411
+ keyl = {}
412
+ for aid in keys:
413
+ j = asset_track.get(aid)
414
+ if j is None:
415
+ continue
416
+ filters.append(f"{raw[j]}asplit=2[amx{j}][akey{j}]")
417
+ mix[j], keyl[aid] = f"[amx{j}]", f"[akey{j}]"
418
+ # apply sidechain ducking: music under its key VO
419
+ for i, tr in enumerate(aud):
420
+ d = tr.get("ducking")
421
+ if d and keyl.get(d.get("under")):
422
+ filters.append(f"{mix[i]}{keyl[d['under']]}sidechaincompress="
423
+ f"threshold=0.03:ratio=8:attack=20:release=300[aduck{i}]")
424
+ mix[i] = f"[aduck{i}]"
425
+ notes.append(f"ducking: '{tr['asset']}' compressed under '{d['under']}' (sidechain)")
426
+ alabels = [mix[i] for i in range(len(aud))]
357
427
  have_audio = bool(alabels)
358
428
  if have_audio:
359
429
  if len(alabels) == 1:
360
430
  filters.append(f"{alabels[0]}aresample=async=1[amix]")
361
431
  else:
362
- filters.append(f"{''.join(alabels)}amix=inputs={len(alabels)}:duration=longest[amix]")
363
- norm = next((t for t in aud if isinstance(t, dict)), {}) # master normalize lives on audio? use output
432
+ filters.append(f"{''.join(alabels)}amix=inputs={len(alabels)}:duration=longest:normalize=0[amix]")
364
433
  tgt = (spec.get("audio_normalize") or {}).get("I", -14)
365
434
  filters.append(f"[amix]loudnorm=I={tgt}:TP=-1.5:LRA=11[aout]")
366
435
 
@@ -374,6 +443,61 @@ def c_compile(a):
374
443
  return emit(cmd, a.run)
375
444
 
376
445
 
446
+ def c_autocut(a):
447
+ """Remove silent gaps automatically (auto-editor's idea, via ffmpeg
448
+ silencedetect). Prints the detect command; with --run it detects, computes the
449
+ kept ranges (padded by --margin), and renders the trimmed video+audio."""
450
+ import re
451
+ detect = ["ffmpeg", "-hide_banner", "-i", a.input, "-af",
452
+ f"silencedetect=noise={a.noise}:d={a.min_silence}", "-f", "null", "-"]
453
+ print("# step 1 (detect): " + " ".join(shlex.quote(x) for x in detect))
454
+ print("# step 2: kept ranges = complement of detected silence, padded by --margin, then trim+concat.")
455
+ if not a.run:
456
+ print("# re-run with --run to detect and render the cut (needs ffmpeg + ffprobe).")
457
+ return 0
458
+ if not (shutil.which("ffmpeg") and shutil.which("ffprobe")):
459
+ sys.stderr.write("autocut --run needs ffmpeg + ffprobe on PATH.\n")
460
+ return 3
461
+ total = float(subprocess.run(["ffprobe", "-v", "error", "-show_entries",
462
+ "format=duration", "-of", "csv=p=0", a.input],
463
+ capture_output=True, text=True).stdout.strip() or 0)
464
+ err = subprocess.run(detect, capture_output=True, text=True).stderr
465
+ starts = [float(x) for x in re.findall(r"silence_start: ([0-9.]+)", err)]
466
+ ends = [float(x) for x in re.findall(r"silence_end: ([0-9.]+)", err)]
467
+ if len(ends) < len(starts): # trailing silence runs to the end
468
+ ends.append(total)
469
+ sil = list(zip(starts, ends))
470
+ keep, cur = [], 0.0
471
+ for s, e in sil:
472
+ if s > cur:
473
+ keep.append([cur, s])
474
+ cur = max(cur, e)
475
+ if cur < total:
476
+ keep.append([cur, total])
477
+ m = a.margin
478
+ padded = [[max(0.0, s - m), min(total, e + m)] for s, e in keep]
479
+ merged = []
480
+ for seg in padded:
481
+ if merged and seg[0] <= merged[-1][1]:
482
+ merged[-1][1] = max(merged[-1][1], seg[1])
483
+ else:
484
+ merged.append(seg)
485
+ print(f"# detected {len(sil)} silence gap(s); keeping {len(merged)} segment(s), "
486
+ f"{round(sum(e - s for s, e in merged), 2)}s of {round(total, 2)}s")
487
+ if not merged:
488
+ sys.stderr.write("autocut: everything was silence; nothing to keep.\n")
489
+ return 1
490
+ parts, labels = [], []
491
+ for i, (s, e) in enumerate(merged):
492
+ parts.append(f"[0:v]trim=start={s}:end={e},setpts=PTS-STARTPTS[v{i}]")
493
+ parts.append(f"[0:a]atrim=start={s}:end={e},asetpts=PTS-STARTPTS[a{i}]")
494
+ labels.append(f"[v{i}][a{i}]")
495
+ fc = ";".join(parts) + f";{''.join(labels)}concat=n={len(merged)}:v=1:a=1[v][a]"
496
+ cmd = PREFIX + ["-i", a.input, "-filter_complex", fc, "-map", "[v]", "-map", "[a]"] + \
497
+ MP4_OUT + ["-c:a", "aac", "-b:a", "128k", a.output]
498
+ return emit(cmd, True)
499
+
500
+
377
501
  def c_captions(a):
378
502
  """Transcribe speech to an SRT with a local, offline engine (no paid API).
379
503
  Pairs with `subtitle` (burn in) and `compile` (a captions overlay)."""
@@ -470,6 +594,14 @@ def main(argv=None):
470
594
  cp.add_argument("--output", help="override the output path from the spec")
471
595
  cp.set_defaults(fn=c_compile)
472
596
 
597
+ ac = sub.add_parser("autocut", help="remove silent gaps automatically (silencedetect)")
598
+ ac.add_argument("--input", required=True); ac.add_argument("--output", required=True)
599
+ ac.add_argument("--noise", default="-30dB", help="silence threshold, e.g. -30dB")
600
+ ac.add_argument("--min-silence", dest="min_silence", type=float, default=0.5,
601
+ help="minimum silence length to cut (s)")
602
+ ac.add_argument("--margin", type=float, default=0.1, help="padding kept around speech (s)")
603
+ ac.set_defaults(fn=c_autocut)
604
+
473
605
  ca = sub.add_parser("captions", help="transcribe speech to SRT (local whisper)")
474
606
  ca.add_argument("--input", required=True); ca.add_argument("--output", required=True, help="target .srt")
475
607
  ca.add_argument("--engine", choices=["whisper", "whispercpp", "whisperx"], default="whisper")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.8.10",
3
+ "version": "0.8.12",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,41 @@
1
+ # Captions and transcription
2
+
3
+ Local, offline speech-to-text for video captions and transcripts, used by
4
+ `maggie-video`'s `captions` subcommand. All engines run on the user's machine - **no
5
+ paid API** - so transcription has no per-minute cost, only local CPU/GPU time.
6
+ Distilled from the reference library's R-category projects.
7
+
8
+ ## Engines
9
+
10
+ | Engine | What / how | License | Reach for it when |
11
+ |---|---|---|---|
12
+ | openai-whisper | The reference Python CLI: `whisper IN --model base --output_format srt --output_dir DIR` | MIT | the default; simplest install (`pip install openai-whisper`) |
13
+ | whisper.cpp | C/C++ port, fast on CPU, GGML models: `whisper-cli -m model.bin -f IN -osrt -of OUTBASE` | MIT | no GPU / fastest CPU / embedding in a pipeline |
14
+ | whisperX | whisper + forced alignment for **word-level timestamps** and diarization: `whisperx IN --model large-v3 --output_format srt --output_dir DIR` | BSD-4 | word-accurate caption timing or speaker labels |
15
+
16
+ Model size trades speed for accuracy: `tiny` / `base` (fast, rough) -> `small` /
17
+ `medium` -> `large-v3` (slow, best). Use `--lang` to skip auto-detect when known.
18
+
19
+ ## Workflow (voiceover -> captions -> on-screen)
20
+
21
+ 1. **Transcribe** the voiceover or the video's audio to SRT with a local engine
22
+ (`maggie_video.py captions --input vo.mp3 --output vo.srt --engine whisper
23
+ --model base`). A larger model is slower but more accurate.
24
+ 2. **Review** the SRT - never ship an unreviewed transcript; whisper mishears names,
25
+ numbers, and brand terms. A human edits it.
26
+ 3. **Put it on screen**, one of:
27
+ - burn in (`maggie_video.py subtitle --input video.mp4 --srt vo.srt --output out.mp4`),
28
+ - soft-mux a toggleable track (`subtitle --soft`), or
29
+ - a `captions` layer in a `maggie_video.py compile` timeline (see
30
+ [video timeline model](video-timeline-model.md)).
31
+
32
+ ## Rules
33
+
34
+ - Captions lift watch time and serve sound-off viewing - caption every social video.
35
+ - **Never fabricate a transcript**; generate it from the real audio and have a human
36
+ review before publishing (accuracy, names, and brand terms).
37
+ - Local/offline: no audio leaves the machine, no paid API. If a hosted transcription
38
+ API is ever wired in instead, the global spend-first rule applies - state the cost.
39
+ - Keep caption text inside the safe area (see `maggie-video` channel presets).
40
+
41
+ See `maggie-video` for the subcommands and the burn-in / soft-mux / compile options.
@@ -103,9 +103,13 @@ ffmpeg -y -i IN -vf "thumbnail,scale=1280:-1" -frames:v 1 OUT.jpg # most repres
103
103
 
104
104
  ```bash
105
105
  ffmpeg -i IN -af silencedetect=noise=-30dB:d=0.3 -f null - # detect ranges
106
- # then build a select/concat of the kept ranges (pad each ~0.2s)
106
+ # then build a trim+concat of the kept ranges (pad each ~0.1s)
107
107
  ```
108
108
 
109
+ `maggie_video.py autocut --input talk.mp4 --output tight.mp4 --run` does both:
110
+ detects the silence with `silencedetect`, computes the kept ranges padded by
111
+ `--margin`, and renders the trimmed video+audio.
112
+
109
113
  ## Transitions (between two clips)
110
114
 
111
115
  `xfade=transition=fade:duration=0.5:offset=5.0` for video and `acrossfade=d=0.5`