@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.
- package/README.md +156 -106
- package/bin/nodesign.js +0 -0
- package/package.json +1 -1
- package/server/api/README.md +45 -45
- package/server/api/_guard.js +75 -75
- package/server/api/admin.js +187 -187
- package/server/api/assets/docx-page.js +103 -103
- package/server/api/assets/helpers.js +22 -22
- package/server/api/assets/notes.js +144 -144
- package/server/api/assets.js +896 -896
- package/server/api/board.js +57 -57
- package/server/api/browse.js +138 -138
- package/server/api/canvas.js +444 -444
- package/server/api/chatai.js +324 -324
- package/server/api/exports/build-standalone.js +980 -980
- package/server/api/exports/cards.js +118 -118
- package/server/api/exports/docx-pdf.js +46 -46
- package/server/api/exports/export-page.js +142 -142
- package/server/api/exports/handoff.js +147 -147
- package/server/api/exports.js +690 -690
- package/server/api/instruction.js +75 -75
- package/server/api/me.js +116 -116
- package/server/api/memory.js +185 -185
- package/server/api/pending-changes.js +366 -366
- package/server/api/plugins.js +164 -164
- package/server/api/projects.js +220 -220
- package/server/api/publish.js +64 -64
- package/server/api/recent.js +73 -73
- package/server/api/sessions.js +589 -589
- package/server/api/skills.js +77 -77
- package/server/api/standalone-fit.js +168 -168
- package/server/api/turn-compose.js +180 -180
- package/server/api/turn-inflight.js +44 -44
- package/server/auth/README.md +17 -17
- package/server/auth/middleware.js +138 -138
- package/server/auth/origin-guard.js +71 -71
- package/server/auth/session.js +108 -108
- package/server/auth/tier.js +113 -113
- package/server/auth/users-store.js +348 -348
- package/server/edit/README.md +29 -29
- package/server/engine/README.md +113 -113
- package/server/engine/agent/agent-shared.js +550 -550
- package/server/engine/agent/auto-mode-default-hard-deny.txt +4 -4
- package/server/engine/agent/auto-mode-rules.js +78 -78
- package/server/engine/agent/context.js +418 -418
- package/server/engine/agent/events.js +464 -464
- package/server/engine/agent/hooks/canvas-validate.js +199 -199
- package/server/engine/agent/hooks/failure.js +120 -120
- package/server/engine/agent/hooks/file-events.js +75 -75
- package/server/engine/agent/hooks/lifecycle.js +233 -233
- package/server/engine/agent/hooks/post-canvas-focus.js +80 -80
- package/server/engine/agent/hooks/post-guidance.js +206 -206
- package/server/engine/agent/hooks/post-subagent-report.js +104 -104
- package/server/engine/agent/hooks/post-trim.js +58 -58
- package/server/engine/agent/hooks/pre-board-neighborhood.js +34 -34
- package/server/engine/agent/hooks/pre-defaults.js +84 -84
- package/server/engine/agent/hooks/pre-injectors.js +277 -277
- package/server/engine/agent/hooks/pre-performance-log-guard.js +78 -78
- package/server/engine/agent/hooks/pre-starter-files.js +61 -61
- package/server/engine/agent/hooks/pre-workspace-scope-guard.js +78 -78
- package/server/engine/agent/hooks/site-validate.js +94 -94
- package/server/engine/agent/hooks/tool-prompts.js +31 -31
- package/server/engine/agent/hooks/turn-state-memory.js +56 -56
- package/server/engine/agent/hooks/user-prompt-submit.js +215 -215
- package/server/engine/agent/hooks.js +354 -354
- package/server/engine/agent/init-contract.js +69 -69
- package/server/engine/agent/isolation.js +159 -159
- package/server/engine/agent/model-table.js +292 -292
- package/server/engine/agent/plugin-loader.js +266 -266
- package/server/engine/agent/prompts/nodesign-prelude.md +336 -336
- package/server/engine/agent/prompts/tools/ask-user-question-protocol.md +71 -71
- package/server/engine/agent/prompts/tools/direct-edit-protocol.md +98 -98
- package/server/engine/agent/prompts/tools/generate-image-cookbook.gemini-gateway.md +583 -583
- package/server/engine/agent/prompts/tools/generate-image-cookbook.md +483 -483
- package/server/engine/agent/prompts/tools/hybrid-reference.md +100 -100
- package/server/engine/agent/prompts/tools/paint-still-cookbook.md +270 -270
- package/server/engine/agent/prompts/tools/roll-film-cookbook.md +70 -70
- package/server/engine/agent/prompts/tools/site-reference.md +106 -106
- package/server/engine/agent/prompts/tools/tweaks-syntax.md +125 -125
- package/server/engine/agent/prompts/tools/vision-checker-dispatch.md +48 -48
- package/server/engine/agent/session-loop.js +1024 -1024
- package/server/engine/agent/session-model.js +164 -164
- package/server/engine/agent/skill.js +204 -204
- package/server/engine/agent/task-events.js +78 -78
- package/server/engine/agents/ds-extractor.md +132 -132
- package/server/engine/agents/explorer.md +190 -190
- package/server/engine/agents/index.js +230 -230
- package/server/engine/agents/schemas/design-system.json +156 -156
- package/server/engine/agents/schemas/tweak-schema.json +113 -113
- package/server/engine/agents/tweak-proposer.md +127 -127
- package/server/engine/agents/vision-checker.md +254 -254
- package/server/engine/browse/capture.js +422 -422
- package/server/engine/browse/card.js +137 -137
- package/server/engine/browse/handover.js +59 -59
- package/server/engine/browse/page-digest.js +119 -119
- package/server/engine/browse/refs.js +196 -196
- package/server/engine/browse/registry.js +307 -307
- package/server/engine/browse/screencast.js +170 -170
- package/server/engine/browse/state.js +121 -121
- package/server/engine/chatai/chat-log.js +79 -79
- package/server/engine/chatai/index.js +193 -193
- package/server/engine/chatai/openai-compat.js +152 -152
- package/server/engine/chatai/orchestrate.js +309 -309
- package/server/engine/chatai/perform.js +40 -40
- package/server/engine/chatai/summarize.js +79 -79
- package/server/engine/mcp/capability-gate.js +52 -52
- package/server/engine/mcp/index.js +339 -339
- package/server/engine/mcp/param-sanitizer.js +142 -142
- package/server/engine/mcp/tools/arrange-on-board.js +124 -124
- package/server/engine/mcp/tools/artifact-session.js +278 -278
- package/server/engine/mcp/tools/browse-computer.js +421 -421
- package/server/engine/mcp/tools/browse-find-batch.js +183 -183
- package/server/engine/mcp/tools/browse-screenshot.js +157 -157
- package/server/engine/mcp/tools/browse.js +480 -480
- package/server/engine/mcp/tools/build-docx.js +101 -101
- package/server/engine/mcp/tools/clear-pending-changes.js +99 -99
- package/server/engine/mcp/tools/create-on-board.js +155 -155
- package/server/engine/mcp/tools/crystallize-skill.js +168 -168
- package/server/engine/mcp/tools/explain-style.js +237 -237
- package/server/engine/mcp/tools/export-handoff.js +133 -133
- package/server/engine/mcp/tools/expose-tweaks.js +149 -149
- package/server/engine/mcp/tools/generate-image.js +842 -842
- package/server/engine/mcp/tools/get-computed-styles.js +164 -164
- package/server/engine/mcp/tools/get-pending-changes.js +205 -205
- package/server/engine/mcp/tools/h3box-ssh.js +84 -84
- package/server/engine/mcp/tools/helpers/acquire-page.js +82 -82
- package/server/engine/mcp/tools/helpers/motion-lab.js +554 -554
- package/server/engine/mcp/tools/helpers/motion-scroll.js +112 -112
- package/server/engine/mcp/tools/helpers/perception-page.js +215 -215
- package/server/engine/mcp/tools/helpers/reference-download.js +103 -103
- package/server/engine/mcp/tools/helpers/rembg-bridge.py +80 -80
- package/server/engine/mcp/tools/helpers/shot-pipeline.js +280 -280
- package/server/engine/mcp/tools/highlight.js +68 -68
- package/server/engine/mcp/tools/list-pages.js +222 -222
- package/server/engine/mcp/tools/lookup-tags.js +59 -59
- package/server/engine/mcp/tools/navigate-to-page.js +60 -60
- package/server/engine/mcp/tools/organize-board.js +64 -64
- package/server/engine/mcp/tools/paint-still.js +434 -434
- package/server/engine/mcp/tools/pin-to-board.js +130 -130
- package/server/engine/mcp/tools/preview-deck.js +130 -130
- package/server/engine/mcp/tools/profile-scroll.js +305 -305
- package/server/engine/mcp/tools/publish-site.js +102 -102
- package/server/engine/mcp/tools/query-elements.js +195 -195
- package/server/engine/mcp/tools/read-board.js +103 -103
- package/server/engine/mcp/tools/read-document.js +93 -93
- package/server/engine/mcp/tools/read-page.js +264 -264
- package/server/engine/mcp/tools/read-tavern-json.js +143 -143
- package/server/engine/mcp/tools/record-decision.js +140 -140
- package/server/engine/mcp/tools/relate-on-board.js +110 -110
- package/server/engine/mcp/tools/report-issue.js +115 -115
- package/server/engine/mcp/tools/roll-film.js +266 -266
- package/server/engine/mcp/tools/screenshot-docx.js +138 -138
- package/server/engine/mcp/tools/screenshot-url.js +195 -195
- package/server/engine/mcp/tools/screenshot.js +583 -583
- package/server/engine/mcp/tools/tier-gate.js +96 -96
- package/server/engine/mcp/tools/trace-motion.js +215 -215
- package/server/engine/mcp/tools/web-search.js +548 -548
- package/server/engine/motion/inventory.js +349 -349
- package/server/engine/perception/session.js +235 -235
- package/server/engine/plugins/nodesign/.claude-plugin/plugin.json +5 -5
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/SKILL.md +192 -192
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/canvas.template.html +667 -667
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/hybrid-grid.md +22 -22
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/image-led-cover.md +25 -25
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/portrait.md +23 -23
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/quote-backdrop.md +21 -21
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/section-divider.md +23 -23
- package/server/engine/plugins/nodesign/skills/deskskill-engine-mini/patterns/text-led.md +21 -21
- package/server/engine/plugins/nodesign/skills/docx-craft/SKILL.md +162 -162
- package/server/engine/plugins/nodesign/skills/docx-craft/references/token-schema.md +407 -407
- package/server/engine/plugins/nodesign/skills/docx-craft//346/226/207/346/241/243.template.json +80 -80
- package/server/engine/plugins/nodesign/skills/rp-craft/SKILL.md +228 -228
- 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
- package/server/engine/plugins/nodesign/skills/rp-craft//346/274/224/345/207/272.template.js +268 -268
- package/server/engine/plugins/nodesign/skills/site-craft/SKILL.md +323 -323
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/build-lane.md +134 -134
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/cutout-collage.md +61 -61
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/mock-app.md +163 -163
- package/server/engine/plugins/nodesign/skills/site-craft/patterns/page-transitions.md +86 -86
- package/server/engine/runs/active-runs.js +698 -698
- package/server/engine/runs/live-turn.js +252 -252
- package/server/engine/runs/store.js +364 -364
- package/server/engine/runs/turn-relay.js +217 -217
- package/server/engine/runtime/workspace.js +164 -164
- package/server/lib/artifact-file-path.js +80 -80
- package/server/lib/artifact-target.js +284 -284
- package/server/lib/asset-refs.js +239 -239
- package/server/lib/async-queue.js +104 -104
- package/server/lib/auto-relations.js +130 -130
- package/server/lib/binding-types.js +60 -60
- package/server/lib/board-kind-sizes.js +62 -62
- package/server/lib/board-relations.js +133 -133
- package/server/lib/browse-proxy.js +180 -180
- package/server/lib/canvas-id.js +36 -36
- package/server/lib/cover.js +227 -227
- package/server/lib/danbooru-tags.js +291 -291
- package/server/lib/doc-extract.js +156 -156
- package/server/lib/docx/build-from-source.js +260 -260
- package/server/lib/docx/build.js +490 -490
- package/server/lib/docx/dump-styles.js +287 -287
- package/server/lib/docx/fonts/nodesign-cjk.conf +164 -164
- package/server/lib/docx/merge-runs.js +129 -129
- package/server/lib/docx/numbering.js +218 -218
- package/server/lib/docx/order.js +152 -152
- package/server/lib/docx/rawzip.js +172 -172
- package/server/lib/docx/render.js +92 -92
- package/server/lib/docx/text-lint.js +225 -225
- package/server/lib/docx/tokens.js +335 -335
- package/server/lib/docx/units.js +29 -29
- package/server/lib/docx/xml.js +291 -291
- package/server/lib/docx-pages.js +187 -187
- package/server/lib/export-collect.js +289 -289
- package/server/lib/export-package.js +210 -210
- package/server/lib/html-srcset.js +145 -145
- package/server/lib/image-variant.js +460 -460
- package/server/lib/ingress/forward-openai-chat.js +305 -305
- package/server/lib/ingress/openai-chat.js +491 -491
- package/server/lib/ingress/session-notice.js +66 -66
- package/server/lib/ingress/session-routes.js +68 -68
- package/server/lib/ingress/slot-probe.js +130 -130
- package/server/lib/ingress/upstream-billing.js +52 -52
- package/server/lib/ingress/upstream-fail-streak.js +72 -72
- package/server/lib/ingress/upstream-truncation.js +48 -48
- package/server/lib/issues-store.js +182 -182
- package/server/lib/kinds/deck.js +72 -72
- package/server/lib/kinds/docx.js +283 -283
- package/server/lib/kinds/file-kinds.js +113 -113
- package/server/lib/kinds/index.js +202 -202
- package/server/lib/kinds/site.js +161 -161
- package/server/lib/model-ingress.js +543 -543
- package/server/lib/moderation.js +255 -255
- package/server/lib/notice-store.js +103 -103
- package/server/lib/plugin-install.js +104 -104
- package/server/lib/plugin-validator.js +721 -721
- package/server/lib/publish-store.js +115 -115
- package/server/lib/quick-summary.js +52 -52
- package/server/lib/quota.js +270 -270
- package/server/lib/rate-window.js +29 -29
- package/server/lib/reference-assets.js +92 -92
- package/server/lib/region-shot.js +135 -135
- package/server/lib/safe-path.js +73 -73
- package/server/lib/sdk-session.js +33 -33
- package/server/lib/showcase-store.js +92 -92
- package/server/lib/site-publish.js +465 -465
- package/server/lib/ssrf-guard.js +432 -432
- package/server/lib/task-scan.js +158 -158
- package/server/lib/tavern-json.js +154 -154
- package/server/lib/video-variant.js +237 -237
- package/server/lib/workspace-path.js +33 -33
- package/server/ops/fix-sdk-musl.mjs +40 -40
- package/server/ops/install-macos-fonts.sh +114 -114
- package/server/ops/macos-fonts.conf +336 -336
- package/server/ops/sandbox-shim/bwrap +57 -57
- package/server/projects/assets-summary.js +119 -119
- package/server/projects/auto-name.js +53 -53
- package/server/projects/board-store.js +640 -640
- package/server/projects/move-entry.js +103 -103
- package/server/projects/store.js +270 -270
- package/server/projects/ui-config.js +54 -54
- package/server/projects/workspace-templates.js +53 -53
- package/server/projects/workspace.js +1025 -1025
- package/server/runtime/local-config.js +177 -177
- package/server/runtime/profile.js +72 -72
- package/server/services/rembg-service.py +334 -334
- package/server/shared/README.md +25 -25
- package/server/shared/deck.js +54 -54
- package/server/shared/time.js +49 -49
- package/server/style-pipeline/README.md +53 -53
- package/server/ws/broker.js +36 -36
- package/server/ws/browse-channel.js +177 -177
- package/server/ws/index.js +386 -386
- package/web/README.md +89 -89
- package/web/dist/assets/BrowserWindow-BYCgizHw.js +11 -0
- package/web/dist/assets/{DeckWindow-4G0xX7P3.js → DeckWindow-pNydEM4L.js} +1 -1
- package/web/dist/assets/{DocxWindow-BXQsoPkh.js → DocxWindow-PPgzh3Gq.js} +1 -1
- package/web/dist/assets/{SiteWindow-D6eS-2gM.js → SiteWindow-DM0LQPCO.js} +1 -1
- package/web/dist/assets/{index-Cbl3ATvp.js → index-BHTBJlpY.js} +32 -32
- package/web/dist/assets/{pending-edit-apply-DTjo2geW.js → pending-edit-apply-C0I13CbD.js} +1 -1
- package/web/dist/assets/{rotate-cw-LMT3x1Zj.js → rotate-cw-B1UQ8Hld.js} +1 -1
- package/web/dist/assets/{square-dashed-mouse-pointer-CBoAkfhi.js → square-dashed-mouse-pointer-8lrgqmfv.js} +1 -1
- package/web/dist/index.html +17 -17
- 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
|
+
}
|