@topy-ai/maggie 0.8.7 → 0.8.9
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/ffmpeg-recipes.md +126 -0
- package/bundled-references/programmatic-video.md +80 -0
- package/bundled-references/video-timeline-model.md +127 -0
- package/bundled-skills/catalog.json +4 -0
- package/bundled-skills/maggie-video/SKILL.md +135 -0
- package/bundled-tools/clis/maggie_video.py +399 -0
- package/package.json +1 -1
- package/references/ffmpeg-recipes.md +126 -0
- package/references/programmatic-video.md +80 -0
- package/references/video-timeline-model.md +127 -0
package/README-zh-TW.md
CHANGED
|
@@ -143,7 +143,7 @@ structure drift 或重複 treatment product 時 fail closed。
|
|
|
143
143
|
deployment、memory、feedback 和 MaggieDash。內容先 draft/review,外部寫入、
|
|
144
144
|
publish 與 production deployment 需要明確確認。
|
|
145
145
|
|
|
146
|
-
目前 npm 版本為 `0.8.7
|
|
146
|
+
目前 npm 版本為 `0.8.9`,為 `maggie_video.py` 新增 `compile` 子命令(把宣告式 timeline JSON 編譯成單一 ffmpeg 命令,已實測)。先前 0.8.8 新增 `maggie-video`(製作與處理行銷影片:剪輯、字幕、裁切成各渠道比例、浮水印、轉碼、響度正規化、clip 組裝、程式化動畫),附一個實測可用的 ffmpeg 配方 CLI(`maggie_video.py`),從參考庫的影片專案(moviepy/editly/ffmpeg 命令層、Revideo/Motion Canvas/CCapture 程式化渲染、MLT/OpenShot/Olive 的 timeline 資料形狀)提煉而來;另加 `ffmpeg-recipes`、`programmatic-video`、`video-timeline-model` 三個 reference。先前 0.8.7 把 redesign 稽核併入 `maggie-design`(改編自 MIT 的 `elayadesign/redesign-skill`):掃描 + 診斷 AI 通病/缺失狀態/雜亂程式碼,再經 in-place/verify 流程就地修正,因此「稽核/打磨/升級既有站點」會路由到 maggie-design。先前 0.8.6 補齊兩個設計技能:加入完整的生產 reference,並把設計來源明確為 Figma 非必需(實時 URL、既有程式碼、Figma、或純截圖)。先前 0.8.5 採納兩個設計技能(改編自 MIT 的 `elayadesign/ai-design-skills`):maggie-landing-page(轉化策略 + 視覺系統紀律,具體數值為可被專案 DESIGN.md 覆蓋的預設值;由 maggie-design 生產與驗證)與 maggie-design-motion(靜態設計→像素級動畫 HTML 複刻→匯出 MP4 + GIF)。先前 0.8.4 將技能庫擴展成完整的「一人用 AI 跑一家公司」C-suite,新增八條職能線:maggie-sales(CRM 中立的銷售管線)、maggie-finance(只解釋的 CFO 指標,含確定性 maggie_finance.py 計算器)、maggie-support(面向客戶的客戶成功)、maggie-product(路線圖 + 預註冊實驗)、maggie-fundraising(pitch deck、data room、投資人更新)、maggie-legal(ToS/隱私/DPA/SOC2 readiness)、maggie-monetization(定價/paywall/dunning,金錢操作需人工批准)、maggie-orchestrator(CEO 編排層:將目標拆成帶審批門的 mission 分派到各職能線,含 maggie_orchestrator.py 規劃器)。先前 0.8.3 將 cmo.so/www 開發經驗沉澱進技能庫:新增兩個技能 maggie-skill-authoring(依 catalog 與 MCP 工具白名單撰寫/校驗社區營銷 playbook 技能)與 maggie-feedback-triage(反饋的團隊接收端:決策/定級/分派、PRD + issue 流程、24/7 隊列守護);新增多個 reference(OAuth PKCE 登入、Expo 約定與 Router 骨架、MCP evals 測試框架、conversation cards、WordPress→Firebase 認證遷移手冊);並增補 maggie-assistant-tools(tool≤user 每輪工具過濾、agent loop 安全不變量、preview-only 橋接代理、MCP resources/prompts、注解加固、CLI 與 ChatGPT 插件打包)、maggie-auth-reference(自清理登入測試套件、自簽單次令牌)、maggie-mobile-app(OTA 開機拉取、live==built 部署守衛)、maggie-deployment(pm2 單包發布、live-hash 校驗)與 maggie-ops(template-as-code 郵件同步)。先前 0.8.2 新增 maggie-spirit(為 AI 品牌生成平面像素 spirit/sprite 系統)與 goal-led AI agent 設計參考;0.8.0 為安全優化 roadmap(scan、image-patch、標頭評分、SSVC、GDPR 日誌規則、新 MCP 工具)。先前 0.8.0 為安全優化 roadmap(scan、image-patch、標頭評分、SSVC、GDPR 日誌規則、新 MCP 工具),含 fix-plan 修補風險分級 T1–T4(判斷補丁是否安全套用)與 host-impact 補丁影響面量測(哪些服務需重啟、是否需重開機)、public-check、Maggie Security、batch 31 排程 agent routines、報告收件匣與無人值守 agent 限制、PHP runtime 盤點與主機鎖、pull 網站改寫、query 點子與社群佇列檢查,batch 30 MaggieDash 先裝核心、release privacy gate、feedback 備援、社群連結與圖片檢查、大量頁面產生與 app 檢查,batch 29 預約規則、部署、app QA、助理與社群功能,以及 batch 28 產品 app、assistant tools、mobile、product release、skills catalog 與 bridge host 功能、batch 26、27 feedback 修正與 idempotent `maggie update`與 Booking email-template 合約/CLI validator 同步。本版完成 2026-09-24 booking feedback batch:Calendar grid/free-slot entry 與 host modal focus-restoration evidence。本版新增 Starter marketplace provisioning plan;本版完成 feedback batches 22、23:加入 declarative browser interaction evidence、design markup/style/content-key gate、compiler source anchoring、entity double-escape protection、`update --help` read-only、noindex hypothetical indexability、可過期 baseline acknowledgement、DOM-only link extraction regression,以及 MaggieDash 0.2.10 的 content override、短期 browser session、CDN-safe public-session cache contracts。authentication 和 isolated dev server 仍由 host 負責。`maggie-booking` workflow、傳統 email/password、Stripe、Resend、public booking flow 及既有安全邊界維持不變;provider-bound video generation 與 transactional delivery 仍記錄在 issue #39,前一批延後功能仍在 issue #38,不會直接成為 active memory。0.7.33 記錄並審查一筆更正回饋;確認 0.7.32 已正確處理 batch index,沒有修改已驗證的 shared behavior。0.7.32 加入 feedback 一基索引支援、MaggieDash panel 的 source/freshness/error evidence、Consent Mode 與實際 write/no-op reconciliation、SEO baseline recapture、sitemap origin rebasing、content-diff scope、migration ledger reconciliation、deployment credential preflight、release provenance/runtime preflight、icon release gate、opt-in blog auto-publish gate,以及 least-privilege VPS deployer。也修復 top-level `maggie feedback` dispatcher parity。0.7.31 加入 update 差異摘要、atomic installer copy、subset install manifest merge,以及 inventory drift 時 doctor non-zero。0.7.30 補上 feedback tracking issue closeout gate 與 batch 14/15 audit ledger。0.7.29 修正 npm package landing page 使用英文 README;0.7.28 加入 versioned Gemini model policy、明確 fallback 與 provenance、bundled-first CLI
|
|
147
147
|
dispatch、`maggie --version`、feedback batch review 聚合與重複偵測、marketplace enrichment evidence、booking
|
|
148
148
|
worker/resolver evidence,以及 read-only migration preflight。它也包含 host-owned mobile app surface contract、signed-in
|
|
149
149
|
camera-state QA、直接 Astro route resolution、correlated feedback batch,以及
|
package/README.md
CHANGED
|
@@ -87,9 +87,9 @@ Then invoke the installed skills from your coding agent, for example:
|
|
|
87
87
|
|
|
88
88
|
Maggie keeps the existing project foundation and asks for decisions before
|
|
89
89
|
shared routes, analytics, or publishing boundaries change. The current
|
|
90
|
-
package ships
|
|
90
|
+
package ships 37 installable skills and a local-first MaggieDash foundation.
|
|
91
91
|
|
|
92
|
-
This package is `0.8.
|
|
92
|
+
This package is `0.8.9` — it adds a `compile` subcommand to `maggie_video.py` (declarative timeline JSON -> one ffmpeg command, tested end-to-end). `0.8.8` added `maggie-video` (produce and process marketing video: cut, caption, resize to channel aspects, watermark, transcode, normalize loudness, assemble clips, render code-drawn animation) with a tested ffmpeg-recipe CLI, distilled from the reference library's video projects; plus references `ffmpeg-recipes`, `programmatic-video`, `video-timeline-model`. `0.8.7` folded a redesign audit into `maggie-design` (from the MIT `elayadesign/redesign-skill`): scan + diagnose generic AI-design patterns/weak states/sloppy code, then fix in place via the in-place/verify flow, so "audit/polish/upgrade an existing site" routes to maggie-design. `0.8.6` completed the two adopted design skills with full production references and made the design source Figma-optional (live URL, existing code, Figma, or a screenshot). `0.8.5` adopted the two design skills (adapted, MIT, from `elayadesign/ai-design-skills`): `maggie-landing-page` (conversion strategy + a visual-system discipline whose values defer to the project's DESIGN.md) and `maggie-design-motion` (static design → pixel-exact animated HTML replica → MP4 + GIF export). `0.8.4` expanded the library into a full "one person runs a company with AI" C-suite with eight new function lanes: `maggie-sales` (CRM-neutral pipeline), `maggie-finance` (explain-only CFO metrics + a deterministic `maggie_finance.py` calculator), `maggie-support` (customer success), `maggie-product` (roadmap + pre-registered experiments), `maggie-fundraising` (deck, data room, investor updates), `maggie-legal` (ToS/privacy/DPA/SOC2 readiness), `maggie-monetization` (pricing/paywalls/dunning; money actions human-approved), and `maggie-orchestrator` (the CEO layer that decomposes a goal into gated missions across the lanes, with a `maggie_orchestrator.py` planner). `0.8.3` folded cmo.so/www development learnings in (maggie-skill-authoring, maggie-feedback-triage, and mobile/MCP/auth references); `0.8.2` added `maggie-spirit`; `0.8.0` was the security optimization roadmap.
|
|
93
93
|
|
|
94
94
|
The 0.7.59 release made `maggie security host-audit` (the deep
|
|
95
95
|
lynis audit over SSH) robust on hosts where a storage test can stall: lynis runs
|
|
@@ -0,0 +1,126 @@
|
|
|
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 compile --spec timeline.json # declarative timeline -> one command
|
|
124
|
+
maggie tool maggie_video.py --run encode --input in.mov --output out.mp4 # actually runs
|
|
125
|
+
maggie tool maggie_video.py presets
|
|
126
|
+
```
|
|
@@ -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).
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Marketing-video timeline model
|
|
2
|
+
|
|
3
|
+
An engine-neutral, provider-neutral data shape for "assemble these clips, images,
|
|
4
|
+
text, captions and audio into a video", distilled from the open NLEs (MLT /
|
|
5
|
+
Shotcut / Kdenlive, OpenShot, Olive). Used by `maggie-video`. We adopt the **data
|
|
6
|
+
shape** (an uncopyrightable idea), never their code - MLT/Shotcut/Kdenlive/OpenShot/
|
|
7
|
+
Olive are LGPL/GPL. It renders through the **ffmpeg binary** (the common
|
|
8
|
+
denominator, arm's-length), or optionally through `melt` as a GPL external tool.
|
|
9
|
+
|
|
10
|
+
## Conventions (chosen for LLM-friendliness)
|
|
11
|
+
|
|
12
|
+
- **Seconds** (floats) at the authoring layer, not frames (OpenShot-style).
|
|
13
|
+
- **`position`** = where a clip starts on the timeline; **`start`/`end`** = the trim
|
|
14
|
+
within its source (OpenShot's split; MLT calls the trim `in`/`out`).
|
|
15
|
+
- **`z`** = track stacking; higher is on top (mirrors MLT `b_track` over `a_track`,
|
|
16
|
+
OpenShot's higher layer number).
|
|
17
|
+
- **Keyframes** as `{t, v, ease}` arrays, ease in `linear | hold | smooth | bezier`
|
|
18
|
+
(MLT's linear/discrete/smooth + OpenShot's Linear/Constant/Bezier).
|
|
19
|
+
- **Relative positions/sizes** (`"80%"`, 0..1) + named anchors, so it is
|
|
20
|
+
resolution-independent.
|
|
21
|
+
|
|
22
|
+
## The model
|
|
23
|
+
|
|
24
|
+
```jsonc
|
|
25
|
+
{
|
|
26
|
+
"output": { "width": 1080, "height": 1920, "fps": 30, "format": "mp4",
|
|
27
|
+
"vcodec": "h264", "acodec": "aac", "duration": "auto" },
|
|
28
|
+
"assets": {
|
|
29
|
+
"shot1": { "type": "video", "src": "footage/a.mp4" },
|
|
30
|
+
"product": { "type": "image", "src": "footage/product.jpg" },
|
|
31
|
+
"logo": { "type": "image", "src": "brand/logo.png" },
|
|
32
|
+
"vo": { "type": "audio", "src": "audio/vo.mp3" },
|
|
33
|
+
"music": { "type": "audio", "src": "audio/track.mp3" }
|
|
34
|
+
},
|
|
35
|
+
"tracks": [
|
|
36
|
+
{ "id": "main", "kind": "video", "z": 0, "clips": [
|
|
37
|
+
{ "asset": "shot1", "start": 2.0, "end": 7.0, "position": 0.0, "fit": "cover",
|
|
38
|
+
"effects": [ { "type": "zoom", "keyframes": [
|
|
39
|
+
{"t":0.0,"v":1.0,"ease":"linear"}, {"t":5.0,"v":1.12,"ease":"smooth"} ] } ] },
|
|
40
|
+
{ "asset": "product", "position": 5.0, "duration": 4.0, "fit": "cover" }
|
|
41
|
+
] },
|
|
42
|
+
{ "id": "overlay", "kind": "video", "z": 10, "clips": [
|
|
43
|
+
{ "type": "text", "text": "50% OFF TODAY", "position": 1.0, "duration": 3.0,
|
|
44
|
+
"style": { "font": "Inter-Bold", "size": 96, "color": "#FFFFFF", "box": "#E11D48", "align": "center" },
|
|
45
|
+
"rect": { "x": "10%", "y": "70%", "w": "80%", "h": "15%" },
|
|
46
|
+
"effects": [ { "type": "opacity", "keyframes": [
|
|
47
|
+
{"t":0,"v":0}, {"t":0.4,"v":1}, {"t":2.6,"v":1}, {"t":3.0,"v":0} ] } ] },
|
|
48
|
+
{ "asset": "logo", "position": 0.0, "duration": 9.0,
|
|
49
|
+
"rect": { "x": "80%", "y": "5%", "w": "15%", "h": "auto" } },
|
|
50
|
+
{ "type": "captions", "src": "audio/vo.srt" }
|
|
51
|
+
] }
|
|
52
|
+
],
|
|
53
|
+
"transitions": [ { "type": "crossfade", "between": ["shot1","product"], "position": 5.0, "duration": 0.5 } ],
|
|
54
|
+
"audio": [
|
|
55
|
+
{ "asset": "vo", "position": 0.5, "gain": 0 },
|
|
56
|
+
{ "asset": "music", "position": 0.0, "gain": -18, "ducking": { "under": "vo", "amount": -12 },
|
|
57
|
+
"fade_out": { "at": 8.5, "duration": 0.5 } }
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Minimal required: `output{width,height,fps,format}`, `assets{}`, one video track of
|
|
63
|
+
`clips[]` each with `asset`+`position`+(`duration` or `start`/`end`). Everything
|
|
64
|
+
else is additive.
|
|
65
|
+
|
|
66
|
+
## Field crosswalk (stay compatible with any backend)
|
|
67
|
+
|
|
68
|
+
| this model | MLT | OpenShot | Olive |
|
|
69
|
+
|---|---|---|---|
|
|
70
|
+
| asset `src` | producer `resource` | `files[].path` | footage node |
|
|
71
|
+
| `start`/`end` (trim) | `in`/`out` (frames) | `start`/`end` (sec) | `kMediaInInput` |
|
|
72
|
+
| `position` (timeline) | playlist order + `blank` | `position` (sec) | `ClipBlock.in()` |
|
|
73
|
+
| track / `z` | playlist in multitrack | `layer` number | block in sequence |
|
|
74
|
+
| overlay | `composite` transition | higher layer | graph edge |
|
|
75
|
+
| keyframe ease | `linear`/`discrete`(`|`)/`smooth`(`~`) | Linear/Constant/Bezier | kLinear/kHold/kBezier |
|
|
76
|
+
| time base | frames | seconds | rational |
|
|
77
|
+
|
|
78
|
+
## Rendering through ffmpeg (default, no GPL linking)
|
|
79
|
+
|
|
80
|
+
Compile the model to one `filter_complex`:
|
|
81
|
+
|
|
82
|
+
- **trim / in-out:** `-ss start -t dur -i a.mp4`, or `trim=start=..:end=..,setpts=PTS-STARTPTS`.
|
|
83
|
+
- **fit to canvas:** `"cover"` = `scale=W:H:force_original_aspect_ratio=increase,crop=W:H`;
|
|
84
|
+
`"contain"` = `...decrease,pad=...` (see [ffmpeg recipes](ffmpeg-recipes.md)).
|
|
85
|
+
- **sequence:** `concat` back-to-back; `overlay=enable='between(t,P,P+D)'` for timed overlays.
|
|
86
|
+
- **crossfade:** `xfade=transition=fade:duration=0.5:offset=5.0`; audio `acrossfade`.
|
|
87
|
+
- **text / lower-thirds:** `drawtext=...:enable='between(t,1,4)'`; animate via `x=`/`alpha=` expressions in `t`.
|
|
88
|
+
- **logo:** `overlay=W*0.8:H*0.05`. **captions:** `subtitles=vo.srt`.
|
|
89
|
+
- **audio mix + ducking:** `amix`, `volume`, `sidechaincompress` (music under VO), `afade`.
|
|
90
|
+
- **keyframes:** ffmpeg `t`-expressions (`alpha='if(lt(t,0.4),t/0.4,1)'`) or `zoompan`; for rich curves precompute per-frame values from the `{t,v,ease}` arrays.
|
|
91
|
+
- **output:** `-c:v libx264 -r fps -pix_fmt yuv420p -c:a aac out.mp4`.
|
|
92
|
+
|
|
93
|
+
`mifi/editly` (MIT) and `Zulko/moviepy` (MIT) are working prior art that this JSON
|
|
94
|
+
shape renders through ffmpeg without any GPL engine.
|
|
95
|
+
|
|
96
|
+
### maggie_video.py compile
|
|
97
|
+
|
|
98
|
+
`maggie_video.py compile --spec timeline.json` compiles this model into one ffmpeg
|
|
99
|
+
`filter_complex` command (printed; run with `--run`). Supported today: a main video
|
|
100
|
+
track of clips (video/image) fit `cover`/`contain` to the canvas, hard-cut `concat`
|
|
101
|
+
or sequential `crossfade` (from `transitions`), image/text/caption overlays with
|
|
102
|
+
`position`+`duration` timing and `rect` placement, and an `audio[]` mix with
|
|
103
|
+
per-track `position`/`gain` plus `loudnorm`. Output is bounded to the computed video
|
|
104
|
+
duration and uses the shared web-safe profile. Not compiled yet (emitted as
|
|
105
|
+
`# NOTE:` lines): keyframes, ken-burns, and sidechain ducking - set static values or
|
|
106
|
+
use a programmatic engine for those.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
maggie tool maggie_video.py compile --spec timeline.json # print the command
|
|
110
|
+
maggie tool maggie_video.py --run compile --spec timeline.json # render it
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Optional higher-fidelity backend: melt (GPL, external)
|
|
114
|
+
|
|
115
|
+
The same model can emit **MLT XML** and render with
|
|
116
|
+
`melt project.mlt -consumer avformat:out.mp4` - keyframes map to MLT's
|
|
117
|
+
`frame=value` animation strings and `composite`/`luma` transitions. Treat `melt` as
|
|
118
|
+
an optional external tool behind a provider-adapter boundary (see
|
|
119
|
+
[provider adapter contract](provider-adapter-contract.md)); it is GPL, invoked at
|
|
120
|
+
arm's length, never linked or vendored.
|
|
121
|
+
|
|
122
|
+
## What belongs where
|
|
123
|
+
|
|
124
|
+
- **This reference:** the data model, the crosswalk, the ffmpeg compilation map, the
|
|
125
|
+
keyframe/ease vocabulary, the licensing note - stable and engine-neutral.
|
|
126
|
+
- **`maggie-video` skill:** channel presets, caption/CTA safe-areas, the operational
|
|
127
|
+
ffmpeg command layer (via `maggie_video.py`), and tool wiring.
|
|
@@ -145,6 +145,10 @@
|
|
|
145
145
|
{
|
|
146
146
|
"name": "maggie-template",
|
|
147
147
|
"description": "Discover, recommend, inspect, and apply verified Astro homepage templates from the Maggie marketplace. Use when a user wants to start a site from a selected template, compare available designs, or choose a visual direction before maggie-clone or maggie-design."
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
"name": "maggie-video",
|
|
151
|
+
"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."
|
|
148
152
|
}
|
|
149
153
|
]
|
|
150
154
|
}
|
|
@@ -0,0 +1,135 @@
|
|
|
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.1
|
|
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 run
|
|
39
|
+
`maggie_video.py compile --spec timeline.json`, which compiles it to one ffmpeg
|
|
40
|
+
`filter_complex` command (main track fit + concat/crossfade, image/text/caption
|
|
41
|
+
overlays with timing, audio mix + loudnorm; keyframes/ken-burns/ducking are flagged
|
|
42
|
+
as notes). For higher fidelity, emit MLT XML and render with `melt` as an optional
|
|
43
|
+
external GPL tool behind an adapter.
|
|
44
|
+
- **Code-drawn animation** (kinetic type, charts, N data-driven variants) →
|
|
45
|
+
[programmatic video](../../references/programmatic-video.md): use **Revideo**
|
|
46
|
+
(MIT, headless, parameterized) by default; **CCapture** for an existing canvas
|
|
47
|
+
loop; **Remotion only as an option the end user licenses** (its license requires
|
|
48
|
+
a paid Company License for for-profit orgs over 3 employees - no hard dependency).
|
|
49
|
+
- **Animating an existing rendered page/design** → stay in `maggie-design-motion`
|
|
50
|
+
Part C; do not reinvent it here.
|
|
51
|
+
|
|
52
|
+
## The ffmpeg CLI
|
|
53
|
+
|
|
54
|
+
`maggie_video.py` generates the correct ffmpeg command (printed, so it is reviewable
|
|
55
|
+
and loggable) and runs it with `--run` when `ffmpeg` is on PATH:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
maggie tool maggie_video.py social --input in.mp4 --output reel.mp4 --aspect reel # 9:16, pad
|
|
59
|
+
maggie tool maggie_video.py social --input in.mp4 --output feed.mp4 --aspect square --mode crop
|
|
60
|
+
maggie tool maggie_video.py gif --input in.mp4 --output out.gif --fps 15 --width 480
|
|
61
|
+
maggie tool maggie_video.py subtitle --input in.mp4 --srt vo.srt --output sub.mp4 # burn in
|
|
62
|
+
maggie tool maggie_video.py loudnorm --input in.mp4 --output norm.mp4 --target -14 # social loudness
|
|
63
|
+
maggie tool maggie_video.py overlay --input in.mp4 --logo logo.png --output branded.mp4
|
|
64
|
+
maggie tool maggie_video.py trim --input in.mp4 --output cut.mp4 --start 5 --duration 10
|
|
65
|
+
maggie tool maggie_video.py concat --inputs a.mp4 b.mp4 --output all.mp4 --reencode
|
|
66
|
+
maggie tool maggie_video.py thumb --input in.mp4 --output poster.jpg --at 00:00:03
|
|
67
|
+
maggie tool maggie_video.py compile --spec timeline.json # declarative -> one command
|
|
68
|
+
maggie tool maggie_video.py --run encode --input in.mov --output out.mp4 # actually runs
|
|
69
|
+
maggie tool maggie_video.py presets
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Subcommands: `encode` (web-safe MP4 / VP9 WebM), `gif`, `trim`, `concat`, `social`,
|
|
73
|
+
`overlay`, `subtitle` (burn or soft-mux), `loudnorm`, `thumb`, `compile` (timeline
|
|
74
|
+
JSON -> ffmpeg), `presets`. `--run` (before the subcommand) executes when ffmpeg is
|
|
75
|
+
on PATH; otherwise the command is printed for review.
|
|
76
|
+
|
|
77
|
+
## Channel presets and safe areas
|
|
78
|
+
|
|
79
|
+
- **9:16** (1080x1920) - Reels / Shorts / TikTok / Stories, <= 60s for feeds.
|
|
80
|
+
- **1:1** (1080x1080) - feed posts.
|
|
81
|
+
- **16:9** (1920x1080) - YouTube / landscape.
|
|
82
|
+
- Keep captions and the CTA inside a safe area (roughly 10% margin top/bottom, more
|
|
83
|
+
at the bottom for platform UI). Logo in a fixed corner (see `overlay`). Default
|
|
84
|
+
loudness **-14 LUFS**. Prefer **crop (fill)** for full-bleed footage, **pad
|
|
85
|
+
(letterbox)** when the whole frame must be visible.
|
|
86
|
+
|
|
87
|
+
## Output profile (shared with maggie-design-motion)
|
|
88
|
+
|
|
89
|
+
H.264 high / `yuv420p` / `+faststart` / AAC 128k, even dimensions; GIF via
|
|
90
|
+
`palettegen`+`paletteuse` (lanczos). Always force `-pix_fmt yuv420p` and `setsar=1`.
|
|
91
|
+
|
|
92
|
+
## Quality bar
|
|
93
|
+
|
|
94
|
+
- Output plays everywhere (yuv420p, faststart, even dimensions); aspect matches the
|
|
95
|
+
target channel with no distortion.
|
|
96
|
+
- Captions are inside the safe area and readable; loudness normalized to target.
|
|
97
|
+
- Every ffmpeg command is printed/logged before (or instead of) running.
|
|
98
|
+
- No GPL library linked or vendored; ffmpeg/melt invoked at arm's length.
|
|
99
|
+
|
|
100
|
+
## Guardrails
|
|
101
|
+
|
|
102
|
+
- Rendering is a **local CPU/GPU/time cost**, not a paid API; flag long renders.
|
|
103
|
+
If any **hosted or LLM video generation** is wired in (e.g. a Q-category model),
|
|
104
|
+
the global BYOK rule (OpenRouter `provider.order` + `allow_fallbacks:false` +
|
|
105
|
+
explicit `max_tokens`) and the spend-first rule apply - state cost first.
|
|
106
|
+
- Never claim a render ran unless `--run` executed and produced the file.
|
|
107
|
+
- Licensing: adopt ideas/shape, not GPL code; invoke the `ffmpeg` binary (and
|
|
108
|
+
optionally `melt`) rather than linking libav. **Remotion** is reference-only (not
|
|
109
|
+
a free license for a commercial product).
|
|
110
|
+
- Do not fabricate captions/transcripts; generate them from the real audio (see
|
|
111
|
+
the R-category caption tools, e.g. whisper) and let a human review.
|
|
112
|
+
- Publishing a video is a separate approved step via `maggie-social-share`.
|
|
113
|
+
|
|
114
|
+
## Related skills
|
|
115
|
+
|
|
116
|
+
- `maggie-design-motion` - animate a static design into a showcase (shares the
|
|
117
|
+
render-then-encode core).
|
|
118
|
+
- `maggie-social-share` - publish the finished video as platform-native posts.
|
|
119
|
+
- `maggie-spirit` - brand marks/avatars that can appear in a video.
|
|
120
|
+
- `maggie media generate` - AI image/video generation for b-roll (Q-category).
|
|
121
|
+
|
|
122
|
+
## Boundaries
|
|
123
|
+
|
|
124
|
+
- Engine-neutral production + processing; the data model and recipes live in the
|
|
125
|
+
references, channel presets and the ffmpeg layer live here. A human approves
|
|
126
|
+
publishing and any paid generation.
|
|
127
|
+
|
|
128
|
+
## Credits
|
|
129
|
+
|
|
130
|
+
Distilled (concepts and command shapes, not code) from the reference library's
|
|
131
|
+
N-category video projects: moviepy, editly, auto-editor (MIT/Unlicense) for the
|
|
132
|
+
ffmpeg command layer and timeline spec; Motion Canvas, Revideo, CCapture (MIT) for
|
|
133
|
+
the programmatic render pattern; MLT / Shotcut / Kdenlive / OpenShot / Olive
|
|
134
|
+
(LGPL/GPL) for the timeline data-model shape only; ffmpeg (LGPL/GPL) and HandBrake
|
|
135
|
+
(GPL) invoked at arm's length.
|
|
@@ -0,0 +1,399 @@
|
|
|
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, compile, presets.
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import json
|
|
16
|
+
import shlex
|
|
17
|
+
import shutil
|
|
18
|
+
import subprocess
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
PREFIX = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error"]
|
|
22
|
+
MP4_OUT = ["-c:v", "libx264", "-profile:v", "high", "-pix_fmt", "yuv420p",
|
|
23
|
+
"-movflags", "+faststart"]
|
|
24
|
+
# channel aspect presets (name -> WxH)
|
|
25
|
+
SOCIAL = {"reel": (1080, 1920), "story": (1080, 1920), "tiktok": (1080, 1920),
|
|
26
|
+
"square": (1080, 1080), "feed": (1080, 1080),
|
|
27
|
+
"youtube": (1920, 1080), "landscape": (1920, 1080)}
|
|
28
|
+
OVERLAY_POS = {"br": "W-w-{m}:H-h-{m}", "bl": "{m}:H-h-{m}",
|
|
29
|
+
"tr": "W-w-{m}:{m}", "tl": "{m}:{m}"}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def emit(args, run):
|
|
33
|
+
printable = " ".join(shlex.quote(a) for a in args)
|
|
34
|
+
print(printable)
|
|
35
|
+
if not run:
|
|
36
|
+
return 0
|
|
37
|
+
if not shutil.which("ffmpeg"):
|
|
38
|
+
sys.stderr.write("ffmpeg not found on PATH; printed the command only.\n")
|
|
39
|
+
return 3
|
|
40
|
+
return subprocess.run(args).returncode
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def c_encode(a):
|
|
44
|
+
out = ["-crf", str(a.crf), "-preset", a.preset]
|
|
45
|
+
if a.vcodec == "vp9":
|
|
46
|
+
v = ["-c:v", "libvpx-vp9", "-crf", str(a.crf), "-b:v", "0", "-row-mt", "1",
|
|
47
|
+
"-pix_fmt", "yuv420p", "-c:a", "libopus", "-b:a", "128k"]
|
|
48
|
+
return emit(PREFIX + ["-i", a.input] + v + [a.output], a.run)
|
|
49
|
+
cmd = PREFIX + ["-i", a.input, "-c:v", "libx264", "-profile:v", "high",
|
|
50
|
+
"-preset", a.preset, "-crf", str(a.crf), "-pix_fmt", "yuv420p",
|
|
51
|
+
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", a.output]
|
|
52
|
+
return emit(cmd, a.run)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def c_gif(a):
|
|
56
|
+
vf = (f"fps={a.fps},scale={a.width}:-1:flags=lanczos,split[s0][s1];"
|
|
57
|
+
f"[s0]palettegen[p];[s1][p]paletteuse")
|
|
58
|
+
return emit(PREFIX + ["-i", a.input, "-vf", vf, "-loop", "0", a.output], a.run)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def c_trim(a):
|
|
62
|
+
dur = ["-t", str(a.duration)] if a.duration else (["-to", str(a.end)] if a.end else [])
|
|
63
|
+
if a.reencode:
|
|
64
|
+
cmd = PREFIX + ["-ss", str(a.start), "-i", a.input] + dur + \
|
|
65
|
+
["-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt",
|
|
66
|
+
"yuv420p", "-c:a", "aac", "-movflags", "+faststart", a.output]
|
|
67
|
+
else:
|
|
68
|
+
cmd = PREFIX + ["-ss", str(a.start), "-i", a.input] + dur + \
|
|
69
|
+
["-map", "0", "-c", "copy", "-avoid_negative_ts", "make_zero", a.output]
|
|
70
|
+
return emit(cmd, a.run)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def c_concat(a):
|
|
74
|
+
if a.reencode or len(a.inputs) < 1:
|
|
75
|
+
ins = []
|
|
76
|
+
for p in a.inputs:
|
|
77
|
+
ins += ["-i", p]
|
|
78
|
+
n = len(a.inputs)
|
|
79
|
+
streams = "".join(f"[{i}:v][{i}:a]" for i in range(n))
|
|
80
|
+
fc = f"{streams}concat=n={n}:v=1:a=1[v][a]"
|
|
81
|
+
cmd = PREFIX + ins + ["-filter_complex", fc, "-map", "[v]", "-map", "[a]"] + \
|
|
82
|
+
["-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
|
83
|
+
"-c:a", "aac", "-movflags", "+faststart", a.output]
|
|
84
|
+
return emit(cmd, a.run)
|
|
85
|
+
# fast path: a concat list file (same codec). Print how to build it.
|
|
86
|
+
print("# same-codec fast concat: write a list file, then run ffmpeg:")
|
|
87
|
+
print("# printf \"file '%s'\\n\" " + " ".join(shlex.quote(p) for p in a.inputs) + " > concat.txt")
|
|
88
|
+
return emit(PREFIX + ["-f", "concat", "-safe", "0", "-i", "concat.txt",
|
|
89
|
+
"-c", "copy", a.output], a.run)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def c_social(a):
|
|
93
|
+
w, h = SOCIAL[a.aspect]
|
|
94
|
+
if a.mode == "crop":
|
|
95
|
+
vf = (f"scale={w}:{h}:force_original_aspect_ratio=increase,"
|
|
96
|
+
f"crop={w}:{h},setsar=1")
|
|
97
|
+
else:
|
|
98
|
+
vf = (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
|
99
|
+
f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={a.color},setsar=1")
|
|
100
|
+
cmd = PREFIX + ["-i", a.input, "-vf", vf, "-crf", str(a.crf)] + MP4_OUT + \
|
|
101
|
+
["-c:a", "aac", "-b:a", "128k", a.output]
|
|
102
|
+
return emit(cmd, a.run)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def c_overlay(a):
|
|
106
|
+
pos = OVERLAY_POS[a.position].format(m=a.margin)
|
|
107
|
+
fc = (f"[1][0]scale2ref=w=oh*mdar:h=ih*{a.scale}[logo][base];"
|
|
108
|
+
f"[base][logo]overlay={pos}")
|
|
109
|
+
cmd = PREFIX + ["-i", a.input, "-i", a.logo, "-filter_complex", fc,
|
|
110
|
+
"-c:a", "copy", "-c:v", "libx264", "-crf", "20",
|
|
111
|
+
"-pix_fmt", "yuv420p", "-movflags", "+faststart", a.output]
|
|
112
|
+
return emit(cmd, a.run)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def c_subtitle(a):
|
|
116
|
+
if a.soft:
|
|
117
|
+
cmd = PREFIX + ["-i", a.input, "-i", a.srt, "-c", "copy", "-c:s",
|
|
118
|
+
"mov_text", "-metadata:s:s:0", f"language={a.lang}", a.output]
|
|
119
|
+
else:
|
|
120
|
+
style = f"force_style='FontName={a.font},FontSize={a.size},BorderStyle=3'"
|
|
121
|
+
cmd = PREFIX + ["-i", a.input, "-vf", f"subtitles={a.srt}:{style}",
|
|
122
|
+
"-c:v", "libx264", "-crf", "20", "-pix_fmt", "yuv420p",
|
|
123
|
+
"-c:a", "copy", a.output]
|
|
124
|
+
return emit(cmd, a.run)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def c_loudnorm(a):
|
|
128
|
+
cmd = PREFIX + ["-i", a.input, "-af",
|
|
129
|
+
f"loudnorm=I={a.target}:TP=-1.5:LRA=11",
|
|
130
|
+
"-c:v", "copy", "-c:a", "aac", "-b:a", "128k", a.output]
|
|
131
|
+
print("# quick single-pass loudnorm (I=-14 LUFS = social/YouTube target).")
|
|
132
|
+
print("# for accuracy, measure first: ffmpeg -i IN -af loudnorm=...:print_format=json -f null -")
|
|
133
|
+
return emit(cmd, a.run)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def c_thumb(a):
|
|
137
|
+
if a.smart:
|
|
138
|
+
cmd = PREFIX + ["-i", a.input, "-vf", "thumbnail,scale=1280:-1",
|
|
139
|
+
"-frames:v", "1", a.output]
|
|
140
|
+
else:
|
|
141
|
+
cmd = PREFIX + ["-ss", str(a.at), "-i", a.input, "-frames:v", "1",
|
|
142
|
+
"-q:v", "2", a.output]
|
|
143
|
+
return emit(cmd, a.run)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fit(w, h, mode, fps, color="black"):
|
|
147
|
+
"""A filter chain that fits a source to WxH and normalizes it for concat/xfade."""
|
|
148
|
+
if mode == "contain":
|
|
149
|
+
scale = (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
|
150
|
+
f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={color}")
|
|
151
|
+
else: # cover (fill)
|
|
152
|
+
scale = f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
|
|
153
|
+
return f"{scale},fps={fps},format=yuv420p,setsar=1"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _clip_dur(clip, default):
|
|
157
|
+
if isinstance(clip.get("duration"), (int, float)):
|
|
158
|
+
return float(clip["duration"])
|
|
159
|
+
if isinstance(clip.get("start"), (int, float)) and isinstance(clip.get("end"), (int, float)):
|
|
160
|
+
return float(clip["end"]) - float(clip["start"])
|
|
161
|
+
return float(default)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _px(val, total):
|
|
165
|
+
"""Resolve '80%' / 0.8 / 120 against a total dimension to an int pixel value."""
|
|
166
|
+
if isinstance(val, str) and val.strip().endswith("%"):
|
|
167
|
+
return int(round(float(val.strip()[:-1]) / 100.0 * total))
|
|
168
|
+
v = float(val)
|
|
169
|
+
return int(round(v * total)) if 0 < v <= 1 else int(round(v))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def c_compile(a):
|
|
173
|
+
"""Compile a timeline-model JSON (references/video-timeline-model.md) into one
|
|
174
|
+
ffmpeg command. Supports: a main video track of clips (video/image) fit to the
|
|
175
|
+
canvas, hard-cut concat or sequential crossfade, image/text/caption overlays
|
|
176
|
+
with timing, and an audio mix with per-track gain + optional loudnorm. Keyframes,
|
|
177
|
+
ken-burns, and sidechain ducking are not compiled yet (flagged as notes)."""
|
|
178
|
+
with open(a.spec, encoding="utf-8") as fh:
|
|
179
|
+
spec = json.load(fh)
|
|
180
|
+
out = spec.get("output", {})
|
|
181
|
+
W, H = int(out.get("width", 1080)), int(out.get("height", 1920))
|
|
182
|
+
FPS = int(out.get("fps", 30))
|
|
183
|
+
outfile = a.output or out.get("path") or "out.mp4"
|
|
184
|
+
assets = spec.get("assets", {})
|
|
185
|
+
defaults = spec.get("defaults", {})
|
|
186
|
+
def_dur = defaults.get("clipDuration", 4)
|
|
187
|
+
notes, inputs, filters = [], [], []
|
|
188
|
+
|
|
189
|
+
def add_input(opts, path):
|
|
190
|
+
inputs.extend(opts + ["-i", path])
|
|
191
|
+
return len([x for x in inputs if x == "-i"]) - 1 # index of this input
|
|
192
|
+
|
|
193
|
+
tracks = spec.get("tracks", [])
|
|
194
|
+
vtracks = [t for t in tracks if t.get("kind", "video") == "video"]
|
|
195
|
+
if not vtracks:
|
|
196
|
+
raise SystemExit("spec needs at least one video track")
|
|
197
|
+
main = sorted(vtracks, key=lambda t: t.get("z", 0))[0]
|
|
198
|
+
overlays = [t for t in sorted(vtracks, key=lambda t: t.get("z", 0))[1:]]
|
|
199
|
+
|
|
200
|
+
# --- main track: build each clip segment, then concat or crossfade ---
|
|
201
|
+
segs, durs = [], []
|
|
202
|
+
for i, clip in enumerate(main.get("clips", [])):
|
|
203
|
+
aid = clip.get("asset")
|
|
204
|
+
asset = assets.get(aid, {})
|
|
205
|
+
dur = _clip_dur(clip, def_dur)
|
|
206
|
+
fit = clip.get("fit", "cover")
|
|
207
|
+
if asset.get("type") == "image" or clip.get("type") == "image":
|
|
208
|
+
idx = add_input(["-loop", "1", "-t", f"{dur}"], asset.get("src", clip.get("src", "")))
|
|
209
|
+
filters.append(f"[{idx}:v]{_fit(W, H, fit, FPS)}[v{i}]")
|
|
210
|
+
elif asset.get("type") == "video":
|
|
211
|
+
idx = add_input([], asset.get("src", ""))
|
|
212
|
+
s = clip.get("start", 0)
|
|
213
|
+
trim = f"trim=start={s}:end={s + dur},setpts=PTS-STARTPTS,"
|
|
214
|
+
filters.append(f"[{idx}:v]{trim}{_fit(W, H, fit, FPS)}[v{i}]")
|
|
215
|
+
else:
|
|
216
|
+
notes.append(f"clip {i}: unsupported type (need asset of type image/video)")
|
|
217
|
+
continue
|
|
218
|
+
segs.append(f"[v{i}]")
|
|
219
|
+
durs.append(dur)
|
|
220
|
+
|
|
221
|
+
transitions = spec.get("transitions", [])
|
|
222
|
+
tdur0 = float(transitions[0].get("duration", 0.5)) if transitions else 0.0
|
|
223
|
+
video_dur = (sum(durs) - tdur0 * (len(segs) - 1)) if (transitions and len(segs) > 1) else sum(durs)
|
|
224
|
+
if len(segs) == 1:
|
|
225
|
+
filters.append(f"{segs[0]}copy[vbase]")
|
|
226
|
+
elif transitions:
|
|
227
|
+
# sequential crossfade; offset = cumulative shown time - transition dur
|
|
228
|
+
tdur = float(transitions[0].get("duration", 0.5))
|
|
229
|
+
tname = transitions[0].get("type", "fade")
|
|
230
|
+
if tname in ("crossfade",):
|
|
231
|
+
tname = "fade"
|
|
232
|
+
prev, off = segs[0], 0.0
|
|
233
|
+
for i in range(1, len(segs)):
|
|
234
|
+
off += durs[i - 1] - tdur
|
|
235
|
+
label = "[vbase]" if i == len(segs) - 1 else f"[vx{i}]"
|
|
236
|
+
filters.append(f"{prev}{segs[i]}xfade=transition={tname}:duration={tdur}:offset={off}{label}")
|
|
237
|
+
prev = label
|
|
238
|
+
notes.append(f"crossfade '{tname}' {tdur}s between all clips (first transition's settings)")
|
|
239
|
+
else:
|
|
240
|
+
filters.append(f"{''.join(segs)}concat=n={len(segs)}:v=1:a=0[vbase]")
|
|
241
|
+
|
|
242
|
+
# --- overlays (image / text / captions) ---
|
|
243
|
+
cur = "[vbase]"
|
|
244
|
+
step = 0
|
|
245
|
+
for t in overlays:
|
|
246
|
+
for clip in t.get("clips", []):
|
|
247
|
+
nxt = f"[vo{step}]"
|
|
248
|
+
pos = float(clip.get("position", 0))
|
|
249
|
+
dur = _clip_dur(clip, def_dur)
|
|
250
|
+
en = f"enable='between(t,{pos},{pos + dur})'"
|
|
251
|
+
if clip.get("effects"):
|
|
252
|
+
notes.append("overlay effects/keyframes (opacity/zoom) not compiled; shown at full opacity")
|
|
253
|
+
ctype = clip.get("type")
|
|
254
|
+
if ctype == "text":
|
|
255
|
+
st = clip.get("style", {})
|
|
256
|
+
rect = clip.get("rect", {})
|
|
257
|
+
x = _px(rect.get("x", "10%"), W)
|
|
258
|
+
y = _px(rect.get("y", "80%"), H)
|
|
259
|
+
txt = str(clip.get("text", "")).replace(":", r"\:").replace("'", "’")
|
|
260
|
+
box = f":box=1:boxcolor={st['box']}:boxborderw=12" if st.get("box") else ""
|
|
261
|
+
dt = (f"drawtext=text='{txt}':x={x}:y={y}:fontsize={st.get('size',48)}:"
|
|
262
|
+
f"fontcolor={st.get('color','#FFFFFF')}{box}:{en}")
|
|
263
|
+
filters.append(f"{cur}{dt}{nxt}")
|
|
264
|
+
elif ctype == "captions":
|
|
265
|
+
src = clip.get("src", "")
|
|
266
|
+
filters.append(f"{cur}subtitles={src}{nxt}")
|
|
267
|
+
elif assets.get(clip.get("asset"), {}).get("type") == "image":
|
|
268
|
+
asset = assets[clip["asset"]]
|
|
269
|
+
rect = clip.get("rect", {})
|
|
270
|
+
ow = _px(rect.get("w", "15%"), W)
|
|
271
|
+
idx = add_input(["-loop", "1"], asset.get("src", ""))
|
|
272
|
+
filters.append(f"[{idx}:v]scale={ow}:-1[ov{step}]")
|
|
273
|
+
x = _px(rect.get("x", "80%"), W)
|
|
274
|
+
y = _px(rect.get("y", "5%"), H)
|
|
275
|
+
filters.append(f"{cur}[ov{step}]overlay={x}:{y}:{en}:eof_action=pass{nxt}")
|
|
276
|
+
else:
|
|
277
|
+
notes.append(f"overlay clip of type {ctype!r} not compiled")
|
|
278
|
+
continue
|
|
279
|
+
cur = nxt
|
|
280
|
+
step += 1
|
|
281
|
+
filters.append(f"{cur}format=yuv420p[vout]")
|
|
282
|
+
|
|
283
|
+
# --- audio: mix audio[] tracks with gain + optional loudnorm ---
|
|
284
|
+
aud = spec.get("audio", [])
|
|
285
|
+
alabels = []
|
|
286
|
+
for i, tr in enumerate(aud):
|
|
287
|
+
asset = assets.get(tr.get("asset"), {})
|
|
288
|
+
if not asset.get("src"):
|
|
289
|
+
continue
|
|
290
|
+
idx = add_input([], asset["src"])
|
|
291
|
+
chain = f"[{idx}:a]"
|
|
292
|
+
pos = float(tr.get("position", 0))
|
|
293
|
+
if pos > 0:
|
|
294
|
+
ms = int(pos * 1000)
|
|
295
|
+
chain += f"adelay={ms}|{ms},"
|
|
296
|
+
gain = tr.get("gain", 0)
|
|
297
|
+
if gain:
|
|
298
|
+
chain += f"volume={gain}dB,"
|
|
299
|
+
if tr.get("ducking"):
|
|
300
|
+
notes.append("audio ducking (sidechain) not compiled; set music gain lower instead")
|
|
301
|
+
chain = chain.rstrip(",") + f"[a{i}]"
|
|
302
|
+
filters.append(chain)
|
|
303
|
+
alabels.append(f"[a{i}]")
|
|
304
|
+
have_audio = bool(alabels)
|
|
305
|
+
if have_audio:
|
|
306
|
+
if len(alabels) == 1:
|
|
307
|
+
filters.append(f"{alabels[0]}aresample=async=1[amix]")
|
|
308
|
+
else:
|
|
309
|
+
filters.append(f"{''.join(alabels)}amix=inputs={len(alabels)}:duration=longest[amix]")
|
|
310
|
+
norm = next((t for t in aud if isinstance(t, dict)), {}) # master normalize lives on audio? use output
|
|
311
|
+
tgt = (spec.get("audio_normalize") or {}).get("I", -14)
|
|
312
|
+
filters.append(f"[amix]loudnorm=I={tgt}:TP=-1.5:LRA=11[aout]")
|
|
313
|
+
|
|
314
|
+
fc = ";".join(filters)
|
|
315
|
+
cmd = PREFIX + inputs + ["-filter_complex", fc, "-map", "[vout]"]
|
|
316
|
+
if have_audio:
|
|
317
|
+
cmd += ["-map", "[aout]", "-shortest", "-c:a", "aac", "-b:a", "128k"]
|
|
318
|
+
cmd += ["-t", f"{round(video_dur, 3)}", "-r", str(FPS)] + MP4_OUT + [outfile]
|
|
319
|
+
for n in notes:
|
|
320
|
+
print(f"# NOTE: {n}")
|
|
321
|
+
return emit(cmd, a.run)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def c_presets(a):
|
|
325
|
+
print("Channel aspect presets (use with `social --aspect <name>`):")
|
|
326
|
+
for name, (w, h) in SOCIAL.items():
|
|
327
|
+
print(f" {name:<10} {w}x{h}")
|
|
328
|
+
print("\nOutput profile: H.264 high / yuv420p / +faststart / AAC 128k (web-safe).")
|
|
329
|
+
print("GIF: palettegen+paletteuse, lanczos. Loudness target: -14 LUFS.")
|
|
330
|
+
return 0
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def main(argv=None):
|
|
334
|
+
ap = argparse.ArgumentParser(prog="maggie_video", description=__doc__,
|
|
335
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
336
|
+
ap.add_argument("--run", action="store_true", help="execute (needs ffmpeg on PATH); default prints only")
|
|
337
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
338
|
+
|
|
339
|
+
e = sub.add_parser("encode", help="transcode to a web-safe MP4 (or VP9 WebM)")
|
|
340
|
+
e.add_argument("--input", required=True); e.add_argument("--output", required=True)
|
|
341
|
+
e.add_argument("--crf", type=int, default=20); e.add_argument("--preset", default="medium")
|
|
342
|
+
e.add_argument("--vcodec", choices=["h264", "vp9"], default="h264"); e.set_defaults(fn=c_encode)
|
|
343
|
+
|
|
344
|
+
g = sub.add_parser("gif", help="high-quality palette GIF")
|
|
345
|
+
g.add_argument("--input", required=True); g.add_argument("--output", required=True)
|
|
346
|
+
g.add_argument("--fps", type=int, default=15); g.add_argument("--width", type=int, default=480)
|
|
347
|
+
g.set_defaults(fn=c_gif)
|
|
348
|
+
|
|
349
|
+
t = sub.add_parser("trim", help="cut a segment (copy by default, --reencode for frame-accurate)")
|
|
350
|
+
t.add_argument("--input", required=True); t.add_argument("--output", required=True)
|
|
351
|
+
t.add_argument("--start", required=True); t.add_argument("--duration"); t.add_argument("--end")
|
|
352
|
+
t.add_argument("--reencode", action="store_true"); t.set_defaults(fn=c_trim)
|
|
353
|
+
|
|
354
|
+
cc = sub.add_parser("concat", help="join clips (fast same-codec, or --reencode)")
|
|
355
|
+
cc.add_argument("--inputs", nargs="+", required=True); cc.add_argument("--output", required=True)
|
|
356
|
+
cc.add_argument("--reencode", action="store_true"); cc.set_defaults(fn=c_concat)
|
|
357
|
+
|
|
358
|
+
s = sub.add_parser("social", help="scale+pad or crop to a channel aspect")
|
|
359
|
+
s.add_argument("--input", required=True); s.add_argument("--output", required=True)
|
|
360
|
+
s.add_argument("--aspect", choices=list(SOCIAL), required=True)
|
|
361
|
+
s.add_argument("--mode", choices=["pad", "crop"], default="pad")
|
|
362
|
+
s.add_argument("--color", default="black"); s.add_argument("--crf", type=int, default=20)
|
|
363
|
+
s.set_defaults(fn=c_social)
|
|
364
|
+
|
|
365
|
+
o = sub.add_parser("overlay", help="overlay a logo/watermark")
|
|
366
|
+
o.add_argument("--input", required=True); o.add_argument("--logo", required=True)
|
|
367
|
+
o.add_argument("--output", required=True)
|
|
368
|
+
o.add_argument("--position", choices=list(OVERLAY_POS), default="br")
|
|
369
|
+
o.add_argument("--margin", type=int, default=24); o.add_argument("--scale", default="0.12")
|
|
370
|
+
o.set_defaults(fn=c_overlay)
|
|
371
|
+
|
|
372
|
+
sb = sub.add_parser("subtitle", help="burn in (default) or soft-mux an SRT")
|
|
373
|
+
sb.add_argument("--input", required=True); sb.add_argument("--srt", required=True)
|
|
374
|
+
sb.add_argument("--output", required=True); sb.add_argument("--soft", action="store_true")
|
|
375
|
+
sb.add_argument("--font", default="Inter"); sb.add_argument("--size", type=int, default=24)
|
|
376
|
+
sb.add_argument("--lang", default="eng"); sb.set_defaults(fn=c_subtitle)
|
|
377
|
+
|
|
378
|
+
ln = sub.add_parser("loudnorm", help="normalize loudness to a target LUFS")
|
|
379
|
+
ln.add_argument("--input", required=True); ln.add_argument("--output", required=True)
|
|
380
|
+
ln.add_argument("--target", default="-14"); ln.set_defaults(fn=c_loudnorm)
|
|
381
|
+
|
|
382
|
+
th = sub.add_parser("thumb", help="extract a poster/thumbnail frame")
|
|
383
|
+
th.add_argument("--input", required=True); th.add_argument("--output", required=True)
|
|
384
|
+
th.add_argument("--at", default="00:00:03"); th.add_argument("--smart", action="store_true")
|
|
385
|
+
th.set_defaults(fn=c_thumb)
|
|
386
|
+
|
|
387
|
+
cp = sub.add_parser("compile", help="compile a timeline-model JSON into one ffmpeg command")
|
|
388
|
+
cp.add_argument("--spec", required=True, help="timeline JSON (video-timeline-model.md)")
|
|
389
|
+
cp.add_argument("--output", help="override the output path from the spec")
|
|
390
|
+
cp.set_defaults(fn=c_compile)
|
|
391
|
+
|
|
392
|
+
sub.add_parser("presets", help="list channel aspect presets").set_defaults(fn=c_presets)
|
|
393
|
+
|
|
394
|
+
a = ap.parse_args(argv)
|
|
395
|
+
raise SystemExit(a.fn(a))
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
if __name__ == "__main__":
|
|
399
|
+
main()
|
package/package.json
CHANGED
|
@@ -0,0 +1,126 @@
|
|
|
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 compile --spec timeline.json # declarative timeline -> one command
|
|
124
|
+
maggie tool maggie_video.py --run encode --input in.mov --output out.mp4 # actually runs
|
|
125
|
+
maggie tool maggie_video.py presets
|
|
126
|
+
```
|
|
@@ -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).
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# Marketing-video timeline model
|
|
2
|
+
|
|
3
|
+
An engine-neutral, provider-neutral data shape for "assemble these clips, images,
|
|
4
|
+
text, captions and audio into a video", distilled from the open NLEs (MLT /
|
|
5
|
+
Shotcut / Kdenlive, OpenShot, Olive). Used by `maggie-video`. We adopt the **data
|
|
6
|
+
shape** (an uncopyrightable idea), never their code - MLT/Shotcut/Kdenlive/OpenShot/
|
|
7
|
+
Olive are LGPL/GPL. It renders through the **ffmpeg binary** (the common
|
|
8
|
+
denominator, arm's-length), or optionally through `melt` as a GPL external tool.
|
|
9
|
+
|
|
10
|
+
## Conventions (chosen for LLM-friendliness)
|
|
11
|
+
|
|
12
|
+
- **Seconds** (floats) at the authoring layer, not frames (OpenShot-style).
|
|
13
|
+
- **`position`** = where a clip starts on the timeline; **`start`/`end`** = the trim
|
|
14
|
+
within its source (OpenShot's split; MLT calls the trim `in`/`out`).
|
|
15
|
+
- **`z`** = track stacking; higher is on top (mirrors MLT `b_track` over `a_track`,
|
|
16
|
+
OpenShot's higher layer number).
|
|
17
|
+
- **Keyframes** as `{t, v, ease}` arrays, ease in `linear | hold | smooth | bezier`
|
|
18
|
+
(MLT's linear/discrete/smooth + OpenShot's Linear/Constant/Bezier).
|
|
19
|
+
- **Relative positions/sizes** (`"80%"`, 0..1) + named anchors, so it is
|
|
20
|
+
resolution-independent.
|
|
21
|
+
|
|
22
|
+
## The model
|
|
23
|
+
|
|
24
|
+
```jsonc
|
|
25
|
+
{
|
|
26
|
+
"output": { "width": 1080, "height": 1920, "fps": 30, "format": "mp4",
|
|
27
|
+
"vcodec": "h264", "acodec": "aac", "duration": "auto" },
|
|
28
|
+
"assets": {
|
|
29
|
+
"shot1": { "type": "video", "src": "footage/a.mp4" },
|
|
30
|
+
"product": { "type": "image", "src": "footage/product.jpg" },
|
|
31
|
+
"logo": { "type": "image", "src": "brand/logo.png" },
|
|
32
|
+
"vo": { "type": "audio", "src": "audio/vo.mp3" },
|
|
33
|
+
"music": { "type": "audio", "src": "audio/track.mp3" }
|
|
34
|
+
},
|
|
35
|
+
"tracks": [
|
|
36
|
+
{ "id": "main", "kind": "video", "z": 0, "clips": [
|
|
37
|
+
{ "asset": "shot1", "start": 2.0, "end": 7.0, "position": 0.0, "fit": "cover",
|
|
38
|
+
"effects": [ { "type": "zoom", "keyframes": [
|
|
39
|
+
{"t":0.0,"v":1.0,"ease":"linear"}, {"t":5.0,"v":1.12,"ease":"smooth"} ] } ] },
|
|
40
|
+
{ "asset": "product", "position": 5.0, "duration": 4.0, "fit": "cover" }
|
|
41
|
+
] },
|
|
42
|
+
{ "id": "overlay", "kind": "video", "z": 10, "clips": [
|
|
43
|
+
{ "type": "text", "text": "50% OFF TODAY", "position": 1.0, "duration": 3.0,
|
|
44
|
+
"style": { "font": "Inter-Bold", "size": 96, "color": "#FFFFFF", "box": "#E11D48", "align": "center" },
|
|
45
|
+
"rect": { "x": "10%", "y": "70%", "w": "80%", "h": "15%" },
|
|
46
|
+
"effects": [ { "type": "opacity", "keyframes": [
|
|
47
|
+
{"t":0,"v":0}, {"t":0.4,"v":1}, {"t":2.6,"v":1}, {"t":3.0,"v":0} ] } ] },
|
|
48
|
+
{ "asset": "logo", "position": 0.0, "duration": 9.0,
|
|
49
|
+
"rect": { "x": "80%", "y": "5%", "w": "15%", "h": "auto" } },
|
|
50
|
+
{ "type": "captions", "src": "audio/vo.srt" }
|
|
51
|
+
] }
|
|
52
|
+
],
|
|
53
|
+
"transitions": [ { "type": "crossfade", "between": ["shot1","product"], "position": 5.0, "duration": 0.5 } ],
|
|
54
|
+
"audio": [
|
|
55
|
+
{ "asset": "vo", "position": 0.5, "gain": 0 },
|
|
56
|
+
{ "asset": "music", "position": 0.0, "gain": -18, "ducking": { "under": "vo", "amount": -12 },
|
|
57
|
+
"fade_out": { "at": 8.5, "duration": 0.5 } }
|
|
58
|
+
]
|
|
59
|
+
}
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Minimal required: `output{width,height,fps,format}`, `assets{}`, one video track of
|
|
63
|
+
`clips[]` each with `asset`+`position`+(`duration` or `start`/`end`). Everything
|
|
64
|
+
else is additive.
|
|
65
|
+
|
|
66
|
+
## Field crosswalk (stay compatible with any backend)
|
|
67
|
+
|
|
68
|
+
| this model | MLT | OpenShot | Olive |
|
|
69
|
+
|---|---|---|---|
|
|
70
|
+
| asset `src` | producer `resource` | `files[].path` | footage node |
|
|
71
|
+
| `start`/`end` (trim) | `in`/`out` (frames) | `start`/`end` (sec) | `kMediaInInput` |
|
|
72
|
+
| `position` (timeline) | playlist order + `blank` | `position` (sec) | `ClipBlock.in()` |
|
|
73
|
+
| track / `z` | playlist in multitrack | `layer` number | block in sequence |
|
|
74
|
+
| overlay | `composite` transition | higher layer | graph edge |
|
|
75
|
+
| keyframe ease | `linear`/`discrete`(`|`)/`smooth`(`~`) | Linear/Constant/Bezier | kLinear/kHold/kBezier |
|
|
76
|
+
| time base | frames | seconds | rational |
|
|
77
|
+
|
|
78
|
+
## Rendering through ffmpeg (default, no GPL linking)
|
|
79
|
+
|
|
80
|
+
Compile the model to one `filter_complex`:
|
|
81
|
+
|
|
82
|
+
- **trim / in-out:** `-ss start -t dur -i a.mp4`, or `trim=start=..:end=..,setpts=PTS-STARTPTS`.
|
|
83
|
+
- **fit to canvas:** `"cover"` = `scale=W:H:force_original_aspect_ratio=increase,crop=W:H`;
|
|
84
|
+
`"contain"` = `...decrease,pad=...` (see [ffmpeg recipes](ffmpeg-recipes.md)).
|
|
85
|
+
- **sequence:** `concat` back-to-back; `overlay=enable='between(t,P,P+D)'` for timed overlays.
|
|
86
|
+
- **crossfade:** `xfade=transition=fade:duration=0.5:offset=5.0`; audio `acrossfade`.
|
|
87
|
+
- **text / lower-thirds:** `drawtext=...:enable='between(t,1,4)'`; animate via `x=`/`alpha=` expressions in `t`.
|
|
88
|
+
- **logo:** `overlay=W*0.8:H*0.05`. **captions:** `subtitles=vo.srt`.
|
|
89
|
+
- **audio mix + ducking:** `amix`, `volume`, `sidechaincompress` (music under VO), `afade`.
|
|
90
|
+
- **keyframes:** ffmpeg `t`-expressions (`alpha='if(lt(t,0.4),t/0.4,1)'`) or `zoompan`; for rich curves precompute per-frame values from the `{t,v,ease}` arrays.
|
|
91
|
+
- **output:** `-c:v libx264 -r fps -pix_fmt yuv420p -c:a aac out.mp4`.
|
|
92
|
+
|
|
93
|
+
`mifi/editly` (MIT) and `Zulko/moviepy` (MIT) are working prior art that this JSON
|
|
94
|
+
shape renders through ffmpeg without any GPL engine.
|
|
95
|
+
|
|
96
|
+
### maggie_video.py compile
|
|
97
|
+
|
|
98
|
+
`maggie_video.py compile --spec timeline.json` compiles this model into one ffmpeg
|
|
99
|
+
`filter_complex` command (printed; run with `--run`). Supported today: a main video
|
|
100
|
+
track of clips (video/image) fit `cover`/`contain` to the canvas, hard-cut `concat`
|
|
101
|
+
or sequential `crossfade` (from `transitions`), image/text/caption overlays with
|
|
102
|
+
`position`+`duration` timing and `rect` placement, and an `audio[]` mix with
|
|
103
|
+
per-track `position`/`gain` plus `loudnorm`. Output is bounded to the computed video
|
|
104
|
+
duration and uses the shared web-safe profile. Not compiled yet (emitted as
|
|
105
|
+
`# NOTE:` lines): keyframes, ken-burns, and sidechain ducking - set static values or
|
|
106
|
+
use a programmatic engine for those.
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
maggie tool maggie_video.py compile --spec timeline.json # print the command
|
|
110
|
+
maggie tool maggie_video.py --run compile --spec timeline.json # render it
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Optional higher-fidelity backend: melt (GPL, external)
|
|
114
|
+
|
|
115
|
+
The same model can emit **MLT XML** and render with
|
|
116
|
+
`melt project.mlt -consumer avformat:out.mp4` - keyframes map to MLT's
|
|
117
|
+
`frame=value` animation strings and `composite`/`luma` transitions. Treat `melt` as
|
|
118
|
+
an optional external tool behind a provider-adapter boundary (see
|
|
119
|
+
[provider adapter contract](provider-adapter-contract.md)); it is GPL, invoked at
|
|
120
|
+
arm's length, never linked or vendored.
|
|
121
|
+
|
|
122
|
+
## What belongs where
|
|
123
|
+
|
|
124
|
+
- **This reference:** the data model, the crosswalk, the ffmpeg compilation map, the
|
|
125
|
+
keyframe/ease vocabulary, the licensing note - stable and engine-neutral.
|
|
126
|
+
- **`maggie-video` skill:** channel presets, caption/CTA safe-areas, the operational
|
|
127
|
+
ffmpeg command layer (via `maggie_video.py`), and tool wiring.
|