@ninkch/dsh-infinite-canvas 0.17.17 → 0.17.18
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-craft/SKILL.md +14 -15
- package/assets/canvas-layout/SKILL.md +61 -0
- package/assets/canvas-studio/SKILL.md +19 -16
- package/client.js +288 -59
- package/index.js +14 -14
- package/package.json +1 -1
|
@@ -38,21 +38,20 @@ B:……
|
|
|
38
38
|
分区呈现,一图读懂资产的全部视觉设定。所有面板文字一律**简体中文、清晰可读**;
|
|
39
39
|
同一资产在所有分格中外观**完全一致**(发型/服装/配色逐格复用设定集关键词)。
|
|
40
40
|
|
|
41
|
-
### 角色设定图(16:9
|
|
42
|
-
-
|
|
43
|
-
|
|
44
|
-
-
|
|
45
|
-
-
|
|
46
|
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
|
|
50
|
-
-
|
|
51
|
-
|
|
52
|
-
- 提示词结构示例:「角色人设图(16:9
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
——面板名与标注文字全部写进提示词,模型才能排出完整版式。
|
|
41
|
+
### 角色设定图(16:9 横版——主图与三视图占大版面)
|
|
42
|
+
- **版面主体(约三分之二)给画面**:中央大幅全身立绘主图(正面站姿),主图下方一排
|
|
43
|
+
**三视图(正面 / 侧面 / 背面 全身)**——三视图是必须项,后续视频要靠它保证侧背面的准确形态。
|
|
44
|
+
- **右侧窄栏做辅助信息(压缩排布)**:
|
|
45
|
+
- 信息条:姓名 / 性别 / 年龄 / 身份,3–4 行小字;
|
|
46
|
+
- 脸部特征:一格正面特写(或正/侧两小格)+ 一两行五官标注;
|
|
47
|
+
- 服饰特征:2–3 行服装要点;
|
|
48
|
+
- 表情条:一行 4–6 个小头像,逐个标注表情名(按角色性格取舍,如 温柔 / 惊讶 / 坚定)。
|
|
49
|
+
- **细节格(可选)**:2–4 个小格——发饰 / 佩饰 / 纹样特写,各配一行小字。
|
|
50
|
+
- 所有分格中同一角色外观完全一致(发型 / 服装 / 配色逐格复用设定集关键词);
|
|
51
|
+
面板文字一律简体中文清晰可读。
|
|
52
|
+
- 提示词结构示例:「角色人设图(16:9 横版设定集排版):中央大幅全身立绘,主图下方三视图一排
|
|
53
|
+
(正面/侧面/背面全身);右侧窄栏:姓名性别年龄小字信息、一格脸部特写、一行表情小头像;
|
|
54
|
+
面板文字全部写进提示词,模型才能排出完整版式。」
|
|
56
55
|
|
|
57
56
|
### 道具设定图(横竖皆可——按道具特性选,不限制)
|
|
58
57
|
- **画幅选择**:长条形/单件道具(剑、箫、杖)竖版更能展示完整形态;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: canvas-layout
|
|
3
|
+
description: 画布节点布局纪律:一类型一列、竖向排列、绝不重叠、批量建点后一键重排。在建任何节点之前加载本技能;节点一多,布局就是可操作性本身。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 画布布局纪律(一类型一列 · 绝不重叠)
|
|
7
|
+
|
|
8
|
+
节点一多,画布就难操作(画布不能无限缩小)。**布局不是审美问题,是可操作性问题**——
|
|
9
|
+
200 个节点如果类型交叉、互相重叠,就只能一块一块挪,无法整体把握。
|
|
10
|
+
本技能是「建节点」之前的硬约束,与 `canvas-studio`(操作流程)、`canvas-craft`(创作方法论)配合使用。
|
|
11
|
+
|
|
12
|
+
## 一、铁律
|
|
13
|
+
|
|
14
|
+
1. **一类型一列**:同类型节点永远排在**同一竖列**里、从上往下顺次排列。
|
|
15
|
+
列的固定顺序(从左到右):
|
|
16
|
+
**剧本 → 场景文本 → 分镜表 → 资产图(角色/场景/道具)→ 视频 → 成片**。
|
|
17
|
+
绝不把同类型节点横向散开,也绝不与其他类型混列。
|
|
18
|
+
2. **连线不改列**:连线只表达数据流(剧本→分镜、分镜→视频、资产→参考图)。
|
|
19
|
+
分镜表不会因为连了"某场文本"就移动到那一列旁边——它永远待在「分镜表」列里。
|
|
20
|
+
3. **绝不重叠**:任意两个节点框不得相交,最小间距 24px。
|
|
21
|
+
插件已内建自动挤开(节点变高、被拖动、被拉大时,相交的节点会被自动推到其下方),
|
|
22
|
+
**不要手动去挪别人的节点**——你只要保证"建点顺序 = 列内顺序"即可。
|
|
23
|
+
4. **建点顺序就是叙事顺序**:同类型节点按 场次/序号 顺序创建,
|
|
24
|
+
后建的自动追加在列尾,列的竖向顺序天然等于叙事顺序。
|
|
25
|
+
**不要回填到列中间**(会造成列内乱序,后续只能靠 `canvas_relayout` 修正)。
|
|
26
|
+
5. **命名带序**:同类节点名字里带序号(`第1集-场1文本` / `第1集-场1分镜表` / `角色-林晚 设定图`),
|
|
27
|
+
便于在长列里定位。
|
|
28
|
+
6. **类型多时子列**:资产图较多(角色/场景/道具混在一列会很长)时,可按
|
|
29
|
+
「角色图列 / 场景图列 / 道具图列」再分列——但每个子列内部仍遵守竖向排列。
|
|
30
|
+
|
|
31
|
+
## 二、标准建点节奏(保持列干净的写法)
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
1) 剧本节点 (1 个,列 1)
|
|
35
|
+
2) 场景文本 × N 按场次顺序建(列 2,竖向 = 场1…场N)
|
|
36
|
+
3) 分镜表 × N 按场次顺序建(列 3;用 connectFrom 连到对应场景文本,但列归属不变)
|
|
37
|
+
4) 资产图 × M 角色 → 场景 → 道具 顺序建(列 4)
|
|
38
|
+
5) 视频节点 × N 按场次顺序建(列 5)
|
|
39
|
+
6) 成片节点 (列 6,通常 1 个)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
- 每建完一批同类节点,列尾自然延伸,**不要中途跳去建另一类型的节点再回来**。
|
|
43
|
+
- 需要给已建节点补连线时,用 `canvas_connect` ——**连线不会移动节点**,放心连。
|
|
44
|
+
|
|
45
|
+
## 三、布局乱了怎么办(不要手工挪)
|
|
46
|
+
|
|
47
|
+
- **一键重排**:`canvas_relayout({ projectId })` —— 按行重排:
|
|
48
|
+
横向数据流决定列(文本→分镜表→视频),竖向关系(承接 `prev` / 首帧 `frame`)留在同列,
|
|
49
|
+
同行 = 一个横向连通组(「场N文本 ↔ 场N分镜表」对齐),行高取最高节点、行间保留间距、绝不重叠。
|
|
50
|
+
- 重排是**幂等**的:可以反复调用,不会破坏连线与内容。
|
|
51
|
+
- 批量创建(一次建 5 个以上节点)之后,**紧接着调用一次 `canvas_relayout`**,
|
|
52
|
+
把新节点规整到列里,再继续后续步骤。
|
|
53
|
+
|
|
54
|
+
## 四、自检(每次建完一批节点后)
|
|
55
|
+
|
|
56
|
+
- [ ] 同类型节点是否全在同一竖列?竖向顺序是否等于叙事顺序?
|
|
57
|
+
- [ ] 有没有任意两个节点框相交(含 24px 间距)?
|
|
58
|
+
- [ ] 有没有类型交叉(例如某个分镜表跑到资产图列里)?
|
|
59
|
+
- [ ] 列数是否等于当前用到的类型数(而非更多)?
|
|
60
|
+
|
|
61
|
+
任何一项不满足 → 直接 `canvas_relayout({ projectId })`,不要手工一个个挪。
|
|
@@ -36,7 +36,7 @@ description: 用无限画布(Infinite Canvas)做短剧/视频创作时使用
|
|
|
36
36
|
(`canvas_delete_node` / `canvas_delete_project`)。
|
|
37
37
|
7. **完成后让用户看到结果**:调用 `canvas_open({ projectId })`,画布会在数秒内自动跳转。
|
|
38
38
|
8. **一致性告警必须闭环。** 分镜节点生成/更新后,`canvas_node_status` 会返回 `warnings`——
|
|
39
|
-
分镜中出现但**未在设定集登记**或**已登记但描述为空**的实体清单(含画面/动作文本里的
|
|
39
|
+
分镜中出现但**未在设定集登记**或**已登记但描述为空**的实体清单(含画面/动作文本里的 `@资产名@` 提及)。
|
|
40
40
|
只要 `warnings` 非空:**逐条** `canvas_update_bible` 补全(名称与告警**逐字一致**、描述写实可生成),
|
|
41
41
|
再生成参考图;**绝不忽略、绝不跳过、绝不只补一部分**。设定集更新后告警会自动重算
|
|
42
42
|
(几秒内随文档同步刷新),以最新一次 `canvas_node_status` 为准。
|
|
@@ -109,11 +109,11 @@ description: 用无限画布(Infinite Canvas)做短剧/视频创作时使用
|
|
|
109
109
|
- `canvas_create_node({ projectId, type: 'video-gen', name: '第X节-场Y视频', connectFrom: 分镜表节点id, duration: 各镜时长之和 })`
|
|
110
110
|
(不传 connectFrom 且项目里有已拆解镜头的分镜表时会自动连线;prompt 留空);
|
|
111
111
|
- **【禁止逐镜建节点】**一段 10–30 秒的场景视频**不需要逐镜的图片节点或"镜头图→镜头视频"链路**:
|
|
112
|
-
|
|
112
|
+
多镜头一致性靠角色/场景/道具**参考图资产**(`@资产名@` 引用自动挂载)保证,视频模型在一次调用里完成全部镜头。
|
|
113
113
|
最多为**开场首帧或 1–2 个关键镜头**补定帧图(接同一视频节点的 frame/ref 端口)。
|
|
114
114
|
一旦发现自己在批量创建"第X节-场Y-镜Z"这类镜头图片/视频节点 = 走错路,**立即停止**并回到本条。
|
|
115
115
|
- 参考图:把角色/场景/道具参考图资产连到视频生成节点的 `ref` 端口(`canvas_connect`);
|
|
116
|
-
提示词里的
|
|
116
|
+
提示词里的 `@资产名@` 命中的资产也会自动挂上
|
|
117
117
|
- **【跨段连贯:分镜承接 + 尾帧 → 首帧】**连贯分两层,先文本后画面:
|
|
118
118
|
**①分镜承接(文本层,先做)**:上一分镜表 `tail` → 本分镜表 `prev`(见硬约束 13 / 工作流第 3 步),
|
|
119
119
|
让分镜第 1 镜从上一场尾镜的最后一个动作继续、重叠 ≤0.2s、背景声连续;
|
|
@@ -132,7 +132,7 @@ description: 用无限画布(Infinite Canvas)做短剧/视频创作时使用
|
|
|
132
132
|
要让同一角色**跨视频保持音色**:用视频/成片节点的「提取声音参考」按钮——
|
|
133
133
|
**输入角色名即可**:系统自动按分镜 `dialogue` 台词标注定位该角色的全部时间窗,
|
|
134
134
|
逐窗取音轨**拼接为一个声音参考文件**(`角色-<名> 声音`,自动绑定实体),
|
|
135
|
-
之后该角色的视频提示词里
|
|
135
|
+
之后该角色的视频提示词里 `@<名称>@`(双 @ 包裹)会自动把这条声音参考放进 `audios[]`,
|
|
136
136
|
模型生成的对白将向参考音色对齐(携带声音参考时会自动强制开启同步音频)。
|
|
137
137
|
时间窗归属靠分镜的 `dialogue` 标注(对白逐字来自剧本、按镜头分配,一镜尽量单说话人);
|
|
138
138
|
分镜缺少台词标注时按钮会降级为手动模式并提示重新生成分镜。
|
|
@@ -141,9 +141,9 @@ description: 用无限画布(Infinite Canvas)做短剧/视频创作时使用
|
|
|
141
141
|
(命名规范「角色-<名> 设定图」/「场景-<名> 设定图」/「道具-<名> 设定图」),
|
|
142
142
|
**prompt 必须按 `canvas-craft`「资产设定图版式」一节的排版模板写详细版式提示词,
|
|
143
143
|
禁止只写一句三视图/外观关键词**——信息量不足,撑不起后续视频的角色一致性。要点(完整模板见 canvas-craft):
|
|
144
|
-
- **角色设定图**(16:9
|
|
145
|
-
|
|
146
|
-
|
|
144
|
+
- **角色设定图**(16:9 横版):**主图 + 三视图一排(正/侧/背全身,必须项)占大版面(约 2/3)**;
|
|
145
|
+
右窄栏压缩辅助信息(信息条 3–4 行小字 / 脸部特写一格 / 服饰要点 2–3 行 / 表情条一行 4–6 个小头像);
|
|
146
|
+
细节格可选;所有分格中同一角色外观完全一致
|
|
147
147
|
- **道具设定图**(横竖皆可,按道具特性选):顶部道具名大标题+类别副标题;中央主图(斜 45° 悬浮+氛围光);
|
|
148
148
|
材质/部件细节特写带名称小字;侧栏侧视图;底部圆形色卡 3–5 枚带颜色名
|
|
149
149
|
- **场景设定图**(主图+环绕小图):一张主场景大图占主体,周边环绕补充小图(多角度 / 其他时间段 / 细节格,3–6 张各配标注)——大致参考不强约束
|
|
@@ -158,38 +158,41 @@ description: 用无限画布(Infinite Canvas)做短剧/视频创作时使用
|
|
|
158
158
|
|
|
159
159
|
## 三、@引用与资产命名(跨集一致性的核心手法)
|
|
160
160
|
|
|
161
|
-
**写图片/视频提示词时必须用
|
|
161
|
+
**写图片/视频提示词时必须用 `@资产名@` 引用设定集条目(前后各一个 @ 精确包裹,缺一不可)**,
|
|
162
|
+
不要只写名字,更不要写单个 @:
|
|
162
163
|
|
|
163
164
|
```
|
|
164
|
-
|
|
165
|
+
中近景,@张山@ 站在 @褶皱巷@ 的雨里,手里攥着 @火漆信@,暖黄门灯,写实电影感
|
|
165
166
|
```
|
|
166
167
|
|
|
167
168
|
插件会自动完成(用户也能在节点里看到"@引用命中"):
|
|
168
169
|
|
|
169
|
-
-
|
|
170
|
+
- `@张山@` → 取设定集里「张山」的外观描述,并把它名下**所有参考图资产**按顺序追加为 `[图N]`
|
|
170
171
|
- 连线参考图排在最前(`[图1..n]`,顺序可在节点里上下调整),`@` 命中的资产按**实体出现顺序**接在后面
|
|
171
|
-
-
|
|
172
|
-
|
|
172
|
+
- `@资产名@` 在输入框里会**蓝色显示、两个 @ 自动隐藏**(人类可读),送给模型前会自动转成带 `[图N]`
|
|
173
|
+
与设定描述的标准提示词
|
|
174
|
+
- **句末引用也必须闭合**:正确「……举起 @火漆信@。」;错误「……举起 @火漆信。」——漏写闭合 @ 的引用
|
|
175
|
+
不会生效(生成时会按设定集名称自动补全修复,但不要依赖)
|
|
176
|
+
- 未命中设定集的名字不会报错,但会失去一致性保障——所以先补设定集再加 `@资产名@`
|
|
173
177
|
|
|
174
178
|
**资产命名规范(必须遵守,否则跨集引用会断)**:
|
|
175
179
|
|
|
176
180
|
| 用途 | 命名 | 例子 |
|
|
177
181
|
| --- | --- | --- |
|
|
178
|
-
|
|
|
182
|
+
| 角色参考图/设定图 | `角色-<名称> <说明>` | `角色-张山 设定图`、`角色-张山 参考图` |
|
|
179
183
|
| 场景参考图 | `场景-<名称>` | `场景-褶皱巷` |
|
|
180
184
|
| 道具参考图 | `道具-<名称>` | `道具-火漆信` |
|
|
181
185
|
| 分镜表整段视频 | `<集数>-<场次> 多镜头视频` | `第1集-场3 多镜头视频` |
|
|
182
186
|
|
|
183
187
|
生成角色/场景/道具参考图时,**用 `canvas_create_node` 的 `name` 直接写这个规范名**;
|
|
184
|
-
插件会把资产记录绑定到对应实体(`entity` 字段),之后任何
|
|
188
|
+
插件会把资产记录绑定到对应实体(`entity` 字段),之后任何 `@资产名@` 都能精确取到它。
|
|
185
189
|
|
|
186
190
|
**每集/后续工作的一致性纪律**:
|
|
187
191
|
|
|
188
192
|
1. 每集开工前先 `canvas_get_bible`,**只沿用已有名称**;新元素先 `canvas_update_bible` 补卡再使用。
|
|
189
193
|
2. **绝不改名**:一个角色的名字一旦写进设定集,后续所有集数、所有提示词都用同一个名字。
|
|
190
|
-
3.
|
|
194
|
+
3. 已生成的参考图资产**持续复用**:`@资产名@` 会自动带上它们,不需要重复生成。
|
|
191
195
|
4. 若某实体还没有参考图,先为它建一个 `image-gen` 节点生成(用规范命名),再进入镜头生成。
|
|
192
|
-
|
|
193
196
|
## 四、节点类型与字段
|
|
194
197
|
|
|
195
198
|
| 类型 | 用途 | 主要字段 |
|
package/client.js
CHANGED
|
@@ -1033,10 +1033,11 @@ window.__ModuleLoader__.load({
|
|
|
1033
1033
|
.dshic-rs-n{top:-3px;left:14px;right:14px;height:7px;cursor:ns-resize;}
|
|
1034
1034
|
.dshic-rs-se{right:-4px;bottom:-4px;width:16px;height:16px;cursor:nwse-resize;}
|
|
1035
1035
|
.dshic-node-body{padding:8px;display:flex;flex-direction:column;gap:6px;font-size:12px;flex:1;min-height:0;overflow:auto;}
|
|
1036
|
-
/*
|
|
1036
|
+
/* 手动拉过高高度的节点:主体内的文本框随节点一起拉伸(拖高=对话框变高,而不是只撑外框);
|
|
1037
|
+
文本框不允许被 flex 压扁(分镜列表/流式变高时宁可下方截断,输入框保持自身可读高度) */
|
|
1037
1038
|
.dshic-node.h-manual .dshic-node-body{overflow:hidden;}
|
|
1038
1039
|
.dshic-node.h-manual .dshic-node-body > .dshic-textarea{flex:1 1 auto;min-height:0;}
|
|
1039
|
-
.dshic-node.h-manual .dshic-node-body > .dshic-prompt{flex:1 1 auto;display:flex;flex-direction:column;}
|
|
1040
|
+
.dshic-node.h-manual .dshic-node-body > .dshic-prompt{flex:1 1 auto;display:flex;flex-direction:column;flex-shrink:0;}
|
|
1040
1041
|
.dshic-node.h-manual .dshic-node-body > .dshic-prompt > .dshic-prompt-input{flex:1 1 auto;min-height:0;}
|
|
1041
1042
|
.dshic-textarea{width:100%;box-sizing:border-box;min-height:56px;resize:vertical;border:1px solid var(--dsw-alias-border-l2);
|
|
1042
1043
|
border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;
|
|
@@ -1180,16 +1181,21 @@ window.__ModuleLoader__.load({
|
|
|
1180
1181
|
display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;}
|
|
1181
1182
|
.dshic-textcard-sub{font-size:10px;line-height:14px;color:var(--dsw-alias-label-secondary);}
|
|
1182
1183
|
/* @引用高亮输入框:字号/行高/内距与 .dshic-textarea 完全一致(背景层与透明输入框必须逐像素对齐) */
|
|
1183
|
-
|
|
1184
|
+
/* overflow+圆角:背层是绝对定位,父级高度由 textarea 撑开时 bottom/inset 不一定能把背层锁死,
|
|
1185
|
+
多出来的行会画出边框。裁切必须做在由输入框撑高的这一层上,框内保留、框外遮住(含圆角)。 */
|
|
1186
|
+
.dshic-prompt{position:relative;display:block;width:100%;overflow:hidden;border-radius:8px;}
|
|
1184
1187
|
.dshic-prompt-back,.dshic-prompt-input{font:inherit;font-size:12px;line-height:18px;padding:7px 9px;box-sizing:border-box;
|
|
1185
1188
|
white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;}
|
|
1186
|
-
.dshic-prompt-back{position:absolute;
|
|
1187
|
-
border:1px solid transparent;border-radius:
|
|
1189
|
+
.dshic-prompt-back{position:absolute;top:0;left:0;right:0;height:100%;overflow:hidden;pointer-events:none;color:var(--dsw-alias-label-primary);
|
|
1190
|
+
border:1px solid transparent;border-radius:8px;}
|
|
1188
1191
|
.dshic-prompt-ph{color:var(--dsw-alias-label-tertiary);}
|
|
1189
1192
|
.dshic-prompt-input{position:relative;background:transparent;color:transparent;caret-color:var(--dsw-alias-label-primary);
|
|
1190
|
-
resize:vertical;}
|
|
1193
|
+
resize:vertical;overflow-y:auto;scrollbar-width:none;}
|
|
1194
|
+
/* 隐藏输入框滚动条:滚动条占位会让透明输入框与背层的换行点不一致(错位的另一来源);滚轮滚动不受影响 */
|
|
1195
|
+
.dshic-prompt-input::-webkit-scrollbar{width:0;height:0;}
|
|
1191
1196
|
.dshic-prompt-input::placeholder{color:transparent;}
|
|
1192
|
-
.dshic-mention{color:#2f7cf6;
|
|
1197
|
+
.dshic-mention{color:#2f7cf6;}
|
|
1198
|
+
/* 注意:@名称 高亮不能加粗——加粗字形更宽,背层与透明输入框宽度不一致会导致逐字错位 */
|
|
1193
1199
|
/* 生成中的一行思考内容:单行、省略号,让用户看到"在动"而不是卡住 */
|
|
1194
1200
|
.dshic-stream{display:flex;align-items:center;gap:5px;font-size:10px;line-height:14px;color:var(--dsw-alias-label-secondary);
|
|
1195
1201
|
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding:0 2px;font-variant-numeric:tabular-nums;}
|
|
@@ -1262,6 +1268,29 @@ window.__ModuleLoader__.load({
|
|
|
1262
1268
|
.dshic-zoom-val{min-width:52px;text-align:center;font-size:12px;color:var(--dsw-alias-label-secondary);
|
|
1263
1269
|
font-variant-numeric:tabular-nums;cursor:pointer;background:transparent;border:none;height:26px;font:inherit;font-size:12px;}
|
|
1264
1270
|
.dshic-zoom-val:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 8%,transparent);color:var(--dsw-alias-label-primary);}
|
|
1271
|
+
|
|
1272
|
+
/* ── 视觉精修层(统一质感;追加在基础规则之后生效)──────────────── */
|
|
1273
|
+
.dshic-node{border-radius:14px;box-shadow:0 1px 2px rgba(0,0,0,.10),0 6px 18px rgba(0,0,0,.10);}
|
|
1274
|
+
.dshic-node:hover{box-shadow:0 2px 4px rgba(0,0,0,.10),0 10px 26px rgba(0,0,0,.14);}
|
|
1275
|
+
.dshic-node.is-selected{box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-brand-primary) 38%,transparent),0 10px 26px rgba(0,0,0,.14);}
|
|
1276
|
+
.dshic-node.st-running{box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-brand-primary) 45%,transparent),0 8px 22px rgba(0,0,0,.12);}
|
|
1277
|
+
.dshic-node-head{height:32px;padding:0 10px;}
|
|
1278
|
+
.dshic-node-head .dshic-node-label,.dshic-node-head strong{font-weight:600;letter-spacing:.2px;}
|
|
1279
|
+
.dshic-node-body{padding:10px;display:flex;flex-direction:column;gap:8px;}
|
|
1280
|
+
.dshic-gen{height:32px;font-weight:600;letter-spacing:.3px;box-shadow:0 1px 3px rgba(0,0,0,.18);}
|
|
1281
|
+
.dshic-gen:hover:not(:disabled){box-shadow:0 2px 6px rgba(0,0,0,.22);}
|
|
1282
|
+
.dshic-textarea:focus,.dshic-input:focus,.dshic-prompt-input:focus{border-color:var(--dsw-alias-brand-primary);
|
|
1283
|
+
box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,transparent);}
|
|
1284
|
+
/* 高亮框自己 overflow:hidden,子元素的光圈会被裁掉;光圈画在外层,外层自己的阴影不受自身 overflow 影响 */
|
|
1285
|
+
.dshic-prompt:focus-within{box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,transparent);}
|
|
1286
|
+
.dshic-prompt:focus-within .dshic-prompt-input{box-shadow:none;}
|
|
1287
|
+
.dshic-shot-label{font-size:10px;letter-spacing:.4px;color:var(--dsw-alias-label-secondary);}
|
|
1288
|
+
.dshic-hint{line-height:15px;}
|
|
1289
|
+
.dshic-card{border-radius:12px;box-shadow:0 1px 2px rgba(0,0,0,.06);}
|
|
1290
|
+
.dshic-mini{border-radius:7px;}
|
|
1291
|
+
.dshic-toolbar{backdrop-filter:blur(6px);}
|
|
1292
|
+
.dshic-node .dshic-textarea,.dshic-node .dshic-input{border-radius:8px;}
|
|
1293
|
+
.dshic-preview{border-radius:10px;}
|
|
1265
1294
|
`;
|
|
1266
1295
|
|
|
1267
1296
|
// ============================================================
|
|
@@ -1599,7 +1628,36 @@ window.__ModuleLoader__.load({
|
|
|
1599
1628
|
if (entities && entities.length) return entityLabelOf(entities[0].kind) + '-' + entities[0].name + ' ' + nowStamp();
|
|
1600
1629
|
return (fallbackKind === 'video' ? '视频' : '图片') + '-' + nowStamp();
|
|
1601
1630
|
}
|
|
1602
|
-
|
|
1631
|
+
/** 修复未闭合的 @引用:模型在句尾常丢闭合 @(写作 @糯糯。 而非 @糯糯@。)——
|
|
1632
|
+
* 未闭合引用不会被词法识别,参考图会静默丢失。这里按已知资产名自动补全并归一为完整名;
|
|
1633
|
+
* 无法对上已知名的裸 @ 原样保留(不误伤普通文本)。 */
|
|
1634
|
+
function repairUnclosedMentions(text, knownNames) {
|
|
1635
|
+
const t = String(text || '');
|
|
1636
|
+
const names = (knownNames || []).filter((x) => x && String(x).trim()).map((x) => String(x).trim());
|
|
1637
|
+
if (!t || !names.length) return t;
|
|
1638
|
+
const stop = /[\s@,。;:、!?…()【】\[\]"'“”<>《》/\\|]/;
|
|
1639
|
+
let out = '';
|
|
1640
|
+
let i = 0;
|
|
1641
|
+
while (i < t.length) {
|
|
1642
|
+
if (t[i] !== '@') { out += t[i]; i++; continue; }
|
|
1643
|
+
let j = i + 1;
|
|
1644
|
+
while (j < t.length && !stop.test(t[j])) j++;
|
|
1645
|
+
const name = t.slice(i + 1, j);
|
|
1646
|
+
if (!name) { out += t[i]; i++; continue; }
|
|
1647
|
+
if (t[j] === '@') { out += t.slice(i, j + 1); i = j + 1; continue; } // 已闭合:原样
|
|
1648
|
+
const hit = names.find((k) => k === name || name.startsWith(k) || k.startsWith(name));
|
|
1649
|
+
if (hit) { out += '@' + hit + '@'; i = j; continue; } // 补闭合 + 归一为完整名
|
|
1650
|
+
out += t.slice(i, j + 1); i = j + 1; // 未命中:原样保留(含终止符)
|
|
1651
|
+
}
|
|
1652
|
+
return out;
|
|
1653
|
+
}
|
|
1654
|
+
/** 设定集里的全部资产名(角色/场景/道具),供未闭合引用修复与一致性检查使用。 */
|
|
1655
|
+
function bibleNames(bible) {
|
|
1656
|
+
const b = bible || {};
|
|
1657
|
+
return [].concat(b.characters || [], b.scenes || [], b.props || [])
|
|
1658
|
+
.map((x) => (x && x.name ? String(x.name).trim() : '')).filter(Boolean);
|
|
1659
|
+
}
|
|
1660
|
+
// <<<MENTION_PURE_END>>>
|
|
1603
1661
|
|
|
1604
1662
|
/** 解析提示词里的 @引用:返回命中实体 + 其名下的参考图资产与**声音参考资产**(按实体出现顺序、各实体内按生成时间)。
|
|
1605
1663
|
* 实体的参考资产来源:① 设定条目里**链接的资产**(assetIds,绑定关系优先)
|
|
@@ -4944,10 +5002,11 @@ window.__ModuleLoader__.load({
|
|
|
4944
5002
|
try {
|
|
4945
5003
|
const text = await aiScriptNodes(n.data.idea, n.data.model, (kind, d) => aiStreamAppend(n.id, kind, d));
|
|
4946
5004
|
if (!String(text || '').trim()) throw new Error(T('aiEmptyResult'));
|
|
4947
|
-
|
|
5005
|
+
const fixedText = repairUnclosedMentions(text, bibleNames(loadBible(S.activeId)));
|
|
5006
|
+
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.output = fixedText; nd.data.prevOutput = null; nd.data.aiStatus = 'idle'; nd.data.status = 'done'; nd.data.error = null; } s.assetsTick++; });
|
|
4948
5007
|
await saveTextAssetRecord(S.activeId, {
|
|
4949
5008
|
name: (String(n.data.label || '').trim() || T('scriptAssetPrefix')) + ' ' + nowStamp(),
|
|
4950
|
-
text, category: 'script', prompt: n.data.idea,
|
|
5009
|
+
text: fixedText, category: 'script', prompt: n.data.idea,
|
|
4951
5010
|
});
|
|
4952
5011
|
update((s) => { s.assetsTick++; });
|
|
4953
5012
|
flushSave();
|
|
@@ -4988,6 +5047,13 @@ window.__ModuleLoader__.load({
|
|
|
4988
5047
|
try {
|
|
4989
5048
|
const prev = prevSceneTail(n); // 场 N-1 的尾镜 → 作为本场第 1 镜的硬性承接约束
|
|
4990
5049
|
const shots = await aiStoryboardShots(st, n.data.requirement, n.data.count || 6, n.data.model, (kind, d) => aiStreamAppend(n.id, kind, d), prev);
|
|
5050
|
+
// 未闭合引用自愈:句尾丢闭合 @ 的引用按设定集名补全(否则参考图静默丢失)
|
|
5051
|
+
const _known = bibleNames(loadBible(S.activeId));
|
|
5052
|
+
for (const sh of shots) {
|
|
5053
|
+
sh.visual = repairUnclosedMentions(sh.visual, _known);
|
|
5054
|
+
sh.motion = repairUnclosedMentions(sh.motion, _known);
|
|
5055
|
+
sh.sfx = repairUnclosedMentions(sh.sfx, _known);
|
|
5056
|
+
}
|
|
4991
5057
|
// 先自动补录缺失条目(空描述),再算一致性告警:否则告警标志的条目其实马上就被自动补录了,"未登记"语义会误导用户与对话侧
|
|
4992
5058
|
try { await autoRegisterBibleEntries(shots); } catch { /* 补录失败不影响分镜 */ }
|
|
4993
5059
|
const warnings = await checkShotConsistency(shots);
|
|
@@ -5098,8 +5164,11 @@ window.__ModuleLoader__.load({
|
|
|
5098
5164
|
if (type === 'storyboard') { await genStoryboardNode(n); return true; }
|
|
5099
5165
|
if (type === 'finalcut') { await genFinalCutNode(n); return true; }
|
|
5100
5166
|
if (type !== 'image-gen' && type !== 'video-gen') throw new Error(T('nodeNotRunnable'));
|
|
5101
|
-
|
|
5167
|
+
let prompt = resolvePrompt(n);
|
|
5102
5168
|
const bible = loadBible(S.activeId);
|
|
5169
|
+
// 未闭合引用自愈:@糯糯。 → @糯糯@(按设定集名归一),修复结果回写节点让用户可见
|
|
5170
|
+
const _fixed = repairUnclosedMentions(prompt, bibleNames(bible));
|
|
5171
|
+
if (_fixed !== prompt) { prompt = _fixed; update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.prompt = _fixed; }); }
|
|
5103
5172
|
if (type === 'image-gen') {
|
|
5104
5173
|
// 参考图 = 连线(用户顺序)+ @引用命中的设定集资产(按出现顺序)
|
|
5105
5174
|
const { refs, entities } = await collectRefs(n, prompt, bible);
|
|
@@ -5415,6 +5484,9 @@ window.__ModuleLoader__.load({
|
|
|
5415
5484
|
el.style.top = ny + 'px';
|
|
5416
5485
|
movedIds.add(st.id);
|
|
5417
5486
|
}
|
|
5487
|
+
// —— 实时碰撞推挤(物理感):拖动节点碰到谁,谁沿远离方向实时让位;被推者再推别人(级联)。
|
|
5488
|
+
// 上下左右同理:下节点上移碰到上节点 → 上节点实时上移;全程无弹跳,松手即定格。
|
|
5489
|
+
resolvePushNow(movedIds);
|
|
5418
5490
|
if (movedIds.size) {
|
|
5419
5491
|
for (const e2 of S.graph.edges) {
|
|
5420
5492
|
if (!movedIds.has(e2.fromNode) && !movedIds.has(e2.toNode)) continue;
|
|
@@ -5627,24 +5699,90 @@ window.__ModuleLoader__.load({
|
|
|
5627
5699
|
});
|
|
5628
5700
|
const TYPE_KIND = { 'text': 'text', 'shot': 'shot', 'script': 'script', 'storyboard': 'shot', 'image-gen': 'image', 'video-gen': 'video', 'image-asset': 'image', 'video-asset': 'video', 'finalcut': 'video', 'group': 'text' };
|
|
5629
5701
|
|
|
5630
|
-
/**
|
|
5702
|
+
/** 节点框相交判定(含最小间距,默认 24px)。 */
|
|
5703
|
+
/** 实时碰撞推挤共享核心(拖动 / 节点尺寸变化共用):与 movers 里节点相交的其他节点,
|
|
5704
|
+
* 沿远离主动节点的方向让位(重叠较少的轴先动,上下左右机会均等),被推者再推别人,级联最多 4 轮。
|
|
5705
|
+
* 高频路径:直接写模型坐标并同步被推节点的 DOM(不触发 React 重渲染),由调用方在静止时收尾落盘。 */
|
|
5706
|
+
function resolvePushNow(movers) {
|
|
5707
|
+
const PUSH_GAP = 8; // 触距:小于静止间距,贴近"贴上就推"的手感
|
|
5708
|
+
let frontier = [...movers];
|
|
5709
|
+
for (let pass = 0; pass < 4 && frontier.length; pass++) {
|
|
5710
|
+
const next = [];
|
|
5711
|
+
for (const fid of frontier) {
|
|
5712
|
+
const a = S.graph.nodes[fid];
|
|
5713
|
+
if (!a) continue;
|
|
5714
|
+
const al = a.x, ar = a.x + (a.w || 240), at = a.y, ab = a.y + (a.h || 96);
|
|
5715
|
+
for (const other of Object.values(S.graph.nodes)) {
|
|
5716
|
+
if (!other || movers.has(other.id) || other.type === 'group' || (other.data && other.data.hidden)) continue;
|
|
5717
|
+
const bl = other.x, br = other.x + (other.w || 240), bt = other.y, bb = other.y + (other.h || 96);
|
|
5718
|
+
if (al >= br + PUSH_GAP || ar + PUSH_GAP <= bl || at >= bb + PUSH_GAP || ab + PUSH_GAP <= bt) continue; // 未相交
|
|
5719
|
+
// 沿重叠较小的轴推开,方向 = 远离主动节点
|
|
5720
|
+
const penX = Math.min(ar, br) - Math.max(al, bl);
|
|
5721
|
+
const penY = Math.min(ab, bb) - Math.max(at, bt);
|
|
5722
|
+
const acx = (al + ar) / 2, acy = (at + ab) / 2;
|
|
5723
|
+
const bcx = (bl + br) / 2, bcy = (bt + bb) / 2;
|
|
5724
|
+
if (penX <= penY) {
|
|
5725
|
+
other.x = Math.round(bcx >= acx ? ar + PUSH_GAP : al - (br - bl) - PUSH_GAP);
|
|
5726
|
+
} else {
|
|
5727
|
+
other.y = Math.round(bcy >= acy ? ab + PUSH_GAP : at - (bb - bt) - PUSH_GAP);
|
|
5728
|
+
}
|
|
5729
|
+
const oel = nodeEls.get(other.id);
|
|
5730
|
+
if (oel) { oel.style.left = other.x + 'px'; oel.style.top = other.y + 'px'; }
|
|
5731
|
+
movers.add(other.id);
|
|
5732
|
+
next.push(other.id);
|
|
5733
|
+
}
|
|
5734
|
+
}
|
|
5735
|
+
frontier = next;
|
|
5736
|
+
}
|
|
5737
|
+
return movers;
|
|
5738
|
+
}
|
|
5739
|
+
|
|
5740
|
+
/** 节点尺寸自适应:RO 上报最新尺寸(拖节点边、拉长内容输入框、流式长高、宽度变化都算),
|
|
5741
|
+
* 尺寸一变就实时推开相交的邻居(resolvePushNow,与拖动碰撞同一套物理推挤,所有节点类型统一走这里);
|
|
5742
|
+
* 静止约 260ms 后收尾:通知 React + 记撤销快照 + 落盘。
|
|
5743
|
+
* 分组框高度由创建/折叠样式决定,不采集;首次测量只登记尺寸,避免载入时画面自行跳动。 */
|
|
5631
5744
|
function observeNodeHeight(id, el) {
|
|
5632
5745
|
if (!el) return;
|
|
5746
|
+
let raf = 0;
|
|
5747
|
+
let settleTimer = null;
|
|
5748
|
+
let touched = false;
|
|
5749
|
+
const scheduleSettle = () => {
|
|
5750
|
+
if (settleTimer) clearTimeout(settleTimer);
|
|
5751
|
+
settleTimer = setTimeout(() => {
|
|
5752
|
+
settleTimer = null;
|
|
5753
|
+
if (!touched) return;
|
|
5754
|
+
touched = false;
|
|
5755
|
+
notify();
|
|
5756
|
+
pushHistory();
|
|
5757
|
+
flushSave();
|
|
5758
|
+
}, 260);
|
|
5759
|
+
};
|
|
5633
5760
|
const ro = new ResizeObserver(() => {
|
|
5634
5761
|
const nd = S.graph.nodes[id];
|
|
5635
5762
|
if (!nd || nd.type === 'group') return;
|
|
5636
|
-
|
|
5637
|
-
|
|
5638
|
-
|
|
5639
|
-
|
|
5640
|
-
|
|
5641
|
-
|
|
5642
|
-
|
|
5763
|
+
const w = el.offsetWidth, h = el.offsetHeight;
|
|
5764
|
+
if (!w || !h) return;
|
|
5765
|
+
if (Math.abs((nd.h || 0) - h) <= 1 && Math.abs((nd.w || 0) - w) <= 1) return;
|
|
5766
|
+
const first = !(nd.h || 0) && !(nd.w || 0);
|
|
5767
|
+
nd.w = w; nd.h = h;
|
|
5768
|
+
for (const e of S.graph.edges) {
|
|
5769
|
+
if (e.fromNode === id || e.toNode === id) {
|
|
5770
|
+
const p = edgePathEls.get(e.id);
|
|
5771
|
+
if (p) p.setAttribute('d', edgePath(e));
|
|
5643
5772
|
}
|
|
5644
5773
|
}
|
|
5774
|
+
if (!first && !raf) {
|
|
5775
|
+
raf = requestAnimationFrame(() => {
|
|
5776
|
+
raf = 0;
|
|
5777
|
+
try {
|
|
5778
|
+
const pushed = resolvePushNow(new Set([id]));
|
|
5779
|
+
if (pushed.size > 1) { touched = true; scheduleSettle(); }
|
|
5780
|
+
} catch { /* 布局自愈失败不影响内容 */ }
|
|
5781
|
+
});
|
|
5782
|
+
}
|
|
5645
5783
|
});
|
|
5646
5784
|
ro.observe(el);
|
|
5647
|
-
return () => ro.disconnect();
|
|
5785
|
+
return () => { ro.disconnect(); if (raf) cancelAnimationFrame(raf); if (settleTimer) clearTimeout(settleTimer); };
|
|
5648
5786
|
}
|
|
5649
5787
|
|
|
5650
5788
|
function PortEl({ n, port, dir, corner }) {
|
|
@@ -5771,6 +5909,8 @@ window.__ModuleLoader__.load({
|
|
|
5771
5909
|
const up = () => {
|
|
5772
5910
|
window.removeEventListener('pointermove', move);
|
|
5773
5911
|
window.removeEventListener('pointerup', up);
|
|
5912
|
+
flushSave(); // 拖拽结束立即落盘:否则防抖期间切换页面会丢失 w/h/hManual
|
|
5913
|
+
try { if (resolvePushNow(new Set([n.id])).size > 1) notify(); } catch { /* 布局自愈失败不影响尺寸 */ }
|
|
5774
5914
|
pushHistory();
|
|
5775
5915
|
};
|
|
5776
5916
|
window.addEventListener('pointermove', move);
|
|
@@ -6244,13 +6384,15 @@ window.__ModuleLoader__.load({
|
|
|
6244
6384
|
const assetUrlV = useAssetUrl(n.data.assetId);
|
|
6245
6385
|
return [
|
|
6246
6386
|
promptEdge
|
|
6247
|
-
? h(
|
|
6248
|
-
key: 'p',
|
|
6249
|
-
|
|
6250
|
-
|
|
6387
|
+
? h(PromptArea, {
|
|
6388
|
+
key: 'p', value: n.data.prompt || '', minHeight: 72, readOnly: true,
|
|
6389
|
+
...phBind(n, 'p'),
|
|
6390
|
+
placeholder: fromStoryboard ? T('multiShotHint') : T('connected'),
|
|
6391
|
+
onChange: () => { /* 只读展示:内容由上游连线组合 */ },
|
|
6251
6392
|
})
|
|
6252
6393
|
: h(PromptArea, {
|
|
6253
6394
|
key: 'p', value: n.data.prompt || '', minHeight: 72,
|
|
6395
|
+
...phBind(n, 'p'),
|
|
6254
6396
|
placeholder: T('promptPlaceholder'),
|
|
6255
6397
|
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.prompt = v; }),
|
|
6256
6398
|
}),
|
|
@@ -6564,6 +6706,7 @@ window.__ModuleLoader__.load({
|
|
|
6564
6706
|
value: n.data.text || '',
|
|
6565
6707
|
placeholder: T('textPlaceholder'),
|
|
6566
6708
|
minHeight: 96,
|
|
6709
|
+
...phBind(n, 'text'),
|
|
6567
6710
|
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.text = v; }),
|
|
6568
6711
|
});
|
|
6569
6712
|
}
|
|
@@ -6590,37 +6733,100 @@ window.__ModuleLoader__.load({
|
|
|
6590
6733
|
refLines.map((x) => x.lb + ':' + x.names.join('、')).join(' | '))
|
|
6591
6734
|
: null,
|
|
6592
6735
|
h('span', { className: 'dshic-shot-label' }, T('visualLabel')),
|
|
6593
|
-
h(
|
|
6594
|
-
|
|
6736
|
+
h(PromptArea, {
|
|
6737
|
+
value: n.data.visual || '', minHeight: 52,
|
|
6738
|
+
...phBind(n, 'visual'),
|
|
6595
6739
|
placeholder: T('shotVisualPlaceholder'),
|
|
6596
|
-
onChange: (
|
|
6597
|
-
onPointerDown: (e) => e.stopPropagation(),
|
|
6740
|
+
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.visual = v; }),
|
|
6598
6741
|
}),
|
|
6599
6742
|
h('span', { className: 'dshic-shot-label' }, T('motionLabel')),
|
|
6600
|
-
h(
|
|
6601
|
-
|
|
6743
|
+
h(PromptArea, {
|
|
6744
|
+
value: n.data.motion || '', minHeight: 44,
|
|
6745
|
+
...phBind(n, 'motion'),
|
|
6602
6746
|
placeholder: T('shotMotionPlaceholder'),
|
|
6603
|
-
onChange: (
|
|
6604
|
-
onPointerDown: (e) => e.stopPropagation(),
|
|
6747
|
+
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.motion = v; }),
|
|
6605
6748
|
}),
|
|
6606
6749
|
);
|
|
6607
6750
|
}
|
|
6608
6751
|
|
|
6752
|
+
/** 文本框独立拉高的记忆:PromptArea 的原生 grip 拖高写进节点 data.ph[key],
|
|
6753
|
+
* 换页/刷新重挂载后原样恢复(不再退回 minHeight)。key 在同一节点内需唯一。 */
|
|
6754
|
+
function phBind(n, key) {
|
|
6755
|
+
return {
|
|
6756
|
+
phH: (n.data.ph && n.data.ph[key]) || null,
|
|
6757
|
+
onPh: (px) => update((s) => {
|
|
6758
|
+
const nd = s.graph.nodes[n.id];
|
|
6759
|
+
if (!nd) return;
|
|
6760
|
+
nd.data.ph = { ...(nd.data.ph || {}), [key]: px };
|
|
6761
|
+
}),
|
|
6762
|
+
// 手动高度节点里拖原生 grip 拉对话框:视觉高度被 flex 定死,但 inline height 的增量=拖动量,
|
|
6763
|
+
// 换算成节点 hManual 的增量 → 对话框随节点一起变长;自适应节点不需要(高度本来就跟内容走)。
|
|
6764
|
+
grow: (d) => update((s) => {
|
|
6765
|
+
const nd = s.graph.nodes[n.id];
|
|
6766
|
+
if (!nd || !d || !nd.hManual) return;
|
|
6767
|
+
nd.hManual = Math.max(96, Math.round(nd.hManual + d));
|
|
6768
|
+
nd.h = nd.hManual;
|
|
6769
|
+
}),
|
|
6770
|
+
};
|
|
6771
|
+
}
|
|
6772
|
+
|
|
6609
6773
|
/**
|
|
6610
6774
|
* 提示词输入框:@名称 蓝色加粗高亮。
|
|
6611
6775
|
* 与主流工具同一做法——透明文字的 textarea 叠在同步滚动的背景层上,
|
|
6612
6776
|
* 背景层渲染同一段文本并给 @名称 上色,光标/选区/中文输入法都不受影响。
|
|
6613
6777
|
*/
|
|
6614
|
-
function PromptArea({ value, onChange, placeholder, minHeight, style }) {
|
|
6778
|
+
function PromptArea({ value, onChange, placeholder, minHeight, style, readOnly, phH, onPh, grow }) {
|
|
6615
6779
|
const taRef = useRef(null);
|
|
6616
6780
|
const backRef = useRef(null);
|
|
6781
|
+
// 记忆高度的基准:React 上一次写进 textarea 的 height(null=未设置)。
|
|
6782
|
+
// 原生 grip 拉高会直接改 inline style,与基准不同即视为用户改动 → 回调存进节点数据。
|
|
6783
|
+
const hBaseRef = useRef(null);
|
|
6784
|
+
// grip 拖动轨迹:inline height 的上一次值(手动高度节点用它把拖动量换算成节点高度增量)
|
|
6785
|
+
const lastInlineRef = useRef(null);
|
|
6786
|
+
useEffect(() => {
|
|
6787
|
+
hBaseRef.current = phH || null;
|
|
6788
|
+
lastInlineRef.current = phH || null;
|
|
6789
|
+
}, [phH]);
|
|
6617
6790
|
const sync = () => {
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6791
|
+
const ta = taRef.current;
|
|
6792
|
+
const back = backRef.current;
|
|
6793
|
+
if (!ta || !back) return;
|
|
6794
|
+
// 父级高度不定时绝对定位的 bottom 会失效,背层随文字撑高并画出边框。
|
|
6795
|
+
// 用输入框的实际高度锁死背层,滚动才对齐;外层 overflow:hidden 再兜底裁切。
|
|
6796
|
+
const h = ta.offsetHeight;
|
|
6797
|
+
if (h > 0 && back.style.height !== h + 'px') back.style.height = h + 'px';
|
|
6798
|
+
back.scrollTop = ta.scrollTop;
|
|
6799
|
+
back.scrollLeft = ta.scrollLeft;
|
|
6622
6800
|
};
|
|
6623
|
-
|
|
6801
|
+
useEffect(() => {
|
|
6802
|
+
sync();
|
|
6803
|
+
const ta = taRef.current;
|
|
6804
|
+
if (!ta || typeof ResizeObserver === 'undefined') return undefined;
|
|
6805
|
+
let hRaf = 0;
|
|
6806
|
+
const ro = new ResizeObserver(() => {
|
|
6807
|
+
sync();
|
|
6808
|
+
if (!onPh) return;
|
|
6809
|
+
const px = parseInt(ta.style.height, 10) || 0;
|
|
6810
|
+
if (px > 0 && px !== (hBaseRef.current || 0) && !hRaf) {
|
|
6811
|
+
hRaf = requestAnimationFrame(() => {
|
|
6812
|
+
hRaf = 0;
|
|
6813
|
+
const cur = parseInt(ta.style.height, 10) || 0;
|
|
6814
|
+
if (cur > 0 && cur !== (hBaseRef.current || 0)) {
|
|
6815
|
+
if (grow && lastInlineRef.current != null) grow(cur - lastInlineRef.current);
|
|
6816
|
+
lastInlineRef.current = cur;
|
|
6817
|
+
onPh(cur);
|
|
6818
|
+
}
|
|
6819
|
+
});
|
|
6820
|
+
}
|
|
6821
|
+
});
|
|
6822
|
+
ro.observe(ta);
|
|
6823
|
+
return () => { ro.disconnect(); if (hRaf) cancelAnimationFrame(hRaf); };
|
|
6824
|
+
}, []);
|
|
6825
|
+
// 值变化后补一次滚动同步:在末尾输入时 textarea 会自动滚动到光标,但背层不一定收到 scroll 事件
|
|
6826
|
+
useEffect(() => { sync(); }, [value]);
|
|
6827
|
+
// 结尾换行补偿:<textarea> 的结尾 \n 会多出一条空行,而 pre-wrap 的 <div> 不生成该行盒
|
|
6828
|
+
// → 两层 scrollHeight 差一行,滚到底时背层露出半截错位文字。补零宽字符让行盒存在。
|
|
6829
|
+
const html = highlightMentions(value || '').replace(/\n$/, '\n\u200b');
|
|
6624
6830
|
const ph = placeholder ? '<span class="dshic-prompt-ph">' + escapeHtml(placeholder) + '</span>' : '';
|
|
6625
6831
|
return h('div', { className: 'dshic-prompt' },
|
|
6626
6832
|
h('div', {
|
|
@@ -6630,7 +6836,8 @@ window.__ModuleLoader__.load({
|
|
|
6630
6836
|
h('textarea', {
|
|
6631
6837
|
ref: taRef, className: 'dshic-textarea dshic-prompt-input', value: value || '',
|
|
6632
6838
|
placeholder: html ? undefined : placeholder,
|
|
6633
|
-
|
|
6839
|
+
readOnly: readOnly === true,
|
|
6840
|
+
style: { minHeight: minHeight || 72, height: phH || undefined, ...(style || {}) },
|
|
6634
6841
|
onChange: (e) => onChange(e.target.value),
|
|
6635
6842
|
onScroll: sync,
|
|
6636
6843
|
onPointerDown: (e) => e.stopPropagation(),
|
|
@@ -6700,11 +6907,11 @@ window.__ModuleLoader__.load({
|
|
|
6700
6907
|
h('div', { className: 'dshic-flex' }),
|
|
6701
6908
|
),
|
|
6702
6909
|
h('span', { key: 'l1', className: 'dshic-shot-label' }, T('ideaLabel')),
|
|
6703
|
-
h(
|
|
6704
|
-
key: 'idea',
|
|
6910
|
+
h(PromptArea, {
|
|
6911
|
+
key: 'idea', value: n.data.idea || '', minHeight: 72,
|
|
6912
|
+
...phBind(n, 'idea'),
|
|
6705
6913
|
placeholder: T('ideaPlaceholder'),
|
|
6706
|
-
onChange: (
|
|
6707
|
-
onPointerDown: (e) => e.stopPropagation(),
|
|
6914
|
+
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.idea = v; }),
|
|
6708
6915
|
}),
|
|
6709
6916
|
h('button', { key: 'gen', type: 'button', className: 'dshic-gen', disabled: running, onClick: generate, onPointerDown: (e) => e.stopPropagation() },
|
|
6710
6917
|
running ? h('span', { className: 'dshic-spin' }) : Ic('spark', 12), running ? T('aiRunning') : T('aiScript')),
|
|
@@ -6731,11 +6938,11 @@ window.__ModuleLoader__.load({
|
|
|
6731
6938
|
),
|
|
6732
6939
|
n.data.aiError ? h('div', { key: 'err', className: 'dshic-err' }, n.data.aiError) : null,
|
|
6733
6940
|
h('span', { key: 'l2', className: 'dshic-shot-label' }, T('scriptOutputLabel')),
|
|
6734
|
-
h(
|
|
6735
|
-
key: 'out',
|
|
6941
|
+
h(PromptArea, {
|
|
6942
|
+
key: 'out', value: n.data.output || '', minHeight: 180,
|
|
6943
|
+
...phBind(n, 'out'),
|
|
6736
6944
|
placeholder: running ? T('aiRunning') : '',
|
|
6737
|
-
onChange: (
|
|
6738
|
-
onPointerDown: (e) => e.stopPropagation(),
|
|
6945
|
+
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.output = v; }),
|
|
6739
6946
|
}),
|
|
6740
6947
|
];
|
|
6741
6948
|
}
|
|
@@ -6747,13 +6954,36 @@ window.__ModuleLoader__.load({
|
|
|
6747
6954
|
const startResize = (e) => {
|
|
6748
6955
|
e.stopPropagation(); e.preventDefault();
|
|
6749
6956
|
const startY = e.clientY; const startH = listH; const startNodeH = Number(n.h) || 0;
|
|
6957
|
+
const startHManual = n.hManual ? Number(n.hManual) : 0;
|
|
6750
6958
|
const onMove = (ev) => {
|
|
6751
6959
|
const nh = Math.max(80, Math.min(1200, startH + (ev.clientY - startY)));
|
|
6752
6960
|
const dh = nh - startH;
|
|
6753
|
-
|
|
6754
|
-
|
|
6961
|
+
update((s) => {
|
|
6962
|
+
const nd = s.graph.nodes[n.id]; if (!nd) return;
|
|
6963
|
+
nd.data.shotsListH = nh;
|
|
6964
|
+
if (startHManual > 0) {
|
|
6965
|
+
// 手动高度节点:列表加多高,节点就长多高(否则固定高度会把上方输入框挤扁)
|
|
6966
|
+
nd.hManual = Math.max(120, startHManual + dh);
|
|
6967
|
+
nd.h = nd.hManual;
|
|
6968
|
+
} else if (startNodeH > 0) {
|
|
6969
|
+
nd.h = Math.max(120, startNodeH + dh);
|
|
6970
|
+
}
|
|
6971
|
+
});
|
|
6972
|
+
};
|
|
6973
|
+
const onUp = () => {
|
|
6974
|
+
window.removeEventListener('pointermove', onMove);
|
|
6975
|
+
window.removeEventListener('pointerup', onUp);
|
|
6976
|
+
try {
|
|
6977
|
+
// 收尾对齐:手动高度若还截着内容(此前被挤压过的存量节点),按内容自然高度补齐一次
|
|
6978
|
+
const el = nodeEls.get(n.id);
|
|
6979
|
+
const body = el && el.querySelector('.dshic-node-body');
|
|
6980
|
+
if (body && body.scrollHeight > body.clientHeight + 1) {
|
|
6981
|
+
const deficit = body.scrollHeight - body.clientHeight;
|
|
6982
|
+
update((s) => { const nd = s.graph.nodes[n.id]; if (nd && nd.hManual) nd.hManual = Math.round(nd.hManual + deficit); });
|
|
6983
|
+
}
|
|
6984
|
+
} catch { /* 布局自愈失败不影响功能 */ }
|
|
6985
|
+
flushSave();
|
|
6755
6986
|
};
|
|
6756
|
-
const onUp = () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); flushSave(); };
|
|
6757
6987
|
window.addEventListener('pointermove', onMove);
|
|
6758
6988
|
window.addEventListener('pointerup', onUp);
|
|
6759
6989
|
};
|
|
@@ -6779,18 +7009,17 @@ window.__ModuleLoader__.load({
|
|
|
6779
7009
|
h('div', { className: 'dshic-flex' }),
|
|
6780
7010
|
),
|
|
6781
7011
|
h('span', { key: 'l1', className: 'dshic-shot-label' }, T('storyboardScriptLabel') + (scriptEdge ? ' · ' + T('connected') : '')),
|
|
6782
|
-
h(
|
|
6783
|
-
key: 'script',
|
|
7012
|
+
h(PromptArea, {
|
|
7013
|
+
key: 'script', value: n.data.script || '', minHeight: 56, readOnly: !!scriptEdge,
|
|
7014
|
+
...phBind(n, 'script'),
|
|
6784
7015
|
placeholder: scriptEdge ? T('connected') : T('storyboardScriptLabel'),
|
|
6785
|
-
|
|
6786
|
-
onChange: (e) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.script = e.target.value; }),
|
|
6787
|
-
onPointerDown: (e) => e.stopPropagation(),
|
|
7016
|
+
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.script = v; }),
|
|
6788
7017
|
}),
|
|
6789
|
-
h(
|
|
6790
|
-
key: 'req',
|
|
7018
|
+
h(PromptArea, {
|
|
7019
|
+
key: 'req', value: n.data.requirement || '', minHeight: 40,
|
|
7020
|
+
...phBind(n, 'req'),
|
|
6791
7021
|
placeholder: T('storyboardReqLabel'),
|
|
6792
|
-
onChange: (
|
|
6793
|
-
onPointerDown: (e) => e.stopPropagation(),
|
|
7022
|
+
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.requirement = v; }),
|
|
6794
7023
|
}),
|
|
6795
7024
|
h('div', { key: 'row', className: 'dshic-node-row' },
|
|
6796
7025
|
h(MiniSelect, {
|
package/index.js
CHANGED
|
@@ -787,23 +787,23 @@ function nextNodePos(doc, type, connectFromId) {
|
|
|
787
787
|
const EST_H = { storyboard: 560, 'video-gen': 620, 'image-gen': 560, script: 420, finalcut: 420, text: 220 };
|
|
788
788
|
const estH = EST_H[type] || 300;
|
|
789
789
|
const estW = type === 'finalcut' ? 300 : 280;
|
|
790
|
+
const same = nodes.filter((n) => n.type === type);
|
|
790
791
|
let x = 80;
|
|
791
792
|
let y = 40;
|
|
792
|
-
if (
|
|
793
|
+
if (same.length) {
|
|
794
|
+
// 一类型一列(布局铁律):该类型已有节点 → 永远追加到同类型列尾;
|
|
795
|
+
// 连线只表达数据流,不改变列归属(否则类型会交叉、画布无法辨认)
|
|
796
|
+
x = Math.min(...same.map((n) => n.x || 0));
|
|
797
|
+
y = Math.max(...same.map((n) => (n.y || 0) + H(n))) + GAP_Y;
|
|
798
|
+
} else if (connectFromId && doc.nodes && doc.nodes[connectFromId]) {
|
|
799
|
+
// 该类型的第一个节点:放在上游右侧一行(自然形成新的一列)
|
|
793
800
|
const src = doc.nodes[connectFromId];
|
|
794
801
|
x = (src.x || 0) + W(src) + GAP_X;
|
|
795
802
|
y = src.y || 40;
|
|
796
|
-
} else {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
x = Math.min(...same.map((n) => n.x || 0));
|
|
801
|
-
y = Math.max(...same.map((n) => (n.y || 0) + H(n))) + GAP_Y;
|
|
802
|
-
} else if (nodes.length) {
|
|
803
|
-
// 该类型还没有节点 → 在最右侧另起一列(绝不插进已有列里跟别的类型混排)
|
|
804
|
-
x = Math.max(...nodes.map((n) => (n.x || 0) + W(n))) + GAP_X;
|
|
805
|
-
y = Math.min(...nodes.map((n) => n.y || 0));
|
|
806
|
-
}
|
|
803
|
+
} else if (nodes.length) {
|
|
804
|
+
// 该类型还没有节点且无上游 → 在最右侧另起一列(绝不插进已有列里跟别的类型混排)
|
|
805
|
+
x = Math.max(...nodes.map((n) => (n.x || 0) + W(n))) + GAP_X;
|
|
806
|
+
y = Math.min(...nodes.map((n) => n.y || 40));
|
|
807
807
|
}
|
|
808
808
|
const hit = (yy) => nodes.find((n) => (n.x || 0) < x + estW + MIN_GAP && (n.x || 0) + W(n) + MIN_GAP > x
|
|
809
809
|
&& (n.y || 0) < yy + estH + MIN_GAP && (n.y || 0) + H(n) + MIN_GAP > yy);
|
|
@@ -813,8 +813,8 @@ function nextNodePos(doc, type, connectFromId) {
|
|
|
813
813
|
return { x, y };
|
|
814
814
|
}
|
|
815
815
|
// ---------- 内置技能(操作规程 + 创作方法论,约束并提升对话的画布创作)----------
|
|
816
|
-
// canvas-studio=操作硬约束;canvas-craft
|
|
817
|
-
const SKILL_NAMES = ['canvas-studio', 'canvas-craft'];
|
|
816
|
+
// canvas-studio=操作硬约束;canvas-craft=剧本/分镜/提示词创作方法论;canvas-layout=节点布局纪律
|
|
817
|
+
const SKILL_NAMES = ['canvas-studio', 'canvas-craft', 'canvas-layout'];
|
|
818
818
|
// = @deepseek-ai/dsh-skill 的 BUNDLED_SKILL_RANK(避免 import DSH 包;registry 要求 rank 必须是有限数字)
|
|
819
819
|
const SKILL_RANK = 600;
|
|
820
820
|
/** 解析 SKILL.md 的 YAML frontmatter(仅需 description 与正文)。 */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ninkch/dsh-infinite-canvas",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.18",
|
|
4
4
|
"description": "Node-based short-drama studio for DeepSeek Harness: script → storyboard → assets → shots → voiced, subtitled final cut on an infinite canvas.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|