agent-trellis 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/README.md +45 -206
  2. package/dist/adapters/zcode.d.ts +21 -0
  3. package/dist/adapters/zcode.js +234 -0
  4. package/dist/cli.js +119 -10
  5. package/dist/commands/doctor.js +2 -0
  6. package/dist/commands/init.d.ts +1 -1
  7. package/dist/commands/init.js +4 -1
  8. package/dist/commands/mcp.d.ts +56 -14
  9. package/dist/commands/mcp.js +105 -28
  10. package/dist/commands/mcpGateway.js +2 -1
  11. package/dist/commands/mcpImport.d.ts +21 -2
  12. package/dist/commands/mcpImport.js +30 -7
  13. package/dist/commands/memory.d.ts +13 -1
  14. package/dist/commands/memory.js +15 -0
  15. package/dist/commands/migrate.js +4 -1
  16. package/dist/commands/onboard.js +15 -4
  17. package/dist/commands/remoteSkill.d.ts +90 -0
  18. package/dist/commands/remoteSkill.js +392 -0
  19. package/dist/commands/rollback.js +55 -1
  20. package/dist/commands/secretsAudit.js +4 -1
  21. package/dist/commands/skill.d.ts +37 -6
  22. package/dist/commands/skill.js +73 -15
  23. package/dist/commands/sync.js +3 -1
  24. package/dist/core/adapter.d.ts +11 -1
  25. package/dist/core/canonical.d.ts +17 -1
  26. package/dist/core/canonical.js +54 -0
  27. package/dist/core/types.d.ts +1 -1
  28. package/dist/core/types.js +1 -0
  29. package/dist/lib/agentBridge.d.ts +169 -0
  30. package/dist/lib/agentBridge.js +367 -0
  31. package/dist/lib/agentBridgeProvider.d.ts +14 -0
  32. package/dist/lib/agentBridgeProvider.js +95 -0
  33. package/dist/lib/backup.d.ts +42 -0
  34. package/dist/lib/backup.js +36 -3
  35. package/dist/lib/chatAgents.d.ts +42 -0
  36. package/dist/lib/chatAgents.js +59 -0
  37. package/dist/lib/dirDigest.d.ts +11 -0
  38. package/dist/lib/dirDigest.js +52 -0
  39. package/dist/lib/envVarNames.d.ts +3 -0
  40. package/dist/lib/envVarNames.js +19 -0
  41. package/dist/lib/installAgent.d.ts +1 -1
  42. package/dist/lib/installAgent.js +1 -1
  43. package/dist/lib/mcpMigrateRead.d.ts +1 -0
  44. package/dist/lib/mcpMigrateRead.js +19 -1
  45. package/dist/lib/realHomeSnapshot.js +5 -0
  46. package/dist/lib/remoteSkillLock.d.ts +26 -0
  47. package/dist/lib/remoteSkillLock.js +88 -0
  48. package/dist/lib/remoteSkillSource.d.ts +45 -0
  49. package/dist/lib/remoteSkillSource.js +206 -0
  50. package/dist/lib/runtimeControlProvider.js +2 -0
  51. package/dist/lib/taskStore.js +1 -1
  52. package/dist/lib/terminalPicker.js +33 -6
  53. package/dist/lib/zcodeOwnership.d.ts +17 -0
  54. package/dist/lib/zcodeOwnership.js +37 -0
  55. package/dist/pi-bridge/bundle.js +2 -1
  56. package/dist/probes/codex.d.ts +7 -0
  57. package/dist/probes/codex.js +37 -5
  58. package/dist/probes/zcode.d.ts +32 -0
  59. package/dist/probes/zcode.js +140 -0
  60. package/docs/architecture.md +9 -5
  61. package/docs/assets/brand-v3/trellis-mark-v3-a.png +0 -0
  62. package/docs/assets/brand-v3/trellis-mark-v3-a.svg +10 -0
  63. package/docs/assets/poster-series-v3/README.md +60 -0
  64. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-horizontal-en.png +0 -0
  65. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-horizontal-en.svg +51 -0
  66. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-horizontal-zh.png +0 -0
  67. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-horizontal-zh.svg +51 -0
  68. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-memory-en.png +0 -0
  69. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-memory-en.svg +48 -0
  70. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-memory-zh.png +0 -0
  71. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-memory-zh.svg +48 -0
  72. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-migrate-en.png +0 -0
  73. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-migrate-en.svg +56 -0
  74. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-migrate-zh.png +0 -0
  75. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-migrate-zh.svg +56 -0
  76. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-switch-en.png +0 -0
  77. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-switch-en.svg +47 -0
  78. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-switch-zh.png +0 -0
  79. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-switch-zh.svg +47 -0
  80. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-unified-en.png +0 -0
  81. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-unified-en.svg +51 -0
  82. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-unified-zh.png +0 -0
  83. package/docs/assets/poster-series-v3/agent-trellis-poster-v3-unified-zh.svg +51 -0
  84. package/docs/getting-started.md +244 -14
  85. package/docs/overview.md +6 -3
  86. package/docs/overview.zh-CN.md +5 -3
  87. package/docs/roadmap.md +2 -1
  88. package/docs/video/README.md +76 -0
  89. package/docs/video/build/audio/01-opening.aiff +0 -0
  90. package/docs/video/build/audio/01-opening.mp3 +0 -0
  91. package/docs/video/build/audio/02-pain.aiff +0 -0
  92. package/docs/video/build/audio/02-pain.mp3 +0 -0
  93. package/docs/video/build/audio/03-solution.aiff +0 -0
  94. package/docs/video/build/audio/03-solution.mp3 +0 -0
  95. package/docs/video/build/audio/04-install.aiff +0 -0
  96. package/docs/video/build/audio/04-install.mp3 +0 -0
  97. package/docs/video/build/audio/04-setup.mp3 +0 -0
  98. package/docs/video/build/audio/05-dryrun.mp3 +0 -0
  99. package/docs/video/build/audio/05-init.aiff +0 -0
  100. package/docs/video/build/audio/05-init.mp3 +0 -0
  101. package/docs/video/build/audio/05-interactive.mp3 +0 -0
  102. package/docs/video/build/audio/06-dryrun.aiff +0 -0
  103. package/docs/video/build/audio/06-dryrun.mp3 +0 -0
  104. package/docs/video/build/audio/06-onboard.mp3 +0 -0
  105. package/docs/video/build/audio/06-run.mp3 +0 -0
  106. package/docs/video/build/audio/07-onboard.aiff +0 -0
  107. package/docs/video/build/audio/07-onboard.mp3 +0 -0
  108. package/docs/video/build/audio/07-verify.mp3 +0 -0
  109. package/docs/video/build/audio/08-closing.mp3 +0 -0
  110. package/docs/video/build/audio/08-doctor.aiff +0 -0
  111. package/docs/video/build/audio/08-doctor.mp3 +0 -0
  112. package/docs/video/build/audio/09-rollback.aiff +0 -0
  113. package/docs/video/build/audio/09-rollback.mp3 +0 -0
  114. package/docs/video/build/audio/10-principles.aiff +0 -0
  115. package/docs/video/build/audio/10-principles.mp3 +0 -0
  116. package/docs/video/build/audio/11-closing.aiff +0 -0
  117. package/docs/video/build/audio/11-closing.mp3 +0 -0
  118. package/docs/video/build/frames/01-opening-0.png +0 -0
  119. package/docs/video/build/frames/02-pain-1.png +0 -0
  120. package/docs/video/build/frames/02-pain-2.png +0 -0
  121. package/docs/video/build/frames/03-solution-3.png +0 -0
  122. package/docs/video/build/frames/04-install-4.png +0 -0
  123. package/docs/video/build/frames/04-setup-4.png +0 -0
  124. package/docs/video/build/frames/04-setup-5.png +0 -0
  125. package/docs/video/build/frames/05-dryrun-6.png +0 -0
  126. package/docs/video/build/frames/05-init-5.png +0 -0
  127. package/docs/video/build/frames/05-interactive-5.png +0 -0
  128. package/docs/video/build/frames/05-interactive-6.png +0 -0
  129. package/docs/video/build/frames/06-dryrun-6.png +0 -0
  130. package/docs/video/build/frames/06-dryrun-7.png +0 -0
  131. package/docs/video/build/frames/06-onboard-7.png +0 -0
  132. package/docs/video/build/frames/06-onboard-8.png +0 -0
  133. package/docs/video/build/frames/06-run-7.png +0 -0
  134. package/docs/video/build/frames/06-run-8.png +0 -0
  135. package/docs/video/build/frames/07-onboard-10.png +0 -0
  136. package/docs/video/build/frames/07-onboard-7.png +0 -0
  137. package/docs/video/build/frames/07-onboard-8.png +0 -0
  138. package/docs/video/build/frames/07-onboard-9.png +0 -0
  139. package/docs/video/build/frames/07-verify-10.png +0 -0
  140. package/docs/video/build/frames/07-verify-8.png +0 -0
  141. package/docs/video/build/frames/07-verify-9.png +0 -0
  142. package/docs/video/build/frames/08-closing-10.png +0 -0
  143. package/docs/video/build/frames/08-closing-11.png +0 -0
  144. package/docs/video/build/frames/08-doctor-11.png +0 -0
  145. package/docs/video/build/frames/08-doctor-9.png +0 -0
  146. package/docs/video/build/frames/09-rollback-10.png +0 -0
  147. package/docs/video/build/frames/09-rollback-12.png +0 -0
  148. package/docs/video/build/frames/10-principles-11.png +0 -0
  149. package/docs/video/build/frames/10-principles-13.png +0 -0
  150. package/docs/video/build/frames/11-closing-12.png +0 -0
  151. package/docs/video/build/frames/11-closing-14.png +0 -0
  152. package/docs/video/build/segments/01-opening.mp4 +0 -0
  153. package/docs/video/build/segments/01-opening.txt +3 -0
  154. package/docs/video/build/segments/02-pain.mp4 +0 -0
  155. package/docs/video/build/segments/02-pain.txt +5 -0
  156. package/docs/video/build/segments/03-solution.mp4 +0 -0
  157. package/docs/video/build/segments/03-solution.txt +3 -0
  158. package/docs/video/build/segments/04-install.mp4 +0 -0
  159. package/docs/video/build/segments/04-install.txt +3 -0
  160. package/docs/video/build/segments/04-setup.mp4 +0 -0
  161. package/docs/video/build/segments/04-setup.txt +3 -0
  162. package/docs/video/build/segments/05-dryrun.mp4 +0 -0
  163. package/docs/video/build/segments/05-dryrun.txt +3 -0
  164. package/docs/video/build/segments/05-init.mp4 +0 -0
  165. package/docs/video/build/segments/05-init.txt +3 -0
  166. package/docs/video/build/segments/05-interactive.mp4 +0 -0
  167. package/docs/video/build/segments/05-interactive.txt +5 -0
  168. package/docs/video/build/segments/06-dryrun.mp4 +0 -0
  169. package/docs/video/build/segments/06-dryrun.txt +3 -0
  170. package/docs/video/build/segments/06-onboard.mp4 +0 -0
  171. package/docs/video/build/segments/06-onboard.txt +5 -0
  172. package/docs/video/build/segments/06-run.mp4 +0 -0
  173. package/docs/video/build/segments/06-run.txt +3 -0
  174. package/docs/video/build/segments/07-onboard.mp4 +0 -0
  175. package/docs/video/build/segments/07-onboard.txt +5 -0
  176. package/docs/video/build/segments/07-verify.mp4 +0 -0
  177. package/docs/video/build/segments/07-verify.txt +5 -0
  178. package/docs/video/build/segments/08-closing.mp4 +0 -0
  179. package/docs/video/build/segments/08-closing.txt +3 -0
  180. package/docs/video/build/segments/08-doctor.mp4 +0 -0
  181. package/docs/video/build/segments/08-doctor.txt +3 -0
  182. package/docs/video/build/segments/09-rollback.mp4 +0 -0
  183. package/docs/video/build/segments/09-rollback.txt +3 -0
  184. package/docs/video/build/segments/10-principles.mp4 +0 -0
  185. package/docs/video/build/segments/10-principles.txt +3 -0
  186. package/docs/video/build/segments/11-closing.mp4 +0 -0
  187. package/docs/video/build/segments/11-closing.txt +3 -0
  188. package/docs/video/build/segments/final.txt +8 -0
  189. package/docs/video/build-ai/audio/01-hook.mp3 +0 -0
  190. package/docs/video/build-ai/audio/02-pain.mp3 +0 -0
  191. package/docs/video/build-ai/audio/03-solution.mp3 +0 -0
  192. package/docs/video/build-ai/audio/04-how.mp3 +0 -0
  193. package/docs/video/build-ai/audio/05-safe.mp3 +0 -0
  194. package/docs/video/build-ai/audio/06-closing.mp3 +0 -0
  195. package/docs/video/build-ai/clips/01-hook.mp4 +0 -0
  196. package/docs/video/build-ai/clips/02-pain.mp4 +0 -0
  197. package/docs/video/build-ai/clips/03-solution.mp4 +0 -0
  198. package/docs/video/build-ai/clips/04-how.mp4 +0 -0
  199. package/docs/video/build-ai/clips/05-safe.mp4 +0 -0
  200. package/docs/video/build-ai/clips/06-closing.mp4 +0 -0
  201. package/docs/video/build-ai/first-frame-02-pain.png +0 -0
  202. package/docs/video/build-ai/first-frame-03-solution.png +0 -0
  203. package/docs/video/build-ai/first-frame-04-how.png +0 -0
  204. package/docs/video/build-ai/first-frame-05-safe.png +0 -0
  205. package/docs/video/build-ai/segments/01-hook.caption.txt +2 -0
  206. package/docs/video/build-ai/segments/01-hook.mp4 +0 -0
  207. package/docs/video/build-ai/segments/02-pain.mp4 +0 -0
  208. package/docs/video/build-ai/segments/03-solution.mp4 +0 -0
  209. package/docs/video/build-ai/segments/04-how.mp4 +0 -0
  210. package/docs/video/build-ai/segments/05-safe.mp4 +0 -0
  211. package/docs/video/build-ai/segments/06-closing.mp4 +0 -0
  212. package/docs/video/build-ai/segments/cap-01-hook.png +0 -0
  213. package/docs/video/build-ai/segments/cap-02-pain.png +0 -0
  214. package/docs/video/build-ai/segments/cap-03-solution.png +0 -0
  215. package/docs/video/build-ai/segments/cap-04-how.png +0 -0
  216. package/docs/video/build-ai/segments/cap-05-safe.png +0 -0
  217. package/docs/video/build-ai/segments/cap-06-closing.png +0 -0
  218. package/docs/video/build-ai/segments/final.txt +6 -0
  219. package/docs/video/build-posters/audio/01-hook.mp3 +0 -0
  220. package/docs/video/build-posters/audio/02-pain.mp3 +0 -0
  221. package/docs/video/build-posters/audio/03-solution.mp3 +0 -0
  222. package/docs/video/build-posters/audio/04-how.mp3 +0 -0
  223. package/docs/video/build-posters/audio/05-safe.mp3 +0 -0
  224. package/docs/video/build-posters/audio/06-closing.mp3 +0 -0
  225. package/docs/video/build-posters/cap-01-hook.png +0 -0
  226. package/docs/video/build-posters/cap-02-pain.png +0 -0
  227. package/docs/video/build-posters/cap-03-solution.png +0 -0
  228. package/docs/video/build-posters/cap-04-how.png +0 -0
  229. package/docs/video/build-posters/cap-05-safe.png +0 -0
  230. package/docs/video/build-posters/cap-06-closing.png +0 -0
  231. package/docs/video/build-posters/card-01-hook.png +0 -0
  232. package/docs/video/build-posters/card-06-closing.png +0 -0
  233. package/docs/video/build-posters/segments/01-hook.mp4 +0 -0
  234. package/docs/video/build-posters/segments/02-pain.mp4 +0 -0
  235. package/docs/video/build-posters/segments/03-solution.mp4 +0 -0
  236. package/docs/video/build-posters/segments/04-how.mp4 +0 -0
  237. package/docs/video/build-posters/segments/05-safe.mp4 +0 -0
  238. package/docs/video/build-posters/segments/06-closing.mp4 +0 -0
  239. package/docs/video/build-posters/segments/final.txt +6 -0
  240. package/docs/video/generate-video-ai.mjs +334 -0
  241. package/docs/video/generate-video-posters.mjs +204 -0
  242. package/docs/video/generate-video.mjs +426 -0
  243. package/docs/video/outline.zh-CN.md +102 -0
  244. package/docs/video/trellis-demo-video-ai.mp4 +0 -0
  245. package/docs/video/trellis-demo-video.mp4 +0 -0
  246. package/docs/video/voiceover.zh-CN.md +49 -0
  247. package/package.json +5 -2
  248. package/schema/servers.example.yaml +1 -1
  249. package/docs/assets/agent-trellis-architecture-horizontal.svg +0 -80
  250. package/docs/assets/agent-trellis-architecture-vertical.svg +0 -82
  251. package/docs/assets/trellis-mark.svg +0 -12
@@ -0,0 +1,334 @@
1
+ // AI-generated demo video: MiniMax Hailuo dynamic clips + MiniMax Speech TTS
2
+ // + caption overlays, assembled to a 1080x1920 vertical mp4.
3
+ // Run: node docs/video/generate-video-ai.mjs
4
+ // Requires MINIMAX_API_KEY in ~/.trellis/mcp/servers.local.env and ffmpeg.
5
+ import { execFileSync } from "node:child_process";
6
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const repoRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
11
+ const buildDir = join(repoRoot, "docs/video/build-ai");
12
+ const clipsDir = join(buildDir, "clips");
13
+ const audioDir = join(buildDir, "audio");
14
+ const segDir = join(buildDir, "segments");
15
+ const outputFile = join(repoRoot, "docs/video/trellis-demo-video-ai.mp4");
16
+ for (const dir of [clipsDir, audioDir, segDir]) mkdirSync(dir, { recursive: true });
17
+
18
+ const API = "https://api.minimax.cn";
19
+ const apiKey = (() => {
20
+ const envFile = readFileSync(join(process.env.HOME, ".trellis/mcp/servers.local.env"), "utf-8");
21
+ return envFile.match(/^MINIMAX_API_KEY=(.+)$/m)?.[1]?.trim();
22
+ })();
23
+ if (!apiKey) throw new Error("MINIMAX_API_KEY not found in ~/.trellis/mcp/servers.local.env");
24
+ const headers = { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" };
25
+
26
+ const posterB64 = readFileSync(join(repoRoot, "docs/assets/poster-series-v3/agent-trellis-poster-v3-unified-zh.png")).toString("base64");
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // First frames drawn with our own diagram language (same motifs as the
30
+ // poster series) so every AI clip animates something the video actually says.
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const INK = "#161c24";
34
+ const PAPER = "#fafaf8";
35
+ const MUTED = "#69727d";
36
+ const ACCENT = "#3457d5";
37
+ const TEAL = "#0c7d6c";
38
+
39
+ const hatchLines = (x, y, w, h, spacing) => {
40
+ const lines = [];
41
+ const span = Math.ceil((w + h) / spacing) + 1;
42
+ for (let k = -span; k <= span; k += 1) {
43
+ const x0 = x + k * spacing;
44
+ lines.push(`<path d="M${x0} ${y + h} L${x0 + h} ${y}"/><path d="M${x0} ${y} L${x0 + h} ${y + h}"/>`);
45
+ }
46
+ return lines.join("");
47
+ };
48
+
49
+ const latticePanel = (id, x, y, w, h, rx, strokeWidth) => `<g fill="none">
50
+ <defs><clipPath id="${id}"><rect x="${x + 8}" y="${y + 8}" width="${w - 16}" height="${h - 16}" rx="${Math.max(rx - 6, 4)}"/></clipPath></defs>
51
+ <rect x="${x}" y="${y}" width="${w}" height="${h}" rx="${rx}" stroke="${INK}" stroke-width="${strokeWidth}"/>
52
+ <g clip-path="url(#${id})" stroke="${MUTED}" stroke-width="2.4" opacity="0.85">${hatchLines(x + 8, y + 8, w - 16, h - 16, 24)}</g>
53
+ </g>`;
54
+
55
+ const agentNode = (x, y, label) => `
56
+ <rect x="${x - 16}" y="${y - 16}" width="32" height="32" rx="8" fill="none" stroke="${INK}" stroke-width="4"/>
57
+ <text x="${x}" y="${y + 46}" text-anchor="middle" font-family="Menlo, monospace" font-size="19" letter-spacing="1" fill="${MUTED}">${label}</text>`;
58
+
59
+ const FRAME_W = 1920;
60
+ const FRAME_H = 1080;
61
+
62
+ const glowDefs = `<defs>
63
+ <radialGradient id="glow" cx="50%" cy="50%" r="55%">
64
+ <stop offset="0%" stop-color="#3457d5" stop-opacity="0.22"/>
65
+ <stop offset="100%" stop-color="#3457d5" stop-opacity="0"/>
66
+ </radialGradient>
67
+ <radialGradient id="warmglow" cx="50%" cy="50%" r="55%">
68
+ <stop offset="0%" stop-color="#c2410c" stop-opacity="0.16"/>
69
+ <stop offset="100%" stop-color="#c2410c" stop-opacity="0"/>
70
+ </radialGradient>
71
+ </defs>`;
72
+
73
+ const firstFrameSvgs = {
74
+ "02-pain": `${glowDefs}<rect width="${FRAME_W}" height="${FRAME_H}" fill="url(#warmglow)"/><g transform="translate(0 ${(FRAME_W - FRAME_H) / 2})">
75
+ ${agentNode(420, 360, "CLAUDE")}
76
+ ${agentNode(960, 620, "CODEX")}
77
+ ${agentNode(1420, 300, "KIMI")}
78
+ <g fill="none" stroke="#c2410c" stroke-width="4" stroke-dasharray="10 12" opacity="0.85">
79
+ <path d="M470 380 C 640 420 760 520 900 580"/>
80
+ <path d="M1000 590 C 1150 520 1280 400 1370 340"/>
81
+ </g>
82
+ <g fill="none" stroke="${MUTED}" stroke-width="2.5" opacity="0.9">
83
+ <rect x="330" y="470" width="70" height="52" rx="6"/><rect x="430" y="500" width="70" height="52" rx="6"/>
84
+ <rect x="880" y="720" width="70" height="52" rx="6"/><rect x="990" y="740" width="70" height="52" rx="6"/>
85
+ <rect x="1340" y="420" width="70" height="52" rx="6"/><rect x="1450" y="450" width="70" height="52" rx="6"/>
86
+ </g>
87
+ </g>`,
88
+ "03-solution": `${glowDefs}<rect width="${FRAME_W}" height="${FRAME_H}" fill="url(#glow)"/><g transform="translate(0 ${(FRAME_W - FRAME_H) / 2})">
89
+ ${agentNode(260, 340, "CLAUDE")}${agentNode(260, 540, "CODEX")}${agentNode(260, 740, "KIMI")}
90
+ ${latticePanel("ff-sol", 810, 330, 300, 340, 26, 5)}
91
+ <g fill="none" stroke="${ACCENT}" stroke-width="5">
92
+ <path d="M292 340 C 520 340 600 420 810 420"/>
93
+ <path d="M292 540 H810"/>
94
+ <path d="M292 740 C 520 740 600 660 810 660"/>
95
+ <path d="M1110 430 H1660 M1110 540 H1660 M1110 650 H1660"/>
96
+ </g>
97
+ <g fill="${ACCENT}">${[430, 540, 650].map((y) => `<circle cx="1672" cy="${y}" r="18" opacity="0.25"/><circle cx="1672" cy="${y}" r="10"/>`).join("")}</g>
98
+ </g>`,
99
+ "04-how": `${glowDefs}<rect width="${FRAME_W}" height="${FRAME_H}" fill="url(#glow)"/><g transform="translate(0 ${(FRAME_W - FRAME_H) / 2})">
100
+ <rect x="240" y="430" width="280" height="240" rx="18" fill="none" stroke="${MUTED}" stroke-width="4" stroke-dasharray="12 10"/>
101
+ <g stroke="${MUTED}" stroke-width="3.5" stroke-linecap="round">
102
+ ${[490, 550, 610].map((y) => `<circle cx="282" cy="${y}" r="6" fill="${MUTED}" stroke="none"/><path d="M306 ${y} H 476"/>`).join("")}
103
+ </g>
104
+ <g fill="none" stroke="${ACCENT}" stroke-width="6"><path d="M520 550 H700"/><path d="M900 550 H1080"/></g>
105
+ <path d="M688 536 L712 550 L688 564 Z" fill="${ACCENT}"/>
106
+ <path d="M1068 536 L1092 550 L1068 564 Z" fill="${ACCENT}"/>
107
+ <circle cx="800" cy="550" r="52" fill="none" stroke="${TEAL}" stroke-width="7"/>
108
+ <path d="M774 550 l18 20 l36 -42" fill="none" stroke="${TEAL}" stroke-width="7" stroke-linecap="round" stroke-linejoin="round"/>
109
+ ${latticePanel("ff-how", 1120, 400, 260, 300, 24, 5)}
110
+ </g>`,
111
+ "05-safe": `${glowDefs}<rect width="${FRAME_W}" height="${FRAME_H}" fill="url(#glow)"/><g transform="translate(0 ${(FRAME_W - FRAME_H) / 2})">
112
+ <path d="M280 380 H1640" fill="none" stroke="${MUTED}" stroke-width="4"/>
113
+ <path d="M280 720 H1640" fill="none" stroke="${MUTED}" stroke-width="4"/>
114
+ <path d="M280 380 H700 C 940 380 980 720 1220 720 H1640" fill="none" stroke="${ACCENT}" stroke-width="7" stroke-linecap="round"/>
115
+ <path d="M280 368 V392 M1640 368 V392 M280 708 V732 M1640 708 V732" fill="none" stroke="${MUTED}" stroke-width="3.5"/>
116
+ <circle cx="1560" cy="720" r="34" fill="none" stroke="#c2410c" stroke-width="4" opacity="0.5"/>
117
+ <circle cx="1560" cy="720" r="20" fill="none" stroke="#c2410c" stroke-width="4"/>
118
+ <circle cx="1560" cy="720" r="11" fill="#c2410c"/>
119
+ </g>`,
120
+ };
121
+
122
+ const quickLookDir = "/tmp/trellis-video-ai-ql";
123
+ mkdirSync(quickLookDir, { recursive: true });
124
+ const renderFirstFrame = (id, body) => {
125
+ const svgPath = join(quickLookDir, `${id}.svg`);
126
+ writeFileSync(svgPath, `<svg xmlns="http://www.w3.org/2000/svg" width="${FRAME_W}" height="${FRAME_W}" viewBox="0 0 ${FRAME_W} ${FRAME_W}"><rect width="${FRAME_W}" height="${FRAME_W}" fill="${PAPER}"/>${body}</svg>`);
127
+ execFileSync("qlmanage", ["-t", "-s", "1920", "-o", quickLookDir, svgPath], { stdio: "ignore" });
128
+ const out = join(buildDir, `first-frame-${id}.png`);
129
+ execFileSync("sips", ["--cropToHeightWidth", String(FRAME_H), String(FRAME_W), join(quickLookDir, `${id}.svg.png`), "--out", out], { stdio: "ignore" });
130
+ return readFileSync(out).toString("base64");
131
+ };
132
+
133
+ const firstFrames = Object.fromEntries(Object.entries(firstFrameSvgs).map(([id, body]) => [id, `data:image/png;base64,${renderFirstFrame(id, body)}`]));
134
+
135
+ if (process.env.FRAMES_ONLY === "1") {
136
+ console.log("first frames rendered to", buildDir);
137
+ process.exit(0);
138
+ }
139
+
140
+ const scenes = [
141
+ {
142
+ id: "01-hook",
143
+ caption: "谁懂啊!Code Agent 装了一堆\n配置全乱套",
144
+ narration: "家人们,谁懂啊!电脑里装了好几个 Code Agent,技能、插件、配置各管各的,乱到崩溃。今天分享一个开源小工具,Trellis。",
145
+ prompt: "镜头缓慢推近,白色海报上的黑色线条棚架字母T标志活了过来,线条发出柔和的靛蓝色微光,光点沿线条流动拖尾,细线如藤蔓生长延伸,柔和光晕,高级感科技风",
146
+ firstFrame: `data:image/png;base64,${posterB64}`,
147
+ duration: 6,
148
+ },
149
+ {
150
+ id: "02-pain",
151
+ caption: "同一个技能\n要复制好几遍",
152
+ narration: "同一个技能想给所有 Agent 用,得复制好几遍;改个配置,得挨个文件去改,谁改谁知道。",
153
+ prompt: "保持原有构图,几个方块节点各自独立闪烁,周围漂浮的文件卡片轻轻晃动,节点之间的橙色虚线忽明忽暗最后断开,缓慢运镜,柔和光晕,电影感",
154
+ firstFrame: firstFrames["02-pain"],
155
+ duration: 6,
156
+ },
157
+ {
158
+ id: "03-solution",
159
+ caption: "改一处,全部生效",
160
+ narration: "Trellis 就把配置收拢到一个地方统一管,自动发给每个 Agent。改一处,全都生效。",
161
+ prompt: "保持原有构图,靛蓝色发光光点从左侧三个节点沿曲线流向中央的网格面板,光线拖尾,网格面板被逐层点亮发出柔光,右侧三颗光点依次亮起,电影感光效",
162
+ firstFrame: firstFrames["03-solution"],
163
+ duration: 6,
164
+ },
165
+ {
166
+ id: "04-how",
167
+ caption: "一条命令,直接开始",
168
+ narration: "用法也简单:安装就一行,onboard 一条命令直接开始,不用先做任何准备。",
169
+ prompt: "保持原有构图,靛蓝色发光光点从左侧虚线框流出,沿箭头经过发光的绿色对勾,流入右侧网格面板,光线拖尾持续流动,面板微微发亮,电影感光效",
170
+ firstFrame: firstFrames["04-how"],
171
+ duration: 6,
172
+ },
173
+ {
174
+ id: "05-safe",
175
+ caption: "不满意?一键退回",
176
+ narration: "它会先问你两个问题,然后自动迁移、同步,每步先备份。不满意,rollback 一键退回。",
177
+ prompt: "保持原有构图,橙色发光圆点沿S形曲线从上方轨道平稳滑向下方轨道并继续前进,靛蓝色光线沿轨道流动拖尾,圆点逆流回退一次再前进,丝滑动效,电影感光晕",
178
+ firstFrame: firstFrames["05-safe"],
179
+ duration: 10,
180
+ },
181
+ {
182
+ id: "06-closing",
183
+ caption: "开源免费,链接在简介",
184
+ narration: "经常在多个 Code Agent 之间切换,或者想迁移到新 Agent 的朋友,希望这个工具能帮到你。链接在简介,冲。",
185
+ prompt: "纯净浅色背景中央,黑色细线条构成的字母T标志,横梁是精致的网格纹理,细线如藤蔓缓慢生长缠绕又舒展,边缘泛起柔和靛蓝色光晕,极简优雅,高级感",
186
+ duration: 6,
187
+ },
188
+ ];
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // MiniMax Hailuo video generation (async task API).
192
+ // ---------------------------------------------------------------------------
193
+
194
+ const submitTask = async (scene) => {
195
+ const body = {
196
+ model: "MiniMax-Hailuo-02",
197
+ prompt: scene.prompt,
198
+ duration: scene.duration,
199
+ resolution: scene.duration > 6 ? "768P" : "1080P",
200
+ prompt_optimizer: true,
201
+ ...(scene.firstFrame ? { first_frame_image: scene.firstFrame } : {}),
202
+ };
203
+ const response = await fetch(`${API}/v1/video_generation`, { method: "POST", headers, body: JSON.stringify(body) });
204
+ const data = await response.json();
205
+ if (data.base_resp?.status_code !== 0 || !data.task_id) {
206
+ throw new Error(`submit ${scene.id} failed: ${data.base_resp?.status_msg ?? JSON.stringify(data).slice(0, 300)}`);
207
+ }
208
+ return data.task_id;
209
+ };
210
+
211
+ const pollTask = async (scene, taskId) => {
212
+ const clipPath = join(clipsDir, `${scene.id}.mp4`);
213
+ const deadline = Date.now() + 12 * 60_000;
214
+ while (Date.now() < deadline) {
215
+ await new Promise((resolve) => setTimeout(resolve, 10_000));
216
+ const response = await fetch(`${API}/v1/query/video_generation?task_id=${taskId}`, { headers });
217
+ const data = await response.json();
218
+ if (data.status === "Success" && data.file_id) {
219
+ const fileResponse = await fetch(`${API}/v1/files/retrieve?file_id=${data.file_id}`, { headers });
220
+ const fileData = await fileResponse.json();
221
+ const url = fileData.file?.download_url;
222
+ if (!url) throw new Error(`${scene.id}: no download_url in retrieve response`);
223
+ execFileSync("curl", ["-sSL", url, "-o", clipPath], { timeout: 300_000 });
224
+ return clipPath;
225
+ }
226
+ if (data.status === "Failed") throw new Error(`${scene.id}: generation failed`);
227
+ }
228
+ throw new Error(`${scene.id}: timed out waiting for generation`);
229
+ };
230
+
231
+ console.log("[1/4] generating AI clips with Hailuo (cached clips are reused)...");
232
+ const pending = [];
233
+ for (const scene of scenes) {
234
+ const clipPath = join(clipsDir, `${scene.id}.mp4`);
235
+ if (existsSync(clipPath)) {
236
+ scene.clip = clipPath;
237
+ console.log(` ${scene.id}: cached`);
238
+ continue;
239
+ }
240
+ // Persist the task id right after submit so a rerun can resume polling
241
+ // instead of paying for a duplicate generation.
242
+ const taskFile = join(clipsDir, `${scene.id}.task`);
243
+ pending.push((async () => {
244
+ let taskId = existsSync(taskFile) ? readFileSync(taskFile, "utf-8").trim() : null;
245
+ if (!taskId) {
246
+ taskId = await submitTask(scene);
247
+ writeFileSync(taskFile, taskId);
248
+ console.log(` ${scene.id}: task ${taskId} submitted`);
249
+ } else {
250
+ console.log(` ${scene.id}: resuming task ${taskId}`);
251
+ }
252
+ scene.clip = await pollTask(scene, taskId);
253
+ rmSync(taskFile, { force: true });
254
+ console.log(` ${scene.id}: downloaded`);
255
+ })());
256
+ }
257
+ await Promise.all(pending);
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // TTS narration.
261
+ // ---------------------------------------------------------------------------
262
+
263
+ console.log("[2/4] synthesizing narration...");
264
+ const audioDuration = (file) => {
265
+ const info = execFileSync("afinfo", [file], { encoding: "utf-8" });
266
+ return Number(info.match(/estimated duration: ([\d.]+)/)?.[1] ?? 3);
267
+ };
268
+ for (const scene of scenes) {
269
+ scene.audio = join(audioDir, `${scene.id}.mp3`);
270
+ if (existsSync(scene.audio)) {
271
+ scene.ttsSeconds = audioDuration(scene.audio);
272
+ console.log(` ${scene.id}: cached ${scene.ttsSeconds.toFixed(1)}s`);
273
+ continue;
274
+ }
275
+ const body = JSON.stringify({
276
+ model: "speech-2.6-hd",
277
+ text: scene.narration,
278
+ voice_setting: { voice_id: "male-qn-jingying", speed: 1.2, vol: 1, pitch: 0 },
279
+ audio_setting: { format: "mp3", sample_rate: 44100, channel: 1 },
280
+ });
281
+ const response = execFileSync("curl", ["-sS", "-X", "POST", `${API}/v1/t2a_v2`, "-H", `Authorization: Bearer ${apiKey}`, "-H", "Content-Type: application/json", "-d", body], { encoding: "utf-8", maxBuffer: 64 * 1024 * 1024, timeout: 120_000 });
282
+ const parsed = JSON.parse(response);
283
+ if (parsed.base_resp?.status_code !== 0 || !parsed.data?.audio) {
284
+ throw new Error(`TTS ${scene.id} failed: ${parsed.base_resp?.status_msg ?? response.slice(0, 300)}`);
285
+ }
286
+ writeFileSync(scene.audio, Buffer.from(parsed.data.audio, "hex"));
287
+ scene.ttsSeconds = audioDuration(scene.audio);
288
+ console.log(` ${scene.id}: ${scene.ttsSeconds.toFixed(1)}s`);
289
+ }
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Per-scene segment: landscape clip → blurred-fill 1080x1920 + caption + TTS.
293
+ // ---------------------------------------------------------------------------
294
+
295
+ console.log("[3/4] assembling segments...");
296
+ // This ffmpeg build has no drawtext filter, so captions are rendered as
297
+ // white-on-black full-frame PNGs (qlmanage handles CJK perfectly) and
298
+ // composited with a screen blend — black vanishes, text stays.
299
+ const renderCaption = (scene) => {
300
+ const lines = scene.caption.split("\n");
301
+ const lineWidth = (s) => [...s].reduce((w, ch) => w + (/[^\x00-\xff]/.test(ch) ? 58 : 32), 0);
302
+ const pillW = Math.min(Math.max(...lines.map(lineWidth)) + 116, 1000);
303
+ const pillH = (lines.length - 1) * 68 + 100;
304
+ const svgPath = join(quickLookDir, `cap-${scene.id}.svg`);
305
+ const text = lines.map((line, i) => `<text x="100" y="${160 + i * 68}" font-family="PingFang SC, sans-serif" font-size="56" font-weight="800" fill="${INK}">${line.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")}</text>`).join("");
306
+ writeFileSync(svgPath, `<svg xmlns="http://www.w3.org/2000/svg" width="${FRAME_W}" height="${FRAME_W}" viewBox="0 0 ${FRAME_W} ${FRAME_W}"><rect width="${FRAME_W}" height="${FRAME_W}" fill="#00ff00"/><g transform="translate(0 ${(FRAME_W - FRAME_H) / 2})"><rect x="44" y="88" width="${pillW}" height="${pillH}" rx="18" fill="#ffffff" opacity="0.94"/><rect x="64" y="112" width="10" height="${(lines.length - 1) * 68 + 52}" fill="${ACCENT}"/>${text}</g></svg>`);
307
+ execFileSync("qlmanage", ["-t", "-s", "1920", "-o", quickLookDir, svgPath], { stdio: "ignore" });
308
+ const out = join(segDir, `cap-${scene.id}.png`);
309
+ execFileSync("sips", ["--cropToHeightWidth", String(FRAME_H), String(FRAME_W), join(quickLookDir, `cap-${scene.id}.svg.png`), "--out", out], { stdio: "ignore" });
310
+ return out;
311
+ };
312
+
313
+ for (const scene of scenes) {
314
+ const captionPng = renderCaption(scene);
315
+ const filter = [
316
+ "[0:v]split[va][vb]",
317
+ "[va]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,boxblur=24[bg]",
318
+ "[vb]scale=1080:-2[fg]",
319
+ "[bg][fg]overlay=(W-w)/2:(H-h)/2,format=yuv420p[base]",
320
+ "[1:v]colorkey=0x00ff00:0.3:0.25[capa]",
321
+ "[base][capa]overlay=0:0[out]",
322
+ ].join(";");
323
+ execFileSync("ffmpeg", ["-y", "-i", scene.clip, "-loop", "1", "-i", captionPng, "-i", scene.audio, "-filter_complex", filter, "-map", "[out]", "-map", "2:a",
324
+ "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
325
+ "-c:a", "aac", "-b:a", "160k", "-ar", "44100", "-shortest", join(segDir, `${scene.id}.mp4`)], { stdio: "ignore" });
326
+ }
327
+
328
+ console.log("[4/4] final concat...");
329
+ const finalList = join(segDir, "final.txt");
330
+ writeFileSync(finalList, scenes.map((scene) => `file '${join(segDir, `${scene.id}.mp4`)}'`).join("\n") + "\n");
331
+ if (existsSync(outputFile)) rmSync(outputFile);
332
+ execFileSync("ffmpeg", ["-y", "-f", "concat", "-safe", "0", "-i", finalList, "-c", "copy", "-movflags", "+faststart", outputFile], { stdio: "ignore" });
333
+
334
+ console.log(`Done: ${outputFile}`);
@@ -0,0 +1,204 @@
1
+ // Poster-motion demo video: our own posters + slow Ken Burns moves + caption
2
+ // pills + MiniMax TTS. No AI video generation — zero API cost on reruns.
3
+ // Run: node docs/video/generate-video-posters.mjs
4
+ import { execFileSync } from "node:child_process";
5
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { dirname, join } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const repoRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
10
+ const buildDir = join(repoRoot, "docs/video/build-posters");
11
+ const audioDir = join(buildDir, "audio");
12
+ const segDir = join(buildDir, "segments");
13
+ const quickLookDir = "/tmp/trellis-video-posters-ql";
14
+ const outputFile = join(repoRoot, "docs/video/trellis-demo-video.mp4");
15
+ for (const dir of [audioDir, segDir, quickLookDir]) mkdirSync(dir, { recursive: true });
16
+
17
+ const FRAME_W = 1920; // square canvas for qlmanage
18
+ const CARD_W = 1080;
19
+ const CARD_H = 1920;
20
+ const INK = "#161c24";
21
+ const PAPER = "#fafaf8";
22
+ const MUTED = "#69727d";
23
+ const ACCENT = "#3457d5";
24
+
25
+ const poster = (slug) => join(repoRoot, `docs/assets/poster-series-v3/agent-trellis-poster-v3-${slug}-zh.png`);
26
+
27
+ const scenes = [
28
+ {
29
+ id: "01-hook",
30
+ card: "opening",
31
+ caption: "谁懂啊!Code Agent 装了一堆\n配置全乱套",
32
+ narration: "家人们,谁懂啊!电脑里装了好几个 Code Agent,技能、插件、配置各管各的,乱到崩溃。今天分享一个开源小工具,Trellis。",
33
+ move: "out",
34
+ },
35
+ {
36
+ id: "02-pain",
37
+ image: poster("migrate"),
38
+ caption: "同一个技能\n要复制好几遍",
39
+ narration: "同一个技能想给所有 Agent 用,得复制好几遍;改个配置,得挨个文件去改,谁改谁知道。",
40
+ move: "out",
41
+ },
42
+ {
43
+ id: "03-solution",
44
+ image: poster("unified"),
45
+ caption: "改一处,全部生效",
46
+ narration: "Trellis 就把配置收拢到一个地方统一管,自动发给每个 Agent。改一处,全都生效。",
47
+ move: "in",
48
+ },
49
+ {
50
+ id: "04-how",
51
+ image: poster("switch"),
52
+ caption: "一条命令,直接开始",
53
+ narration: "用法也简单:安装就一行,onboard 一条命令直接开始,不用先做任何准备。",
54
+ move: "out",
55
+ },
56
+ {
57
+ id: "05-safe",
58
+ image: poster("memory"),
59
+ caption: "不满意?一键退回",
60
+ narration: "它会先问你两个问题,然后自动迁移、同步,每步先备份。不满意,rollback 一键退回。",
61
+ move: "in",
62
+ },
63
+ {
64
+ id: "06-closing",
65
+ card: "closing",
66
+ caption: "开源免费,链接在简介",
67
+ narration: "经常在多个 Code Agent 之间切换,或者想迁移到新 Agent 的朋友,希望这个工具能帮到你。链接在简介,冲。",
68
+ move: "out",
69
+ },
70
+ ];
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Brand cards (opening / closing) and caption pills, rendered via qlmanage.
74
+ // ---------------------------------------------------------------------------
75
+
76
+ const hatchLines = (x, y, w, h, spacing) => {
77
+ const lines = [];
78
+ const span = Math.ceil((w + h) / spacing) + 1;
79
+ for (let k = -span; k <= span; k += 1) {
80
+ const x0 = x + k * spacing;
81
+ lines.push(`<path d="M${x0} ${y + h} L${x0 + h} ${y}"/><path d="M${x0} ${y} L${x0 + h} ${y + h}"/>`);
82
+ }
83
+ return lines.join("");
84
+ };
85
+
86
+ const logoMark = (x, y, scale, color = INK) => `<g transform="translate(${x} ${y}) scale(${scale})" fill="none" stroke="${color}" stroke-linecap="round">
87
+ <defs><clipPath id="beam-${x}"><rect x="21" y="23" width="54" height="9" rx="4.5"/></clipPath></defs>
88
+ <rect x="16" y="18" width="64" height="18" rx="9" stroke-width="5.5"/>
89
+ <g stroke-width="2.6" clip-path="url(#beam-${x})">${hatchLines(25.5, 23, 54, 9, 18)}</g>
90
+ <path d="M48 36 V78" stroke-width="8"/>
91
+ </g>`;
92
+
93
+ const cardText = (value, x, y, options = {}) => {
94
+ const { size = 40, weight = 400, fill = INK, anchor = "start", family = "Helvetica Neue, PingFang SC, sans-serif", letterSpacing = 0 } = options;
95
+ return String(value).split("\n").map((line, i) =>
96
+ `<text x="${x}" y="${y + i * Math.round(size * 1.35)}" text-anchor="${anchor}" font-family="${family}" font-size="${size}" font-weight="${weight}" letter-spacing="${letterSpacing}" fill="${fill}">${line.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")}</text>`).join("");
97
+ };
98
+
99
+ const cardSvg = (kind) => {
100
+ const bg = `<rect width="${FRAME_W}" height="${FRAME_W}" fill="${PAPER}"/><g transform="translate(${(FRAME_W - CARD_W) / 2} 0)">`;
101
+ if (kind === "opening") return `${bg}
102
+ ${logoMark(444, 560, 2)}
103
+ ${cardText("Trellis", 540, 920, { size: 92, weight: 800, anchor: "middle", letterSpacing: -1 })}
104
+ ${cardText("一个 Runtime,统一所有 Code Agent", 540, 986, { size: 36, weight: 450, fill: MUTED, anchor: "middle" })}
105
+ <rect x="460" y="1030" width="160" height="5" fill="${ACCENT}"/>
106
+ </g>`;
107
+ return `${bg}
108
+ ${logoMark(444, 530, 2)}
109
+ ${cardText("开源 · MIT", 540, 890, { size: 72, weight: 800, anchor: "middle" })}
110
+ ${cardText("早期版本,欢迎试用,欢迎 Issue", 540, 958, { size: 34, fill: MUTED, anchor: "middle" })}
111
+ <rect x="150" y="1030" width="780" height="80" rx="14" fill="none" stroke="${INK}" stroke-width="3"/>
112
+ ${cardText("github.com/paul-leo/Trellis", 540, 1083, { size: 36, weight: 500, family: "Menlo, monospace", anchor: "middle" })}
113
+ </g>`;
114
+ };
115
+
116
+ const renderPng = (name, svgBody) => {
117
+ const svgPath = join(quickLookDir, `${name}.svg`);
118
+ writeFileSync(svgPath, `<svg xmlns="http://www.w3.org/2000/svg" width="${FRAME_W}" height="${FRAME_W}" viewBox="0 0 ${FRAME_W} ${FRAME_W}">${svgBody}</svg>`);
119
+ execFileSync("qlmanage", ["-t", "-s", "1920", "-o", quickLookDir, svgPath], { stdio: "ignore" });
120
+ const out = join(buildDir, `${name}.png`);
121
+ execFileSync("sips", ["--cropToHeightWidth", String(CARD_H), String(CARD_W), join(quickLookDir, `${name}.svg.png`), "--out", out], { stdio: "ignore" });
122
+ return out;
123
+ };
124
+
125
+ // Caption pill on a green key background (nothing else in the pill is green).
126
+ const renderCaption = (scene) => {
127
+ const lines = scene.caption.split("\n");
128
+ const lineWidth = (s) => [...s].reduce((w, ch) => w + (/[^\x00-\xff]/.test(ch) ? 58 : 32), 0);
129
+ const pillW = Math.min(Math.max(...lines.map(lineWidth)) + 116, 1000);
130
+ const pillH = (lines.length - 1) * 68 + 100;
131
+ const text = lines.map((line, i) => `<text x="100" y="${160 + i * 68}" font-family="PingFang SC, sans-serif" font-size="56" font-weight="800" fill="${INK}">${line.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")}</text>`).join("");
132
+ return renderPng(`cap-${scene.id}`, `<rect width="${FRAME_W}" height="${FRAME_W}" fill="#00ff00"/><g transform="translate(${(FRAME_W - CARD_W) / 2} 0)"><rect x="44" y="88" width="${pillW}" height="${pillH}" rx="18" fill="#ffffff" opacity="0.94"/><rect x="64" y="112" width="10" height="${(lines.length - 1) * 68 + 52}" fill="${ACCENT}"/>${text}</g>`);
133
+ };
134
+
135
+ console.log("[1/3] rendering cards and captions...");
136
+ for (const scene of scenes) {
137
+ scene.still = scene.card ? renderPng(`card-${scene.id}`, cardSvg(scene.card)) : scene.image;
138
+ scene.captionPng = renderCaption(scene);
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // TTS (MiniMax Speech; cached per scene id).
143
+ // ---------------------------------------------------------------------------
144
+
145
+ console.log("[2/3] synthesizing narration...");
146
+ const apiKey = readFileSync(join(process.env.HOME, ".trellis/mcp/servers.local.env"), "utf-8").match(/^MINIMAX_API_KEY=(.+)$/m)?.[1]?.trim();
147
+ const audioDuration = (file) => {
148
+ const info = execFileSync("afinfo", [file], { encoding: "utf-8" });
149
+ return Number(info.match(/estimated duration: ([\d.]+)/)?.[1] ?? 3);
150
+ };
151
+ for (const scene of scenes) {
152
+ scene.audio = join(audioDir, `${scene.id}.mp3`);
153
+ if (!existsSync(scene.audio)) {
154
+ if (!apiKey) throw new Error("MINIMAX_API_KEY not found");
155
+ const body = JSON.stringify({
156
+ model: "speech-2.6-hd",
157
+ text: scene.narration,
158
+ voice_setting: { voice_id: "male-qn-jingying", speed: 1.05, vol: 1, pitch: 0 },
159
+ audio_setting: { format: "mp3", sample_rate: 44100, channel: 1 },
160
+ });
161
+ const response = execFileSync("curl", ["-sS", "-X", "POST", "https://api.minimax.cn/v1/t2a_v2", "-H", `Authorization: Bearer ${apiKey}`, "-H", "Content-Type: application/json", "-d", body], { encoding: "utf-8", maxBuffer: 64 * 1024 * 1024, timeout: 120_000 });
162
+ const parsed = JSON.parse(response);
163
+ if (parsed.base_resp?.status_code !== 0 || !parsed.data?.audio) {
164
+ throw new Error(`TTS ${scene.id} failed: ${parsed.base_resp?.status_msg ?? response.slice(0, 300)}`);
165
+ }
166
+ writeFileSync(scene.audio, Buffer.from(parsed.data.audio, "hex"));
167
+ }
168
+ scene.ttsSeconds = audioDuration(scene.audio);
169
+ scene.duration = Math.max(scene.ttsSeconds + 1.2, 6);
170
+ console.log(` ${scene.id}: ${scene.ttsSeconds.toFixed(1)}s narration, ${scene.duration.toFixed(1)}s segment`);
171
+ }
172
+
173
+ // ---------------------------------------------------------------------------
174
+ // Segments: blur-fill 1080x1920 + slow zoompan + caption key overlay + TTS.
175
+ // ---------------------------------------------------------------------------
176
+
177
+ console.log("[3/3] assembling segments...");
178
+ for (const scene of scenes) {
179
+ const frames = Math.ceil(scene.duration * 30);
180
+ const zoom = scene.move === "in"
181
+ ? `z='min(zoom+0.0006,1.16)':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'`
182
+ : `z='if(lte(on,1),1.16,max(pzoom-0.0006,1.0))':x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)'`;
183
+ const filter = [
184
+ "[0:v]split[va][vb]",
185
+ "[va]scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,gblur=sigma=30[bg]",
186
+ "[vb]scale=1080:-2[fg]",
187
+ "[bg][fg]overlay=(W-w)/2:(H-h)/2[base]",
188
+ `[base]zoompan=${zoom}:d=${frames}:s=1080x1920:fps=30[moved]`,
189
+ "[1:v]colorkey=0x00ff00:0.22:0.15[capa]",
190
+ "[moved][capa]overlay=0:0[out]",
191
+ ].join(";");
192
+ execFileSync("ffmpeg", ["-y", "-loop", "1", "-framerate", "30", "-i", scene.still, "-loop", "1", "-framerate", "30", "-i", scene.captionPng, "-i", scene.audio,
193
+ "-filter_complex", filter, "-map", "[out]", "-map", "2:a",
194
+ "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p",
195
+ "-c:a", "aac", "-b:a", "160k", "-ar", "44100", "-t", String(scene.duration), join(segDir, `${scene.id}.mp4`)], { stdio: "ignore" });
196
+ }
197
+
198
+ const finalList = join(segDir, "final.txt");
199
+ writeFileSync(finalList, scenes.map((scene) => `file '${join(segDir, `${scene.id}.mp4`)}'`).join("\n") + "\n");
200
+ if (existsSync(outputFile)) rmSync(outputFile);
201
+ execFileSync("ffmpeg", ["-y", "-f", "concat", "-safe", "0", "-i", finalList, "-c", "copy", "-movflags", "+faststart", outputFile], { stdio: "ignore" });
202
+
203
+ console.log(`Done: ${outputFile}`);
204
+ console.log(`Scenes: ${scenes.length}, total ~${scenes.reduce((sum, s) => sum + s.duration, 0).toFixed(0)}s`);