@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.
- package/README-zh-TW.md +1 -1
- package/README.md +2 -2
- package/bundled-references/captions-transcription.md +41 -0
- package/bundled-references/ffmpeg-recipes.md +5 -1
- package/bundled-references/generative-visuals.md +90 -0
- package/bundled-references/image-recipes.md +89 -0
- package/bundled-references/video-timeline-model.md +10 -8
- package/bundled-references/web-animation-patterns.md +364 -0
- package/bundled-skills/catalog.json +4 -0
- package/bundled-skills/maggie-design/SKILL.md +7 -2
- package/bundled-skills/maggie-design-motion/SKILL.md +4 -1
- package/bundled-skills/maggie-image/SKILL.md +100 -0
- package/bundled-skills/maggie-landing-page/SKILL.md +5 -1
- package/bundled-skills/maggie-spirit/SKILL.md +4 -2
- package/bundled-skills/maggie-video/SKILL.md +9 -5
- package/bundled-tools/clis/maggie_image.py +221 -0
- package/bundled-tools/clis/maggie_video.py +176 -44
- package/package.json +1 -1
- package/references/captions-transcription.md +41 -0
- package/references/ffmpeg-recipes.md +5 -1
- package/references/generative-visuals.md +90 -0
- package/references/image-recipes.md +89 -0
- package/references/video-timeline-model.md +10 -8
- package/references/web-animation-patterns.md +364 -0
|
@@ -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
|
|
166
|
-
|
|
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
|
-
|
|
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
|
|
179
|
-
"""A piecewise-linear ffmpeg expression
|
|
180
|
-
|
|
181
|
-
expr = f"{
|
|
182
|
-
for (ta, va), (tb, vb) in reversed(list(zip(
|
|
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,{
|
|
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
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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={
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
|
337
|
-
aud = spec.get("audio", [])
|
|
338
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
@@ -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
|
|
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`
|