@xiaobuyu/nodesign 0.0.1 → 0.0.2

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 (282) hide show
  1. package/README.md +156 -106
  2. package/bin/nodesign.js +0 -0
  3. package/package.json +1 -1
  4. package/server/api/README.md +45 -45
  5. package/server/api/_guard.js +75 -75
  6. package/server/api/admin.js +187 -187
  7. package/server/api/assets/docx-page.js +103 -103
  8. package/server/api/assets/helpers.js +22 -22
  9. package/server/api/assets/notes.js +144 -144
  10. package/server/api/assets.js +896 -896
  11. package/server/api/board.js +57 -57
  12. package/server/api/browse.js +138 -138
  13. package/server/api/canvas.js +444 -444
  14. package/server/api/chatai.js +324 -324
  15. package/server/api/exports/build-standalone.js +980 -980
  16. package/server/api/exports/cards.js +118 -118
  17. package/server/api/exports/docx-pdf.js +46 -46
  18. package/server/api/exports/export-page.js +142 -142
  19. package/server/api/exports/handoff.js +147 -147
  20. package/server/api/exports.js +690 -690
  21. package/server/api/instruction.js +75 -75
  22. package/server/api/me.js +116 -116
  23. package/server/api/memory.js +185 -185
  24. package/server/api/pending-changes.js +366 -366
  25. package/server/api/plugins.js +164 -164
  26. package/server/api/projects.js +220 -220
  27. package/server/api/publish.js +64 -64
  28. package/server/api/recent.js +73 -73
  29. package/server/api/sessions.js +589 -589
  30. package/server/api/skills.js +77 -77
  31. package/server/api/standalone-fit.js +168 -168
  32. package/server/api/turn-compose.js +180 -180
  33. package/server/api/turn-inflight.js +44 -44
  34. package/server/auth/README.md +17 -17
  35. package/server/auth/middleware.js +138 -138
  36. package/server/auth/origin-guard.js +71 -71
  37. package/server/auth/session.js +108 -108
  38. package/server/auth/tier.js +113 -113
  39. package/server/auth/users-store.js +348 -348
  40. package/server/edit/README.md +29 -29
  41. package/server/engine/README.md +113 -113
  42. package/server/engine/agent/agent-shared.js +550 -550
  43. package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
  44. package/server/engine/agent/auto-mode-rules.js +78 -78
  45. package/server/engine/agent/context.js +418 -418
  46. package/server/engine/agent/events.js +464 -464
  47. package/server/engine/agent/hooks/canvas-validate.js +199 -199
  48. package/server/engine/agent/hooks/failure.js +120 -120
  49. package/server/engine/agent/hooks/file-events.js +75 -75
  50. package/server/engine/agent/hooks/lifecycle.js +233 -233
  51. package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
  52. package/server/engine/agent/hooks/post-guidance.js +206 -206
  53. package/server/engine/agent/hooks/post-subagent-report.js +104 -104
  54. package/server/engine/agent/hooks/post-trim.js +58 -58
  55. package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
  56. package/server/engine/agent/hooks/pre-defaults.js +84 -84
  57. package/server/engine/agent/hooks/pre-injectors.js +277 -277
  58. package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
  59. package/server/engine/agent/hooks/pre-starter-files.js +61 -61
  60. package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
  61. package/server/engine/agent/hooks/site-validate.js +94 -94
  62. package/server/engine/agent/hooks/tool-prompts.js +31 -31
  63. package/server/engine/agent/hooks/turn-state-memory.js +56 -56
  64. package/server/engine/agent/hooks/user-prompt-submit.js +215 -215
  65. package/server/engine/agent/hooks.js +354 -354
  66. package/server/engine/agent/init-contract.js +69 -69
  67. package/server/engine/agent/isolation.js +159 -159
  68. package/server/engine/agent/model-table.js +292 -292
  69. package/server/engine/agent/plugin-loader.js +266 -266
  70. package/server/engine/agent/prompts/nodesign-prelude.md +336 -336
  71. package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
  72. package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
  73. package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
  74. package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
  75. package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
  76. package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
  77. package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
  78. package/server/engine/agent/prompts/tools/site-reference.md +106 -106
  79. package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
  80. package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
  81. package/server/engine/agent/session-loop.js +1024 -1024
  82. package/server/engine/agent/session-model.js +164 -164
  83. package/server/engine/agent/skill.js +204 -204
  84. package/server/engine/agent/task-events.js +78 -78
  85. package/server/engine/agents/ds-extractor.md +132 -132
  86. package/server/engine/agents/explorer.md +190 -190
  87. package/server/engine/agents/index.js +230 -230
  88. package/server/engine/agents/schemas/design-system.json +156 -156
  89. package/server/engine/agents/schemas/tweak-schema.json +113 -113
  90. package/server/engine/agents/tweak-proposer.md +127 -127
  91. package/server/engine/agents/vision-checker.md +254 -254
  92. package/server/engine/browse/capture.js +422 -422
  93. package/server/engine/browse/card.js +137 -137
  94. package/server/engine/browse/handover.js +59 -59
  95. package/server/engine/browse/page-digest.js +119 -119
  96. package/server/engine/browse/refs.js +196 -196
  97. package/server/engine/browse/registry.js +307 -307
  98. package/server/engine/browse/screencast.js +170 -170
  99. package/server/engine/browse/state.js +121 -121
  100. package/server/engine/chatai/chat-log.js +79 -79
  101. package/server/engine/chatai/index.js +193 -193
  102. package/server/engine/chatai/openai-compat.js +152 -152
  103. package/server/engine/chatai/orchestrate.js +309 -309
  104. package/server/engine/chatai/perform.js +40 -40
  105. package/server/engine/chatai/summarize.js +79 -79
  106. package/server/engine/mcp/capability-gate.js +52 -52
  107. package/server/engine/mcp/index.js +339 -339
  108. package/server/engine/mcp/param-sanitizer.js +142 -142
  109. package/server/engine/mcp/tools/arrange-on-board.js +124 -124
  110. package/server/engine/mcp/tools/artifact-session.js +278 -278
  111. package/server/engine/mcp/tools/browse-computer.js +421 -421
  112. package/server/engine/mcp/tools/browse-find-batch.js +183 -183
  113. package/server/engine/mcp/tools/browse-screenshot.js +157 -157
  114. package/server/engine/mcp/tools/browse.js +480 -480
  115. package/server/engine/mcp/tools/build-docx.js +101 -101
  116. package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
  117. package/server/engine/mcp/tools/create-on-board.js +155 -155
  118. package/server/engine/mcp/tools/crystallize-skill.js +168 -168
  119. package/server/engine/mcp/tools/explain-style.js +237 -237
  120. package/server/engine/mcp/tools/export-handoff.js +133 -133
  121. package/server/engine/mcp/tools/expose-tweaks.js +149 -149
  122. package/server/engine/mcp/tools/generate-image.js +842 -842
  123. package/server/engine/mcp/tools/get-computed-styles.js +164 -164
  124. package/server/engine/mcp/tools/get-pending-changes.js +205 -205
  125. package/server/engine/mcp/tools/h3box-ssh.js +84 -84
  126. package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
  127. package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
  128. package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
  129. package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
  130. package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
  131. package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
  132. package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
  133. package/server/engine/mcp/tools/highlight.js +68 -68
  134. package/server/engine/mcp/tools/list-pages.js +222 -222
  135. package/server/engine/mcp/tools/lookup-tags.js +59 -59
  136. package/server/engine/mcp/tools/navigate-to-page.js +60 -60
  137. package/server/engine/mcp/tools/organize-board.js +64 -64
  138. package/server/engine/mcp/tools/paint-still.js +434 -434
  139. package/server/engine/mcp/tools/pin-to-board.js +130 -130
  140. package/server/engine/mcp/tools/preview-deck.js +130 -130
  141. package/server/engine/mcp/tools/profile-scroll.js +305 -305
  142. package/server/engine/mcp/tools/publish-site.js +102 -102
  143. package/server/engine/mcp/tools/query-elements.js +195 -195
  144. package/server/engine/mcp/tools/read-board.js +103 -103
  145. package/server/engine/mcp/tools/read-document.js +93 -93
  146. package/server/engine/mcp/tools/read-page.js +264 -264
  147. package/server/engine/mcp/tools/read-tavern-json.js +143 -143
  148. package/server/engine/mcp/tools/record-decision.js +140 -140
  149. package/server/engine/mcp/tools/relate-on-board.js +110 -110
  150. package/server/engine/mcp/tools/report-issue.js +115 -115
  151. package/server/engine/mcp/tools/roll-film.js +266 -266
  152. package/server/engine/mcp/tools/screenshot-docx.js +138 -138
  153. package/server/engine/mcp/tools/screenshot-url.js +195 -195
  154. package/server/engine/mcp/tools/screenshot.js +583 -583
  155. package/server/engine/mcp/tools/tier-gate.js +96 -96
  156. package/server/engine/mcp/tools/trace-motion.js +215 -215
  157. package/server/engine/mcp/tools/web-search.js +548 -548
  158. package/server/engine/motion/inventory.js +349 -349
  159. package/server/engine/perception/session.js +235 -235
  160. package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
  161. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -192
  162. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
  163. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
  164. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
  165. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
  166. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
  167. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
  168. package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
  169. package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -162
  170. package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
  171. package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
  172. package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -228
  173. package/server/engine/plugins/nodesign/skills/rp-craft/patterns//346/274/224/345/207/272/351/241/265-/346/234/200/345/260/217/345/256/236/347/216/260.html +78 -78
  174. package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
  175. package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -323
  176. package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
  177. package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
  178. package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
  179. package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
  180. package/server/engine/runs/active-runs.js +698 -698
  181. package/server/engine/runs/live-turn.js +252 -252
  182. package/server/engine/runs/store.js +364 -364
  183. package/server/engine/runs/turn-relay.js +217 -217
  184. package/server/engine/runtime/workspace.js +164 -164
  185. package/server/lib/artifact-file-path.js +80 -80
  186. package/server/lib/artifact-target.js +284 -284
  187. package/server/lib/asset-refs.js +239 -239
  188. package/server/lib/async-queue.js +104 -104
  189. package/server/lib/auto-relations.js +130 -130
  190. package/server/lib/binding-types.js +60 -60
  191. package/server/lib/board-kind-sizes.js +62 -62
  192. package/server/lib/board-relations.js +133 -133
  193. package/server/lib/browse-proxy.js +180 -180
  194. package/server/lib/canvas-id.js +36 -36
  195. package/server/lib/cover.js +227 -227
  196. package/server/lib/danbooru-tags.js +291 -291
  197. package/server/lib/doc-extract.js +156 -156
  198. package/server/lib/docx/build-from-source.js +260 -260
  199. package/server/lib/docx/build.js +490 -490
  200. package/server/lib/docx/dump-styles.js +287 -287
  201. package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
  202. package/server/lib/docx/merge-runs.js +129 -129
  203. package/server/lib/docx/numbering.js +218 -218
  204. package/server/lib/docx/order.js +152 -152
  205. package/server/lib/docx/rawzip.js +172 -172
  206. package/server/lib/docx/render.js +92 -92
  207. package/server/lib/docx/text-lint.js +225 -225
  208. package/server/lib/docx/tokens.js +335 -335
  209. package/server/lib/docx/units.js +29 -29
  210. package/server/lib/docx/xml.js +291 -291
  211. package/server/lib/docx-pages.js +187 -187
  212. package/server/lib/export-collect.js +289 -289
  213. package/server/lib/export-package.js +210 -210
  214. package/server/lib/html-srcset.js +145 -145
  215. package/server/lib/image-variant.js +460 -460
  216. package/server/lib/ingress/forward-openai-chat.js +305 -305
  217. package/server/lib/ingress/openai-chat.js +491 -491
  218. package/server/lib/ingress/session-notice.js +66 -66
  219. package/server/lib/ingress/session-routes.js +68 -68
  220. package/server/lib/ingress/slot-probe.js +130 -130
  221. package/server/lib/ingress/upstream-billing.js +52 -52
  222. package/server/lib/ingress/upstream-fail-streak.js +72 -72
  223. package/server/lib/ingress/upstream-truncation.js +48 -48
  224. package/server/lib/issues-store.js +182 -182
  225. package/server/lib/kinds/deck.js +72 -72
  226. package/server/lib/kinds/docx.js +283 -283
  227. package/server/lib/kinds/file-kinds.js +113 -113
  228. package/server/lib/kinds/index.js +202 -202
  229. package/server/lib/kinds/site.js +161 -161
  230. package/server/lib/model-ingress.js +543 -543
  231. package/server/lib/moderation.js +255 -255
  232. package/server/lib/notice-store.js +103 -103
  233. package/server/lib/plugin-install.js +104 -104
  234. package/server/lib/plugin-validator.js +721 -721
  235. package/server/lib/publish-store.js +115 -115
  236. package/server/lib/quick-summary.js +52 -52
  237. package/server/lib/quota.js +270 -270
  238. package/server/lib/rate-window.js +29 -29
  239. package/server/lib/reference-assets.js +92 -92
  240. package/server/lib/region-shot.js +135 -135
  241. package/server/lib/safe-path.js +73 -73
  242. package/server/lib/sdk-session.js +33 -33
  243. package/server/lib/showcase-store.js +92 -92
  244. package/server/lib/site-publish.js +465 -465
  245. package/server/lib/ssrf-guard.js +432 -432
  246. package/server/lib/task-scan.js +158 -158
  247. package/server/lib/tavern-json.js +154 -154
  248. package/server/lib/video-variant.js +237 -237
  249. package/server/lib/workspace-path.js +33 -33
  250. package/server/ops/fix-sdk-musl.mjs +40 -40
  251. package/server/ops/install-macos-fonts.sh +114 -114
  252. package/server/ops/macos-fonts.conf +336 -336
  253. package/server/ops/sandbox-shim/bwrap +57 -57
  254. package/server/projects/assets-summary.js +119 -119
  255. package/server/projects/auto-name.js +53 -53
  256. package/server/projects/board-store.js +640 -640
  257. package/server/projects/move-entry.js +103 -103
  258. package/server/projects/store.js +270 -270
  259. package/server/projects/ui-config.js +54 -54
  260. package/server/projects/workspace-templates.js +53 -53
  261. package/server/projects/workspace.js +1025 -1025
  262. package/server/runtime/local-config.js +177 -177
  263. package/server/runtime/profile.js +72 -72
  264. package/server/services/rembg-service.py +334 -334
  265. package/server/shared/README.md +25 -25
  266. package/server/shared/deck.js +54 -54
  267. package/server/shared/time.js +49 -49
  268. package/server/style-pipeline/README.md +53 -53
  269. package/server/ws/broker.js +36 -36
  270. package/server/ws/browse-channel.js +177 -177
  271. package/server/ws/index.js +386 -386
  272. package/web/README.md +89 -89
  273. package/web/dist/assets/BrowserWindow-BYCgizHw.js +11 -0
  274. package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-pNydEM4L.js} +1 -1
  275. package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-PPgzh3Gq.js} +1 -1
  276. package/web/dist/assets/{SiteWindow-D6eS-2gM.js → SiteWindow-DM0LQPCO.js} +1 -1
  277. package/web/dist/assets/{index-Cbl3ATvp.js → index-BHTBJlpY.js} +32 -32
  278. package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-C0I13CbD.js} +1 -1
  279. package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-B1UQ8Hld.js} +1 -1
  280. package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-8lrgqmfv.js} +1 -1
  281. package/web/dist/index.html +17 -17
  282. package/web/dist/assets/BrowserWindow-Dc7J7CYc.js +0 -11
@@ -1,266 +1,266 @@
1
- /**
2
- * mcp/tools/roll-film.js — roll_film MCP tool(2026-08-08;批量制同晚补上)
3
- *
4
- * 自部署 MiniMax-H3 视频产线。一次调用 = 一批镜头(1-16 条),全批共用一个
5
- * seed(成片纪律);盒上串行渲,每出一镜当场落盘上墙。后端两档:
6
- * box(默认) 站主 5090 盒子(h3box.py video over SSH),模型常驻零冷启动。
7
- * modal Modal H100+sage 备用档,NODESIGN_FILM_BACKEND=modal 显式才走。
8
- * 配方恒定:Turbo 8 步 / 1344×768 / 单镜 ≤12.25s。批准制:admin+获批账号。
9
- * 只回文本路径,不回 image block;agent 可以自己抽帧看(2026-08-18 解禁),
10
- * 但"这条好不好"归用户判。
11
- */
12
-
13
- import os from 'node:os';
14
- import path from 'node:path';
15
- import fs from 'node:fs/promises';
16
- import { spawn } from 'node:child_process';
17
- import { tool } from '@anthropic-ai/claude-agent-sdk';
18
- import { z } from 'zod';
19
- import { Events } from '../../agent/events.js';
20
- import { getProject } from '../../../projects/store.js';
21
- import { getUserById } from '../../../auth/users-store.js';
22
- import { can, localGenApproved, DENIAL } from '../../../auth/tier.js';
23
- import { boxConfig, runBox, sshArgs, scpArgs, localBoxEnabled, BOX_OFF_MSG } from './h3box-ssh.js';
24
-
25
- const H3_REPO = process.env.NODESIGN_H3_REPO || '/home/wangang-dev/projects/minimax-h3-modal';
26
- const MODAL_BIN = process.env.NODESIGN_MODAL_BIN || path.join(os.homedir(), '.local/bin/modal');
27
- const PER_SHOT_TIMEOUT_MS = Number(process.env.NODESIGN_FILM_TIMEOUT_MS) || 900_000;
28
-
29
- function frameCount(durationS) {
30
- const f = Math.max(5, Math.round(durationS * 24));
31
- return f + ((5 - (f % 17)) % 17 + 17) % 17;
32
- }
33
-
34
- function estCostUsd(d, backend) {
35
- const pts = backend === 'modal'
36
- ? [[5.17, 0.10], [8, 0.16], [10, 0.21], [12.25, 0.28]]
37
- : [[5.17, 0.015], [8, 0.02], [12.25, 0.03]];
38
- if (d <= pts[0][0]) return pts[0][1];
39
- for (let i = 1; i < pts.length; i++) {
40
- if (d <= pts[i][0]) {
41
- const [x0, y0] = pts[i - 1]; const [x1, y1] = pts[i];
42
- return Math.round((y0 + (d - x0) * (y1 - y0) / (x1 - x0)) * 1000) / 1000;
43
- }
44
- }
45
- return pts[pts.length - 1][1];
46
- }
47
-
48
- function runModal(args, { cwd, signal, timeoutMs }) {
49
- return new Promise((resolve) => {
50
- const child = spawn(MODAL_BIN, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
51
- let out = ''; let err = '';
52
- child.stdout.on('data', (d) => { out = (out + d).slice(-8000); });
53
- child.stderr.on('data', (d) => { err = (err + d).slice(-4000); });
54
- const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* */ } }, timeoutMs);
55
- const onAbort = () => { try { child.kill('SIGKILL'); } catch { /* */ } };
56
- signal?.addEventListener?.('abort', onAbort, { once: true });
57
- child.on('close', (code) => {
58
- clearTimeout(timer);
59
- signal?.removeEventListener?.('abort', onAbort);
60
- resolve({ code, out, err });
61
- });
62
- child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, out, err: String(e.message) }); });
63
- });
64
- }
65
-
66
- /** 单镜落盘 + sidecar + 上墙事件;返回 caption 行 */
67
- async function landShot({ localMp4, shot, seed, backend, wallS, outDir, ctx, sessionId }) {
68
- const fileName = `${shot.name}-${shot.jobId.slice(5)}.mp4`;
69
- const absOut = path.join(outDir, fileName);
70
- await fs.rename(localMp4, absOut).catch(async () => {
71
- await fs.copyFile(localMp4, absOut); await fs.unlink(localMp4).catch(() => { /* */ });
72
- });
73
- const sizeBytes = (await fs.stat(absOut)).size;
74
- const cost = estCostUsd(shot.duration, backend);
75
- try {
76
- const metaDir = path.join(outDir, '.meta');
77
- await fs.mkdir(metaDir, { recursive: true });
78
- await fs.writeFile(path.join(metaDir, `${path.parse(fileName).name}.json`), JSON.stringify({
79
- prompt: shot.prompt, kind: 'film', durationS: shot.duration, frames: frameCount(shot.duration),
80
- seed, model: backend === 'modal' ? 'minimax-h3-turbo8-h100-sage' : 'minimax-h3-turbo8-5090-sage2',
81
- estCostUsd: cost, wallClockS: wallS,
82
- firstFrame: shot.first_frame || null, lastFrame: shot.last_frame || null,
83
- sessionId: ctx?.sessionId || sessionId || null, runId: ctx?.runId || null,
84
- ts: new Date().toISOString(),
85
- }, null, 2));
86
- } catch (e) { console.warn(`[roll-film] meta sidecar failed: ${e.message}`); }
87
- // MCP 工具写盘不走自动 file_changed —— 手动发,出一镜上墙一镜
88
- // 工作区相对路径(fileChanged 正字法,2026-08-14 普查改;绝对路径=前端寻址哑弹)
89
- try { ctx?.emit?.(Events.fileChanged(path.posix.join('assets', 'generated', fileName), 'add')); } catch { /* */ }
90
- return `[${shot.name}] assets/generated/${fileName} — ${shot.duration}s ${(sizeBytes / 1e6).toFixed(1)}MB ${Math.floor(wallS / 60)}m${wallS % 60}s ~$${cost}`;
91
- }
92
-
93
- export async function rollFilm(
94
- { workspaceRoot, sharedRoot, projectId, sessionId, ctx },
95
- { shots, seed },
96
- ) {
97
- const asText = (text, isError = false) =>
98
- ({ content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) });
99
- try {
100
- const project = getProject(projectId);
101
- if (!project) return asText('错误:项目不存在', true);
102
- const owner = project.ownerId ? getUserById(project.ownerId) : null;
103
- if (!owner) return asText('错误:找不到项目归属用户', true);
104
- // 档位闸 + 逐人批准(auth/tier.js):basic 档不开本地产线;pro 档还要被站主批过
105
- if (!can(owner, 'localGen')) return asText(DENIAL.localGenTier, true);
106
- if (!localGenApproved(owner)) return asText(DENIAL.localGenApproval, true);
107
-
108
- // 全批先验完再花钱:帧域 + 关键帧存在性 + 名字唯一
109
- const names = new Set();
110
- for (const s of shots) {
111
- const frames = frameCount(s.duration);
112
- if (frames > 294) return asText(`[${s.name}] ${frames} 帧超产线安全域 294(12.25s),拆镜`, true);
113
- if (names.has(s.name)) return asText(`镜名重复:${s.name}`, true);
114
- names.add(s.name);
115
- for (const slot of ['first_frame', 'last_frame']) {
116
- if (!s[slot]) continue;
117
- const abs = path.resolve(workspaceRoot || process.cwd(), s[slot]);
118
- try {
119
- if (!(await fs.stat(abs)).isFile()) throw new Error('x');
120
- } catch { return asText(`[${s.name}] ${slot} 找不到:${s[slot]}`, true); }
121
- s[`_abs_${slot}`] = abs;
122
- }
123
- }
124
-
125
- const backend = (process.env.NODESIGN_FILM_BACKEND || 'box').toLowerCase();
126
- // 只拦 box 后端:modal 是另一台机器,站主关本地盒子不影响它
127
- if (backend === 'box' && !localBoxEnabled()) {
128
- return asText(BOX_OFF_MSG, true);
129
- }
130
- const signal = ctx?.abortController?.signal;
131
- const outDir = path.join(sharedRoot || workspaceRoot, 'assets', 'generated');
132
- await fs.mkdir(outDir, { recursive: true });
133
- const batch = Date.now().toString(36);
134
- const lines = []; const failed = [];
135
-
136
- let box = null;
137
- if (backend !== 'modal') {
138
- box = boxConfig();
139
- if (!box) return asText('视频盒子未配置(站主没开机)。转告用户;备用 Modal 档要站主设 NODESIGN_FILM_BACKEND=modal。', true);
140
- const mk = await runBox(box, 'ssh', [...sshArgs(box), 'mkdir -p nd_jobs'], { timeoutMs: 30_000, signal });
141
- if (mk.code !== 0) return asText(`盒子连不上(没开机/地址过期):\n${(mk.err || '').slice(-400)}\n转告用户。`, true);
142
- }
143
-
144
- // 逐镜串行:一镜完整走完(渲→取回→上墙)再下一镜,agent 中途被打断也保住已出的
145
- for (let i = 0; i < shots.length; i++) {
146
- const shot = shots[i];
147
- shot.jobId = `film-${batch}s${i}`;
148
- const job = { name: shot.jobId, prompt: shot.prompt, seed, duration: shot.duration, width: 1344, height: 768 };
149
- if (shot.steps) job.steps = shot.steps;
150
- const t0 = Date.now();
151
- let localMp4 = null; let failMsg = null;
152
-
153
- if (backend === 'modal') {
154
- if (shot._abs_first_frame) job.first_frame = shot._abs_first_frame;
155
- if (shot._abs_last_frame) job.last_frame = shot._abs_last_frame;
156
- const jp = path.join(os.tmpdir(), `nd-${shot.jobId}.json`);
157
- await fs.writeFile(jp, JSON.stringify([job]));
158
- const r = await runModal(['run', 'h3_comfy.py', '--jobs-file', jp], { cwd: H3_REPO, signal, timeoutMs: PER_SHOT_TIMEOUT_MS });
159
- fs.unlink(jp).catch(() => { /* */ });
160
- if (r.code !== 0) failMsg = (r.err || r.out).slice(-600);
161
- else {
162
- const hits = (await fs.readdir(path.join(H3_REPO, 'outputs'))).filter((f) => f.startsWith(`${shot.jobId}_`) && f.endsWith('.mp4'));
163
- if (hits.length) localMp4 = path.join(H3_REPO, 'outputs', hits[0]);
164
- else failMsg = `无成片。输出尾:${r.out.slice(-400)}`;
165
- }
166
- } else {
167
- for (const slot of ['first_frame', 'last_frame']) {
168
- const abs = shot[`_abs_${slot}`];
169
- if (!abs) continue;
170
- const remote = `nd_jobs/${shot.jobId}-${slot}${path.extname(abs) || '.png'}`;
171
- const up = await runBox(box, 'scp', [...scpArgs(box), abs, `${box.target}:${remote}`], { timeoutMs: 60_000, signal });
172
- if (up.code !== 0) { failMsg = `关键帧上传失败:${up.err.slice(-300)}`; break; }
173
- job[slot] = `~/${remote}`;
174
- }
175
- if (!failMsg) {
176
- const jl = path.join(os.tmpdir(), `nd-${shot.jobId}.json`);
177
- await fs.writeFile(jl, JSON.stringify([job]));
178
- const upJ = await runBox(box, 'scp', [...scpArgs(box), jl, `${box.target}:nd_jobs/${shot.jobId}.json`], { timeoutMs: 30_000, signal });
179
- fs.unlink(jl).catch(() => { /* */ });
180
- if (upJ.code !== 0) failMsg = `任务上传失败:${upJ.err.slice(-300)}`;
181
- else {
182
- const gen = await runBox(box, 'ssh', [...sshArgs(box), `python3 ~/h3box.py video ~/nd_jobs/${shot.jobId}.json`], { timeoutMs: PER_SHOT_TIMEOUT_MS, signal });
183
- if (gen.code !== 0 || !gen.out.includes('success')) failMsg = `渲染失败(exit ${gen.code}):${(gen.err || gen.out).slice(-600)}`;
184
- else {
185
- const pd = await fs.mkdtemp(path.join(os.tmpdir(), 'nd-film-'));
186
- const pull = await runBox(box, 'scp', [...scpArgs(box), `${box.target}:outputs/${shot.jobId}_*.mp4`, pd], { timeoutMs: 120_000, signal });
187
- const got = pull.code === 0 ? (await fs.readdir(pd)).filter((f) => f.endsWith('.mp4')) : [];
188
- if (got.length) localMp4 = path.join(pd, got[0]);
189
- else failMsg = `成片取回失败:${pull.err.slice(-300)}`;
190
- }
191
- }
192
- }
193
- }
194
-
195
- const wallS = Math.round((Date.now() - t0) / 1000);
196
- if (localMp4) {
197
- lines.push(await landShot({ localMp4, shot, seed, backend, wallS, outDir, ctx, sessionId }));
198
- } else {
199
- failed.push(`[${shot.name}] ${failMsg}`);
200
- if (signal?.aborted) break;
201
- break; // 串行批中途失败即停:后镜大概率同因失败,别空烧
202
- }
203
- }
204
-
205
- const head = `Batch done ${lines.length}/${shots.length} shots, seed=${seed}, via ${backend === 'modal' ? 'Modal H100+sage' : '5090 盒子 sage2'}`;
206
- // 2026-08-18:解禁。mp4 塞不进视觉通道,要看就 ffmpeg 抽帧再看那几张图。
207
- const tail = 'You may check these: pull two or three frames with ffmpeg and look at them '
208
- + 'for technical breakage (colour cast, duplicated figures, broken limbs, mush, first '
209
- + 'frame not matching the anchor) and re-roll those yourself; delete the temp frames after. '
210
- + 'Do NOT judge whether a shot is good — hand the paths to the user for that.';
211
- if (failed.length) {
212
- return asText([head, ...lines, 'FAILED(批在此中断,未渲镜不再空烧):', ...failed, tail].join('\n'), lines.length === 0);
213
- }
214
- return asText([head, ...lines, tail].join('\n'));
215
- } catch (err) {
216
- return asText(`roll_film 失败:${err.message}`, true);
217
- }
218
- }
219
-
220
- /**
221
- * @param {object} deps
222
- */
223
- export function makeRollFilmTool(deps) {
224
- return tool(
225
- 'roll_film',
226
- ((localBoxEnabled() || (process.env.NODESIGN_FILM_BACKEND || 'box').toLowerCase() === 'modal')
227
- ? ''
228
- : '⛔ CURRENTLY UNAVAILABLE — the owner powers this GPU box on and off by hand, '
229
- + 'and it is off right now. Do not call this tool; tell the user the local box is off.\n\n')
230
- + `Generate video shots (picture + native audio) on the owner's self-hosted
231
- MiniMax-H3 lane (RTX 5090 box, SageAttention, Turbo 8-step, 24fps). One call =
232
- one batch of 1-16 shots rendered back-to-back; each finished shot lands on the
233
- canvas immediately. Max 12.25s per shot — longer stories are multiple shots.
234
-
235
- Call ONLY when the user explicitly asks for video, and align the shot list with
236
- the user before rolling a multi-shot batch. Roughly 3-5 minutes per shot,
237
- serial; tell the user the camera is rolling. Never auto-retry shots you already
238
- got back. Requires the GPU box online — if unreachable, relay that and stop.
239
-
240
- Prompts must follow the H3 three-field English format (cookbook arrives as a
241
- system reminder attached to your FIRST call — treat your first batch as the
242
- learning pass and refine from it). Keep character/style blocks verbatim
243
- identical across shots; the whole batch shares ONE seed automatically. Keyframe
244
- anchors (first_frame/last_frame) accept paths like "assets/generated/kf1.png"
245
- (make them with paint_still or generate_image, ideally 1344x768).
246
-
247
- Clips land at assets/generated/<name>-*.mp4. To check one, pull a couple of
248
- frames with ffmpeg and look at those — catch colour cast, duplicated figures,
249
- broken limbs, mush, or a first frame that does not match its anchor, and re-roll
250
- those yourself (delete the temp frames after). Whether a shot is GOOD is the
251
- user's call, not yours: report paths
252
- and move on.`,
253
- {
254
- shots: z.array(z.object({
255
- prompt: z.string().describe('H3 three-field English prompt'),
256
- duration: z.number().min(5.2).max(12.25).default(8),
257
- name: z.string().regex(/^[\w-]{1,40}$/).describe('shot slug, unique in batch'),
258
- first_frame: z.string().optional(),
259
- last_frame: z.string().optional(),
260
- steps: z.number().int().min(4).max(8).optional().describe('default 8; 4 = fast draft'),
261
- })).min(1).max(16).describe('shots rendered serially in one batch'),
262
- seed: z.number().int().default(1101).describe('ONE seed shared by the whole batch (film discipline)'),
263
- },
264
- (args) => rollFilm(deps, args),
265
- );
266
- }
1
+ /**
2
+ * mcp/tools/roll-film.js — roll_film MCP tool(2026-08-08;批量制同晚补上)
3
+ *
4
+ * 自部署 MiniMax-H3 视频产线。一次调用 = 一批镜头(1-16 条),全批共用一个
5
+ * seed(成片纪律);盒上串行渲,每出一镜当场落盘上墙。后端两档:
6
+ * box(默认) 站主 5090 盒子(h3box.py video over SSH),模型常驻零冷启动。
7
+ * modal Modal H100+sage 备用档,NODESIGN_FILM_BACKEND=modal 显式才走。
8
+ * 配方恒定:Turbo 8 步 / 1344×768 / 单镜 ≤12.25s。批准制:admin+获批账号。
9
+ * 只回文本路径,不回 image block;agent 可以自己抽帧看(2026-08-18 解禁),
10
+ * 但"这条好不好"归用户判。
11
+ */
12
+
13
+ import os from 'node:os';
14
+ import path from 'node:path';
15
+ import fs from 'node:fs/promises';
16
+ import { spawn } from 'node:child_process';
17
+ import { tool } from '@anthropic-ai/claude-agent-sdk';
18
+ import { z } from 'zod';
19
+ import { Events } from '../../agent/events.js';
20
+ import { getProject } from '../../../projects/store.js';
21
+ import { getUserById } from '../../../auth/users-store.js';
22
+ import { can, localGenApproved, DENIAL } from '../../../auth/tier.js';
23
+ import { boxConfig, runBox, sshArgs, scpArgs, localBoxEnabled, BOX_OFF_MSG } from './h3box-ssh.js';
24
+
25
+ const H3_REPO = process.env.NODESIGN_H3_REPO || '/home/wangang-dev/projects/minimax-h3-modal';
26
+ const MODAL_BIN = process.env.NODESIGN_MODAL_BIN || path.join(os.homedir(), '.local/bin/modal');
27
+ const PER_SHOT_TIMEOUT_MS = Number(process.env.NODESIGN_FILM_TIMEOUT_MS) || 900_000;
28
+
29
+ function frameCount(durationS) {
30
+ const f = Math.max(5, Math.round(durationS * 24));
31
+ return f + ((5 - (f % 17)) % 17 + 17) % 17;
32
+ }
33
+
34
+ function estCostUsd(d, backend) {
35
+ const pts = backend === 'modal'
36
+ ? [[5.17, 0.10], [8, 0.16], [10, 0.21], [12.25, 0.28]]
37
+ : [[5.17, 0.015], [8, 0.02], [12.25, 0.03]];
38
+ if (d <= pts[0][0]) return pts[0][1];
39
+ for (let i = 1; i < pts.length; i++) {
40
+ if (d <= pts[i][0]) {
41
+ const [x0, y0] = pts[i - 1]; const [x1, y1] = pts[i];
42
+ return Math.round((y0 + (d - x0) * (y1 - y0) / (x1 - x0)) * 1000) / 1000;
43
+ }
44
+ }
45
+ return pts[pts.length - 1][1];
46
+ }
47
+
48
+ function runModal(args, { cwd, signal, timeoutMs }) {
49
+ return new Promise((resolve) => {
50
+ const child = spawn(MODAL_BIN, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: process.env });
51
+ let out = ''; let err = '';
52
+ child.stdout.on('data', (d) => { out = (out + d).slice(-8000); });
53
+ child.stderr.on('data', (d) => { err = (err + d).slice(-4000); });
54
+ const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* */ } }, timeoutMs);
55
+ const onAbort = () => { try { child.kill('SIGKILL'); } catch { /* */ } };
56
+ signal?.addEventListener?.('abort', onAbort, { once: true });
57
+ child.on('close', (code) => {
58
+ clearTimeout(timer);
59
+ signal?.removeEventListener?.('abort', onAbort);
60
+ resolve({ code, out, err });
61
+ });
62
+ child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, out, err: String(e.message) }); });
63
+ });
64
+ }
65
+
66
+ /** 单镜落盘 + sidecar + 上墙事件;返回 caption 行 */
67
+ async function landShot({ localMp4, shot, seed, backend, wallS, outDir, ctx, sessionId }) {
68
+ const fileName = `${shot.name}-${shot.jobId.slice(5)}.mp4`;
69
+ const absOut = path.join(outDir, fileName);
70
+ await fs.rename(localMp4, absOut).catch(async () => {
71
+ await fs.copyFile(localMp4, absOut); await fs.unlink(localMp4).catch(() => { /* */ });
72
+ });
73
+ const sizeBytes = (await fs.stat(absOut)).size;
74
+ const cost = estCostUsd(shot.duration, backend);
75
+ try {
76
+ const metaDir = path.join(outDir, '.meta');
77
+ await fs.mkdir(metaDir, { recursive: true });
78
+ await fs.writeFile(path.join(metaDir, `${path.parse(fileName).name}.json`), JSON.stringify({
79
+ prompt: shot.prompt, kind: 'film', durationS: shot.duration, frames: frameCount(shot.duration),
80
+ seed, model: backend === 'modal' ? 'minimax-h3-turbo8-h100-sage' : 'minimax-h3-turbo8-5090-sage2',
81
+ estCostUsd: cost, wallClockS: wallS,
82
+ firstFrame: shot.first_frame || null, lastFrame: shot.last_frame || null,
83
+ sessionId: ctx?.sessionId || sessionId || null, runId: ctx?.runId || null,
84
+ ts: new Date().toISOString(),
85
+ }, null, 2));
86
+ } catch (e) { console.warn(`[roll-film] meta sidecar failed: ${e.message}`); }
87
+ // MCP 工具写盘不走自动 file_changed —— 手动发,出一镜上墙一镜
88
+ // 工作区相对路径(fileChanged 正字法,2026-08-14 普查改;绝对路径=前端寻址哑弹)
89
+ try { ctx?.emit?.(Events.fileChanged(path.posix.join('assets', 'generated', fileName), 'add')); } catch { /* */ }
90
+ return `[${shot.name}] assets/generated/${fileName} — ${shot.duration}s ${(sizeBytes / 1e6).toFixed(1)}MB ${Math.floor(wallS / 60)}m${wallS % 60}s ~$${cost}`;
91
+ }
92
+
93
+ export async function rollFilm(
94
+ { workspaceRoot, sharedRoot, projectId, sessionId, ctx },
95
+ { shots, seed },
96
+ ) {
97
+ const asText = (text, isError = false) =>
98
+ ({ content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) });
99
+ try {
100
+ const project = getProject(projectId);
101
+ if (!project) return asText('错误:项目不存在', true);
102
+ const owner = project.ownerId ? getUserById(project.ownerId) : null;
103
+ if (!owner) return asText('错误:找不到项目归属用户', true);
104
+ // 档位闸 + 逐人批准(auth/tier.js):basic 档不开本地产线;pro 档还要被站主批过
105
+ if (!can(owner, 'localGen')) return asText(DENIAL.localGenTier, true);
106
+ if (!localGenApproved(owner)) return asText(DENIAL.localGenApproval, true);
107
+
108
+ // 全批先验完再花钱:帧域 + 关键帧存在性 + 名字唯一
109
+ const names = new Set();
110
+ for (const s of shots) {
111
+ const frames = frameCount(s.duration);
112
+ if (frames > 294) return asText(`[${s.name}] ${frames} 帧超产线安全域 294(12.25s),拆镜`, true);
113
+ if (names.has(s.name)) return asText(`镜名重复:${s.name}`, true);
114
+ names.add(s.name);
115
+ for (const slot of ['first_frame', 'last_frame']) {
116
+ if (!s[slot]) continue;
117
+ const abs = path.resolve(workspaceRoot || process.cwd(), s[slot]);
118
+ try {
119
+ if (!(await fs.stat(abs)).isFile()) throw new Error('x');
120
+ } catch { return asText(`[${s.name}] ${slot} 找不到:${s[slot]}`, true); }
121
+ s[`_abs_${slot}`] = abs;
122
+ }
123
+ }
124
+
125
+ const backend = (process.env.NODESIGN_FILM_BACKEND || 'box').toLowerCase();
126
+ // 只拦 box 后端:modal 是另一台机器,站主关本地盒子不影响它
127
+ if (backend === 'box' && !localBoxEnabled()) {
128
+ return asText(BOX_OFF_MSG, true);
129
+ }
130
+ const signal = ctx?.abortController?.signal;
131
+ const outDir = path.join(sharedRoot || workspaceRoot, 'assets', 'generated');
132
+ await fs.mkdir(outDir, { recursive: true });
133
+ const batch = Date.now().toString(36);
134
+ const lines = []; const failed = [];
135
+
136
+ let box = null;
137
+ if (backend !== 'modal') {
138
+ box = boxConfig();
139
+ if (!box) return asText('视频盒子未配置(站主没开机)。转告用户;备用 Modal 档要站主设 NODESIGN_FILM_BACKEND=modal。', true);
140
+ const mk = await runBox(box, 'ssh', [...sshArgs(box), 'mkdir -p nd_jobs'], { timeoutMs: 30_000, signal });
141
+ if (mk.code !== 0) return asText(`盒子连不上(没开机/地址过期):\n${(mk.err || '').slice(-400)}\n转告用户。`, true);
142
+ }
143
+
144
+ // 逐镜串行:一镜完整走完(渲→取回→上墙)再下一镜,agent 中途被打断也保住已出的
145
+ for (let i = 0; i < shots.length; i++) {
146
+ const shot = shots[i];
147
+ shot.jobId = `film-${batch}s${i}`;
148
+ const job = { name: shot.jobId, prompt: shot.prompt, seed, duration: shot.duration, width: 1344, height: 768 };
149
+ if (shot.steps) job.steps = shot.steps;
150
+ const t0 = Date.now();
151
+ let localMp4 = null; let failMsg = null;
152
+
153
+ if (backend === 'modal') {
154
+ if (shot._abs_first_frame) job.first_frame = shot._abs_first_frame;
155
+ if (shot._abs_last_frame) job.last_frame = shot._abs_last_frame;
156
+ const jp = path.join(os.tmpdir(), `nd-${shot.jobId}.json`);
157
+ await fs.writeFile(jp, JSON.stringify([job]));
158
+ const r = await runModal(['run', 'h3_comfy.py', '--jobs-file', jp], { cwd: H3_REPO, signal, timeoutMs: PER_SHOT_TIMEOUT_MS });
159
+ fs.unlink(jp).catch(() => { /* */ });
160
+ if (r.code !== 0) failMsg = (r.err || r.out).slice(-600);
161
+ else {
162
+ const hits = (await fs.readdir(path.join(H3_REPO, 'outputs'))).filter((f) => f.startsWith(`${shot.jobId}_`) && f.endsWith('.mp4'));
163
+ if (hits.length) localMp4 = path.join(H3_REPO, 'outputs', hits[0]);
164
+ else failMsg = `无成片。输出尾:${r.out.slice(-400)}`;
165
+ }
166
+ } else {
167
+ for (const slot of ['first_frame', 'last_frame']) {
168
+ const abs = shot[`_abs_${slot}`];
169
+ if (!abs) continue;
170
+ const remote = `nd_jobs/${shot.jobId}-${slot}${path.extname(abs) || '.png'}`;
171
+ const up = await runBox(box, 'scp', [...scpArgs(box), abs, `${box.target}:${remote}`], { timeoutMs: 60_000, signal });
172
+ if (up.code !== 0) { failMsg = `关键帧上传失败:${up.err.slice(-300)}`; break; }
173
+ job[slot] = `~/${remote}`;
174
+ }
175
+ if (!failMsg) {
176
+ const jl = path.join(os.tmpdir(), `nd-${shot.jobId}.json`);
177
+ await fs.writeFile(jl, JSON.stringify([job]));
178
+ const upJ = await runBox(box, 'scp', [...scpArgs(box), jl, `${box.target}:nd_jobs/${shot.jobId}.json`], { timeoutMs: 30_000, signal });
179
+ fs.unlink(jl).catch(() => { /* */ });
180
+ if (upJ.code !== 0) failMsg = `任务上传失败:${upJ.err.slice(-300)}`;
181
+ else {
182
+ const gen = await runBox(box, 'ssh', [...sshArgs(box), `python3 ~/h3box.py video ~/nd_jobs/${shot.jobId}.json`], { timeoutMs: PER_SHOT_TIMEOUT_MS, signal });
183
+ if (gen.code !== 0 || !gen.out.includes('success')) failMsg = `渲染失败(exit ${gen.code}):${(gen.err || gen.out).slice(-600)}`;
184
+ else {
185
+ const pd = await fs.mkdtemp(path.join(os.tmpdir(), 'nd-film-'));
186
+ const pull = await runBox(box, 'scp', [...scpArgs(box), `${box.target}:outputs/${shot.jobId}_*.mp4`, pd], { timeoutMs: 120_000, signal });
187
+ const got = pull.code === 0 ? (await fs.readdir(pd)).filter((f) => f.endsWith('.mp4')) : [];
188
+ if (got.length) localMp4 = path.join(pd, got[0]);
189
+ else failMsg = `成片取回失败:${pull.err.slice(-300)}`;
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ const wallS = Math.round((Date.now() - t0) / 1000);
196
+ if (localMp4) {
197
+ lines.push(await landShot({ localMp4, shot, seed, backend, wallS, outDir, ctx, sessionId }));
198
+ } else {
199
+ failed.push(`[${shot.name}] ${failMsg}`);
200
+ if (signal?.aborted) break;
201
+ break; // 串行批中途失败即停:后镜大概率同因失败,别空烧
202
+ }
203
+ }
204
+
205
+ const head = `Batch done ${lines.length}/${shots.length} shots, seed=${seed}, via ${backend === 'modal' ? 'Modal H100+sage' : '5090 盒子 sage2'}`;
206
+ // 2026-08-18:解禁。mp4 塞不进视觉通道,要看就 ffmpeg 抽帧再看那几张图。
207
+ const tail = 'You may check these: pull two or three frames with ffmpeg and look at them '
208
+ + 'for technical breakage (colour cast, duplicated figures, broken limbs, mush, first '
209
+ + 'frame not matching the anchor) and re-roll those yourself; delete the temp frames after. '
210
+ + 'Do NOT judge whether a shot is good — hand the paths to the user for that.';
211
+ if (failed.length) {
212
+ return asText([head, ...lines, 'FAILED(批在此中断,未渲镜不再空烧):', ...failed, tail].join('\n'), lines.length === 0);
213
+ }
214
+ return asText([head, ...lines, tail].join('\n'));
215
+ } catch (err) {
216
+ return asText(`roll_film 失败:${err.message}`, true);
217
+ }
218
+ }
219
+
220
+ /**
221
+ * @param {object} deps
222
+ */
223
+ export function makeRollFilmTool(deps) {
224
+ return tool(
225
+ 'roll_film',
226
+ ((localBoxEnabled() || (process.env.NODESIGN_FILM_BACKEND || 'box').toLowerCase() === 'modal')
227
+ ? ''
228
+ : '⛔ CURRENTLY UNAVAILABLE — the owner powers this GPU box on and off by hand, '
229
+ + 'and it is off right now. Do not call this tool; tell the user the local box is off.\n\n')
230
+ + `Generate video shots (picture + native audio) on the owner's self-hosted
231
+ MiniMax-H3 lane (RTX 5090 box, SageAttention, Turbo 8-step, 24fps). One call =
232
+ one batch of 1-16 shots rendered back-to-back; each finished shot lands on the
233
+ canvas immediately. Max 12.25s per shot — longer stories are multiple shots.
234
+
235
+ Call ONLY when the user explicitly asks for video, and align the shot list with
236
+ the user before rolling a multi-shot batch. Roughly 3-5 minutes per shot,
237
+ serial; tell the user the camera is rolling. Never auto-retry shots you already
238
+ got back. Requires the GPU box online — if unreachable, relay that and stop.
239
+
240
+ Prompts must follow the H3 three-field English format (cookbook arrives as a
241
+ system reminder attached to your FIRST call — treat your first batch as the
242
+ learning pass and refine from it). Keep character/style blocks verbatim
243
+ identical across shots; the whole batch shares ONE seed automatically. Keyframe
244
+ anchors (first_frame/last_frame) accept paths like "assets/generated/kf1.png"
245
+ (make them with paint_still or generate_image, ideally 1344x768).
246
+
247
+ Clips land at assets/generated/<name>-*.mp4. To check one, pull a couple of
248
+ frames with ffmpeg and look at those — catch colour cast, duplicated figures,
249
+ broken limbs, mush, or a first frame that does not match its anchor, and re-roll
250
+ those yourself (delete the temp frames after). Whether a shot is GOOD is the
251
+ user's call, not yours: report paths
252
+ and move on.`,
253
+ {
254
+ shots: z.array(z.object({
255
+ prompt: z.string().describe('H3 three-field English prompt'),
256
+ duration: z.number().min(5.2).max(12.25).default(8),
257
+ name: z.string().regex(/^[\w-]{1,40}$/).describe('shot slug, unique in batch'),
258
+ first_frame: z.string().optional(),
259
+ last_frame: z.string().optional(),
260
+ steps: z.number().int().min(4).max(8).optional().describe('default 8; 4 = fast draft'),
261
+ })).min(1).max(16).describe('shots rendered serially in one batch'),
262
+ seed: z.number().int().default(1101).describe('ONE seed shared by the whole batch (film discipline)'),
263
+ },
264
+ (args) => rollFilm(deps, args),
265
+ );
266
+ }