@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.
- package/assets/canvas-studio/SKILL.md +6 -1
- package/assets/support/README.md +21 -0
- package/assets/support/wechat.png +0 -0
- package/client.js +193 -37
- package/index.js +183 -19
- package/package.json +56 -55
|
@@ -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
|
-
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
1452
1483
|
.replace(/"/g, '"').replace(/'/g, ''');
|
|
1453
1484
|
}
|
|
1454
|
-
/** 把提示词转成高亮 HTML
|
|
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[
|
|
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 @
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
5504
|
-
|
|
5505
|
-
|
|
5506
|
-
|
|
5507
|
-
|
|
5508
|
-
|
|
5509
|
-
|
|
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
|
-
|
|
6227
|
-
|
|
6299
|
+
// 文本节点同样走高亮输入层:输入 @资产名@ 自动显示为蓝色加粗(两个 @ 隐藏),与资产库强绑定
|
|
6300
|
+
return h(PromptArea, {
|
|
6301
|
+
value: n.data.text || '',
|
|
6228
6302
|
placeholder: T('textPlaceholder'),
|
|
6229
|
-
|
|
6230
|
-
|
|
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
|
-
|
|
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('
|
|
7544
|
-
|
|
7545
|
-
|
|
7546
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1204
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
}
|