@ninkch/dsh-infinite-canvas 0.17.13 → 0.17.14

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.
@@ -136,7 +136,12 @@ description: 用无限画布(Infinite Canvas)做短剧/视频创作时使用
136
136
  5. **生成角色/场景/道具参考图**:为设定集里的每个条目
137
137
  `canvas_create_node({ projectId, type: 'image-gen', name: '角色-张山 多视图', prompt: '按设定集写外观关键词' })`
138
138
  → `canvas_run_node({ projectId, nodeId })` 触发(画布用它配置的图片接口)
139
- - 需要补连任意两节点时用 `canvas_connect({ projectId, fromNode, toNode })`(端口类型必须匹配)
139
+ - 需要补连任意两节点时用 `canvas_connect({ projectId, fromNode, toNode })`(端口类型必须匹配;
140
+ 连分镜表时系统按语义自动选口:剧本/文本 → `script`,分镜表 → `prev`,不用手写端口)
141
+ - **布局乱了/节点重叠**:`canvas_relayout({ projectId })` 一键重排——横向数据流决定列
142
+ (文本→分镜表→视频),竖向关系(承接 `prev` / 首帧 `frame`)留在同列,同行 = 一个横向连通组
143
+ (如「场N文本 ↔ 场N分镜表」对齐),行间保留间距、绝不重叠。新建节点已做碰撞检测;
144
+ 存量节点乱了一律用这个工具重排,不要手工挪。
140
145
  6. `canvas_open({ projectId })` —— 让用户看到画布结果
141
146
 
142
147
  ## 三、@引用与资产命名(跨集一致性的核心手法)
@@ -0,0 +1,21 @@
1
+ # 帮助与反馈静态资源
2
+
3
+ 画布界面里的「帮助与反馈」弹窗(工具栏按钮 / 设置面板一行入口)会加载这里的文件。
4
+
5
+ ## 微信二维码
6
+
7
+ - 当前文件:`wechat.png`(**个人微信二维码**——永久有效;群二维码 7 天过期,难维护,所以用个人码 + 手动拉群)
8
+ - 换图:直接替换本目录的 `wechat.png`(同名即可;若换扩展名,请同步改 `client.js` 里的 `SUPPORT_QR_URL`)
9
+ - 建议正方形、400×400 以上,且**不带多余文字**(弹窗里已有说明文案,图上再印字会重复)
10
+ - **改完需重启 DSH**(静态资源路由在插件启动时注册)
11
+ - 文件缺失时弹窗显示占位提示,不会报错
12
+
13
+ 访问方式(插件自身路由,白名单 `assets/` 目录 + 拒绝路径穿越):
14
+
15
+ ```
16
+ /infinite-canvas/api/plugin-asset?rel=assets/support/wechat.png
17
+ ```
18
+
19
+ ## GitHub 地址
20
+
21
+ 仓库与 Issue 地址写在 `client.js` 顶部的 `SUPPORT_GITHUB` / `SUPPORT_ISSUES` 常量里,改地址只需改这两行。
Binary file
package/client.js CHANGED
@@ -252,9 +252,23 @@ window.__ModuleLoader__.load({
252
252
  'nStoryboard': '分镜表',
253
253
  'nScript': '剧本',
254
254
  'vendorLabel': '厂商格式(选后只需填 Base URL + 模型 id)',
255
+ 'showKey': '显示明文',
256
+ 'hideKey': '隐藏(脱敏显示)',
257
+ 'supportBtn': '帮助与反馈',
258
+ 'supportTitle': '帮助与反馈',
259
+ 'supportNote': '有问题或改进建议,提 Issue 或进群反馈。',
260
+ 'supportGithub': 'GitHub 仓库',
261
+ 'supportIssues': '访问 GitHub,提 Issue',
262
+ 'supportWechat': '交流群',
263
+ 'supportWechatHint': '扫码添加,进入交流群',
264
+ 'supportCopy': '复制地址',
265
+ 'supportCopied': '已复制到剪贴板',
266
+ 'supportOpen': '打开',
267
+ 'supportQrMissing': '二维码待放置:assets/support/wechat.png',
268
+ 'supportClose': '关闭',
255
269
  'portScript': '剧本',
256
- 'portPrev': '衔接上一镜(尾镜)',
257
- 'portTail': '尾镜(供下一分镜衔接)',
270
+ 'portPrev': '衔接上一镜',
271
+ 'portTail': '尾镜输出',
258
272
  'aiScript': 'AI 生成剧本',
259
273
  'aiStoryboard': 'AI 生成分镜',
260
274
  'expand': '铺开到画布',
@@ -643,6 +657,20 @@ window.__ModuleLoader__.load({
643
657
  'nScript': 'Script',
644
658
  'portScript': 'script',
645
659
  'vendorLabel': 'Vendor format (then just Base URL + model id)',
660
+ 'showKey': 'Show key',
661
+ 'hideKey': 'Hide key (masked)',
662
+ 'supportBtn': 'Help & feedback',
663
+ 'supportTitle': 'Help & feedback',
664
+ 'supportNote': 'Found a bug or have an idea? File an issue or join the group.',
665
+ 'supportGithub': 'GitHub repository',
666
+ 'supportIssues': 'Visit GitHub & file an issue',
667
+ 'supportWechat': 'Community group',
668
+ 'supportWechatHint': 'Scan to join the group',
669
+ 'supportCopy': 'Copy URL',
670
+ 'supportCopied': 'Copied to clipboard',
671
+ 'supportOpen': 'Open',
672
+ 'supportQrMissing': 'QR not added yet: assets/support/wechat.png',
673
+ 'supportClose': 'Close',
646
674
  'portPrev': 'Continue prev tail',
647
675
  'portTail': 'Tail shot (for next)',
648
676
  'aiScript': 'AI write script',
@@ -1039,6 +1067,8 @@ window.__ModuleLoader__.load({
1039
1067
  .dshic-port:hover,.dshic-port.is-active{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);
1040
1068
  box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,transparent);}
1041
1069
  .dshic-port-wrap{position:absolute;width:0;height:0;}
1070
+ /* 角点端口(分镜尾镜输出/衔接输入):显式以锚点为中心,保证连线正好从点位中心垂直穿出 */
1071
+ .dshic-port.is-corner{left:-5.5px;top:-5.5px;}
1042
1072
  .dshic-port-label{position:absolute;font-size:9px;color:var(--dsw-alias-label-secondary);white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .12s;}
1043
1073
  .dshic-node:hover .dshic-port-label{opacity:1;}
1044
1074
  .dshic-marquee{position:absolute;border:1px solid var(--dsw-alias-brand-primary);
@@ -1432,8 +1462,9 @@ window.__ModuleLoader__.load({
1432
1462
  // @ 引用(mention):提示词里写 @张山 → 自动带上该实体的设定集描述与其参考图资产
1433
1463
  // 以下为纯函数(无副作用),供 scripts/test-mentions.mjs 抽取单测
1434
1464
  // <<<MENTION_PURE_BEGIN>>>
1435
- /** @名称 的词法:到空白或常见标点为止(支持中英文名与 - _ ·) */
1436
- const MENTION_RE = /@([^\s@,。;:、,.;:!?!?()()\[\]【】"'“”<>《》/\\|]{1,24})/g;
1465
+ /** @资产名@ 的词法(双 @ 精确包裹):终止符是闭合 @,绝不可能把后文吞进名称;
1466
+ * 名称内禁止 @、空白与常见标点。前端渲染时隐藏两个 @,名称显示为蓝色加粗。 */
1467
+ const MENTION_RE = /@([^@\s,。;:、,.;:!?!?()()\[\]【】"'“”<>《》/\\|]{1,64}?)@/g;
1437
1468
  /** 解析提示词里的 @名称(含位置,按出现顺序)。 */
1438
1469
  function parseMentions(text) {
1439
1470
  const out = [];
@@ -1451,7 +1482,7 @@ window.__ModuleLoader__.load({
1451
1482
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1452
1483
  .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
1453
1484
  }
1454
- /** 把提示词转成高亮 HTML:@名称 用 .dshic-mention(蓝色加粗)标记。 */
1485
+ /** 把提示词转成高亮 HTML:@资产名@ 两个 @ 隐藏(占位但不可见),名称显示为蓝色加粗。 */
1455
1486
  function highlightMentions(text) {
1456
1487
  const src = String(text || '');
1457
1488
  let out = '';
@@ -1460,16 +1491,31 @@ window.__ModuleLoader__.load({
1460
1491
  let m;
1461
1492
  while ((m = re.exec(src)) !== null) {
1462
1493
  out += escapeHtml(src.slice(last, m.index));
1463
- out += '<span class="dshic-mention">' + escapeHtml(m[0]) + '</span>';
1494
+ out += '<span style="visibility:hidden">@</span><span class="dshic-mention">' + escapeHtml(m[1]) + '</span><span style="visibility:hidden">@</span>';
1464
1495
  last = m.index + m[0].length;
1465
1496
  }
1466
1497
  out += escapeHtml(src.slice(last));
1467
1498
  return out === '' ? '' : out + '\n'; // 末尾补换行,保证背景层与 textarea 行高对齐
1468
1499
  }
1469
- /** 送给模型前去掉 @ 标记(保留名称本体)。 */
1500
+ /** 送给模型前去掉 @ 包裹标记(保留名称本体)。 */
1470
1501
  function stripMentionMarks(text) {
1471
1502
  return String(text || '').replace(new RegExp(MENTION_RE.source, 'g'), '$1');
1472
1503
  }
1504
+ /** 展示用:把 @资产名@ 渲染为「蓝色加粗资产名」(隐藏两个 @,前后各留 2px 视觉空隙)。 */
1505
+ function mentionNodes(text) {
1506
+ const src = String(text || '');
1507
+ const out = [];
1508
+ const re = new RegExp(MENTION_RE.source, 'g');
1509
+ let m;
1510
+ let last = 0;
1511
+ while ((m = re.exec(src)) !== null) {
1512
+ if (m.index > last) out.push(src.slice(last, m.index));
1513
+ out.push(h('span', { className: 'dshic-mention', style: { margin: '0 2px' } }, m[1]));
1514
+ last = m.index + m[0].length;
1515
+ }
1516
+ if (last < src.length) out.push(src.slice(last));
1517
+ return out.length ? out : [src];
1518
+ }
1473
1519
  function entityLabelOf(kind) {
1474
1520
  return kind === 'character' ? '角色' : kind === 'scene' ? '场景' : kind === 'prop' ? '道具' : '元素';
1475
1521
  }
@@ -3398,6 +3444,11 @@ window.__ModuleLoader__.load({
3398
3444
  const nodes = { ...((live && live.nodes) ? live.nodes : local.nodes) };
3399
3445
  for (const [nid, n] of Object.entries(full.nodes || {})) {
3400
3446
  if (deadNodes.has(nid)) continue;
3447
+ const cur = nodes[nid];
3448
+ // 生成产物保护:本地分镜已有 shots 而主机副本没有 → 主机副本是竞态过期数据,保留本地
3449
+ // (否则刚生成完的分镜会被一次过期合并清空,客户端再推回去就永久丢失)
3450
+ if (cur && cur.type === 'storyboard' && Array.isArray(cur.data && cur.data.shots) && cur.data.shots.length
3451
+ && !(n && n.data && Array.isArray(n.data.shots) && n.data.shots.length)) continue;
3401
3452
  nodes[nid] = n; // Host 为准(Agent 写入是有意的)
3402
3453
  }
3403
3454
  const deadEdges = new Set([...(local.edgeTombstones || []), ...(full.edgeTombstones || []), ...((live && live.edgeTombstones) || [])]);
@@ -3816,6 +3867,7 @@ window.__ModuleLoader__.load({
3816
3867
  '- **每个场景必须以「【场N】场景名(地点·时间)」单独一行开头**(如【场1】城市街头(夜))——这是后续自动铺开到分镜的分场标记,格式不可省略或改写;',
3817
3868
  '- 每场容量 10-15 秒成片(约 60-150 字,含该场全部对白);全部场景时长之和 ≈ 1-2 分钟;',
3818
3869
  '- 场景头之后:可视化动作描写 + 对白(角色:台词,单独成行);需要的声音(环境音/旁白)标注在括号里;',
3870
+ '- **【资产锚定·硬性】正文中所有角色/场景/道具名称一律写成 @名称@ 引用形式**(前后各一个 @ 精确包裹,名称与设定集逐字一致,如「@陈拾@ 在 @城市街头@ 的 @铁皮围挡@ 旁」)——下游分镜生成靠这些锚点识别资产并挂接参考图;叙述性文字正常书写,不要把动作描述包进 @…@;',
3819
3871
  '- 结尾单独一行「人物:」与「场景/道具:」清单,便于后续资产制作。',
3820
3872
  ].join('\n') : [
3821
3873
  `Idea / source:\n${String(idea ?? '').trim()}`,
@@ -3824,6 +3876,7 @@ window.__ModuleLoader__.load({
3824
3876
  '- **Every scene MUST start with its own line "【SCENE N】name (location · time)"** — this marker drives the automatic per-scene expansion and must not be omitted;',
3825
3877
  '- Each scene holds 10-15 seconds of screen time (≈60-150 words incl. dialogue); all scenes sum to ≈1-2 minutes;',
3826
3878
  '- After each scene header: visual action + dialogue ("Character: line" on its own lines); mark sounds in brackets;',
3879
+ '- **[ASSET ANCHORING - HARD] wrap every character/scene/prop name as @Name@** (an @ on each side, names verbatim from the bible, e.g. "@Chen@ stands by @Wall@ at @Street@") - downstream storyboard generation relies on these anchors to attach references; keep narration text normal, never wrap action descriptions inside @...@;',
3827
3880
  '- End with "Characters:" and "Locations/Props:" lists for asset production.',
3828
3881
  ].join('\n');
3829
3882
  return await aiCompleteStream({
@@ -3867,7 +3920,7 @@ window.__ModuleLoader__.load({
3867
3920
  '只输出一个 JSON 对象:{"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"陈拾","line":"收件时间,1999……"}],"sfx":"邮包搭扣声、撕纸声"}]}。',
3868
3921
  '规则:',
3869
3922
  '- visual 是本镜画面描述(景别、人物外观与站位、场景、光线、构图,60 字内);',
3870
- '- **visual 与 motion 里的角色/场景/道具必须写成 @名称 引用形式**(如 `@张山`、`@褶皱巷`),插件会自动带上该实体的设定描述与其参考图资产,保证跨镜一致;',
3923
+ '- **visual 与 motion 里的角色/场景/道具必须写成 @资产名@ 引用形式**(前后各一个 @ 精确包裹,如「@陈拾@ 在 @城市街头@ 的围挡旁拿起 @奇怪快递件@」);名称必须与设定集/资产**逐字一致**;剧本原文里已出现的 @资产名@ 锚点要在镜头中原样保留(名称逐字一致);名称内不得含 @、空格或标点;@ 后绝不允许直接跟其他文字(必须以 @ 闭合)——系统会隐藏两个 @ 并把名称显示为蓝色加粗,未登记的 @名称@ 会被一致性检查提示补充;',
3871
3924
  '- motion 是本镜动作与运镜(主体动作节拍、机位运动方向与速度、情绪节奏,50 字内);与 visual 不重复;',
3872
3925
  '- characters/scene/props 使用剧本与设定集中的名称(不带 @),便于匹配资产;无道具给空数组;',
3873
3926
  '- **dialogue 是本镜的角色台词**:把剧本中落在这一镜的对白逐行搬进来({"role":"角色名","line":"台词原文"}),台词必须**逐字来自剧本**、不新写不改写;本镜无台词给空数组;一镜尽量只放一个角色的台词(单说话人镜头利于声音参考提取);',
@@ -3884,7 +3937,7 @@ window.__ModuleLoader__.load({
3884
3937
  'Output only one JSON object: {"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"Chen","line":"…"}],"sfx":"zipper snap, paper tear"}]}.',
3885
3938
  'Rules:',
3886
3939
  '- visual = the shot\'s frame description (shot size, wardrobe, staging, light, framing, <=60 words);',
3887
- '- **characters/scenes/props inside visual and motion must be written as @name references** (e.g. `@ZhangSan`); the plugin then attaches that entity\'s bible description and reference images automatically;',
3940
+ '- **characters/scenes/props inside visual and motion must be written as @Name@ references** (an @ on each side, e.g. "@Chen@ picks up @Parcel@ by @Street@"); names must match the bible/assets VERBATIM, with no @, space or punctuation inside; an @ must never be followed directly by other text (always close with @) - the UI hides the @s and shows the name in blue bold; unregistered @Name@ is flagged by the consistency check;',
3888
3941
  '- motion = action & camera for the shot (action beats, camera move, emotional rhythm, <=50 words); do not repeat visual;',
3889
3942
  '- characters/scene/props arrays must use plain names from the script and series bible (no @) so assets match; empty props array if none;',
3890
3943
  '- **dialogue = the character lines that fall in this shot**, moved verbatim from the script ({"role":"Name","line":"exact line"}); never rewrite lines; empty array if none; keep one speaker per shot when possible (helps voice-reference extraction);',
@@ -4028,11 +4081,9 @@ window.__ModuleLoader__.load({
4028
4081
  for (const nm of (sh.characters || [])) check(title, T('catCharacter'), nm);
4029
4082
  check(title, T('catScene'), sh.scene);
4030
4083
  for (const nm of (sh.props || [])) check(title, T('catProp'), nm);
4031
- // visual/motion 文本里的 @提及 同样纳入检查(数组外的实体也要登记,杜绝检查漏)
4084
+ // visual/motion 文本里的 @资产名@ 提及同样纳入检查(未登记的会提示补充资产——这是检查的价值所在)
4032
4085
  const text = String(sh.visual || '') + ' ' + String(sh.motion || '');
4033
- for (const m of (text.match(/@[^\s@,。;、!?::()"'))]+/g) || [])) {
4034
- check(title, T('catMention'), m.slice(1).replace(/[))]+$/, ''));
4035
- }
4086
+ for (const mm of parseMentions(text)) check(title, T('catMention'), mm.name);
4036
4087
  }
4037
4088
  return warnings;
4038
4089
  }
@@ -4457,15 +4508,28 @@ window.__ModuleLoader__.load({
4457
4508
  function portWorldPos(nodeId, portId, dir) {
4458
4509
  const n = S.graph.nodes[nodeId];
4459
4510
  if (!n) return { x: 0, y: 0 };
4511
+ // 分镜承接端口专属位置(用户设计):tail=节点底部右侧(输出)、prev=节点顶部右侧(输入)
4512
+ if (n.type === 'storyboard' && portId === 'tail') return { x: n.x + n.w - 48, y: n.y + n.h };
4513
+ if (n.type === 'storyboard' && portId === 'prev') return { x: n.x + n.w - 48, y: n.y };
4460
4514
  const list = dir === 'out' ? (PORTS[n.type].outputs || []) : (PORTS[n.type].inputs || []);
4461
4515
  const i = list.findIndex((p) => p.id === portId);
4462
- const y = n.y + 38 + Math.max(0, i) * 22;
4516
+ const stack = (n.type === 'storyboard' && dir === 'in' && i > 0) ? i - 1 : i; // prev 是角点不占常规队列
4517
+ const y = n.y + 38 + Math.max(0, stack) * 22;
4463
4518
  return { x: dir === 'out' ? n.x + n.w : n.x, y };
4464
4519
  }
4465
4520
  function edgeGeom(e) {
4466
4521
  const a = portWorldPos(e.fromNode, e.fromPort, 'out');
4467
4522
  const b = portWorldPos(e.toNode, e.toPort, 'in');
4468
- const dx = Math.max(42, Math.abs(b.x - a.x) * 0.5);
4523
+ const off = Math.abs(b.x - a.x);
4524
+ // 端口横向对齐(同一列上下相接,如分镜承接链):直接连直线,不再外扩控制点画弯线
4525
+ if (off < 12 && Math.abs(b.y - a.y) > 24) {
4526
+ return {
4527
+ d: `M ${a.x} ${a.y} L ${b.x} ${b.y}`,
4528
+ mx: (a.x + b.x) / 2,
4529
+ my: (a.y + b.y) / 2,
4530
+ };
4531
+ }
4532
+ const dx = Math.max(42, off * 0.5);
4469
4533
  const c1x = a.x + dx, c2x = b.x - dx;
4470
4534
  return {
4471
4535
  d: `M ${a.x} ${a.y} C ${c1x} ${a.y}, ${c2x} ${b.y}, ${b.x} ${b.y}`,
@@ -5278,6 +5342,10 @@ window.__ModuleLoader__.load({
5278
5342
  script: 'M6 3h9l4 4v14H6zM15 3v4h4M9 12h7M9 16h7M9 8h3',
5279
5343
  board: 'M4 4h16v16H4zM4 9h16M9 4v5M15 4v5M9 13h6M9 17h6',
5280
5344
  expandAll: 'M4 12h6M4 12l3-3M4 12l3 3M20 12h-6M20 12l-3-3M20 12l-3 3M12 4v6M12 4l-3 3M12 4l3 3M12 20v-6M12 20l-3-3M12 20l3-3',
5345
+ eye: 'M2.5 12S6 6.5 12 6.5 21.5 12 21.5 12 18 17.5 12 17.5 2.5 12 2.5 12zM12 15a3 3 0 100-6 3 3 0 000 6z',
5346
+ eyeOff: 'M4 4l16 16M9.9 5.1A9.6 9.6 0 0112 5c6 0 9.5 7 9.5 7a17 17 0 01-3.3 4.2M6.2 7.4A17 17 0 002.5 12S6 19 12 19a9.4 9.4 0 003.9-.8M10 10a3 3 0 004 4',
5347
+ help: 'M12 21a9 9 0 100-18 9 9 0 000 18zM9.4 9.4a2.6 2.6 0 114.2 2.1c-.9.7-1.6 1.2-1.6 2.3M12 17h.01',
5348
+ link: 'M10 13a5 5 0 007.1 0l2.4-2.4a5 5 0 00-7.1-7.1L11 4.9M14 11a5 5 0 00-7.1 0L4.5 13.4a5 5 0 007.1 7.1L13 19.1',
5281
5349
  };
5282
5350
  function Ic(name, size) { return h(Icon, { d: ICONS[name], size: size || 14 }); }
5283
5351
 
@@ -5322,7 +5390,7 @@ window.__ModuleLoader__.load({
5322
5390
  return () => ro.disconnect();
5323
5391
  }
5324
5392
 
5325
- function PortEl({ n, port, dir }) {
5393
+ function PortEl({ n, port, dir, corner }) {
5326
5394
  const isActive = drag.mode === 'link' && drag.linkFrom && drag.linkFrom.node.id === n.id && drag.linkFrom.port === port.id;
5327
5395
  let compat = false;
5328
5396
  const link = drag.mode === 'link' ? drag.linkFrom : null;
@@ -5332,7 +5400,7 @@ window.__ModuleLoader__.load({
5332
5400
  }
5333
5401
  return h('button', {
5334
5402
  type: 'button',
5335
- className: cls('dshic-port', 'k-' + port.kind, isActive && 'is-active', compat && 'is-compat'),
5403
+ className: cls('dshic-port', 'k-' + port.kind, corner && 'is-corner', isActive && 'is-active', compat && 'is-compat'),
5336
5404
  'data-node-id': n.id,
5337
5405
  ...(dir === 'out' ? { 'data-port-out': port.id } : { 'data-port-in': port.id }),
5338
5406
  'data-kind': port.kind,
@@ -5499,14 +5567,19 @@ window.__ModuleLoader__.load({
5499
5567
  h('div', { className: 'dshic-rs dshic-rs-s', title: T('resizeHeightHint'), onPointerDown: (e) => startResize(e, 's'), onDoubleClick: resetHeight }),
5500
5568
  h('div', { className: 'dshic-rs dshic-rs-n', title: T('resizeHint'), onPointerDown: (e) => startResize(e, 'n') }),
5501
5569
  h('div', { className: 'dshic-rs dshic-rs-se', title: T('resizeHint'), onPointerDown: (e) => startResize(e, 'se') }),
5502
- ...(PORTS[n.type].inputs || []).map((p, i) =>
5503
- h('div', { key: 'in-' + p.id, style: { position: 'absolute', left: -5.5, top: 32.5 + i * 22 }, className: 'dshic-port-wrap' },
5504
- h(PortEl, { n, port: p, dir: 'in' }),
5505
- h('span', { className: 'dshic-port-label', style: { left: 14, top: 0 } }, portLabel(p.id)))),
5506
- ...(PORTS[n.type].outputs || []).map((p, i) =>
5507
- h('div', { key: 'out-' + p.id, style: { position: 'absolute', right: -5.5, top: 32.5 + i * 22 }, className: 'dshic-port-wrap' },
5508
- h(PortEl, { n, port: p, dir: 'out' }),
5509
- h('span', { className: 'dshic-port-label', style: { right: 14, top: 0 } }, portLabel(p.id)))),
5570
+ ...(PORTS[n.type].inputs || []).map((p, i) => {
5571
+ const isCorner = n.type === 'storyboard' && p.id === 'prev'; // 分镜承接:顶部右侧角点
5572
+ const stack = (n.type === 'storyboard' && i > 0) ? i - 1 : i;
5573
+ return h('div', { key: 'in-' + p.id, style: { position: 'absolute', left: isCorner ? undefined : -5.5, right: isCorner ? 48 : undefined, top: isCorner ? 0 : 32.5 + stack * 22 }, className: 'dshic-port-wrap' },
5574
+ h(PortEl, { n, port: p, dir: 'in', corner: isCorner }),
5575
+ h('span', { className: 'dshic-port-label', style: isCorner ? { right: -6, top: -22 } : { left: 14, top: 0 } }, portLabel(p.id)));
5576
+ }),
5577
+ ...(PORTS[n.type].outputs || []).map((p, i) => {
5578
+ const isCorner = n.type === 'storyboard' && p.id === 'tail'; // 分镜尾镜:底部右侧角点
5579
+ return h('div', { key: 'out-' + p.id, style: { position: 'absolute', right: isCorner ? 48 : -5.5, bottom: isCorner ? 0 : undefined, top: isCorner ? undefined : 32.5 + i * 22 }, className: 'dshic-port-wrap' },
5580
+ h(PortEl, { n, port: p, dir: 'out', corner: isCorner }),
5581
+ h('span', { className: 'dshic-port-label', style: isCorner ? { right: -6, top: 12 } : { right: 14, top: 0 } }, portLabel(p.id)));
5582
+ }),
5510
5583
  );
5511
5584
  }
5512
5585
 
@@ -6223,11 +6296,12 @@ window.__ModuleLoader__.load({
6223
6296
  }
6224
6297
 
6225
6298
  function TextNodeBody({ n }) {
6226
- return h('textarea', {
6227
- className: 'dshic-textarea', value: n.data.text || '',
6299
+ // 文本节点同样走高亮输入层:输入 @资产名@ 自动显示为蓝色加粗(两个 @ 隐藏),与资产库强绑定
6300
+ return h(PromptArea, {
6301
+ value: n.data.text || '',
6228
6302
  placeholder: T('textPlaceholder'),
6229
- onChange: (e) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.text = e.target.value; }),
6230
- onPointerDown: (e) => e.stopPropagation(),
6303
+ minHeight: 96,
6304
+ onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.text = v; }),
6231
6305
  });
6232
6306
  }
6233
6307
 
@@ -6409,10 +6483,12 @@ window.__ModuleLoader__.load({
6409
6483
  const listH = Math.max(80, Math.min(1200, Number(n.data.shotsListH) || 180));
6410
6484
  const startResize = (e) => {
6411
6485
  e.stopPropagation(); e.preventDefault();
6412
- const startY = e.clientY; const startH = listH;
6486
+ const startY = e.clientY; const startH = listH; const startNodeH = Number(n.h) || 0;
6413
6487
  const onMove = (ev) => {
6414
6488
  const nh = Math.max(80, Math.min(1200, startH + (ev.clientY - startY)));
6415
- update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.shotsListH = nh; });
6489
+ const dh = nh - startH;
6490
+ // 节点高度随列表一起向下生长(不是内部挤压上面内容)
6491
+ update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.shotsListH = nh; if (startNodeH > 0) nd.h = Math.max(120, startNodeH + dh); } });
6416
6492
  };
6417
6493
  const onUp = () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); flushSave(); };
6418
6494
  window.addEventListener('pointermove', onMove);
@@ -6481,7 +6557,7 @@ window.__ModuleLoader__.load({
6481
6557
  ? h('div', { key: 'listwrap', style: { position: 'relative', alignSelf: 'stretch' } },
6482
6558
  h('div', { key: 'list', className: 'dshic-shot-list', style: { maxHeight: listH + 'px' } },
6483
6559
  shots.map((sh, i) => h('div', { key: i, className: 'dshic-shot-item', onPointerDown: (e) => e.stopPropagation() },
6484
- h('strong', {}, `${sh.title} · ${sh.duration}s `), sh.visual,
6560
+ h('strong', {}, `${sh.title} · ${sh.duration}s `), ...mentionNodes(sh.visual),
6485
6561
  (sh.dialogue || []).length
6486
6562
  ? h('div', { className: 'dshic-shot-label', style: { marginTop: 3 } }, '台词 ' + sh.dialogue.map((d) => `${d.role}:${d.line}`).join(';'))
6487
6563
  : null,
@@ -6952,6 +7028,8 @@ window.__ModuleLoader__.load({
6952
7028
  : h('button', { type: 'button', className: 'dshic-btn', style: { fontWeight: 600, maxWidth: 180, overflow: 'hidden' }, onDoubleClick: () => setNaming(true), title: T('renameHint') },
6953
7029
  h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, proj ? proj.name : '')),
6954
7030
  h('div', { className: 'dshic-toolbar-sep' }),
7031
+ h(Btn, { onClick: () => update((s) => { s.supportOpen = true; }), title: T('supportBtn') }, Ic('help', 13), T('supportBtn')),
7032
+ h('div', { className: 'dshic-toolbar-sep' }),
6955
7033
  h('button', {
6956
7034
  type: 'button', className: cls('dshic-btn'), style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', gap: 4 },
6957
7035
  title: T('updateBtn') + ((S.updateInfo && S.updateInfo.runningVersion) ? '(当前 v' + S.updateInfo.runningVersion + ')' : ''), disabled: S.updateChecking,
@@ -7538,13 +7616,35 @@ window.__ModuleLoader__.load({
7538
7616
 
7539
7617
  /** 图片/视频模型多配置列表:名称 / 地址 / Key / 模型(/模式),可增删、设默认。 */
7540
7618
  function Field({ label, value, onChange, placeholder, password }) {
7619
+ // 敏感字段(API Key):默认脱敏,点击小眼睛切换明文显示
7620
+ const [reveal, setReveal] = useState(false);
7621
+ if (!password) {
7622
+ return h('div', { className: 'dshic-field' },
7623
+ h('span', { className: 'dshic-field-label' }, label),
7624
+ h('input', {
7625
+ className: 'dshic-input', type: 'text', value, placeholder: placeholder || '',
7626
+ onChange: (e) => onChange(e.target.value),
7627
+ onPointerDown: (e) => e.stopPropagation(),
7628
+ }));
7629
+ }
7541
7630
  return h('div', { className: 'dshic-field' },
7542
7631
  h('span', { className: 'dshic-field-label' }, label),
7543
- h('input', {
7544
- className: 'dshic-input', type: password ? 'password' : 'text', value, placeholder: placeholder || '',
7545
- onChange: (e) => onChange(e.target.value),
7546
- onPointerDown: (e) => e.stopPropagation(),
7547
- }));
7632
+ h('div', { style: { position: 'relative', display: 'flex', alignItems: 'center' } },
7633
+ h('input', {
7634
+ className: 'dshic-input', type: reveal ? 'text' : 'password', value, placeholder: placeholder || '',
7635
+ style: { paddingRight: 30 },
7636
+ onChange: (e) => onChange(e.target.value),
7637
+ onPointerDown: (e) => e.stopPropagation(),
7638
+ }),
7639
+ h('button', {
7640
+ type: 'button', className: 'dshic-iconbtn',
7641
+ title: reveal ? T('hideKey') : T('showKey'),
7642
+ 'aria-label': reveal ? T('hideKey') : T('showKey'),
7643
+ style: { position: 'absolute', right: 3, top: '50%', transform: 'translateY(-50%)', width: 22, height: 22 },
7644
+ onPointerDown: (e) => e.stopPropagation(),
7645
+ onClick: () => setReveal((v) => !v),
7646
+ }, reveal ? Ic('eyeOff', 12) : Ic('eye', 12)),
7647
+ ));
7548
7648
  }
7549
7649
  /** ⓘ 悬停说明:界面简洁化——长解释一律收进悬停提示(原生 title),不再平铺文字。 */
7550
7650
  function InfoTip(text) {
@@ -8136,6 +8236,17 @@ window.__ModuleLoader__.load({
8136
8236
  h(ModelProviderEditor, { key: 'imglist', kind: 'image' }),
8137
8237
  h(ModelProviderEditor, { key: 'vidlist', kind: 'video' }),
8138
8238
  h(ApiListEditor, { key: 'ttslist', kind: 'tts' }),
8239
+ // 帮助与反馈:一行入口(点开弹窗看二维码与提 Issue)
8240
+ h('div', {
8241
+ key: 'support', className: 'dshic-card', style: { cursor: 'pointer' },
8242
+ onClick: () => update((s) => { s.supportOpen = true; }),
8243
+ },
8244
+ h('div', { className: 'dshic-row' },
8245
+ Ic('help', 12),
8246
+ h('strong', { style: { fontSize: 12, flex: 1, minWidth: 0 } }, T('supportBtn')),
8247
+ h('span', { className: 'dshic-hint', style: { flex: 'none' } }, T('supportIssues') + ' / ' + T('supportWechat') + ' ›'),
8248
+ ),
8249
+ ),
8139
8250
  h('div', { key: 'note', className: 'dshic-hint' }, 'DeepSeek Harness · dsh-infinite-canvas' + ((S.updateInfo && S.updateInfo.runningVersion) ? ' v' + S.updateInfo.runningVersion : '')),
8140
8251
  ];
8141
8252
  }
@@ -8634,6 +8745,7 @@ window.__ModuleLoader__.load({
8634
8745
  ),
8635
8746
  S.menu ? h(ContextMenu, { menu: S.menu }) : null,
8636
8747
  S.lightbox ? h(Lightbox) : null,
8748
+ S.supportOpen ? h(SupportDialog) : null,
8637
8749
  S._toast ? h('div', {
8638
8750
  style: {
8639
8751
  position: 'fixed', bottom: 22, left: '50%', transform: 'translateX(-50%)', zIndex: 70,
@@ -8671,6 +8783,50 @@ window.__ModuleLoader__.load({
8671
8783
  );
8672
8784
  }
8673
8785
 
8786
+ // ============================================================
8787
+ // 帮助与反馈:GitHub 仓库/Issue + 微信交流群二维码(插件静态资源)
8788
+ // ============================================================
8789
+ const SUPPORT_GITHUB = 'https://github.com/NINKCH/dsh-infinite_canvas';
8790
+ const SUPPORT_ISSUES = 'https://github.com/NINKCH/dsh-infinite_canvas/issues/new';
8791
+ const SUPPORT_QR_URL = '/' + NS + '/api/plugin-asset?rel=' + encodeURIComponent('assets/support/wechat.png');
8792
+ function copyText(text, okMsg) {
8793
+ try {
8794
+ if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(() => toast(okMsg)).catch(() => toast(text)); return; }
8795
+ } catch { /* 降级 */ }
8796
+ toast(text);
8797
+ }
8798
+ function SupportDialog() {
8799
+ const [qrFailed, setQrFailed] = useState(false);
8800
+ const close = () => update((s) => { s.supportOpen = false; });
8801
+ useEffect(() => {
8802
+ const onKey = (e) => { if (e.key === 'Escape') close(); };
8803
+ window.addEventListener('keydown', onKey);
8804
+ return () => window.removeEventListener('keydown', onKey);
8805
+ }, []);
8806
+ return h('div', { className: 'dshic-modal-overlay', onPointerDown: (e) => { if (e.target === e.currentTarget) close(); } },
8807
+ h('div', { className: 'dshic-modal', style: { width: 300, alignItems: 'center', textAlign: 'center' } },
8808
+ h('div', { className: 'dshic-modal-title' }, T('supportTitle')),
8809
+ h('div', { className: 'dshic-modal-warn', style: { marginTop: 6 } }, T('supportNote')),
8810
+ h('div', { style: { marginTop: 10 } },
8811
+ qrFailed
8812
+ ? h('div', { className: 'dshic-hint' }, T('supportQrMissing'))
8813
+ : h('img', {
8814
+ src: SUPPORT_QR_URL, alt: 'WeChat group QR', draggable: false,
8815
+ style: { width: 180, height: 180, objectFit: 'contain', borderRadius: 8, background: '#fff', padding: 6 },
8816
+ onError: () => setQrFailed(true),
8817
+ })),
8818
+ h('div', { className: 'dshic-hint', style: { marginTop: 6 } }, T('supportWechatHint')),
8819
+ h('button', {
8820
+ type: 'button', className: 'dshic-mini is-ghost', style: { marginTop: 10 },
8821
+ onPointerDown: (e) => e.stopPropagation(),
8822
+ onClick: () => { try { window.open(SUPPORT_ISSUES, '_blank'); } catch { copyText(SUPPORT_ISSUES, T('supportCopied')); } },
8823
+ }, T('supportIssues') + ' ↗'),
8824
+ h('div', { className: 'dshic-modal-actions', style: { marginTop: 8, justifyContent: 'center' } },
8825
+ h('button', { type: 'button', className: 'dshic-mini', onPointerDown: (e) => e.stopPropagation(), onClick: close }, T('supportClose')),
8826
+ ),
8827
+ ));
8828
+ }
8829
+
8674
8830
  // ============================================================
8675
8831
  // 侧栏图标
8676
8832
  // ============================================================
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,37 @@ 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: 420, 'video-gen': 320, 'image-gen': 260, script: 300, finalcut: 300, text: 200 };
788
+ const estH = EST_H[type] || 220;
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
+ x = Math.min(...same.map((n) => n.x || 0));
800
+ y = Math.max(...same.map((n) => (n.y || 0) + H(n))) + GAP_Y;
801
+ }
802
+ }
803
+ const hit = (yy) => nodes.find((n) => (n.x || 0) < x + estW + MIN_GAP && (n.x || 0) + W(n) + MIN_GAP > x
804
+ && (n.y || 0) < yy + estH + MIN_GAP && (n.y || 0) + H(n) + MIN_GAP > yy);
805
+ let blocker = hit(y);
806
+ let guard = 0;
807
+ while (blocker && guard++ < 80) { y = (blocker.y || 0) + H(blocker) + MIN_GAP; blocker = hit(y); }
808
+ return { x, y };
757
809
  }
758
810
  // ---------- 内置技能(操作规程 + 创作方法论,约束并提升对话的画布创作)----------
759
811
  // canvas-studio=操作硬约束;canvas-craft=剧本/分镜/提示词创作方法论
@@ -1042,10 +1094,10 @@ function canvasCreateNode(args) {
1042
1094
  if (!['script', 'text', 'image-gen', 'video-gen', 'storyboard', 'finalcut'].includes(type)) throw new Error('不支持的节点类型: ' + type);
1043
1095
  const label = args && args.name ? String(args.name).slice(0, 40) : '';
1044
1096
  const prompt = String((args && args.prompt) || '');
1045
- const pos = nextNodePos(doc);
1097
+ const connectFrom = String((args && args.connectFrom) || '');
1098
+ const pos = nextNodePos(doc, type, connectFrom);
1046
1099
  let connected = null;
1047
1100
  let autoConnected = null;
1048
- const connectFrom = String((args && args.connectFrom) || '');
1049
1101
  let node;
1050
1102
  if (type === 'image-gen') {
1051
1103
  node = agentNode('text', '', pos.x, pos.y, label || '图片生成');
@@ -1099,11 +1151,14 @@ function canvasCreateNode(args) {
1099
1151
  const explicitConnect = Boolean(connectFrom);
1100
1152
  if (connectFrom) {
1101
1153
  if (!doc.nodes[connectFrom]) throw new Error('connectFrom 指向的节点不存在: ' + connectFrom);
1102
- const edge = connectEdge(doc, connectFrom, node.id, args && args.connectFromPort, args && args.connectToPort);
1154
+ // 分镜表默认连「剧本来源」口(script),而不是输入队列里的第一个口(prev)——场景文本是剧本来源,不是承接来源
1155
+ const srcType = doc.nodes[connectFrom] && doc.nodes[connectFrom].type;
1156
+ const defaultToPort = (type === 'storyboard' && (srcType === 'text' || srcType === 'script')) ? 'script' : undefined;
1157
+ const edge = connectEdge(doc, connectFrom, node.id, args && args.connectFromPort, (args && args.connectToPort) || defaultToPort);
1103
1158
  connected = edge.id;
1104
1159
  } else if (autoConnected && (type === 'storyboard' || type === 'video-gen')) {
1105
1160
  // 未指定连线但上游已存在(分镜表→剧本 / 视频生成→分镜表)→ 默认自动连线(工作流的默认形态,不依赖 Agent 记得)
1106
- const edge = connectEdge(doc, autoConnected.from, node.id);
1161
+ const edge = connectEdge(doc, autoConnected.from, node.id, undefined, type === 'storyboard' ? 'script' : undefined);
1107
1162
  connected = edge.id;
1108
1163
  }
1109
1164
  doc.updatedAt = Date.now();
@@ -1119,6 +1174,62 @@ function canvasCreateNode(args) {
1119
1174
  return { ok: true, projectId: id, nodeId: node.id, type: node.type, label: (node.data && node.data.label) || '', edgeId: connected || undefined, note: note || undefined };
1120
1175
  }
1121
1176
 
1177
+ /** 一键重排:按「行」布局——横向数据流决定列,竖向端口(prev/frame)不参与分列;
1178
+ * 每行 = 一个横向连通分量(如「场N文本 ↔ 场N分镜表」),行内同 y 横向配对,
1179
+ * 行高取行内最高节点、行间保留间距,绝不重叠。 */
1180
+ function canvasRelayout(args) {
1181
+ const id = String((args && args.projectId) || '');
1182
+ const doc = readDoc(id);
1183
+ const nodes = Object.values(doc.nodes || {});
1184
+ if (!nodes.length) return { ok: true, projectId: id, moved: 0, rows: 0 };
1185
+ const VERT = new Set(['prev', 'frame']);
1186
+ const byId = new Map(nodes.map((n) => [n.id, n]));
1187
+ const depth = new Map(nodes.map((n) => [n.id, 0]));
1188
+ for (let i = 0; i < 12; i++) {
1189
+ for (const e of (doc.edges || [])) {
1190
+ if (VERT.has(String(e.toPort))) continue;
1191
+ if (!byId.has(e.fromNode) || !byId.has(e.toNode)) continue;
1192
+ const d = (depth.get(e.fromNode) || 0) + 1;
1193
+ if ((depth.get(e.toNode) || 0) < d) depth.set(e.toNode, d);
1194
+ }
1195
+ }
1196
+ const parent = new Map(nodes.map((n) => [n.id, n.id]));
1197
+ const find = (x) => { let y = x; while (parent.get(y) !== y) y = parent.get(y); return y; };
1198
+ for (const e of (doc.edges || [])) {
1199
+ if (VERT.has(String(e.toPort))) continue;
1200
+ if (!byId.has(e.fromNode) || !byId.has(e.toNode)) continue;
1201
+ const a = find(e.fromNode); const b = find(e.toNode);
1202
+ if (a !== b) parent.set(b, a);
1203
+ }
1204
+ const rows = new Map();
1205
+ for (const n of nodes) {
1206
+ const r = find(n.id);
1207
+ if (!rows.has(r)) rows.set(r, []);
1208
+ rows.get(r).push(n);
1209
+ }
1210
+ const rowList = [...rows.values()]
1211
+ .map((ids) => ({ ids, minY: Math.min(...ids.map((n) => Number(n.y) || 0)) }))
1212
+ .sort((a, b) => a.minY - b.minY);
1213
+ const colW = new Map();
1214
+ for (const n of nodes) {
1215
+ const d = depth.get(n.id) || 0;
1216
+ colW.set(d, Math.max(colW.get(d) || 280, Number(n.w) || 280));
1217
+ }
1218
+ const colX = new Map();
1219
+ let cx = 80;
1220
+ for (const d of [...colW.keys()].sort((a, b) => a - b)) { colX.set(d, cx); cx += (colW.get(d) || 280) + 60; }
1221
+ let cy = 40;
1222
+ let moved = 0;
1223
+ for (const row of rowList) {
1224
+ const rowH = Math.max(200, ...row.ids.map((n) => Number(n.h) || 200));
1225
+ for (const n of row.ids) { n.x = colX.get(depth.get(n.id) || 0); n.y = cy; moved++; }
1226
+ cy += rowH + 40;
1227
+ }
1228
+ doc.updatedAt = Date.now();
1229
+ writeIcDoc(id, doc);
1230
+ markDocChanged(id);
1231
+ return { ok: true, projectId: id, moved, rows: rowList.length };
1232
+ }
1122
1233
  async function canvasDeleteProject(args) {
1123
1234
  const id = String((args && args.projectId) || '');
1124
1235
  const f = projectFile(id);
@@ -1198,10 +1309,14 @@ function storyboardConsistencyWarnings(bible, shots, extraNames) {
1198
1309
  for (const nm of (sh.characters || [])) check(title, '角色', nm);
1199
1310
  check(title, '场景', sh && sh.scene);
1200
1311
  for (const nm of (sh.props || [])) check(title, '道具', nm);
1201
- // visual/motion 文本里的 @提及 同样纳入检查(数组外的实体也要登记,杜绝检查漏)
1312
+ // visual/motion 文本里的 @资产名@ 提及同样纳入检查(未登记的提示补充——与客户端 parseMentions 词法一致)
1202
1313
  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(/[))]+$/, ''));
1314
+ const seenMention = new Set();
1315
+ for (const m of (text.match(/@([^@\s,。;:、,.;:!?!?()()[\]【】"'“”<>《》/\\|]{1,64}?)@/g) || [])) {
1316
+ const nm = m.replace(/^@/, '').replace(/@$/, '');
1317
+ if (seenMention.has(nm)) continue;
1318
+ seenMention.add(nm);
1319
+ check(title, '提及', nm);
1205
1320
  }
1206
1321
  }
1207
1322
  return warnings;
@@ -1302,8 +1417,15 @@ function connectEdge(doc, fromId, toId, fromPort, toPort) {
1302
1417
  if (fromId === toId) throw new Error('不能把节点连到自己');
1303
1418
  const outs = (PORT_TABLE[from.type] && PORT_TABLE[from.type].outputs) || [];
1304
1419
  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);
1420
+ const fp = String(fromPort || (outs[0] && outs[0].id) || '');
1421
+ // 目标端口缺省时按语义选择,而不是死板取第一个输入口:
1422
+ // 剧本/文本 → 分镜表 = 剧本来源(script);分镜表 → 分镜表 = 承接上一镜(prev);其余取第一个输入口
1423
+ let tp = String(toPort || '').trim();
1424
+ if (!tp) {
1425
+ if (to.type === 'storyboard' && (from.type === 'text' || from.type === 'script')) tp = 'script';
1426
+ else if (to.type === 'storyboard' && from.type === 'storyboard') tp = 'prev';
1427
+ else tp = String((ins[0] && ins[0].id) || '');
1428
+ }
1307
1429
  const outDef = outs.find((p) => p.id === fp);
1308
1430
  const inDef = ins.find((p) => p.id === tp);
1309
1431
  if (!outDef) throw new Error('源节点没有输出端口 ' + fp);
@@ -1924,6 +2046,22 @@ export function apply(ctx) {
1924
2046
  return { ok: true, projectId: r.projectId, nodeId: r.nodeId, text: '🗑 已删除节点 ' + r.nodeId + '。' };
1925
2047
  },
1926
2048
  });
2049
+ reg({
2050
+ name: 'canvas_relayout',
2051
+ description: '一键重排画布节点布局(消除重叠):按「行」排列——横向数据流决定列(文本→分镜表→视频→资产),竖向关系(衔接 prev / 首帧 frame)不参与分列;每行 = 一个横向连通组(如「场N文本 ↔ 场N分镜表」同行对齐),行高取行内最高节点、行间保留间距。节点重叠、或批量建点后布局混乱时调用。',
2052
+ parameters: {
2053
+ type: 'object',
2054
+ properties: { projectId: { type: 'string', description: '项目 id' } },
2055
+ required: ['projectId'],
2056
+ additionalProperties: false,
2057
+ },
2058
+ output: textOutput,
2059
+ timeoutMs: 30000,
2060
+ async execute(args) {
2061
+ const r = canvasRelayout(args);
2062
+ return { ok: true, projectId: r.projectId, text: '✅ 已重排 ' + r.moved + ' 个节点(' + r.rows + ' 行,无重叠)。' };
2063
+ },
2064
+ });
1927
2065
  reg({
1928
2066
  name: 'canvas_delete_project',
1929
2067
  description: '删除无限画布上的一个项目(含其全部节点)。用户明确要求删除项目、或清理误创建的空项目时使用;删除前请先用 canvas_list_projects 确认 id。',
@@ -2249,6 +2387,32 @@ export function apply(ctx) {
2249
2387
  }
2250
2388
  },
2251
2389
  }),
2390
+ // 插件自带静态资源(帮助与反馈用的微信群二维码等):白名单 assets/ 目录,拒绝路径穿越
2391
+ ctx.webServer.register({
2392
+ kind: 'exact',
2393
+ path: `/${NAME}/api/plugin-asset`,
2394
+ async handler(req, res) {
2395
+ try {
2396
+ if (req.method !== 'GET') { res.statusCode = 405; res.end('仅接受 GET'); return; }
2397
+ const u = new URL(req.url, 'http://localhost');
2398
+ const rel = String(u.searchParams.get('rel') || '').replace(/\\/g, '/').replace(/^\/+/, '');
2399
+ if (rel.includes('..') || !/^assets\/[\w.\u4e00-\u9fa5/-]+$/.test(rel)) { res.statusCode = 400; res.end('非法路径'); return; }
2400
+ const assetsDir = path.join(HERE, 'assets');
2401
+ const abs = path.resolve(path.join(HERE, rel));
2402
+ if (!abs.startsWith(assetsDir)) { res.statusCode = 400; res.end('非法路径'); return; }
2403
+ let st;
2404
+ try { st = statSync(abs); } catch { res.statusCode = 404; res.end('not found'); return; }
2405
+ if (!st.isFile()) { res.statusCode = 404; res.end('not found'); return; }
2406
+ res.setHeader('content-type', mimeOf(abs));
2407
+ res.setHeader('cache-control', 'no-cache');
2408
+ res.setHeader('content-length', String(st.size));
2409
+ pipeFile(abs, {}, res);
2410
+ } catch (e) {
2411
+ res.statusCode = 500;
2412
+ res.end(String((e && e.message) || e));
2413
+ }
2414
+ },
2415
+ }),
2252
2416
  ];
2253
2417
  return () => {
2254
2418
  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.14",
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
+ }