agent-trellis 0.8.2 → 0.9.1

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 (178) hide show
  1. package/README.md +3 -0
  2. package/dist/cli.js +94 -1
  3. package/dist/commands/remoteSkill.d.ts +90 -0
  4. package/dist/commands/remoteSkill.js +392 -0
  5. package/dist/commands/rollback.js +36 -5
  6. package/dist/commands/skill.d.ts +2 -0
  7. package/dist/commands/skill.js +6 -2
  8. package/dist/core/canonical.d.ts +17 -1
  9. package/dist/core/canonical.js +54 -0
  10. package/dist/lib/backup.d.ts +17 -5
  11. package/dist/lib/backup.js +22 -4
  12. package/dist/lib/dirDigest.d.ts +11 -0
  13. package/dist/lib/dirDigest.js +52 -0
  14. package/dist/lib/remoteSkillLock.d.ts +26 -0
  15. package/dist/lib/remoteSkillLock.js +88 -0
  16. package/dist/lib/remoteSkillSource.d.ts +45 -0
  17. package/dist/lib/remoteSkillSource.js +206 -0
  18. package/docs/getting-started.md +71 -2
  19. package/package.json +4 -2
  20. package/docs/video/README.md +0 -76
  21. package/docs/video/build/audio/01-opening.aiff +0 -0
  22. package/docs/video/build/audio/01-opening.mp3 +0 -0
  23. package/docs/video/build/audio/02-pain.aiff +0 -0
  24. package/docs/video/build/audio/02-pain.mp3 +0 -0
  25. package/docs/video/build/audio/03-solution.aiff +0 -0
  26. package/docs/video/build/audio/03-solution.mp3 +0 -0
  27. package/docs/video/build/audio/04-install.aiff +0 -0
  28. package/docs/video/build/audio/04-install.mp3 +0 -0
  29. package/docs/video/build/audio/04-setup.mp3 +0 -0
  30. package/docs/video/build/audio/05-dryrun.mp3 +0 -0
  31. package/docs/video/build/audio/05-init.aiff +0 -0
  32. package/docs/video/build/audio/05-init.mp3 +0 -0
  33. package/docs/video/build/audio/05-interactive.mp3 +0 -0
  34. package/docs/video/build/audio/06-dryrun.aiff +0 -0
  35. package/docs/video/build/audio/06-dryrun.mp3 +0 -0
  36. package/docs/video/build/audio/06-onboard.mp3 +0 -0
  37. package/docs/video/build/audio/06-run.mp3 +0 -0
  38. package/docs/video/build/audio/07-onboard.aiff +0 -0
  39. package/docs/video/build/audio/07-onboard.mp3 +0 -0
  40. package/docs/video/build/audio/07-verify.mp3 +0 -0
  41. package/docs/video/build/audio/08-closing.mp3 +0 -0
  42. package/docs/video/build/audio/08-doctor.aiff +0 -0
  43. package/docs/video/build/audio/08-doctor.mp3 +0 -0
  44. package/docs/video/build/audio/09-rollback.aiff +0 -0
  45. package/docs/video/build/audio/09-rollback.mp3 +0 -0
  46. package/docs/video/build/audio/10-principles.aiff +0 -0
  47. package/docs/video/build/audio/10-principles.mp3 +0 -0
  48. package/docs/video/build/audio/11-closing.aiff +0 -0
  49. package/docs/video/build/audio/11-closing.mp3 +0 -0
  50. package/docs/video/build/frames/01-opening-0.png +0 -0
  51. package/docs/video/build/frames/02-pain-1.png +0 -0
  52. package/docs/video/build/frames/02-pain-2.png +0 -0
  53. package/docs/video/build/frames/03-solution-3.png +0 -0
  54. package/docs/video/build/frames/04-install-4.png +0 -0
  55. package/docs/video/build/frames/04-setup-4.png +0 -0
  56. package/docs/video/build/frames/04-setup-5.png +0 -0
  57. package/docs/video/build/frames/05-dryrun-6.png +0 -0
  58. package/docs/video/build/frames/05-init-5.png +0 -0
  59. package/docs/video/build/frames/05-interactive-5.png +0 -0
  60. package/docs/video/build/frames/05-interactive-6.png +0 -0
  61. package/docs/video/build/frames/06-dryrun-6.png +0 -0
  62. package/docs/video/build/frames/06-dryrun-7.png +0 -0
  63. package/docs/video/build/frames/06-onboard-7.png +0 -0
  64. package/docs/video/build/frames/06-onboard-8.png +0 -0
  65. package/docs/video/build/frames/06-run-7.png +0 -0
  66. package/docs/video/build/frames/06-run-8.png +0 -0
  67. package/docs/video/build/frames/07-onboard-10.png +0 -0
  68. package/docs/video/build/frames/07-onboard-7.png +0 -0
  69. package/docs/video/build/frames/07-onboard-8.png +0 -0
  70. package/docs/video/build/frames/07-onboard-9.png +0 -0
  71. package/docs/video/build/frames/07-verify-10.png +0 -0
  72. package/docs/video/build/frames/07-verify-8.png +0 -0
  73. package/docs/video/build/frames/07-verify-9.png +0 -0
  74. package/docs/video/build/frames/08-closing-10.png +0 -0
  75. package/docs/video/build/frames/08-closing-11.png +0 -0
  76. package/docs/video/build/frames/08-doctor-11.png +0 -0
  77. package/docs/video/build/frames/08-doctor-9.png +0 -0
  78. package/docs/video/build/frames/09-rollback-10.png +0 -0
  79. package/docs/video/build/frames/09-rollback-12.png +0 -0
  80. package/docs/video/build/frames/10-principles-11.png +0 -0
  81. package/docs/video/build/frames/10-principles-13.png +0 -0
  82. package/docs/video/build/frames/11-closing-12.png +0 -0
  83. package/docs/video/build/frames/11-closing-14.png +0 -0
  84. package/docs/video/build/segments/01-opening.mp4 +0 -0
  85. package/docs/video/build/segments/01-opening.txt +0 -3
  86. package/docs/video/build/segments/02-pain.mp4 +0 -0
  87. package/docs/video/build/segments/02-pain.txt +0 -5
  88. package/docs/video/build/segments/03-solution.mp4 +0 -0
  89. package/docs/video/build/segments/03-solution.txt +0 -3
  90. package/docs/video/build/segments/04-install.mp4 +0 -0
  91. package/docs/video/build/segments/04-install.txt +0 -3
  92. package/docs/video/build/segments/04-setup.mp4 +0 -0
  93. package/docs/video/build/segments/04-setup.txt +0 -3
  94. package/docs/video/build/segments/05-dryrun.mp4 +0 -0
  95. package/docs/video/build/segments/05-dryrun.txt +0 -3
  96. package/docs/video/build/segments/05-init.mp4 +0 -0
  97. package/docs/video/build/segments/05-init.txt +0 -3
  98. package/docs/video/build/segments/05-interactive.mp4 +0 -0
  99. package/docs/video/build/segments/05-interactive.txt +0 -5
  100. package/docs/video/build/segments/06-dryrun.mp4 +0 -0
  101. package/docs/video/build/segments/06-dryrun.txt +0 -3
  102. package/docs/video/build/segments/06-onboard.mp4 +0 -0
  103. package/docs/video/build/segments/06-onboard.txt +0 -5
  104. package/docs/video/build/segments/06-run.mp4 +0 -0
  105. package/docs/video/build/segments/06-run.txt +0 -3
  106. package/docs/video/build/segments/07-onboard.mp4 +0 -0
  107. package/docs/video/build/segments/07-onboard.txt +0 -5
  108. package/docs/video/build/segments/07-verify.mp4 +0 -0
  109. package/docs/video/build/segments/07-verify.txt +0 -5
  110. package/docs/video/build/segments/08-closing.mp4 +0 -0
  111. package/docs/video/build/segments/08-closing.txt +0 -3
  112. package/docs/video/build/segments/08-doctor.mp4 +0 -0
  113. package/docs/video/build/segments/08-doctor.txt +0 -3
  114. package/docs/video/build/segments/09-rollback.mp4 +0 -0
  115. package/docs/video/build/segments/09-rollback.txt +0 -3
  116. package/docs/video/build/segments/10-principles.mp4 +0 -0
  117. package/docs/video/build/segments/10-principles.txt +0 -3
  118. package/docs/video/build/segments/11-closing.mp4 +0 -0
  119. package/docs/video/build/segments/11-closing.txt +0 -3
  120. package/docs/video/build/segments/final.txt +0 -8
  121. package/docs/video/build-ai/audio/01-hook.mp3 +0 -0
  122. package/docs/video/build-ai/audio/02-pain.mp3 +0 -0
  123. package/docs/video/build-ai/audio/03-solution.mp3 +0 -0
  124. package/docs/video/build-ai/audio/04-how.mp3 +0 -0
  125. package/docs/video/build-ai/audio/05-safe.mp3 +0 -0
  126. package/docs/video/build-ai/audio/06-closing.mp3 +0 -0
  127. package/docs/video/build-ai/clips/01-hook.mp4 +0 -0
  128. package/docs/video/build-ai/clips/02-pain.mp4 +0 -0
  129. package/docs/video/build-ai/clips/03-solution.mp4 +0 -0
  130. package/docs/video/build-ai/clips/04-how.mp4 +0 -0
  131. package/docs/video/build-ai/clips/05-safe.mp4 +0 -0
  132. package/docs/video/build-ai/clips/06-closing.mp4 +0 -0
  133. package/docs/video/build-ai/first-frame-02-pain.png +0 -0
  134. package/docs/video/build-ai/first-frame-03-solution.png +0 -0
  135. package/docs/video/build-ai/first-frame-04-how.png +0 -0
  136. package/docs/video/build-ai/first-frame-05-safe.png +0 -0
  137. package/docs/video/build-ai/segments/01-hook.caption.txt +0 -2
  138. package/docs/video/build-ai/segments/01-hook.mp4 +0 -0
  139. package/docs/video/build-ai/segments/02-pain.mp4 +0 -0
  140. package/docs/video/build-ai/segments/03-solution.mp4 +0 -0
  141. package/docs/video/build-ai/segments/04-how.mp4 +0 -0
  142. package/docs/video/build-ai/segments/05-safe.mp4 +0 -0
  143. package/docs/video/build-ai/segments/06-closing.mp4 +0 -0
  144. package/docs/video/build-ai/segments/cap-01-hook.png +0 -0
  145. package/docs/video/build-ai/segments/cap-02-pain.png +0 -0
  146. package/docs/video/build-ai/segments/cap-03-solution.png +0 -0
  147. package/docs/video/build-ai/segments/cap-04-how.png +0 -0
  148. package/docs/video/build-ai/segments/cap-05-safe.png +0 -0
  149. package/docs/video/build-ai/segments/cap-06-closing.png +0 -0
  150. package/docs/video/build-ai/segments/final.txt +0 -6
  151. package/docs/video/build-posters/audio/01-hook.mp3 +0 -0
  152. package/docs/video/build-posters/audio/02-pain.mp3 +0 -0
  153. package/docs/video/build-posters/audio/03-solution.mp3 +0 -0
  154. package/docs/video/build-posters/audio/04-how.mp3 +0 -0
  155. package/docs/video/build-posters/audio/05-safe.mp3 +0 -0
  156. package/docs/video/build-posters/audio/06-closing.mp3 +0 -0
  157. package/docs/video/build-posters/cap-01-hook.png +0 -0
  158. package/docs/video/build-posters/cap-02-pain.png +0 -0
  159. package/docs/video/build-posters/cap-03-solution.png +0 -0
  160. package/docs/video/build-posters/cap-04-how.png +0 -0
  161. package/docs/video/build-posters/cap-05-safe.png +0 -0
  162. package/docs/video/build-posters/cap-06-closing.png +0 -0
  163. package/docs/video/build-posters/card-01-hook.png +0 -0
  164. package/docs/video/build-posters/card-06-closing.png +0 -0
  165. package/docs/video/build-posters/segments/01-hook.mp4 +0 -0
  166. package/docs/video/build-posters/segments/02-pain.mp4 +0 -0
  167. package/docs/video/build-posters/segments/03-solution.mp4 +0 -0
  168. package/docs/video/build-posters/segments/04-how.mp4 +0 -0
  169. package/docs/video/build-posters/segments/05-safe.mp4 +0 -0
  170. package/docs/video/build-posters/segments/06-closing.mp4 +0 -0
  171. package/docs/video/build-posters/segments/final.txt +0 -6
  172. package/docs/video/generate-video-ai.mjs +0 -334
  173. package/docs/video/generate-video-posters.mjs +0 -204
  174. package/docs/video/generate-video.mjs +0 -426
  175. package/docs/video/outline.zh-CN.md +0 -102
  176. package/docs/video/trellis-demo-video-ai.mp4 +0 -0
  177. package/docs/video/trellis-demo-video.mp4 +0 -0
  178. package/docs/video/voiceover.zh-CN.md +0 -49
@@ -1,204 +0,0 @@
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`);
@@ -1,426 +0,0 @@
1
- // Generate the Trellis demo video locally (never published — docs/video is gitignored).
2
- // Pipeline: capture real CLI output in a sandbox HOME → TTS narration (macOS say)
3
- // → SVG frames via qlmanage → ffmpeg assembly. Run: node docs/video/generate-video.mjs
4
- import { execFileSync, execSync } 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");
11
- const framesDir = join(buildDir, "frames");
12
- const audioDir = join(buildDir, "audio");
13
- const segDir = join(buildDir, "segments");
14
- const quickLookDir = "/tmp/trellis-video-ql";
15
- const demoHome = "/tmp/trellis-demo-home";
16
- const outputFile = join(repoRoot, "docs/video/trellis-demo-video.mp4");
17
-
18
- for (const dir of [framesDir, audioDir, segDir, quickLookDir]) mkdirSync(dir, { recursive: true });
19
-
20
- const W = 1080; // frame width — 9:16 vertical video
21
- const H = 1920; // frame height
22
- const CANVAS = 1920; // qlmanage renders square canvases; content lives in a centered 1080x1920 band
23
- const OX = (CANVAS - W) / 2;
24
- const OY = (CANVAS - H) / 2;
25
-
26
- const trellisBin = (() => {
27
- try {
28
- return execSync("command -v trellis", { encoding: "utf-8", shell: "/bin/bash" }).trim();
29
- } catch {
30
- return null;
31
- }
32
- })();
33
- const cli = trellisBin ? [trellisBin] : [process.execPath, join(repoRoot, "dist/cli.js")];
34
- const env = { ...process.env, HOME: demoHome };
35
-
36
- const run = (args) => {
37
- try {
38
- return execFileSync(cli[0], [...cli.slice(1), ...args], { encoding: "utf-8", env, timeout: 120_000 });
39
- } catch (error) {
40
- return String(error.stdout ?? "") + String(error.stderr ?? "");
41
- }
42
- };
43
- const runShell = (command) => {
44
- try {
45
- return execSync(command, { encoding: "utf-8", env: { ...env, PATH: process.env.PATH }, shell: "/bin/bash", timeout: 60_000 });
46
- } catch (error) {
47
- return String(error.stdout ?? "") + String(error.stderr ?? "");
48
- }
49
- };
50
-
51
- // ---------------------------------------------------------------------------
52
- // 1. Capture real output in a fresh sandbox.
53
- // ---------------------------------------------------------------------------
54
-
55
- console.log("[1/5] capturing real CLI output in sandbox...");
56
- execFileSync("bash", [join(repoRoot, "scripts/video-demo-setup.sh"), demoHome], { stdio: "ignore" });
57
-
58
- const capture = {
59
- lsClaude: runShell(`ls "${demoHome}/.claude" && ls "${demoHome}/.claude/skills"`),
60
- codexConfig: runShell(`cat "${demoHome}/.codex/config.toml"`),
61
- npmLs: runShell(`npm ls -g agent-trellis --depth=0 2>/dev/null | tail -1`),
62
- // Fresh sandbox, no init first — onboard chains init itself, which is
63
- // exactly what the video narrates ("onboard 会自己把配置的家建好").
64
- onboard: run(["onboard", "--agent", "claude-code", "--manage", "codex"]),
65
- doctor: run(["doctor"]),
66
- rollback: run(["rollback"]),
67
- };
68
-
69
- const shorten = (text) => text.split(demoHome).join("~").split("/private/tmp/trellis-demo-home").join("~").trim();
70
-
71
- // The demo shows the interactive `trellis onboard`; the captured run used
72
- // flags (a TTY picker can't be scripted), so its one telltale flag line is
73
- // filtered out of the displayed output.
74
- const displayOnboard = capture.onboard.split("\n").filter((line) => !line.includes("(--agent)")).join("\n");
75
-
76
- // Display curation for the demo's happy path: the embedded doctor's
77
- // cross-agent drift notes (expected — the migration source is deliberately
78
- // not managed), the unconfigured-memory notice, and the resulting exit-code
79
- // tail are trimmed so frames don't end on a red ❌ for by-design behavior.
80
- // Nothing is hidden that the demo itself acts on; every kept line is real.
81
- const NOISE = [
82
- /^Cross-agent:/,
83
- /resolves to different sources/,
84
- /^verdict$/,
85
- /blocking issue/,
86
- /\[doctor\]/,
87
- /\[memory sync\]/,
88
- /^memory sync$/,
89
- /no "memory" MCP server/,
90
- /^exit code:/,
91
- /did not block this run/,
92
- /warnings only/,
93
- ];
94
- const curate = (text) => text.split("\n").filter((line) => !NOISE.some((pattern) => pattern.test(line))).join("\n").replace(/\n{3,}/g, "\n\n");
95
-
96
- // Interactive pickers can't be driven by a script (raw-mode TTY), so the two
97
- // Q&A frames are typeset from the real prompt wording (src/commands/onboard.ts)
98
- // and real option labels/values from this exact sandbox state.
99
- const promptFrames = {
100
- source: [
101
- "选择迁移来源。推荐选择可迁移内容最多的 Agent;这只决定从哪里读取,不会自动托管它:",
102
- "◆ 请选择一个迁移来源",
103
- "│ ● claude-code — Skills: 2, global instructions: yes, MCP: 2, Memory: empty (推荐:可迁移内容最多)",
104
- "│ ○ codex — Skills: 0, global instructions: no, MCP: 1, Memory: empty",
105
- ].join("\n"),
106
- managed: [
107
- "选择 Trellis 要托管的 Agent。推荐选择已安装的 Agent;选择未安装的 Agent 会先请求安装确认:",
108
- "◆ 请选择要启用的项目",
109
- "│ ◻ claude-code — present, 2 skill(s)(推荐:已安装)",
110
- "│ ■ codex — present, 0 skill(s)(推荐:已安装)",
111
- "│ ◻ kiro — not installed",
112
- "│ ◻ pi — not installed",
113
- "│ ◻ kimi-code — not installed",
114
- ].join("\n"),
115
- };
116
-
117
- // ---------------------------------------------------------------------------
118
- // 2. Narration blocks.
119
- // ---------------------------------------------------------------------------
120
-
121
- const blocks = [
122
- {
123
- id: "01-opening",
124
- caption: "谁懂啊!Code Agent 装了一堆\n配置全乱套",
125
- narration: "家人们,谁懂啊!电脑里装了好几个 Code Agent,技能、插件、配置各管各的,乱到崩溃。今天分享一个开源小工具,Trellis。",
126
- frames: [{ card: "opening" }],
127
- },
128
- {
129
- id: "02-pain",
130
- caption: "同一个技能\n要复制好几遍",
131
- narration: "同一个技能想给所有 Agent 用,得复制好几遍;改个配置,得挨个文件去改,谁改谁知道。",
132
- frames: [
133
- { term: { command: "ls ~/.claude ~/.claude/skills", output: capture.lsClaude } },
134
- { term: { command: "cat ~/.codex/config.toml", output: capture.codexConfig } },
135
- ],
136
- },
137
- {
138
- id: "03-solution",
139
- caption: "改一处,全部生效",
140
- narration: "Trellis 就把配置收拢到一个地方统一管,自动发给每个 Agent。改一处,全都生效。",
141
- frames: [{ card: "poster" }],
142
- },
143
- {
144
- id: "04-setup",
145
- caption: "一行装好,零准备",
146
- narration: "安装一行搞定,也不用先准备什么,onboard 直接开始。",
147
- frames: [{ term: { command: "npm install -g agent-trellis", output: capture.npmLs } }],
148
- },
149
- {
150
- id: "05-interactive",
151
- caption: "它先问你两个问题",
152
- narration: "它会先问你两个问题:从哪个 Agent 搬配置,管理哪几个。选完就全自动。",
153
- frames: [
154
- { term: { command: "trellis onboard", output: promptFrames.source } },
155
- { term: { command: "trellis onboard(接着选)", output: promptFrames.managed } },
156
- ],
157
- },
158
- {
159
- id: "06-run",
160
- caption: "全程自动,每步先备份",
161
- narration: "剩下的它自己做完:迁移、同步、检查。每改一个文件之前,都先自动备份。",
162
- frames: [{ term: { command: "trellis onboard", output: curate(displayOnboard) } }],
163
- },
164
- {
165
- id: "07-verify",
166
- caption: "不满意?一键退回",
167
- narration: "跑完 doctor 检查状态,不满意 rollback 一键退回。",
168
- frames: [
169
- { term: { command: "trellis doctor", output: curate(capture.doctor) } },
170
- { term: { command: "trellis rollback", output: capture.rollback } },
171
- ],
172
- },
173
- {
174
- id: "08-closing",
175
- caption: "开源免费,链接在简介",
176
- narration: "和我一样总在几个 Code Agent 之间切换,或者想迁移到新 Agent 的朋友,希望这个工具能帮到你。链接在简介,冲。",
177
- frames: [{ card: "closing" }],
178
- },
179
- ];
180
-
181
- // ---------------------------------------------------------------------------
182
- // 3. Frame rendering (SVG → qlmanage → crop to 1920x1080).
183
- // ---------------------------------------------------------------------------
184
-
185
- const palette = { paper: "#fafaf8", ink: "#161c24", muted: "#69727d", accent: "#3457d5" };
186
- const term = { bg: "#14171c", bar: "#1e222a", text: "#d6dde6", dim: "#7d8794", green: "#6fdd8b", yellow: "#e8c468", red: "#f0887a", blue: "#7aa2f7" };
187
-
188
- const escapeXml = (value) => String(value)
189
- .replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
190
- .replaceAll('"', "&quot;").replaceAll("'", "&apos;");
191
-
192
- const hatchLines = (x, y, w, h, spacing) => {
193
- const lines = [];
194
- const span = Math.ceil((w + h) / spacing) + 1;
195
- for (let k = -span; k <= span; k += 1) {
196
- const x0 = x + k * spacing;
197
- lines.push(`<path d="M${x0} ${y + h} L${x0 + h} ${y}"/><path d="M${x0} ${y} L${x0 + h} ${y + h}"/>`);
198
- }
199
- return lines.join("");
200
- };
201
-
202
- const logoMark = (x, y, scale, color = palette.ink) => `<g transform="translate(${x} ${y}) scale(${scale})" fill="none" stroke="${color}" stroke-linecap="round">
203
- <defs><clipPath id="beam-${x}-${y}"><rect x="21" y="23" width="54" height="9" rx="4.5"/></clipPath></defs>
204
- <rect x="16" y="18" width="64" height="18" rx="9" stroke-width="5.5"/>
205
- <g stroke-width="2.6" clip-path="url(#beam-${x}-${y})">${hatchLines(25.5, 23, 54, 9, 18)}</g>
206
- <path d="M48 36 V78" stroke-width="8"/>
207
- </g>`;
208
-
209
- const displayWidth = (value) => [...value].reduce((width, ch) => width + (/[^\x00-\xff]/.test(ch) ? 2 : 1), 0);
210
-
211
- const wrapLine = (line, maxUnits) => {
212
- if (displayWidth(line) <= maxUnits) return [line];
213
- const parts = [];
214
- let current = "";
215
- for (const ch of line) {
216
- if (displayWidth(current + ch) > maxUnits) {
217
- parts.push(current);
218
- current = ch;
219
- } else {
220
- current += ch;
221
- }
222
- }
223
- if (current) parts.push(current);
224
- return parts;
225
- };
226
-
227
- const classify = (line) => {
228
- if (line.includes("◆")) return "accent";
229
- if (line.includes("●") || line.includes("■")) return "ok";
230
- if (line.includes("❌")) return "err";
231
- if (line.includes("⚠")) return "warn";
232
- if (line.includes("✅")) return "ok";
233
- if (/^\s*(verdict|\[dry run\])/u.test(line)) return "accent";
234
- if (/^(migrate|sync|mcp sync|memory sync|secrets audit|health scan|Cross-agent)/u.test(line)) return "head";
235
- if (/^\s*(—|\s*$)/u.test(line) && line.trim() === "") return "blank";
236
- return "out";
237
- };
238
-
239
- const kindColor = { cmd: "#ffffff", out: term.text, dim: term.dim, ok: term.green, warn: term.yellow, err: term.red, accent: term.blue, head: "#ffffff", blank: term.text };
240
-
241
- const TERM = { x: 40, y: 300, w: 1000, h: 1500, barH: 56, padX: 32, padY: 32, font: 22, lh: 32, maxChars: 53, maxLines: 42 };
242
-
243
- const captionSvg = (caption, color) => {
244
- if (!caption) return "";
245
- const lines = caption.split("\n").length;
246
- return `<g>
247
- <rect x="64" y="92" width="10" height="${lines * 68}" fill="${palette.accent}"/>
248
- ${cardText(caption, 100, 148, { size: 52, weight: 800, fill: color, lineHeight: 68 })}
249
- </g>`;
250
- };
251
-
252
- const terminalSvg = (command, output, pageLines, caption) => {
253
- const allLines = [];
254
- for (const raw of pageLines) {
255
- for (const piece of wrapLine(raw, TERM.maxChars)) allLines.push(piece);
256
- }
257
- const rows = allLines.slice(0, TERM.maxLines).map((line, i) => {
258
- const kind = classify(line);
259
- const y = TERM.y + TERM.barH + TERM.padY + i * TERM.lh + TERM.font * 0.75;
260
- const weight = kind === "head" || kind === "accent" ? 700 : 400;
261
- return `<text x="${TERM.x + TERM.padX}" y="${y}" font-family="Menlo, monospace" font-size="${TERM.font}" font-weight="${weight}" fill="${kindColor[kind] ?? term.text}">${escapeXml(line) || " "}</text>`;
262
- }).join("");
263
- const promptY = TERM.y + TERM.barH + TERM.padY - 14;
264
- return `<rect width="${CANVAS}" height="${CANVAS}" fill="#0e1013"/>
265
- <g transform="translate(${OX} ${OY})">
266
- <rect x="${TERM.x}" y="${TERM.y}" width="${TERM.w}" height="${TERM.h}" rx="18" fill="${term.bg}"/>
267
- <rect x="${TERM.x}" y="${TERM.y}" width="${TERM.w}" height="${TERM.barH}" rx="18" fill="${term.bar}"/>
268
- <rect x="${TERM.x}" y="${TERM.y + TERM.barH / 2}" width="${TERM.w}" height="${TERM.barH / 2}" fill="${term.bar}"/>
269
- <circle cx="${TERM.x + 40}" cy="${TERM.y + 32}" r="9" fill="#ff5f57"/>
270
- <circle cx="${TERM.x + 72}" cy="${TERM.y + 32}" r="9" fill="#febc2e"/>
271
- <circle cx="${TERM.x + 104}" cy="${TERM.y + 32}" r="9" fill="#28c840"/>
272
- <text x="${TERM.x + TERM.w / 2}" y="${TERM.y + 40}" text-anchor="middle" font-family="Menlo, monospace" font-size="22" fill="${term.dim}">trellis demo — zsh</text>
273
- <text x="${TERM.x + TERM.padX}" y="${promptY + TERM.font * 0.75}" font-family="Menlo, monospace" font-size="${TERM.font}" fill="${term.green}">$ <tspan fill="#ffffff">${escapeXml(command)}</tspan></text>
274
- <g transform="translate(0 ${TERM.lh})">${rows}</g>
275
- ${captionSvg(caption, "#ffffff")}
276
- </g>`;
277
- };
278
-
279
- const cardText = (value, x, y, options = {}) => {
280
- const { size = 40, weight = 400, fill = palette.ink, anchor = "start", family = "Helvetica Neue, PingFang SC, sans-serif", letterSpacing = 0, lineHeight = Math.round(size * 1.35) } = options;
281
- return String(value).split("\n").map((line, i) =>
282
- `<text x="${x}" y="${y + i * lineHeight}" text-anchor="${anchor}" font-family="${family}" font-size="${size}" font-weight="${weight}" letter-spacing="${letterSpacing}" fill="${fill}">${escapeXml(line)}</text>`).join("");
283
- };
284
-
285
- const cardSvg = (kind, caption) => {
286
- const bg = `<rect width="${CANVAS}" height="${CANVAS}" fill="${palette.paper}"/><g transform="translate(${OX} ${OY})">`;
287
- const end = `${captionSvg(caption, palette.ink)}</g>`;
288
- if (kind === "opening") return `${bg}
289
- ${logoMark(444, 520, 2)}
290
- ${cardText("Trellis", 540, 880, { size: 92, weight: 800, anchor: "middle", letterSpacing: -1 })}
291
- ${cardText("一个 Runtime,统一所有 Code Agent", 540, 946, { size: 36, weight: 450, fill: palette.muted, anchor: "middle" })}
292
- <rect x="460" y="990" width="160" height="5" fill="${palette.accent}"/>
293
- ${cardText("使 用 分 享", 540, 1070, { size: 26, family: "Menlo, monospace", fill: palette.accent, anchor: "middle", letterSpacing: 6 })}
294
- ${end}`;
295
- if (kind === "poster") {
296
- const poster = readFileSync(join(repoRoot, "docs/assets/poster-series-v3/agent-trellis-poster-v3-unified-zh.png")).toString("base64");
297
- return `${bg}
298
- <rect x="-8" y="272" width="1096" height="1456" fill="#e8e6e0"/>
299
- <image href="data:image/png;base64,${poster}" x="0" y="280" width="1080" height="1440"/>
300
- ${end}`;
301
- }
302
- if (kind === "principles") return `${bg}
303
- ${logoMark(80, 200, 0.9)}
304
- ${cardText("两个在意的设计", 80, 420, { size: 60, weight: 800, letterSpacing: -1 })}
305
- <rect x="80" y="458" width="72" height="6" fill="${palette.accent}"/>
306
- ${cardText("01", 80, 600, { size: 28, weight: 700, family: "Menlo, monospace", fill: palette.accent })}
307
- ${cardText("密钥永远不明文写进配置", 80, 662, { size: 40, weight: 700 })}
308
- ${cardText("只写个名字,有命令专门检查", 80, 716, { size: 28, fill: palette.muted })}
309
- ${cardText("02", 80, 860, { size: 28, weight: 700, family: "Menlo, monospace", fill: palette.accent })}
310
- ${cardText("功能都在隔离环境里实测过", 80, 922, { size: 40, weight: 700 })}
311
- ${cardText("真实安装验证,不是看文档猜的", 80, 976, { size: 28, fill: palette.muted })}
312
- ${end}`;
313
- return `${bg}
314
- ${logoMark(444, 490, 2)}
315
- ${cardText("开源 · MIT", 540, 850, { size: 72, weight: 800, anchor: "middle" })}
316
- ${cardText("早期版本,欢迎试用,欢迎 Issue", 540, 918, { size: 34, fill: palette.muted, anchor: "middle" })}
317
- <rect x="150" y="990" width="780" height="80" rx="14" fill="none" stroke="${palette.ink}" stroke-width="3"/>
318
- ${cardText("github.com/paul-leo/Trellis", 540, 1043, { size: 36, weight: 500, family: "Menlo, monospace", anchor: "middle" })}
319
- ${end}`;
320
- };
321
-
322
- const renderFrame = (name, svgBody) => {
323
- const svgPath = join(quickLookDir, `${name}.svg`);
324
- writeFileSync(svgPath, `<svg xmlns="http://www.w3.org/2000/svg" width="${CANVAS}" height="${CANVAS}" viewBox="0 0 ${CANVAS} ${CANVAS}">${svgBody}</svg>`);
325
- execFileSync("qlmanage", ["-t", "-s", "1920", "-o", quickLookDir, svgPath], { stdio: "ignore" });
326
- execFileSync("sips", ["--cropToHeightWidth", String(H), String(W), join(quickLookDir, `${name}.svg.png`), "--out", join(framesDir, `${name}.png`)], { stdio: "ignore" });
327
- return join(framesDir, `${name}.png`);
328
- };
329
-
330
- console.log("[2/5] rendering frames...");
331
- let frameIndex = 0;
332
- for (const block of blocks) {
333
- block.images = [];
334
- for (const frame of block.frames) {
335
- if (frame.card) {
336
- block.images.push(renderFrame(`${block.id}-${frameIndex++}`, cardSvg(frame.card, block.caption)));
337
- continue;
338
- }
339
- const outputLines = shorten(frame.term.output).split("\n");
340
- const budget = TERM.maxLines - 2;
341
- if (outputLines.length <= budget) {
342
- block.images.push(renderFrame(`${block.id}-${frameIndex++}`, terminalSvg(frame.term.command, null, outputLines, block.caption)));
343
- } else {
344
- for (let page = 0; page * budget < outputLines.length; page += 1) {
345
- block.images.push(renderFrame(`${block.id}-${frameIndex++}`, terminalSvg(page === 0 ? frame.term.command : "(cont.)", null, outputLines.slice(page * budget, (page + 1) * budget), block.caption)));
346
- }
347
- }
348
- }
349
- }
350
-
351
- // ---------------------------------------------------------------------------
352
- // 4. TTS narration + per-block assembly.
353
- // ---------------------------------------------------------------------------
354
-
355
- console.log("[3/5] generating narration audio...");
356
- const audioDuration = (file) => {
357
- const info = execFileSync("afinfo", [file], { encoding: "utf-8" });
358
- return Number(info.match(/estimated duration: ([\d.]+)/)?.[1] ?? 3);
359
- };
360
-
361
- // MiniMax Speech (speech-2.6-hd) when a key is configured in Trellis's local
362
- // secret file; falls back to macOS `say` otherwise. The key is read from
363
- // ~/.trellis/mcp/servers.local.env and never written anywhere else.
364
- const minimaxKey = (() => {
365
- try {
366
- const envFile = readFileSync(join(process.env.HOME, ".trellis/mcp/servers.local.env"), "utf-8");
367
- return envFile.match(/^MINIMAX_API_KEY=(.+)$/m)?.[1]?.trim() || null;
368
- } catch {
369
- return null;
370
- }
371
- })();
372
- console.log(` narration voice: ${minimaxKey ? "MiniMax speech-2.6-hd" : "macOS say (no MINIMAX_API_KEY found)"}`);
373
-
374
- const synthesize = (text, outFile) => {
375
- if (!minimaxKey) {
376
- execFileSync("say", ["-v", "Ting-Ting (Enhanced)", "-r", "200", "-o", outFile, text]);
377
- return;
378
- }
379
- const body = JSON.stringify({
380
- model: "speech-2.6-hd",
381
- text,
382
- voice_setting: { voice_id: "male-qn-jingying", speed: 1.2, vol: 1, pitch: 0 },
383
- audio_setting: { format: "mp3", sample_rate: 44100, channel: 1 },
384
- });
385
- const response = execFileSync("curl", ["-sS", "-X", "POST", "https://api.minimax.cn/v1/t2a_v2",
386
- "-H", `Authorization: Bearer ${minimaxKey}`, "-H", "Content-Type: application/json",
387
- "-d", body], { encoding: "utf-8", maxBuffer: 64 * 1024 * 1024, timeout: 120_000 });
388
- const parsed = JSON.parse(response);
389
- if (parsed.base_resp?.status_code !== 0 || !parsed.data?.audio) {
390
- throw new Error(`MiniMax TTS failed: ${parsed.base_resp?.status_msg ?? response.slice(0, 300)}`);
391
- }
392
- writeFileSync(outFile, Buffer.from(parsed.data.audio, "hex"));
393
- };
394
-
395
- for (const block of blocks) {
396
- block.audio = join(audioDir, `${block.id}.${minimaxKey ? "mp3" : "aiff"}`);
397
- synthesize(block.narration, block.audio);
398
- block.duration = audioDuration(block.audio);
399
- }
400
-
401
- console.log("[4/5] assembling segments...");
402
- execFileSync("ffmpeg", ["-version"], { stdio: "ignore" });
403
-
404
- for (const block of blocks) {
405
- const images = block.images;
406
- const total = block.duration + 0.5;
407
- const first = images.length > 1 ? Math.min(2.4, total * 0.3) : total;
408
- const rest = images.length > 1 ? (total - first) / (images.length - 1) : 0;
409
- const listFile = join(segDir, `${block.id}.txt`);
410
- const lines = images.map((image, i) => `file '${image}'\nduration ${(i === 0 ? first : rest).toFixed(3)}`);
411
- lines.push(`file '${images[images.length - 1]}'`);
412
- writeFileSync(listFile, lines.join("\n") + "\n");
413
- execFileSync("ffmpeg", ["-y", "-f", "concat", "-safe", "0", "-i", listFile, "-i", block.audio,
414
- "-vf", `scale=${W}:${H},fps=30,format=yuv420p`, "-c:v", "libx264", "-preset", "medium", "-crf", "20",
415
- "-c:a", "aac", "-b:a", "160k", "-ar", "44100", "-shortest", join(segDir, `${block.id}.mp4`)], { stdio: "ignore" });
416
- }
417
-
418
- console.log("[5/5] final concat...");
419
- const finalList = join(segDir, "final.txt");
420
- writeFileSync(finalList, blocks.map((block) => `file '${join(segDir, `${block.id}.mp4`)}'`).join("\n") + "\n");
421
- if (existsSync(outputFile)) rmSync(outputFile);
422
- execFileSync("ffmpeg", ["-y", "-f", "concat", "-safe", "0", "-i", finalList, "-c", "copy", "-movflags", "+faststart", outputFile], { stdio: "ignore" });
423
-
424
- const total = blocks.reduce((sum, block) => sum + block.duration + 0.5, 0);
425
- console.log(`Done: ${outputFile}`);
426
- console.log(`Blocks: ${blocks.length}, estimated duration ~${total.toFixed(0)}s`);