@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.
@@ -38,21 +38,20 @@ B:……
38
38
  分区呈现,一图读懂资产的全部视觉设定。所有面板文字一律**简体中文、清晰可读**;
39
39
  同一资产在所有分格中外观**完全一致**(发型/服装/配色逐格复用设定集关键词)。
40
40
 
41
- ### 角色设定图(16:9 横版)
42
- - **左侧信息面板**:「核心人物信息」——姓名 / 性别 / 年龄 / 身份 / 性格 / 配色(逐行列出);
43
- 旁边「角色设定」小面板:两三句背景设定。
44
- - **中央主图**:全身立绘,正面站姿,完整呈现体型与服装(占画面约 1/3 宽)。
45
- - **右上脸部特征**:正面特写 + 侧面特写并排,下方 3–4 行文字标注五官要点
46
- (如「杏眼温润 / 鼻梁秀挺 / 唇色自然 / 侧颜柔和」)。
47
- - **右上服饰特征**:服装要点逐行标注(如「月白浅交领长裙 / 绣兰草纹样 / 腰间系青玉佩」)。
48
- - **左下细节展示**:2×3 六格特写——发饰 / 耳饰 / 腰带 / 袖口纹样 / 下摆纹样 / 随身道具,
49
- 每格下方一行小字标注名称(如「发饰细节:兰花银饰」)。
50
- - **右下表情排列**:2×5 共 10 个头像素描格,逐格标注表情名
51
- (参考序列:温柔、微笑、沉思、惊讶、端庄、凛然、悠然、羞涩、坚定、冷淡——按角色性格取舍)。
52
- - 提示词结构示例:「角色人设图(16:9 横版专业设定集排版):左侧『核心人物信息』文字面板
53
- (姓名:沈清蘅 / 性别:女 / 年龄:28 / 身份:兰衣医修 / 性格:温柔沉静 / 配色:月白浅绿青玉);
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
- 之后该角色的视频提示词里 `@<名称>` 会自动把这条声音参考放进 `audios[]`,
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
- + 脸部特征(正面+侧面特写带文字标注)+ 服饰特征标注 + 细节展示(6 格特写各配名称小字)
146
- + 表情排列(2×5 十格逐格标注表情名);所有分格中同一角色外观完全一致
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
- - `@张山` → 取设定集里「张山」的外观描述,并把它名下**所有参考图资产**按顺序追加为 `[图N]`
170
+ - `@张山@` → 取设定集里「张山」的外观描述,并把它名下**所有参考图资产**按顺序追加为 `[图N]`
170
171
  - 连线参考图排在最前(`[图1..n]`,顺序可在节点里上下调整),`@` 命中的资产按**实体出现顺序**接在后面
171
- - `@名称` 在输入框里会**蓝色加粗显示**(人类可读),送给模型前会自动转成带 `[图N]` 与设定描述的标准提示词
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
- .dshic-prompt{position:relative;display:block;width:100%;}
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;inset:0;overflow:hidden;pointer-events:none;color:var(--dsw-alias-label-primary);
1187
- border:1px solid transparent;border-radius:6px;}
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;font-weight:700;}
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
- // <<<MENTION_PURE_END>>>
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
- update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.output = text; nd.data.prevOutput = null; nd.data.aiStatus = 'idle'; nd.data.status = 'done'; nd.data.error = null; } s.assetsTick++; });
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
- const prompt = resolvePrompt(n);
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
- /** 节点尺寸自适应:RO 上报高度,供连线端口定位(分组框高度由创建/折叠样式决定,不采集) */
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
- if (el.offsetHeight && Math.abs((nd.h || 0) - el.offsetHeight) > 1) {
5637
- nd.h = el.offsetHeight;
5638
- for (const e of S.graph.edges) {
5639
- if (e.fromNode === id || e.toNode === id) {
5640
- const p = edgePathEls.get(e.id);
5641
- if (p) p.setAttribute('d', edgePath(e));
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('textarea', {
6248
- key: 'p', className: 'dshic-textarea', value: n.data.prompt || '',
6249
- placeholder: fromStoryboard ? T('multiShotHint') : T('connected'), disabled: true,
6250
- onPointerDown: (e) => e.stopPropagation(),
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('textarea', {
6594
- className: 'dshic-textarea', style: { minHeight: 52 }, value: n.data.visual || '',
6736
+ h(PromptArea, {
6737
+ value: n.data.visual || '', minHeight: 52,
6738
+ ...phBind(n, 'visual'),
6595
6739
  placeholder: T('shotVisualPlaceholder'),
6596
- onChange: (e) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.visual = e.target.value; }),
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('textarea', {
6601
- className: 'dshic-textarea', style: { minHeight: 44 }, value: n.data.motion || '',
6743
+ h(PromptArea, {
6744
+ value: n.data.motion || '', minHeight: 44,
6745
+ ...phBind(n, 'motion'),
6602
6746
  placeholder: T('shotMotionPlaceholder'),
6603
- onChange: (e) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.motion = e.target.value; }),
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
- if (backRef.current && taRef.current) {
6619
- backRef.current.scrollTop = taRef.current.scrollTop;
6620
- backRef.current.scrollLeft = taRef.current.scrollLeft;
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
- const html = highlightMentions(value || '');
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
- style: { minHeight: minHeight || 72, ...(style || {}) },
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('textarea', {
6704
- key: 'idea', className: 'dshic-textarea', style: { minHeight: 72 }, value: n.data.idea || '',
6910
+ h(PromptArea, {
6911
+ key: 'idea', value: n.data.idea || '', minHeight: 72,
6912
+ ...phBind(n, 'idea'),
6705
6913
  placeholder: T('ideaPlaceholder'),
6706
- onChange: (e) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.idea = e.target.value; }),
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('textarea', {
6735
- key: 'out', className: 'dshic-textarea', style: { minHeight: 180 }, value: n.data.output || '',
6941
+ h(PromptArea, {
6942
+ key: 'out', value: n.data.output || '', minHeight: 180,
6943
+ ...phBind(n, 'out'),
6736
6944
  placeholder: running ? T('aiRunning') : '',
6737
- onChange: (e) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.output = e.target.value; }),
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
- 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); } });
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('textarea', {
6783
- key: 'script', className: 'dshic-textarea', style: { minHeight: 56 }, value: n.data.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
- disabled: !!scriptEdge,
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('textarea', {
6790
- key: 'req', className: 'dshic-textarea', style: { minHeight: 40 }, value: n.data.requirement || '',
7018
+ h(PromptArea, {
7019
+ key: 'req', value: n.data.requirement || '', minHeight: 40,
7020
+ ...phBind(n, 'req'),
6791
7021
  placeholder: T('storyboardReqLabel'),
6792
- onChange: (e) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.requirement = e.target.value; }),
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 (connectFromId && doc.nodes && doc.nodes[connectFromId]) {
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
- const same = nodes.filter((n) => n.type === type);
798
- if (same.length) {
799
- // 同类型竖向成列:跟在同类型最后一个节点下方
800
- x = Math.min(...same.map((n) => n.x || 0));
801
- y = Math.max(...same.map((n) => (n.y || 0) + H(n))) + GAP_Y;
802
- } else if (nodes.length) {
803
- // 该类型还没有节点 → 在最右侧另起一列(绝不插进已有列里跟别的类型混排)
804
- x = Math.max(...nodes.map((n) => (n.x || 0) + W(n))) + GAP_X;
805
- y = Math.min(...nodes.map((n) => n.y || 0));
806
- }
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.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",