@ninkch/dsh-infinite-canvas 0.17.13 → 0.17.15

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/index.js CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { spawn } from 'node:child_process';
10
10
  import fs from 'node:fs/promises';
11
- import { createReadStream, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
11
+ import { createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
12
12
  import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
@@ -110,6 +110,23 @@ async function ensureBundleEntry(profileDir, pkg) {
110
110
  // 配置存在 ~/.dsh/infinite-canvas/config.json:跨重装/重启稳定,绝不放进包目录。
111
111
  const CONFIG_PATH = process.env.DSH_IC_CONFIG_PATH || path.join(os.homedir(), '.dsh', 'infinite-canvas', 'config.json');
112
112
 
113
+ /** 安全递归删除目录。
114
+ * **禁止用 fs.rmSync(recursive)**:本机 Node(24.x / Windows)在**中文路径**上会原生崩溃
115
+ * (STATUS_STACK_BUFFER_OVERRUN,直接把宿主进程带走——删一个中文名项目就能打崩 DSH)。
116
+ * 这里逐层 readdir + unlink/rmdir,走的是不同的系统调用路径,实测稳定。 */
117
+ function rmrf(target) {
118
+ let st;
119
+ try { st = lstatSync(target); } catch { return; } // 不存在 = 已删干净
120
+ if (!st.isDirectory()) {
121
+ try { unlinkSync(target); } catch { /* 被占用则残留,无碍 */ }
122
+ return;
123
+ }
124
+ let names = [];
125
+ try { names = readdirSync(target); } catch { return; }
126
+ for (const name of names) rmrf(path.join(target, name));
127
+ try { rmdirSync(target); } catch { /* 被占用则残留,无碍 */ }
128
+ }
129
+
113
130
  function readIcConfig() {
114
131
  try { return JSON.parse(readFileSync(CONFIG_PATH, 'utf8')) || {}; } catch { return {}; }
115
132
  }
@@ -284,9 +301,7 @@ function removeDataRootProject(pid) {
284
301
  const entry = meta.projects.find((p) => p.id === pid);
285
302
  meta.projects = meta.projects.filter((p) => p.id !== pid);
286
303
  writeDataRootMeta(meta);
287
- if (entry) {
288
- try { rmSync(path.join(root, 'projects', entry.folder), { recursive: true, force: true }); } catch { /* 占用时残留,无碍 */ }
289
- }
304
+ if (entry) rmrf(path.join(root, 'projects', entry.folder));
290
305
  }
291
306
  /** 一次性迁移/对齐数据目录布局:旧 pid 文件夹 → 项目名文件夹,并镜像全部项目文档。 */
292
307
  function dataRootMigrate() {
@@ -321,7 +336,7 @@ function dataRootMigrate() {
321
336
  if (!existsSync(to)) { renameSync(from, to); filesMoved++; }
322
337
  }
323
338
  }
324
- rmSync(oldDir, { recursive: true, force: true });
339
+ rmrf(oldDir);
325
340
  } catch { /* 目录被占用:保留旧目录,下轮再试 */ }
326
341
  }
327
342
  mirrorProjectToDataRoot(id, doc);
@@ -597,6 +612,17 @@ async function projectsPut(args) {
597
612
  if (!dead.has(nid)) merged[nid] = n;
598
613
  else delete merged[nid];
599
614
  }
615
+ // 竞态防护:客户端推来的分镜表没有 shots,而 Host 上已有 shots → 保留 Host 的 shots
616
+ // (客户端旧快照推送会覆盖刚生成的分镜,导致"跑下一个时上一个被清空")
617
+ for (const [nid, n] of Object.entries(merged)) {
618
+ if (!n || n.type !== 'storyboard') continue;
619
+ const hasNew = Array.isArray(n.data && n.data.shots) && n.data.shots.length;
620
+ if (hasNew) continue;
621
+ const prevN = (prev.nodes || {})[nid];
622
+ if (prevN && prevN.type === 'storyboard' && Array.isArray(prevN.data && prevN.data.shots) && prevN.data.shots.length) {
623
+ n.data = { ...(n.data || {}), shots: prevN.data.shots, warnings: prevN.data.warnings || [] };
624
+ }
625
+ }
600
626
  const edgeOk = (e) => e && !deadEdges.has(e.id) && merged[e.fromNode] && merged[e.toNode];
601
627
  const clientEdges = (doc.edges || []).filter(edgeOk);
602
628
  const prevEdges = (prev.edges || []).filter(edgeOk);
@@ -749,11 +775,42 @@ function agentNode(kind, text, x, y, label) {
749
775
  if (label) data.label = String(label).slice(0, 40);
750
776
  return { id, type: isText ? 'text' : 'script', x, y, w: isText ? 260 : 320, h: 100, data };
751
777
  }
752
- function nextNodePos(doc) {
753
- let maxX = 0;
754
- let maxY = 40;
755
- for (const n of Object.values(doc.nodes || {})) { if ((n.x || 0) + (n.w || 240) > maxX) { maxX = (n.x || 0) + (n.w || 240); maxY = n.y || 40; } }
756
- return { x: maxX + 60, y: maxY };
778
+ /** 新节点布局(用户设计):
779
+ * 1) 有 connectFrom 源 → 放在源节点右侧同一行(串联节点横向成对);
780
+ * 2) 无连线 → 同类型竖向成列(排在该类型最后一个节点下方,新起一行);
781
+ * 3) 碰撞检测:候选位置与任何现有节点(含最小间距 28px)相交 → 自动下移到该节点下方,绝不重叠。 */
782
+ function nextNodePos(doc, type, connectFromId) {
783
+ const GAP_X = 60, GAP_Y = 40, MIN_GAP = 28;
784
+ const H = (n) => Number(n && n.h) || 160;
785
+ const W = (n) => Number(n && n.w) || 280;
786
+ const nodes = Object.values(doc.nodes || {});
787
+ const EST_H = { storyboard: 560, 'video-gen': 620, 'image-gen': 560, script: 420, finalcut: 420, text: 220 };
788
+ const estH = EST_H[type] || 300;
789
+ const estW = type === 'finalcut' ? 300 : 280;
790
+ let x = 80;
791
+ let y = 40;
792
+ if (connectFromId && doc.nodes && doc.nodes[connectFromId]) {
793
+ const src = doc.nodes[connectFromId];
794
+ x = (src.x || 0) + W(src) + GAP_X;
795
+ y = src.y || 40;
796
+ } else {
797
+ const same = nodes.filter((n) => n.type === type);
798
+ if (same.length) {
799
+ // 同类型竖向成列:跟在同类型最后一个节点下方
800
+ x = Math.min(...same.map((n) => n.x || 0));
801
+ y = Math.max(...same.map((n) => (n.y || 0) + H(n))) + GAP_Y;
802
+ } else if (nodes.length) {
803
+ // 该类型还没有节点 → 在最右侧另起一列(绝不插进已有列里跟别的类型混排)
804
+ x = Math.max(...nodes.map((n) => (n.x || 0) + W(n))) + GAP_X;
805
+ y = Math.min(...nodes.map((n) => n.y || 0));
806
+ }
807
+ }
808
+ const hit = (yy) => nodes.find((n) => (n.x || 0) < x + estW + MIN_GAP && (n.x || 0) + W(n) + MIN_GAP > x
809
+ && (n.y || 0) < yy + estH + MIN_GAP && (n.y || 0) + H(n) + MIN_GAP > yy);
810
+ let blocker = hit(y);
811
+ let guard = 0;
812
+ while (blocker && guard++ < 80) { y = (blocker.y || 0) + H(blocker) + MIN_GAP; blocker = hit(y); }
813
+ return { x, y };
757
814
  }
758
815
  // ---------- 内置技能(操作规程 + 创作方法论,约束并提升对话的画布创作)----------
759
816
  // canvas-studio=操作硬约束;canvas-craft=剧本/分镜/提示词创作方法论
@@ -1042,10 +1099,10 @@ function canvasCreateNode(args) {
1042
1099
  if (!['script', 'text', 'image-gen', 'video-gen', 'storyboard', 'finalcut'].includes(type)) throw new Error('不支持的节点类型: ' + type);
1043
1100
  const label = args && args.name ? String(args.name).slice(0, 40) : '';
1044
1101
  const prompt = String((args && args.prompt) || '');
1045
- const pos = nextNodePos(doc);
1102
+ const connectFrom = String((args && args.connectFrom) || '');
1103
+ const pos = nextNodePos(doc, type, connectFrom);
1046
1104
  let connected = null;
1047
1105
  let autoConnected = null;
1048
- const connectFrom = String((args && args.connectFrom) || '');
1049
1106
  let node;
1050
1107
  if (type === 'image-gen') {
1051
1108
  node = agentNode('text', '', pos.x, pos.y, label || '图片生成');
@@ -1099,11 +1156,14 @@ function canvasCreateNode(args) {
1099
1156
  const explicitConnect = Boolean(connectFrom);
1100
1157
  if (connectFrom) {
1101
1158
  if (!doc.nodes[connectFrom]) throw new Error('connectFrom 指向的节点不存在: ' + connectFrom);
1102
- const edge = connectEdge(doc, connectFrom, node.id, args && args.connectFromPort, args && args.connectToPort);
1159
+ // 分镜表默认连「剧本来源」口(script),而不是输入队列里的第一个口(prev)——场景文本是剧本来源,不是承接来源
1160
+ const srcType = doc.nodes[connectFrom] && doc.nodes[connectFrom].type;
1161
+ const defaultToPort = (type === 'storyboard' && (srcType === 'text' || srcType === 'script')) ? 'script' : undefined;
1162
+ const edge = connectEdge(doc, connectFrom, node.id, args && args.connectFromPort, (args && args.connectToPort) || defaultToPort);
1103
1163
  connected = edge.id;
1104
1164
  } else if (autoConnected && (type === 'storyboard' || type === 'video-gen')) {
1105
1165
  // 未指定连线但上游已存在(分镜表→剧本 / 视频生成→分镜表)→ 默认自动连线(工作流的默认形态,不依赖 Agent 记得)
1106
- const edge = connectEdge(doc, autoConnected.from, node.id);
1166
+ const edge = connectEdge(doc, autoConnected.from, node.id, undefined, type === 'storyboard' ? 'script' : undefined);
1107
1167
  connected = edge.id;
1108
1168
  }
1109
1169
  doc.updatedAt = Date.now();
@@ -1119,6 +1179,62 @@ function canvasCreateNode(args) {
1119
1179
  return { ok: true, projectId: id, nodeId: node.id, type: node.type, label: (node.data && node.data.label) || '', edgeId: connected || undefined, note: note || undefined };
1120
1180
  }
1121
1181
 
1182
+ /** 一键重排:按「行」布局——横向数据流决定列,竖向端口(prev/frame)不参与分列;
1183
+ * 每行 = 一个横向连通分量(如「场N文本 ↔ 场N分镜表」),行内同 y 横向配对,
1184
+ * 行高取行内最高节点、行间保留间距,绝不重叠。 */
1185
+ function canvasRelayout(args) {
1186
+ const id = String((args && args.projectId) || '');
1187
+ const doc = readDoc(id);
1188
+ const nodes = Object.values(doc.nodes || {});
1189
+ if (!nodes.length) return { ok: true, projectId: id, moved: 0, rows: 0 };
1190
+ const VERT = new Set(['prev', 'frame']);
1191
+ const byId = new Map(nodes.map((n) => [n.id, n]));
1192
+ const depth = new Map(nodes.map((n) => [n.id, 0]));
1193
+ for (let i = 0; i < 12; i++) {
1194
+ for (const e of (doc.edges || [])) {
1195
+ if (VERT.has(String(e.toPort))) continue;
1196
+ if (!byId.has(e.fromNode) || !byId.has(e.toNode)) continue;
1197
+ const d = (depth.get(e.fromNode) || 0) + 1;
1198
+ if ((depth.get(e.toNode) || 0) < d) depth.set(e.toNode, d);
1199
+ }
1200
+ }
1201
+ const parent = new Map(nodes.map((n) => [n.id, n.id]));
1202
+ const find = (x) => { let y = x; while (parent.get(y) !== y) y = parent.get(y); return y; };
1203
+ for (const e of (doc.edges || [])) {
1204
+ if (VERT.has(String(e.toPort))) continue;
1205
+ if (!byId.has(e.fromNode) || !byId.has(e.toNode)) continue;
1206
+ const a = find(e.fromNode); const b = find(e.toNode);
1207
+ if (a !== b) parent.set(b, a);
1208
+ }
1209
+ const rows = new Map();
1210
+ for (const n of nodes) {
1211
+ const r = find(n.id);
1212
+ if (!rows.has(r)) rows.set(r, []);
1213
+ rows.get(r).push(n);
1214
+ }
1215
+ const rowList = [...rows.values()]
1216
+ .map((ids) => ({ ids, minY: Math.min(...ids.map((n) => Number(n.y) || 0)) }))
1217
+ .sort((a, b) => a.minY - b.minY);
1218
+ const colW = new Map();
1219
+ for (const n of nodes) {
1220
+ const d = depth.get(n.id) || 0;
1221
+ colW.set(d, Math.max(colW.get(d) || 280, Number(n.w) || 280));
1222
+ }
1223
+ const colX = new Map();
1224
+ let cx = 80;
1225
+ for (const d of [...colW.keys()].sort((a, b) => a - b)) { colX.set(d, cx); cx += (colW.get(d) || 280) + 60; }
1226
+ let cy = 40;
1227
+ let moved = 0;
1228
+ for (const row of rowList) {
1229
+ const rowH = Math.max(200, ...row.ids.map((n) => Number(n.h) || 200));
1230
+ for (const n of row.ids) { n.x = colX.get(depth.get(n.id) || 0); n.y = cy; moved++; }
1231
+ cy += rowH + 40;
1232
+ }
1233
+ doc.updatedAt = Date.now();
1234
+ writeIcDoc(id, doc);
1235
+ markDocChanged(id);
1236
+ return { ok: true, projectId: id, moved, rows: rowList.length };
1237
+ }
1122
1238
  async function canvasDeleteProject(args) {
1123
1239
  const id = String((args && args.projectId) || '');
1124
1240
  const f = projectFile(id);
@@ -1198,10 +1314,14 @@ function storyboardConsistencyWarnings(bible, shots, extraNames) {
1198
1314
  for (const nm of (sh.characters || [])) check(title, '角色', nm);
1199
1315
  check(title, '场景', sh && sh.scene);
1200
1316
  for (const nm of (sh.props || [])) check(title, '道具', nm);
1201
- // visual/motion 文本里的 @提及 同样纳入检查(数组外的实体也要登记,杜绝检查漏)
1317
+ // visual/motion 文本里的 @资产名@ 提及同样纳入检查(未登记的提示补充——与客户端 parseMentions 词法一致)
1202
1318
  const text = String((sh && sh.visual) || '') + ' ' + String((sh && sh.motion) || '');
1203
- for (const m of (text.match(/@[^\s@,。;、!?::()"'))]+/g) || [])) {
1204
- check(title, '提及', m.slice(1).replace(/[))]+$/, ''));
1319
+ const seenMention = new Set();
1320
+ for (const m of (text.match(/@([^@\s,。;:、,.;:!?!?()()[\]【】"'“”<>《》/\\|]{1,64}?)@/g) || [])) {
1321
+ const nm = m.replace(/^@/, '').replace(/@$/, '');
1322
+ if (seenMention.has(nm)) continue;
1323
+ seenMention.add(nm);
1324
+ check(title, '提及', nm);
1205
1325
  }
1206
1326
  }
1207
1327
  return warnings;
@@ -1302,8 +1422,15 @@ function connectEdge(doc, fromId, toId, fromPort, toPort) {
1302
1422
  if (fromId === toId) throw new Error('不能把节点连到自己');
1303
1423
  const outs = (PORT_TABLE[from.type] && PORT_TABLE[from.type].outputs) || [];
1304
1424
  const ins = (PORT_TABLE[to.type] && PORT_TABLE[to.type].inputs) || [];
1305
- const fp = String(fromPort || outs[0].id);
1306
- const tp = String(toPort || ins[0].id);
1425
+ const fp = String(fromPort || (outs[0] && outs[0].id) || '');
1426
+ // 目标端口缺省时按语义选择,而不是死板取第一个输入口:
1427
+ // 剧本/文本 → 分镜表 = 剧本来源(script);分镜表 → 分镜表 = 承接上一镜(prev);其余取第一个输入口
1428
+ let tp = String(toPort || '').trim();
1429
+ if (!tp) {
1430
+ if (to.type === 'storyboard' && (from.type === 'text' || from.type === 'script')) tp = 'script';
1431
+ else if (to.type === 'storyboard' && from.type === 'storyboard') tp = 'prev';
1432
+ else tp = String((ins[0] && ins[0].id) || '');
1433
+ }
1307
1434
  const outDef = outs.find((p) => p.id === fp);
1308
1435
  const inDef = ins.find((p) => p.id === tp);
1309
1436
  if (!outDef) throw new Error('源节点没有输出端口 ' + fp);
@@ -1924,6 +2051,22 @@ export function apply(ctx) {
1924
2051
  return { ok: true, projectId: r.projectId, nodeId: r.nodeId, text: '🗑 已删除节点 ' + r.nodeId + '。' };
1925
2052
  },
1926
2053
  });
2054
+ reg({
2055
+ name: 'canvas_relayout',
2056
+ description: '一键重排画布节点布局(消除重叠):按「行」排列——横向数据流决定列(文本→分镜表→视频→资产),竖向关系(衔接 prev / 首帧 frame)不参与分列;每行 = 一个横向连通组(如「场N文本 ↔ 场N分镜表」同行对齐),行高取行内最高节点、行间保留间距。节点重叠、或批量建点后布局混乱时调用。',
2057
+ parameters: {
2058
+ type: 'object',
2059
+ properties: { projectId: { type: 'string', description: '项目 id' } },
2060
+ required: ['projectId'],
2061
+ additionalProperties: false,
2062
+ },
2063
+ output: textOutput,
2064
+ timeoutMs: 30000,
2065
+ async execute(args) {
2066
+ const r = canvasRelayout(args);
2067
+ return { ok: true, projectId: r.projectId, text: '✅ 已重排 ' + r.moved + ' 个节点(' + r.rows + ' 行,无重叠)。' };
2068
+ },
2069
+ });
1927
2070
  reg({
1928
2071
  name: 'canvas_delete_project',
1929
2072
  description: '删除无限画布上的一个项目(含其全部节点)。用户明确要求删除项目、或清理误创建的空项目时使用;删除前请先用 canvas_list_projects 确认 id。',
@@ -2249,6 +2392,32 @@ export function apply(ctx) {
2249
2392
  }
2250
2393
  },
2251
2394
  }),
2395
+ // 插件自带静态资源(帮助与反馈用的微信群二维码等):白名单 assets/ 目录,拒绝路径穿越
2396
+ ctx.webServer.register({
2397
+ kind: 'exact',
2398
+ path: `/${NAME}/api/plugin-asset`,
2399
+ async handler(req, res) {
2400
+ try {
2401
+ if (req.method !== 'GET') { res.statusCode = 405; res.end('仅接受 GET'); return; }
2402
+ const u = new URL(req.url, 'http://localhost');
2403
+ const rel = String(u.searchParams.get('rel') || '').replace(/\\/g, '/').replace(/^\/+/, '');
2404
+ if (rel.includes('..') || !/^assets\/[\w.\u4e00-\u9fa5/-]+$/.test(rel)) { res.statusCode = 400; res.end('非法路径'); return; }
2405
+ const assetsDir = path.join(HERE, 'assets');
2406
+ const abs = path.resolve(path.join(HERE, rel));
2407
+ if (!abs.startsWith(assetsDir)) { res.statusCode = 400; res.end('非法路径'); return; }
2408
+ let st;
2409
+ try { st = statSync(abs); } catch { res.statusCode = 404; res.end('not found'); return; }
2410
+ if (!st.isFile()) { res.statusCode = 404; res.end('not found'); return; }
2411
+ res.setHeader('content-type', mimeOf(abs));
2412
+ res.setHeader('cache-control', 'no-cache');
2413
+ res.setHeader('content-length', String(st.size));
2414
+ pipeFile(abs, {}, res);
2415
+ } catch (e) {
2416
+ res.statusCode = 500;
2417
+ res.end(String((e && e.message) || e));
2418
+ }
2419
+ },
2420
+ }),
2252
2421
  ];
2253
2422
  return () => {
2254
2423
  for (const off of registrations) { try { if (off) off(); } catch { /* 忽略 */ } }
package/package.json CHANGED
@@ -1,56 +1,57 @@
1
1
  {
2
- "name": "@ninkch/dsh-infinite-canvas",
3
- "version": "0.17.13",
4
- "description": "Node-based short-drama studio for DeepSeek Harness: script → storyboard → assets → shots → voiced, subtitled final cut on an infinite canvas.",
5
- "keywords": [
6
- "dsh",
7
- "deepseek-harness",
8
- "plugin",
9
- "infinite-canvas",
10
- "storyboard",
11
- "short-drama",
12
- "ai-video",
13
- "image-generation",
14
- "video-generation",
15
- "seedance",
16
- "photocraft",
17
- "filmcraft"
18
- ],
19
- "license": "Apache-2.0",
20
- "author": "NINKCH",
21
- "publishConfig": {
22
- "registry": "https://registry.npmjs.org/"
23
- },
24
- "repository": {
25
- "type": "git",
26
- "url": "git+https://github.com/NINKCH/dsh-infinite_canvas.git"
27
- },
28
- "type": "module",
29
- "icon": "./icon.svg",
30
- "exports": {
31
- ".": "./index.js",
32
- "./client": "./client.js",
33
- "./package.json": "./package.json",
34
- "./locale/*.json": "./locale/*.json"
35
- },
36
- "files": [
37
- "index.js",
38
- "client.js",
39
- "icon.svg",
40
- "cordis.patch.yml",
41
- "locale/*.json",
42
- "assets/*/SKILL.md",
43
- "README.md",
44
- "README.zh-CN.md",
45
- "docs/*.md"
46
- ],
47
- "dsh": {
48
- "bundle": {
49
- "patch": "./cordis.patch.yml"
50
- },
51
- "client": {
52
- "platform": "web",
53
- "immediately": true
54
- }
55
- }
56
- }
2
+ "name": "@ninkch/dsh-infinite-canvas",
3
+ "version": "0.17.15",
4
+ "description": "Node-based short-drama studio for DeepSeek Harness: script → storyboard → assets → shots → voiced, subtitled final cut on an infinite canvas.",
5
+ "keywords": [
6
+ "dsh",
7
+ "deepseek-harness",
8
+ "plugin",
9
+ "infinite-canvas",
10
+ "storyboard",
11
+ "short-drama",
12
+ "ai-video",
13
+ "image-generation",
14
+ "video-generation",
15
+ "seedance",
16
+ "photocraft",
17
+ "filmcraft"
18
+ ],
19
+ "license": "Apache-2.0",
20
+ "author": "NINKCH",
21
+ "publishConfig": {
22
+ "registry": "https://registry.npmjs.org/"
23
+ },
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/NINKCH/dsh-infinite_canvas.git"
27
+ },
28
+ "type": "module",
29
+ "icon": "./icon.svg",
30
+ "exports": {
31
+ ".": "./index.js",
32
+ "./client": "./client.js",
33
+ "./package.json": "./package.json",
34
+ "./locale/*.json": "./locale/*.json"
35
+ },
36
+ "files": [
37
+ "index.js",
38
+ "client.js",
39
+ "icon.svg",
40
+ "cordis.patch.yml",
41
+ "locale/*.json",
42
+ "assets/*/SKILL.md",
43
+ "assets/support/*",
44
+ "README.md",
45
+ "README.zh-CN.md",
46
+ "docs/*.md"
47
+ ],
48
+ "dsh": {
49
+ "bundle": {
50
+ "patch": "./cordis.patch.yml"
51
+ },
52
+ "client": {
53
+ "platform": "web",
54
+ "immediately": true
55
+ }
56
+ }
57
+ }