@topy-ai/maggie 0.8.6 → 0.8.8

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,129 @@
1
+ ---
2
+ name: maggie-video
3
+ description: Produce and process marketing video - render a composition to MP4/GIF/WebM, assemble clips/images/text/captions/audio on a timeline, cut to channel aspects (reel/square/youtube), burn in captions, normalize loudness, watermark, transcode, and make social cuts. Engine-neutral over the renderer (ffmpeg for assembly/transcode, a programmatic engine for code-drawn animation). Use when the user wants to make, edit, cut, caption, resize, or export a video for marketing.
4
+ metadata:
5
+ version: 0.1.0
6
+ ---
7
+
8
+ # Maggie Video
9
+
10
+ The video production lane: turn footage, images, text, captions and audio into a
11
+ finished marketing video, and process existing video (cut, caption, resize,
12
+ transcode, watermark, normalize). It is **engine-neutral** - the ffmpeg binary does
13
+ assembly and transcode; a programmatic engine does code-drawn animation; it never
14
+ links a GPL library or vendors GPL code.
15
+
16
+ This complements [`maggie-design-motion`](../maggie-design-motion/SKILL.md), which
17
+ animates a *static design* into a showcase and already implements the deterministic
18
+ render-then-encode core (its Part C). `maggie-video` is broader: real footage,
19
+ clip assembly, captions, platform cuts, and transcode. Finished videos publish
20
+ through [`maggie-social-share`](../maggie-social-share/SKILL.md).
21
+
22
+ Follow the [Maggie Memory Hook](../../references/memory-hook.md) at invocation and
23
+ completion.
24
+
25
+ ## When to use
26
+
27
+ - Cut, caption, resize, watermark, transcode, or normalize an existing video.
28
+ - Assemble clips/images/text/VO/music into a video from a declarative spec.
29
+ - Produce a data-driven or code-drawn animation as video (charts, kinetic type).
30
+ - Make platform-correct social cuts (9:16 reel, 1:1 feed, 16:9 YouTube).
31
+
32
+ ## Pick the route
33
+
34
+ - **Assembly / transcode / captions / resize of real footage** → the **ffmpeg
35
+ layer** directly. Every command is in [ffmpeg recipes](../../references/ffmpeg-recipes.md)
36
+ and generated by the CLI below.
37
+ - **Declarative multi-clip composition** (clips + overlays + transitions + audio) →
38
+ author the [timeline model](../../references/video-timeline-model.md) JSON and
39
+ compile it to one ffmpeg `filter_complex` (or, for higher fidelity, emit MLT XML
40
+ and render with `melt` as an optional external GPL tool behind an adapter).
41
+ - **Code-drawn animation** (kinetic type, charts, N data-driven variants) →
42
+ [programmatic video](../../references/programmatic-video.md): use **Revideo**
43
+ (MIT, headless, parameterized) by default; **CCapture** for an existing canvas
44
+ loop; **Remotion only as an option the end user licenses** (its license requires
45
+ a paid Company License for for-profit orgs over 3 employees - no hard dependency).
46
+ - **Animating an existing rendered page/design** → stay in `maggie-design-motion`
47
+ Part C; do not reinvent it here.
48
+
49
+ ## The ffmpeg CLI
50
+
51
+ `maggie_video.py` generates the correct ffmpeg command (printed, so it is reviewable
52
+ and loggable) and runs it with `--run` when `ffmpeg` is on PATH:
53
+
54
+ ```bash
55
+ maggie tool maggie_video.py social --input in.mp4 --output reel.mp4 --aspect reel # 9:16, pad
56
+ maggie tool maggie_video.py social --input in.mp4 --output feed.mp4 --aspect square --mode crop
57
+ maggie tool maggie_video.py gif --input in.mp4 --output out.gif --fps 15 --width 480
58
+ maggie tool maggie_video.py subtitle --input in.mp4 --srt vo.srt --output sub.mp4 # burn in
59
+ maggie tool maggie_video.py loudnorm --input in.mp4 --output norm.mp4 --target -14 # social loudness
60
+ maggie tool maggie_video.py overlay --input in.mp4 --logo logo.png --output branded.mp4
61
+ maggie tool maggie_video.py trim --input in.mp4 --output cut.mp4 --start 5 --duration 10
62
+ maggie tool maggie_video.py concat --inputs a.mp4 b.mp4 --output all.mp4 --reencode
63
+ maggie tool maggie_video.py thumb --input in.mp4 --output poster.jpg --at 00:00:03
64
+ maggie tool maggie_video.py --run encode --input in.mov --output out.mp4 # actually runs
65
+ maggie tool maggie_video.py presets
66
+ ```
67
+
68
+ Subcommands: `encode` (web-safe MP4 / VP9 WebM), `gif`, `trim`, `concat`, `social`,
69
+ `overlay`, `subtitle` (burn or soft-mux), `loudnorm`, `thumb`, `presets`.
70
+
71
+ ## Channel presets and safe areas
72
+
73
+ - **9:16** (1080x1920) - Reels / Shorts / TikTok / Stories, <= 60s for feeds.
74
+ - **1:1** (1080x1080) - feed posts.
75
+ - **16:9** (1920x1080) - YouTube / landscape.
76
+ - Keep captions and the CTA inside a safe area (roughly 10% margin top/bottom, more
77
+ at the bottom for platform UI). Logo in a fixed corner (see `overlay`). Default
78
+ loudness **-14 LUFS**. Prefer **crop (fill)** for full-bleed footage, **pad
79
+ (letterbox)** when the whole frame must be visible.
80
+
81
+ ## Output profile (shared with maggie-design-motion)
82
+
83
+ H.264 high / `yuv420p` / `+faststart` / AAC 128k, even dimensions; GIF via
84
+ `palettegen`+`paletteuse` (lanczos). Always force `-pix_fmt yuv420p` and `setsar=1`.
85
+
86
+ ## Quality bar
87
+
88
+ - Output plays everywhere (yuv420p, faststart, even dimensions); aspect matches the
89
+ target channel with no distortion.
90
+ - Captions are inside the safe area and readable; loudness normalized to target.
91
+ - Every ffmpeg command is printed/logged before (or instead of) running.
92
+ - No GPL library linked or vendored; ffmpeg/melt invoked at arm's length.
93
+
94
+ ## Guardrails
95
+
96
+ - Rendering is a **local CPU/GPU/time cost**, not a paid API; flag long renders.
97
+ If any **hosted or LLM video generation** is wired in (e.g. a Q-category model),
98
+ the global BYOK rule (OpenRouter `provider.order` + `allow_fallbacks:false` +
99
+ explicit `max_tokens`) and the spend-first rule apply - state cost first.
100
+ - Never claim a render ran unless `--run` executed and produced the file.
101
+ - Licensing: adopt ideas/shape, not GPL code; invoke the `ffmpeg` binary (and
102
+ optionally `melt`) rather than linking libav. **Remotion** is reference-only (not
103
+ a free license for a commercial product).
104
+ - Do not fabricate captions/transcripts; generate them from the real audio (see
105
+ the R-category caption tools, e.g. whisper) and let a human review.
106
+ - Publishing a video is a separate approved step via `maggie-social-share`.
107
+
108
+ ## Related skills
109
+
110
+ - `maggie-design-motion` - animate a static design into a showcase (shares the
111
+ render-then-encode core).
112
+ - `maggie-social-share` - publish the finished video as platform-native posts.
113
+ - `maggie-spirit` - brand marks/avatars that can appear in a video.
114
+ - `maggie media generate` - AI image/video generation for b-roll (Q-category).
115
+
116
+ ## Boundaries
117
+
118
+ - Engine-neutral production + processing; the data model and recipes live in the
119
+ references, channel presets and the ffmpeg layer live here. A human approves
120
+ publishing and any paid generation.
121
+
122
+ ## Credits
123
+
124
+ Distilled (concepts and command shapes, not code) from the reference library's
125
+ N-category video projects: moviepy, editly, auto-editor (MIT/Unlicense) for the
126
+ ffmpeg command layer and timeline spec; Motion Canvas, Revideo, CCapture (MIT) for
127
+ the programmatic render pattern; MLT / Shotcut / Kdenlive / OpenShot / Olive
128
+ (LGPL/GPL) for the timeline data-model shape only; ffmpeg (LGPL/GPL) and HandBrake
129
+ (GPL) invoked at arm's length.
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env python3
2
+ """maggie_video - generate correct ffmpeg commands for marketing video tasks.
3
+
4
+ A recipe generator distilled from moviepy, editly, auto-editor, FFmpeg and
5
+ HandBrake (see references/ffmpeg-recipes.md). Each subcommand prints the exact
6
+ ffmpeg command (so it is reviewable and loggable) and runs it only with --run,
7
+ and only when `ffmpeg` is on PATH. It shells out to the ffmpeg binary (arm's-length
8
+ LGPL/GPL use); it links no libav and vendors no GPL code. Output is always forced
9
+ to a web-safe, platform-correct profile.
10
+
11
+ Subcommands: encode, gif, trim, concat, social, overlay, subtitle, loudnorm,
12
+ thumb, presets.
13
+ """
14
+ import argparse
15
+ import shlex
16
+ import shutil
17
+ import subprocess
18
+ import sys
19
+
20
+ PREFIX = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"]
21
+ MP4_OUT = ["-c:v", "libx264", "-profile:v", "high", "-pix_fmt", "yuv420p",
22
+ "-movflags", "+faststart"]
23
+ # channel aspect presets (name -> WxH)
24
+ SOCIAL = {"reel": (1080, 1920), "story": (1080, 1920), "tiktok": (1080, 1920),
25
+ "square": (1080, 1080), "feed": (1080, 1080),
26
+ "youtube": (1920, 1080), "landscape": (1920, 1080)}
27
+ OVERLAY_POS = {"br": "W-w-{m}:H-h-{m}", "bl": "{m}:H-h-{m}",
28
+ "tr": "W-w-{m}:{m}", "tl": "{m}:{m}"}
29
+
30
+
31
+ def emit(args, run):
32
+ printable = " ".join(shlex.quote(a) for a in args)
33
+ print(printable)
34
+ if not run:
35
+ return 0
36
+ if not shutil.which("ffmpeg"):
37
+ sys.stderr.write("ffmpeg not found on PATH; printed the command only.\n")
38
+ return 3
39
+ return subprocess.run(args).returncode
40
+
41
+
42
+ def c_encode(a):
43
+ out = ["-crf", str(a.crf), "-preset", a.preset]
44
+ if a.vcodec == "vp9":
45
+ v = ["-c:v", "libvpx-vp9", "-crf", str(a.crf), "-b:v", "0", "-row-mt", "1",
46
+ "-pix_fmt", "yuv420p", "-c:a", "libopus", "-b:a", "128k"]
47
+ return emit(PREFIX + ["-i", a.input] + v + [a.output], a.run)
48
+ cmd = PREFIX + ["-i", a.input, "-c:v", "libx264", "-profile:v", "high",
49
+ "-preset", a.preset, "-crf", str(a.crf), "-pix_fmt", "yuv420p",
50
+ "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", a.output]
51
+ return emit(cmd, a.run)
52
+
53
+
54
+ def c_gif(a):
55
+ vf = (f"fps={a.fps},scale={a.width}:-1:flags=lanczos,split[s0][s1];"
56
+ f"[s0]palettegen[p];[s1][p]paletteuse")
57
+ return emit(PREFIX + ["-i", a.input, "-vf", vf, "-loop", "0", a.output], a.run)
58
+
59
+
60
+ def c_trim(a):
61
+ dur = ["-t", str(a.duration)] if a.duration else (["-to", str(a.end)] if a.end else [])
62
+ if a.reencode:
63
+ cmd = PREFIX + ["-ss", str(a.start), "-i", a.input] + dur + \
64
+ ["-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt",
65
+ "yuv420p", "-c:a", "aac", "-movflags", "+faststart", a.output]
66
+ else:
67
+ cmd = PREFIX + ["-ss", str(a.start), "-i", a.input] + dur + \
68
+ ["-map", "0", "-c", "copy", "-avoid_negative_ts", "make_zero", a.output]
69
+ return emit(cmd, a.run)
70
+
71
+
72
+ def c_concat(a):
73
+ if a.reencode or len(a.inputs) < 1:
74
+ ins = []
75
+ for p in a.inputs:
76
+ ins += ["-i", p]
77
+ n = len(a.inputs)
78
+ streams = "".join(f"[{i}:v][{i}:a]" for i in range(n))
79
+ fc = f"{streams}concat=n={n}:v=1:a=1[v][a]"
80
+ cmd = PREFIX + ins + ["-filter_complex", fc, "-map", "[v]", "-map", "[a]"] + \
81
+ ["-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
82
+ "-c:a", "aac", "-movflags", "+faststart", a.output]
83
+ return emit(cmd, a.run)
84
+ # fast path: a concat list file (same codec). Print how to build it.
85
+ print("# same-codec fast concat: write a list file, then run ffmpeg:")
86
+ print("# printf \"file '%s'\\n\" " + " ".join(shlex.quote(p) for p in a.inputs) + " > concat.txt")
87
+ return emit(PREFIX + ["-f", "concat", "-safe", "0", "-i", "concat.txt",
88
+ "-c", "copy", a.output], a.run)
89
+
90
+
91
+ def c_social(a):
92
+ w, h = SOCIAL[a.aspect]
93
+ if a.mode == "crop":
94
+ vf = (f"scale={w}:{h}:force_original_aspect_ratio=increase,"
95
+ f"crop={w}:{h},setsar=1")
96
+ else:
97
+ vf = (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
98
+ f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={a.color},setsar=1")
99
+ cmd = PREFIX + ["-i", a.input, "-vf", vf, "-crf", str(a.crf)] + MP4_OUT + \
100
+ ["-c:a", "aac", "-b:a", "128k", a.output]
101
+ return emit(cmd, a.run)
102
+
103
+
104
+ def c_overlay(a):
105
+ pos = OVERLAY_POS[a.position].format(m=a.margin)
106
+ fc = (f"[1][0]scale2ref=w=oh*mdar:h=ih*{a.scale}[logo][base];"
107
+ f"[base][logo]overlay={pos}")
108
+ cmd = PREFIX + ["-i", a.input, "-i", a.logo, "-filter_complex", fc,
109
+ "-c:a", "copy", "-c:v", "libx264", "-crf", "20",
110
+ "-pix_fmt", "yuv420p", "-movflags", "+faststart", a.output]
111
+ return emit(cmd, a.run)
112
+
113
+
114
+ def c_subtitle(a):
115
+ if a.soft:
116
+ cmd = PREFIX + ["-i", a.input, "-i", a.srt, "-c", "copy", "-c:s",
117
+ "mov_text", "-metadata:s:s:0", f"language={a.lang}", a.output]
118
+ else:
119
+ style = f"force_style='FontName={a.font},FontSize={a.size},BorderStyle=3'"
120
+ cmd = PREFIX + ["-i", a.input, "-vf", f"subtitles={a.srt}:{style}",
121
+ "-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
122
+ "-c:a", "copy", a.output]
123
+ return emit(cmd, a.run)
124
+
125
+
126
+ def c_loudnorm(a):
127
+ cmd = PREFIX + ["-i", a.input, "-af",
128
+ f"loudnorm=I={a.target}:TP=-1.5:LRA=11",
129
+ "-c:v", "copy", "-c:a", "aac", "-b:a", "128k", a.output]
130
+ print("# quick single-pass loudnorm (I=-14 LUFS = social/YouTube target).")
131
+ print("# for accuracy, measure first: ffmpeg -i IN -af loudnorm=...:print_format=json -f null -")
132
+ return emit(cmd, a.run)
133
+
134
+
135
+ def c_thumb(a):
136
+ if a.smart:
137
+ cmd = PREFIX + ["-i", a.input, "-vf", "thumbnail,scale=1280:-1",
138
+ "-frames:v", "1", a.output]
139
+ else:
140
+ cmd = PREFIX + ["-ss", str(a.at), "-i", a.input, "-frames:v", "1",
141
+ "-q:v", "2", a.output]
142
+ return emit(cmd, a.run)
143
+
144
+
145
+ def c_presets(a):
146
+ print("Channel aspect presets (use with `social --aspect <name>`):")
147
+ for name, (w, h) in SOCIAL.items():
148
+ print(f" {name:<10} {w}x{h}")
149
+ print("\nOutput profile: H.264 high / yuv420p / +faststart / AAC 128k (web-safe).")
150
+ print("GIF: palettegen+paletteuse, lanczos. Loudness target: -14 LUFS.")
151
+ return 0
152
+
153
+
154
+ def main(argv=None):
155
+ ap = argparse.ArgumentParser(prog="maggie_video", description=__doc__,
156
+ formatter_class=argparse.RawDescriptionHelpFormatter)
157
+ ap.add_argument("--run", action="store_true", help="execute (needs ffmpeg on PATH); default prints only")
158
+ sub = ap.add_subparsers(dest="cmd", required=True)
159
+
160
+ e = sub.add_parser("encode", help="transcode to a web-safe MP4 (or VP9 WebM)")
161
+ e.add_argument("--input", required=True); e.add_argument("--output", required=True)
162
+ e.add_argument("--crf", type=int, default=20); e.add_argument("--preset", default="medium")
163
+ e.add_argument("--vcodec", choices=["h264", "vp9"], default="h264"); e.set_defaults(fn=c_encode)
164
+
165
+ g = sub.add_parser("gif", help="high-quality palette GIF")
166
+ g.add_argument("--input", required=True); g.add_argument("--output", required=True)
167
+ g.add_argument("--fps", type=int, default=15); g.add_argument("--width", type=int, default=480)
168
+ g.set_defaults(fn=c_gif)
169
+
170
+ t = sub.add_parser("trim", help="cut a segment (copy by default, --reencode for frame-accurate)")
171
+ t.add_argument("--input", required=True); t.add_argument("--output", required=True)
172
+ t.add_argument("--start", required=True); t.add_argument("--duration"); t.add_argument("--end")
173
+ t.add_argument("--reencode", action="store_true"); t.set_defaults(fn=c_trim)
174
+
175
+ cc = sub.add_parser("concat", help="join clips (fast same-codec, or --reencode)")
176
+ cc.add_argument("--inputs", nargs="+", required=True); cc.add_argument("--output", required=True)
177
+ cc.add_argument("--reencode", action="store_true"); cc.set_defaults(fn=c_concat)
178
+
179
+ s = sub.add_parser("social", help="scale+pad or crop to a channel aspect")
180
+ s.add_argument("--input", required=True); s.add_argument("--output", required=True)
181
+ s.add_argument("--aspect", choices=list(SOCIAL), required=True)
182
+ s.add_argument("--mode", choices=["pad", "crop"], default="pad")
183
+ s.add_argument("--color", default="black"); s.add_argument("--crf", type=int, default=20)
184
+ s.set_defaults(fn=c_social)
185
+
186
+ o = sub.add_parser("overlay", help="overlay a logo/watermark")
187
+ o.add_argument("--input", required=True); o.add_argument("--logo", required=True)
188
+ o.add_argument("--output", required=True)
189
+ o.add_argument("--position", choices=list(OVERLAY_POS), default="br")
190
+ o.add_argument("--margin", type=int, default=24); o.add_argument("--scale", default="0.12")
191
+ o.set_defaults(fn=c_overlay)
192
+
193
+ sb = sub.add_parser("subtitle", help="burn in (default) or soft-mux an SRT")
194
+ sb.add_argument("--input", required=True); sb.add_argument("--srt", required=True)
195
+ sb.add_argument("--output", required=True); sb.add_argument("--soft", action="store_true")
196
+ sb.add_argument("--font", default="Inter"); sb.add_argument("--size", type=int, default=24)
197
+ sb.add_argument("--lang", default="eng"); sb.set_defaults(fn=c_subtitle)
198
+
199
+ ln = sub.add_parser("loudnorm", help="normalize loudness to a target LUFS")
200
+ ln.add_argument("--input", required=True); ln.add_argument("--output", required=True)
201
+ ln.add_argument("--target", default="-14"); ln.set_defaults(fn=c_loudnorm)
202
+
203
+ th = sub.add_parser("thumb", help="extract a poster/thumbnail frame")
204
+ th.add_argument("--input", required=True); th.add_argument("--output", required=True)
205
+ th.add_argument("--at", default="00:00:03"); th.add_argument("--smart", action="store_true")
206
+ th.set_defaults(fn=c_thumb)
207
+
208
+ sub.add_parser("presets", help="list channel aspect presets").set_defaults(fn=c_presets)
209
+
210
+ a = ap.parse_args(argv)
211
+ raise SystemExit(a.fn(a))
212
+
213
+
214
+ if __name__ == "__main__":
215
+ main()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@topy-ai/maggie",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "description": "Install and manage Maggie Skills for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,125 @@
1
+ # ffmpeg recipes
2
+
3
+ The distilled, correct ffmpeg command layer for marketing video, used by
4
+ `maggie-video` (and shared with `maggie-design-motion`'s export). These are the
5
+ commands `maggie_video.py` generates; this reference explains each so they can be
6
+ adapted. Distilled from moviepy, editly, auto-editor, FFmpeg and HandBrake (see
7
+ the design reference library category N). We **invoke the `ffmpeg` binary** (not
8
+ link libav), so this stays license-clean; most ffmpeg builds bundle x264/x265 and
9
+ are GPL binaries - fine to invoke, do not vendor a GPL build into a package.
10
+
11
+ Common prefix: `ffmpeg -y -hide_banner -loglevel error`. Always force
12
+ `-pix_fmt yuv420p` and `setsar=1` on output for universal playback (moviepy has a
13
+ latent `yuva420p` typo - do not copy it).
14
+
15
+ ## Encode (web-safe MP4)
16
+
17
+ ```bash
18
+ ffmpeg -y -i IN -c:v libx264 -profile:v high -preset medium -crf 20 \
19
+ -pix_fmt yuv420p -c:a aac -b:a 128k -movflags +faststart OUT.mp4
20
+ ```
21
+
22
+ `+faststart` moves the moov atom to the front for web streaming. CRF 18-23 (lower =
23
+ larger/better). WebM (VP9): `-c:v libvpx-vp9 -crf 32 -b:v 0 -row-mt 1 -pix_fmt
24
+ yuv420p -c:a libopus -b:a 128k`.
25
+
26
+ ## High-quality GIF (one-pass palette)
27
+
28
+ ```bash
29
+ ffmpeg -y -i IN -vf "fps=15,scale=480:-1:flags=lanczos,split[s0][s1];\
30
+ [s0]palettegen[p];[s1][p]paletteuse" -loop 0 OUT.gif
31
+ ```
32
+
33
+ Scale/fps down first to keep size sane; the palette pass avoids banding. Keep bayer
34
+ dithering (`paletteuse=dither=bayer:bayer_scale=4`) when a photo is in frame.
35
+
36
+ ## Trim
37
+
38
+ ```bash
39
+ # fast, no re-encode (cuts at the nearest keyframe)
40
+ ffmpeg -y -ss START -i IN -t DURATION -map 0 -c copy -avoid_negative_ts make_zero OUT.mp4
41
+ # frame-accurate: drop -c copy and re-encode with the MP4 recipe
42
+ ```
43
+
44
+ ## Concat
45
+
46
+ ```bash
47
+ # same codec (fast): concat.txt has lines file '/abs/clip1.mp4'
48
+ ffmpeg -y -f concat -safe 0 -i concat.txt -c copy OUT.mp4
49
+ # mixed sources (re-encode via filter)
50
+ ffmpeg -y -i a.mp4 -i b.mp4 -filter_complex \
51
+ "[0:v][0:a][1:v][1:a]concat=n=2:v=1:a=1[v][a]" -map "[v]" -map "[a]" \
52
+ -c:v libx264 -crf 20 -pix_fmt yuv420p -movflags +faststart OUT.mp4
53
+ ```
54
+
55
+ ## Scale + pad or crop to a channel aspect
56
+
57
+ ```bash
58
+ # 9:16 reel (1080x1920); use 1080x1080 square or 1920x1080 landscape likewise
59
+ # pad (letterbox, no distortion):
60
+ ffmpeg -y -i IN -vf "scale=1080:1920:force_original_aspect_ratio=decrease,\
61
+ pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1" \
62
+ -c:v libx264 -crf 20 -pix_fmt yuv420p -movflags +faststart OUT.mp4
63
+ # crop (fill): force_original_aspect_ratio=increase,crop=1080:1920,setsar=1
64
+ ```
65
+
66
+ ## Overlay / watermark (logo bottom-right, scaled to 12% height, 24px margin)
67
+
68
+ ```bash
69
+ ffmpeg -y -i IN -i logo.png -filter_complex \
70
+ "[1][0]scale2ref=w=oh*mdar:h=ih*0.12[logo][base];[base][logo]overlay=W-w-24:H-h-24" \
71
+ -c:a copy -c:v libx264 -crf 20 -pix_fmt yuv420p OUT.mp4
72
+ ```
73
+
74
+ ## Subtitles
75
+
76
+ ```bash
77
+ # burn in (hardsub) from SRT
78
+ ffmpeg -y -i IN -vf "subtitles=subs.srt:force_style='FontName=Inter,FontSize=24,BorderStyle=3'" \
79
+ -c:v libx264 -crf 20 -pix_fmt yuv420p -c:a copy OUT.mp4
80
+ # soft-mux (toggleable track, MP4)
81
+ ffmpeg -y -i IN -i subs.srt -c copy -c:s mov_text -metadata:s:s:0 language=eng OUT.mp4
82
+ ```
83
+
84
+ ## Loudness normalize (EBU R128; -14 LUFS = social/YouTube target)
85
+
86
+ ```bash
87
+ # quick single pass
88
+ ffmpeg -y -i IN -af loudnorm=I=-14:TP=-1.5:LRA=11 -c:v copy -c:a aac -b:a 128k OUT.mp4
89
+ # accurate two pass: measure first (reads JSON from stderr), then apply measured_* values
90
+ ffmpeg -i IN -af loudnorm=I=-14:TP=-1.5:LRA=11:print_format=json -f null -
91
+ ```
92
+
93
+ editly's cheaper alternative is `dynaudnorm=g=5:maxgain=30`.
94
+
95
+ ## Thumbnail / poster
96
+
97
+ ```bash
98
+ ffmpeg -y -ss 00:00:03 -i IN -frames:v 1 -q:v 2 OUT.jpg # at a timestamp
99
+ ffmpeg -y -i IN -vf "thumbnail,scale=1280:-1" -frames:v 1 OUT.jpg # most representative frame
100
+ ```
101
+
102
+ ## Silence auto-cut (auto-editor's idea, via the CLI)
103
+
104
+ ```bash
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)
107
+ ```
108
+
109
+ ## Transitions (between two clips)
110
+
111
+ `xfade=transition=fade:duration=0.5:offset=5.0` for video and `acrossfade=d=0.5`
112
+ for audio. editly/gl-transitions names (`fade`, `dissolve`, `directional-left`, ...)
113
+ map onto xfade's transition list.
114
+
115
+ ## The CLI
116
+
117
+ `maggie_video.py` generates every command above (and runs it with `--run` when
118
+ `ffmpeg` is on PATH). It prints the command so it is reviewable and loggable:
119
+
120
+ ```bash
121
+ maggie tool maggie_video.py social --input in.mp4 --output reel.mp4 --aspect reel
122
+ maggie tool maggie_video.py gif --input in.mp4 --output out.gif --fps 15 --width 480
123
+ maggie tool maggie_video.py --run encode --input in.mov --output out.mp4 # actually runs
124
+ maggie tool maggie_video.py presets
125
+ ```
@@ -0,0 +1,80 @@
1
+ # Programmatic video: the render-then-encode pattern
2
+
3
+ How to produce video from code deterministically, distilled from the leading
4
+ engines (Motion Canvas, Remotion, Revideo, CCapture). Used by `maggie-video`;
5
+ `maggie-design-motion`'s Part C is one concrete implementation of this pattern (the
6
+ DOM/Playwright case). Author your own code from this; adopt engines per the license
7
+ table below.
8
+
9
+ ## The pattern (every engine agrees)
10
+
11
+ 1. **The frame is the unit of truth; the wall clock is the enemy.** Deterministic
12
+ output comes from driving content by an **absolute frame index or a virtual
13
+ clock**, never real time. Two techniques:
14
+ - **Seek by frame:** re-evaluate the scene at frame N (`playback.seek(frame)` in
15
+ Motion Canvas / Revideo; the current-frame hook in Remotion).
16
+ - **Fake the clock:** monkeypatch `performance.now`, `requestAnimationFrame`,
17
+ `Date`, timers and `currentTime`, and advance one fixed step per captured frame
18
+ (CCapture's `TimeWarp`; Playwright's `page.clock` + `__tick` in Part C).
19
+ 2. **Render then encode, decoupled.** Produce frames as fast as the machine allows
20
+ (not realtime) into a canvas/DOM, then feed an encoder as a separate stage.
21
+ 3. **Two encode routes.** (a) **Shell out to ffmpeg** via a pipe - `image2pipe`
22
+ base64 PNG (Revideo) or `rawvideo`/rgba (Motion Canvas), output
23
+ `-pix_fmt yuv420p -movflags +faststart` (the exact profile in
24
+ [ffmpeg recipes](ffmpeg-recipes.md)). (b) **In-browser WebCodecs** (CCapture) -
25
+ no ffmpeg dependency, hardware-accelerated.
26
+ 4. **Headless browser as the frame factory** when the composition is web tech:
27
+ Revideo = Puppeteer, Remotion = headless Chromium, Part C = Playwright. The page
28
+ renders; the driver screenshots or reads back each frame.
29
+ 5. **One source → preview + render + parameterized batch.** The same scene file
30
+ drives a live preview and the headless render; **variables/props** turn one
31
+ template into many videos (N brand variants, localized cuts, per-product reels).
32
+
33
+ ## Composition models
34
+
35
+ - **React components** (Remotion, Revideo also supports React) - animation driven by
36
+ the current frame via hooks.
37
+ - **Generator functions** (Motion Canvas, Revideo) - a scene is
38
+ `makeScene2D(function* (view) { view.add(<Rect/>); yield* all(rect().position.x(100, 1)); yield* waitFor(0.5); })`;
39
+ timing is the generator's control flow; signals (`createSignal`) provide
40
+ reactivity. These read cleanly for an LLM to author from a prompt.
41
+ - **Canvas draw calls + a recorder** (CCapture) - any existing rAF canvas loop plus
42
+ one `capturer.capture(canvas)` line.
43
+
44
+ ## Choosing an engine (decision)
45
+
46
+ - **Animating an existing rendered page/design** → stay in
47
+ `maggie-design-motion` Part C (Playwright fake-clock). No new engine needed; this
48
+ is the common Maggie case (social/hero motion from a design).
49
+ - **Programmatic, data-driven video from scratch** (N variants from brand data,
50
+ charts, captions) → **Revideo** - MIT, headless-first, parameterizable
51
+ (`renderVideo({projectFile, variables, settings:{outFile, workers}})`),
52
+ parallelizable across workers, and it ships a `revideo serve` render endpoint
53
+ (with `callbackUrl` + SSE progress) for render-as-a-service.
54
+ - **A pure canvas sketch already using rAF** that just needs recording → **CCapture**
55
+ (WebCodecs, no ffmpeg).
56
+ - **Simple clip assembly / transcode / captions** (not code-drawn animation) → the
57
+ ffmpeg layer directly ([ffmpeg recipes](ffmpeg-recipes.md)) or the declarative
58
+ [timeline model](video-timeline-model.md).
59
+
60
+ ## Licenses (critical)
61
+
62
+ | Engine | License | Adopt? |
63
+ |---|---|---|
64
+ | Motion Canvas | MIT | Yes (generators; editor-driven render) |
65
+ | **Revideo** | MIT | **Yes - recommended programmatic default** (headless, parameterized, parallel) |
66
+ | CCapture.js | MIT | Yes - the WebCodecs recorder / clock-faking reference |
67
+ | video.js | Apache-2.0 | Playback only, not production |
68
+ | **Remotion** | **Remotion License (source-available; paid Company License for for-profit orgs > 3 employees)** | **No hard dependency** - reference/option the end user may license; keep Maggie's default on the MIT stack |
69
+
70
+ Rendering is a **local CPU/GPU cost** (ffmpeg, a headless browser), not a paid API -
71
+ flag long renders as a time/resource cost, consistent with "local tools only". If
72
+ any hosted or LLM video generation is wired in, the global BYOK + spend-first rules
73
+ apply.
74
+
75
+ ## Shared encode profile
76
+
77
+ `maggie-video` and `maggie-design-motion` share one profile:
78
+ `libx264 -crf 14-20 -preset slow/medium -pix_fmt yuv420p -movflags +faststart`,
79
+ even dimensions, plus 960px / 800x500 GIF deliverables. For transparent output,
80
+ Revideo uses per-format pixel formats (`yuva420p` WebM, `yuva444p10le` ProRes).