@ninkch/dsh-infinite-canvas 0.18.4 → 0.18.5

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/client.js CHANGED
@@ -20,7 +20,9 @@ window.__ModuleLoader__.load({
20
20
  const NODE_W = 240;
21
21
  const CRAFT_SKILL = 'canvas-craft'; // 内置创作方法论技能(未选技能时默认注入)
22
22
 
23
- const NODE_TYPES = ['text', 'shot', 'image-gen', 'video-gen', 'image-asset', 'video-asset'];
23
+ // 节点类型的唯一事实源是 TYPE_LABEL / TYPE_KIND / PORTS(见下方):历史上这里还有一个
24
+ // 写死的类型清单常量,既不完整(缺 script/storyboard/finalcut/group)又无人引用,
25
+ // 已由 IC-02 删除;audit.mjs 会断言它不会复活。
24
26
 
25
27
  // 每类节点的端口定义(kind: text | image | video)
26
28
  const PORTS = {
@@ -45,10 +47,11 @@ window.__ModuleLoader__.load({
45
47
  },
46
48
  'video-gen': {
47
49
  inputs: [{ id: 'prompt', kind: 'text' }, { id: 'frame', kind: 'image' }, { id: 'ref', kind: 'image' }],
48
- outputs: [{ id: 'out', kind: 'video' }, { id: 'last', kind: 'image' }],
50
+ // last = 真·最后一帧(画面结尾);tailpick = 续帧接力的最佳选帧(动作进行中/清晰/不抖)
51
+ outputs: [{ id: 'out', kind: 'video' }, { id: 'tailpick', kind: 'image' }, { id: 'last', kind: 'image' }],
49
52
  },
50
53
  'image-asset': { inputs: [{ id: 'in', kind: 'image' }], outputs: [{ id: 'out', kind: 'image' }] },
51
- 'video-asset': { inputs: [{ id: 'in', kind: 'video' }], outputs: [{ id: 'out', kind: 'video' }, { id: 'last', kind: 'image' }] },
54
+ 'video-asset': { inputs: [{ id: 'in', kind: 'video' }], outputs: [{ id: 'out', kind: 'video' }, { id: 'tailpick', kind: 'image' }, { id: 'last', kind: 'image' }] },
52
55
  'finalcut': { inputs: [{ id: 'in', kind: 'video' }], outputs: [] },
53
56
  'group': { inputs: [], outputs: [] },
54
57
  };
@@ -188,6 +191,7 @@ window.__ModuleLoader__.load({
188
191
  'duplicate': '复制节点 (Ctrl+D)',
189
192
  'confirmDel': '确认删除?',
190
193
  'newProject': '新建项目',
194
+ 'emptyProjectHint': '还没有项目。点上方「新建项目」创建第一个项目,或直接在对话里说「创建一个项目叫…」。',
191
195
  'projectName': '项目名称',
192
196
  'renameHint': '双击名称重命名',
193
197
  'delProjectHint': '删除项目及其资产',
@@ -258,6 +262,32 @@ window.__ModuleLoader__.load({
258
262
  'hideKey': '隐藏(脱敏显示)',
259
263
  'supportBtn': '帮助与反馈',
260
264
  'supportTitle': '帮助与反馈',
265
+ // 快捷键面板(左上角浮层)
266
+ 'keysBtn': '快捷键',
267
+ 'keysTitle': '快捷键与操作',
268
+ 'guideDesc': '在对话旁直接分镜、生成视频与成片',
269
+ 'hostTakenTitle': '画布已在另一侧打开',
270
+ 'hostTakenHint': '同一时刻画布只能在一处运行(共享同一份节点与生成状态),点击下方按钮把画布切换到这里。',
271
+ 'hostTakeOver': '把画布切换到这里',
272
+ 'keysHint': '点击按钮收起;Esc 也可关闭',
273
+ 'keysDrag': '画布操作',
274
+ 'keysEdit': '编辑',
275
+ 'keysView': '视图',
276
+ 'kAltDrag': '按住 Alt 拖动节点:自由摆放,不推挤/不吸附(可覆盖穿过其他节点)',
277
+ 'kDrag': '拖动节点:碰到邻居时实时推挤让位(保持不重叠)',
278
+ 'kShiftDrag': 'Shift + 拖动:临时关闭对齐吸附',
279
+ 'kShiftClick': 'Shift + 点击:加选/取消选中节点',
280
+ 'kMarquee': 'Shift + 拖动空白:框选',
281
+ 'kMiddle': '中键拖动:平移画布',
282
+ 'kWheel': '滚轮:以指针为中心缩放',
283
+ 'kDblClick': '双击空白:添加节点菜单',
284
+ 'kRightClick': '右键:节点/画布菜单',
285
+ 'kUndo': 'Ctrl / ⌘ + Z:撤销',
286
+ 'kRedo': 'Ctrl / ⌘ + Shift + Z(或 Ctrl + Y):重做',
287
+ 'kCopy': 'Ctrl / ⌘ + D:复制选中节点',
288
+ 'kDel': 'Delete / Backspace:删除选中的节点或连线',
289
+ 'kEsc': 'Esc:关闭菜单 / 取消选择',
290
+ 'kZoomKeys': '双击项目名:重命名项目',
261
291
  'supportNote': '有问题或改进建议,提 Issue 或进群反馈。',
262
292
  'supportGithub': 'GitHub 仓库',
263
293
  'supportIssues': '访问 GitHub,提 Issue',
@@ -296,7 +326,7 @@ window.__ModuleLoader__.load({
296
326
  'lbHint': '点击空白处或按 Esc 关闭',
297
327
  'resizeList': '拖拽调整分镜列表高度',
298
328
  'frameModeLabel': '衔接',
299
- 'frameModeHint': '首帧口连线时的处理方式。网关禁止首帧与参考图混用:融合=尾帧降级为第1张参考图(保留实体参考图,衔接为软约束);硬首帧=用真首帧参数(画面绝对承接,但放弃参考图);忽略=不使用首帧',
329
+ 'frameModeHint': '首帧来源的处理方式(首帧=上一段尾帧,用于跨段接戏)。网关禁止首帧与参考图混用:融合=尾帧降级为第1张参考图(保留实体参考图,衔接为软约束+提示词锚定);硬首帧=用真首帧参数(画面绝对承接,但放弃参考图);忽略=不使用首帧。即使没有连 frame 线,续帧段也会自动取上一段尾帧作首帧',
300
330
  'frameModeAuto': '参考图融合',
301
331
  'frameModeHard': '硬首帧(弃参考图)',
302
332
  'frameModeOff': '忽略首帧',
@@ -385,10 +415,12 @@ window.__ModuleLoader__.load({
385
415
  'modelsLabel': '模型',
386
416
  'modelNamePh': '模型 ID(如 doubao-seedance-2.0)',
387
417
  'starDefault': '设为默认模型(节点未指定时自动路由到这里)',
388
- 'noModelsCfg': '未配置模型——节点运行将报错',
418
+ 'noModelsCfg': '未配置模型——运行会提示先配置(对话侧将返回可读错误)',
389
419
  'errNoImageModel': '未指定图片模型:请在设置里配置图片模型并星标默认模型,或在节点上选择模型',
390
420
  'errNoVideoModel': '未指定视频模型:请在设置里配置视频模型并星标默认模型,或在节点上选择模型',
391
421
  'consistencyTitle': '一致性检查',
422
+ 'refreshCheck': '刷新',
423
+ 'refreshCheckHint': '按当前设定集重新校验(生成后补全的设定集条目不会让旧告警自动消失)',
392
424
  'restartHint': '插件后台尚未加载最新版本:请重启一次 DeepSeek Harness 后重试(仅首次更新需要)',
393
425
  'rpcFail': '请求失败',
394
426
  'skillEnhance': '技能增强(用已安装技能替代内置专家方法论)',
@@ -531,7 +563,24 @@ window.__ModuleLoader__.load({
531
563
  'ungroup': '解散分组',
532
564
  'redraw': '重绘',
533
565
  'lastFrame': '尾帧',
566
+ 'tailPick': '尾帧选帧',
567
+ 'tailPickHint': '续帧接力用的尾帧:从片尾多帧里选「动作进行中、清晰、不抖」的一帧,而不是机械取最后一帧',
534
568
  'chainNext': '接续下一镜',
569
+ 'chainInfo': '续帧:首帧来自上一段尾帧',
570
+ 'chainAuto': '自动取上一段尾帧作首帧',
571
+ 'chainManual': '首帧来自连线',
572
+ 'contToggle': '分镜衔接',
573
+ 'contToggleHint': '开启后,分镜会为每镜写出「承接上镜尾态 / 接点尾态(动作进行中)/ 运镜延续 / 段落类型」——分段生成才能接得上',
574
+ 'shotTail': '接点尾态',
575
+ 'segOpening': '首段·交代',
576
+ 'segContinuation': '续帧',
577
+ 'segNew_character': '新角色入场',
578
+ 'segScene_change': '换场',
579
+ 'segCloseup': '特写插入',
580
+ 'segCut': '特写插入',
581
+ 'seamOk': '接缝通过',
582
+ 'seamFail': '接缝偏跳',
583
+ 'seamHint': '接缝校验:本段首帧与上一段尾帧的画面相似度',
535
584
  'craftCard': 'Craft 后期(ArtCraft)',
536
585
  'craftUnreachable': 'Craft 未连接:确认 dsh-artcraft 插件已安装/启用并重启过 Harness',
537
586
  'craftDataRoot': '数据目录',
@@ -596,6 +645,7 @@ window.__ModuleLoader__.load({
596
645
  'duplicate': 'Duplicate (Ctrl+D)',
597
646
  'confirmDel': 'Confirm delete?',
598
647
  'newProject': 'New project',
648
+ 'emptyProjectHint': 'No projects yet. Click "New project" above to create the first one, or just say "create a project called…" in the chat.',
599
649
  'projectName': 'Project name',
600
650
  'renameHint': 'Double-click to rename',
601
651
  'delProjectHint': 'Delete project and its assets',
@@ -667,6 +717,32 @@ window.__ModuleLoader__.load({
667
717
  'hideKey': 'Hide key (masked)',
668
718
  'supportBtn': 'Help & feedback',
669
719
  'supportTitle': 'Help & feedback',
720
+ // Shortcuts panel (floating, top-left of the canvas)
721
+ 'keysBtn': 'Shortcuts',
722
+ 'keysTitle': 'Shortcuts & gestures',
723
+ 'guideDesc': 'Storyboard, generate and cut right beside the chat',
724
+ 'hostTakenTitle': 'The canvas is open elsewhere',
725
+ 'hostTakenHint': 'The canvas runs in one place at a time (one shared node graph and generation state). Move it here with the button below.',
726
+ 'hostTakeOver': 'Move the canvas here',
727
+ 'keysHint': 'Click the button again to collapse; Esc also closes',
728
+ 'keysDrag': 'Canvas',
729
+ 'keysEdit': 'Editing',
730
+ 'keysView': 'View',
731
+ 'kAltDrag': 'Hold Alt while dragging a node: free placement, no pushing / no snapping (may overlap other nodes)',
732
+ 'kDrag': 'Drag a node: neighbours are pushed aside in real time (nodes never overlap)',
733
+ 'kShiftDrag': 'Shift + drag: temporarily disable alignment snapping',
734
+ 'kShiftClick': 'Shift + click: add / remove a node from the selection',
735
+ 'kMarquee': 'Shift + drag on empty canvas: marquee select',
736
+ 'kMiddle': 'Middle-button drag: pan the canvas',
737
+ 'kWheel': 'Wheel: zoom around the pointer',
738
+ 'kDblClick': 'Double-click empty canvas: add-node menu',
739
+ 'kRightClick': 'Right-click: node / canvas context menu',
740
+ 'kUndo': 'Ctrl / ⌘ + Z: undo',
741
+ 'kRedo': 'Ctrl / ⌘ + Shift + Z (or Ctrl + Y): redo',
742
+ 'kCopy': 'Ctrl / ⌘ + D: duplicate selected nodes',
743
+ 'kDel': 'Delete / Backspace: delete selected nodes or edge',
744
+ 'kEsc': 'Esc: close menu / clear selection',
745
+ 'kZoomKeys': 'Double-click the project name: rename the project',
670
746
  'supportNote': 'Found a bug or have an idea? File an issue or join the group.',
671
747
  'supportGithub': 'GitHub repository',
672
748
  'supportIssues': 'Visit GitHub & file an issue',
@@ -704,7 +780,7 @@ window.__ModuleLoader__.load({
704
780
  'lbHint': 'Click outside or press Esc to close',
705
781
  'resizeList': 'Drag to resize the shot list',
706
782
  'frameModeLabel': 'Chaining',
707
- 'frameModeHint': 'How the first-frame input is used. The gateway forbids mixing first_frame with reference_image: Fuse = the tail frame becomes reference image #1 (keeps entity refs; continuity is a soft constraint); Hard = true first_frame param (exact continuity, drops refs); Ignore = do not use the frame',
783
+ 'frameModeHint': 'How the first frame is sourced (first frame = previous segment tail, for cross-segment continuity). The gateway forbids mixing first_frame with reference_image: Fuse = tail frame becomes reference image #1 (keeps entity refs; soft constraint + prompt anchoring); Hard = true first_frame param (exact continuity, drops refs); Ignore = no frame. Even without a frame wire, continuation segments auto-take the previous tail',
708
784
  'frameModeAuto': 'Fuse with refs',
709
785
  'frameModeHard': 'Hard first frame',
710
786
  'frameModeOff': 'Ignore frame',
@@ -755,6 +831,7 @@ window.__ModuleLoader__.load({
755
831
  'scriptAssetPrefix': 'Script-',
756
832
  'storyboardAssetPrefix': 'Shots-',
757
833
  'defaultProject': 'My Canvas',
834
+ 'emptyProjectHint': 'No projects yet. Click "New Project" above to create your first one, or just ask the assistant: "create a project called …".',
758
835
  'shotPrefix': 'Shot ',
759
836
  'shotsUnit': 'shots',
760
837
  'savedOk': 'Saved as asset',
@@ -793,10 +870,12 @@ window.__ModuleLoader__.load({
793
870
  'modelsLabel': 'Models',
794
871
  'modelNamePh': 'Model id (e.g. doubao-seedance-2.0)',
795
872
  'starDefault': 'Set as default model (nodes without a selection route here)',
796
- 'noModelsCfg': 'No models configured — node runs will fail',
873
+ 'noModelsCfg': 'No models configured — runs will ask you to configure first (readable error in chat)',
797
874
  'errNoImageModel': 'No image model selected: configure image models in Settings and star a default, or pick one on the node',
798
875
  'errNoVideoModel': 'No video model selected: configure video models in Settings and star a default, or pick one on the node',
799
876
  'consistencyTitle': 'Consistency check',
877
+ 'refreshCheck': 'Refresh',
878
+ 'refreshCheckHint': 'Re-check against the current series bible (entries added after generation do not clear stale warnings automatically)',
800
879
  'restartHint': 'The plugin backend is not loaded yet: restart DeepSeek Harness once and retry (first update only)',
801
880
  'rpcFail': 'Request failed',
802
881
  'skillEnhance': 'Skill boost (use installed Harness skills instead of built-in experts)',
@@ -939,7 +1018,24 @@ window.__ModuleLoader__.load({
939
1018
  'ungroup': 'Ungroup',
940
1019
  'redraw': 'Redraw from this',
941
1020
  'lastFrame': 'Last frame',
1021
+ 'tailPick': 'Tail pick',
1022
+ 'tailPickHint': 'Tail frame used for chaining: the best "mid-motion, sharp, stable" frame near the end, not blindly the last one',
942
1023
  'chainNext': 'Chain next shot',
1024
+ 'chainInfo': 'Chained: first frame comes from the previous segment tail',
1025
+ 'chainAuto': 'Auto: previous segment tail as first frame',
1026
+ 'chainManual': 'First frame from wire',
1027
+ 'contToggle': 'Continuity',
1028
+ 'contToggleHint': 'When on, the storyboard writes a tail state / start state / camera carry-over / segment type for every shot — that is what makes separately generated segments connect',
1029
+ 'shotTail': 'Hand-off tail',
1030
+ 'segOpening': 'Opening',
1031
+ 'segContinuation': 'Continuation',
1032
+ 'segNew_character': 'New character',
1033
+ 'segScene_change': 'Scene change',
1034
+ 'segCloseup': 'Insert',
1035
+ 'segCut': 'Insert',
1036
+ 'seamOk': 'Seam OK',
1037
+ 'seamFail': 'Seam jump',
1038
+ 'seamHint': 'Seam check: similarity between this segment first frame and the previous tail frame',
943
1039
  'craftCard': 'Craft post (ArtCraft)',
944
1040
  'craftUnreachable': 'Craft not connected: make sure dsh-artcraft is installed/enabled and Harness was restarted',
945
1041
  'craftDataRoot': 'Data root',
@@ -1248,6 +1344,44 @@ window.__ModuleLoader__.load({
1248
1344
  .dshic-mask-stage img{max-width:100%;max-height:52vh;display:block;}
1249
1345
  .dshic-mask-paint{position:absolute;inset:0;width:100%;height:100%;cursor:crosshair;touch-action:none;}
1250
1346
  .dshic-mask-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
1347
+ /* 快捷键入口(工具栏内)+ 下拉面板(绝对定位在按钮下方) */
1348
+ .dshic-keys-wrap{position:relative;display:inline-flex;flex:none;}
1349
+ .dshic-keys-btn{display:inline-flex;align-items:center;gap:5px;height:28px;padding:0 9px;border-radius:8px;
1350
+ border:none;background:transparent;cursor:pointer;font:inherit;font-size:13px;white-space:nowrap;flex:none;
1351
+ color:var(--dsw-alias-label-primary);}
1352
+ .dshic-keys-btn:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 8%,transparent);}
1353
+ .dshic-keys-btn.is-open{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,transparent);color:var(--dsw-alias-brand-primary);}
1354
+ .dshic-keys-btn-label{line-height:1;}
1355
+ .dshic-keys-caret{font-size:9px;opacity:.75;line-height:1;}
1356
+ /* 面板右对齐:按钮位于工具栏右半侧,向左展开才不会出屏;窄屏换行时也跟随按钮 */
1357
+ .dshic-keys-pop{position:absolute;right:0;top:calc(100% + 6px);z-index:30;
1358
+ width:330px;max-height:min(70vh,520px);overflow:auto;box-sizing:border-box;padding:10px 12px;
1359
+ border-radius:10px;border:1px solid var(--dsw-alias-border-l2);
1360
+ background:color-mix(in srgb,var(--dsw-alias-bg-overlay) 72%,transparent);
1361
+ backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
1362
+ box-shadow:0 8px 24px color-mix(in srgb,var(--dsw-alias-bg-base) 50%,transparent);}
1363
+ .dshic-keys-title{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary);margin-bottom:8px;}
1364
+ .dshic-keys-group{margin-bottom:9px;}
1365
+ .dshic-keys-group-title{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
1366
+ color:var(--dsw-alias-label-secondary);opacity:.8;margin-bottom:4px;}
1367
+ .dshic-keys-row{display:flex;align-items:baseline;gap:8px;padding:2px 0;font-size:12px;line-height:17px;}
1368
+ .dshic-keys-key{flex:none;min-width:84px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
1369
+ font-size:11px;color:var(--dsw-alias-label-primary);background:color-mix(in srgb,var(--dsw-alias-label-primary) 8%,transparent);
1370
+ border-radius:4px;padding:1px 5px;text-align:center;white-space:nowrap;}
1371
+ .dshic-keys-text{flex:1;min-width:0;color:var(--dsw-alias-label-secondary);word-break:break-word;}
1372
+ .dshic-keys-hint{margin-top:2px;padding-top:7px;border-top:1px solid var(--dsw-alias-border-l2);
1373
+ font-size:11px;color:var(--dsw-alias-label-secondary);opacity:.75;}
1374
+ /* Alt 自由摆放(不推挤)时的节点轮廓:虚线 + 轻微透明,明确"现在可以覆盖穿过" */
1375
+ .dshic-node.is-free-drag{outline:2px dashed var(--dsw-alias-brand-primary);outline-offset:2px;opacity:.92;}
1376
+ /* 会话右栏:画布页签(占位卡) */
1377
+ .dshic-host-taken{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
1378
+ background:var(--dsw-alias-bg-base);z-index:5;}
1379
+ .dshic-host-taken-card{max-width:320px;display:flex;flex-direction:column;align-items:center;gap:10px;
1380
+ padding:20px 22px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;
1381
+ background:var(--dsw-alias-bg-layer-1);text-align:center;}
1382
+ .dshic-host-taken-title{font-size:13px;font-weight:600;}
1383
+ .dshic-host-taken-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);}
1384
+ .dshic-pane-tab-title{font-size:12px;font-weight:500;}
1251
1385
  /* 小地图 */
1252
1386
  .dshic-minimap{position:absolute;right:12px;bottom:12px;width:160px;height:100px;background:var(--dsw-alias-bg-overlay);
1253
1387
  border:1px solid var(--dsw-alias-border-l2);border-radius:8px;z-index:15;overflow:hidden;cursor:pointer;box-sizing:border-box;
@@ -1607,15 +1741,17 @@ window.__ModuleLoader__.load({
1607
1741
  * light=true(图片生成专用):只保留风格行与 [图N] 名称绑定——参考图本身就是外观事实源,
1608
1742
  * 内联设定全文会稀释参考图约束,且文字渲染型模型(Qwen-Image)会把追加文本直接画进画面(用户实测:
1609
1743
  * 设定集全文被渲染成画面右下角的乱码文字块、剧情词凭空生成道具)。视频路径不用 light(不渲染文字,上下文有益)。 */
1610
- function buildEffectivePrompt(prompt, refs, entities, bible, light) {
1744
+ function buildEffectivePrompt(prompt, refs, entities, bible, light, opts) {
1611
1745
  const zh = effLang() !== 'en';
1746
+ // 续帧降级模式(图1=尾帧)下实体参考图实际是图2..N+1 —— 与请求侧 videoRefNote 统一口径
1747
+ const picOffset = (opts && opts.picOffset) || 0;
1612
1748
  const bind = refs.map((r, i) => {
1613
1749
  if (r.entity) {
1614
1750
  const ent = matchBibleEntity(r.entity, bible);
1615
1751
  const desc = light ? '' : ((ent && ent.desc) || '');
1616
- return `[图${i + 1}] ${entityLabelOf((ent || {}).kind || 'prop')} ${r.entity}${desc ? '(' + desc + ')' : ''}`;
1752
+ return `[图${i + 1 + picOffset}] ${entityLabelOf((ent || {}).kind || 'prop')} ${r.entity}${desc ? '(' + desc + ')' : ''}`;
1617
1753
  }
1618
- return `[图${i + 1}] ${catLabel(r.category)}:${r.name}`;
1754
+ return `[图${i + 1 + picOffset}] ${catLabel(r.category)}:${r.name}`;
1619
1755
  }).join(';');
1620
1756
  const entLines = light ? [] : entities.map((e) => `${entityLabelOf(e.kind)} ${e.name}:${e.desc}`);
1621
1757
  return [
@@ -1665,6 +1801,439 @@ window.__ModuleLoader__.load({
1665
1801
  return [].concat(b.characters || [], b.scenes || [], b.props || [])
1666
1802
  .map((x) => (x && x.name ? String(x.name).trim() : '')).filter(Boolean);
1667
1803
  }
1804
+
1805
+ // ---------- 分镜衔接(续帧接力 / 段落调度 / 接缝校验)----------
1806
+ // 为什么需要它:每段视频是**独立采样**的,模型不知道上一段最后发生了什么。分镜只写"本镜拍什么"、
1807
+ // 不写"接在哪、怎么接",模型就自己脑补起点 → 每段各演各的。行业解法不是提示词玄学,而是三层工程:
1808
+ // ① 分镜层写「承接上镜尾态 / 本镜起始态 / 接点尾态(动作进行中)/ 运镜延续」;
1809
+ // ② 生成层把上一段尾帧作为本段首帧(首尾帧模式优先,其次尾帧降级为第 1 张参考图并软锚定);
1810
+ // ③ 剪辑/校验层比对接缝(尾帧 vs 下段首帧),超阈值打回。
1811
+ /** 归一到 0-1 的相似度(0.8 以上视为同一画面)。数字或 null。 */
1812
+ function normSim(x) {
1813
+ const v = Number(x);
1814
+ if (!Number.isFinite(v)) return null;
1815
+ return Math.max(0, Math.min(1, v > 1.0001 ? v / 100 : v));
1816
+ }
1817
+ /** 镜头画面文本(visual + motion),用于衔接描述与相似度兜底。 */
1818
+ function shotVisualText(sh) {
1819
+ if (!sh) return '';
1820
+ return [String(sh.visual || '').trim(), String(sh.motion || '').trim()].filter(Boolean).join(';');
1821
+ }
1822
+ /**
1823
+ * 镜头衔接规划:把「尾部状态 / 起始状态 / 运镜延续 / 段落类型 / 接缝档位」统一解出来。
1824
+ * 分镜字段可手写(tail/start/cam),缺失时用 visual 兜底——**绝不编造剧情**,只做"能确认的推断"。
1825
+ * seg: 'opening'(首段,交代)| 'continuation'(续帧,默认)| 'new_character'(新角色入场,参考图模式)
1826
+ * | 'scene_change'(换场,不接尾帧)| 'closeup'(特写/细节插入,接缝不被画面盯着看 → 标准放宽)
1827
+ * join: 'strict'(同步长硬接)| 'loose'(跨场/换景,允许软接)| 'cut'(特写插入,跳切合理,不接力)
1828
+ * @returns {{seg,name,join,fromTail,fromShot,tail,start,cam,chain,note}}
1829
+ */
1830
+ function shotContinuityPlan(sh, prev, idx) {
1831
+ const s = sh || {};
1832
+ const i = Number.isFinite(Number(idx)) ? Number(idx) : 0;
1833
+ const prevShot = i > 0 ? (prev || null) : null;
1834
+ const segRaw = String(s.seg || '').trim().toLowerCase();
1835
+ const SEG_ALIAS = {
1836
+ opening: 'opening', open: 'opening', '首段': 'opening', '开场': 'opening',
1837
+ continuation: 'continuation', cont: 'continuation', '续': 'continuation', '续帧': 'continuation', '承接': 'continuation',
1838
+ new_character: 'new_character', newchar: 'new_character', '新角色': 'new_character', '入场': 'new_character',
1839
+ scene_change: 'scene_change', scene: 'scene_change', '换场': 'scene_change', '转场': 'scene_change',
1840
+ closeup: 'closeup', insert: 'closeup', '特写': 'closeup', '插入': 'closeup',
1841
+ };
1842
+ let seg = SEG_ALIAS[segRaw] || '';
1843
+ const boxed = (k) => String(s[k] || '').trim();
1844
+ if (!seg) {
1845
+ if (!prevShot) seg = 'opening';
1846
+ else if (/换场|场景切换|转场|另一处|另一场景|切至|切到|次日|翌日|与此同时|数日后|[Cc]ut to/.test(shotVisualText(s))) seg = 'scene_change';
1847
+ else if (/新角色|首次出场|走进来|入镜|入场|冒出|推门进|[Ee]nters?/.test(shotVisualText(s))) seg = 'new_character';
1848
+ else if (Number(s.duration) > 0 && Number(s.duration) < 1.5 && !(s.dialogue || []).length) seg = 'closeup';
1849
+ else seg = 'continuation';
1850
+ }
1851
+ if (seg === 'opening' && prevShot) seg = 'continuation'; // 首段只能在第 1 镜;后面标"开场"视为续帧
1852
+ // 接缝档位:换场/新角色入场都放宽(新角色段本就不承接上段画面),特写插入允许跳切
1853
+ const join = (seg === 'scene_change' || seg === 'new_character') ? 'loose' : seg === 'closeup' ? 'cut' : 'strict';
1854
+ // 只有真正需要画面承接的段落才接力尾帧;新角色入场首帧若来自上一段,新角色反而无从进入
1855
+ const chain = seg === 'continuation' && !!prevShot;
1856
+ // ownTail = 本镜的接点尾态(留给**下一镜**);cam = 本镜运镜(同样留给下一镜延续)
1857
+ const tail = boxed('tail');
1858
+ const cam = boxed('cam');
1859
+ // prevTail = **上一镜**的接点尾态 = 本镜的起始态(衔接块要引用的是它,不是本镜自己的 tail)。
1860
+ // 旧分镜没写 tail 时退到上一镜的「最后动作」——诚实标注来源,而不是拿本镜画面冒充。
1861
+ let prevTail = '';
1862
+ let prevTailSource = '';
1863
+ if (prevShot) {
1864
+ const pt = String(prevShot.tail || '').trim();
1865
+ const pm = String(prevShot.motion || '').trim();
1866
+ if (pt) { prevTail = pt; prevTailSource = 'tail'; }
1867
+ else if (pm) { prevTail = pm; prevTailSource = 'motion'; }
1868
+ }
1869
+ const start = boxed('start');
1870
+ return {
1871
+ idx: i, seg, join, fromTail: i > 0, fromShot: i > 0, chain,
1872
+ name: String(s.title || '').trim(),
1873
+ tail, cam, start, prevTail, prevTailSource,
1874
+ note: boxed('note'),
1875
+ };
1876
+ }
1877
+ /**
1878
+ * 由上一段分镜的尾镜推出本分镜的「承接约束」(跨分镜表接戏)。
1879
+ * 与 aiStoryboardShots 的 prevTail 契约一致:{ label, text, shot },但这里显式带出衔接字段。
1880
+ */
1881
+ function storyboardContinuityNote(prevShots, label, zh) {
1882
+ const arr = (prevShots || []).filter(Boolean);
1883
+ const tailShot = arr.length ? arr[arr.length - 1] : null;
1884
+ if (!tailShot) return null;
1885
+ // 尾镜的 tail/cam 是直接字段(第 0 镜没有"上一镜",规划器不会替它推断,所以直接读字段)
1886
+ const plan = shotContinuityPlan(tailShot, null, 0);
1887
+ const tailState = String(tailShot.tail || '').trim();
1888
+ const camState = String(tailShot.cam || '').trim();
1889
+ const text = [
1890
+ zh ? '尾镜画面:' : 'Tail frame: ', String(tailShot.visual || '').trim(),
1891
+ tailState ? (zh ? '\n尾镜接点尾态(本段第 1 镜必须从这里继续,动作处于进行中):' + tailState
1892
+ : '\nTail hand-off state (shot 1 continues from here, mid-motion): ' + tailState) : '',
1893
+ camState ? (zh ? '\n尾镜运镜(第 1 镜须同向延续):' + camState : '\nTail camera (shot 1 continues the same direction): ' + camState) : '',
1894
+ String(tailShot.motion || '').trim() ? '\n' + (zh ? '尾镜最后动作:' : 'Tail last action: ') + String(tailShot.motion).trim() : '',
1895
+ ].filter(Boolean).join('');
1896
+ return { label: String(label || ''), text: text + '\n', shot: tailShot, plan };
1897
+ }
1898
+ /**
1899
+ * 镜头衔接块(写进视频提示词):让模型知道"接在哪、从哪起、留什么给下一段"。
1900
+ * 只在分镜明确给出衔接信息或确属跨分镜承接时生成;普通首镜(无上游)返回 '',绝不无中生有。
1901
+ */
1902
+ function shotContinuityBlock(plan, opts) {
1903
+ const p = plan || {};
1904
+ const o = opts || {};
1905
+ const zh = o.zh !== false;
1906
+ if (!p.fromShot) return '';
1907
+ const isFirst = p.idx === 0;
1908
+ // 段类型:本分镜表内续帧 / 承接上一个分镜表 / 不接(换场·特写插入)
1909
+ let kindLine = '';
1910
+ if (p.seg === 'scene_change') kindLine = zh ? '段落类型:换场(不承接上一镜画面,直接建立新空间;环境声可延续)' : 'Segment type: scene change (do not continue the previous frame; establish the new space; ambience may carry over)';
1911
+ else if (p.seg === 'closeup') kindLine = zh ? '段落类型:特写/细节插入(可与上一镜跳切,不要求画面连续)' : 'Segment type: insert close-up (jump cut allowed, no frame continuity required)';
1912
+ else if (p.seg === 'new_character') kindLine = zh ? '段落类型:新角色入场(新角色须在画面中自然进入,不得凭空出现)' : 'Segment type: new character enters (must enter naturally, never pop in)';
1913
+ else if (p.seg === 'continuation') kindLine = zh
1914
+ ? (isFirst ? '段落类型:承接上一个分镜表(本分镜第 1 镜从上一镜结尾继续,不重演、不重新建立场景)' : '段落类型:续帧(从上一镜结尾继续,不重演)')
1915
+ : (isFirst ? 'Segment type: continues the previous storyboard (no re-establishing, no replay)' : 'Segment type: continuation (continue from the previous shot, no replay)');
1916
+ const parts = [];
1917
+ // 承接上镜结尾:引用的是**上一镜**的接点尾态(本镜的起始态),不是本镜自己的 tail。
1918
+ // 旧分镜没写 tail 时用上一镜的 motion 兜底,并诚实标注来源(不拿本镜画面冒充上一镜结尾)。
1919
+ if (p.prevTail && (p.seg === 'continuation' || p.seg === 'new_character')) {
1920
+ const src = p.prevTailSource === 'motion'
1921
+ ? (zh ? '(上一镜未写接点尾态,按其最后动作推断)' : ' (previous shot has no explicit hand-off state; inferred from its last action)')
1922
+ : '';
1923
+ parts.push((zh ? '承接上镜结尾(本镜起始态,不得重置姿态/位置/手持物):' : 'Continue from the previous shot end state (start exactly here; do not reset posture/position/handheld props): ') + p.prevTail + src);
1924
+ } else if (p.start && p.seg === 'continuation') {
1925
+ parts.push((zh ? '本镜起始态:' : 'Start state: ') + p.start);
1926
+ }
1927
+ // 运镜延续:优先用上一镜的运镜(本镜要接着走);没有则用本镜自己写的 cam
1928
+ if (p.cam) parts.push((zh ? '运镜延续:' : 'Camera continues: ') + p.cam + (zh ? '(方向与速度保持,不反向、不突变)' : ' (same direction and speed; no reversal, no sudden change)'));
1929
+ // 本镜的接点尾态单独提示,明确它是留给下一段的(同时提醒不要在片尾收成定格)
1930
+ if (p.tail && p.seg === 'continuation') {
1931
+ parts.push((zh ? '本镜接点(片尾停在此状态,保持动作进行中,不要演完站定)' : 'End this shot on this hand-off state, still mid-motion; do not settle to a static pose') + ':' + p.tail);
1932
+ }
1933
+ if (p.note) parts.push((zh ? '接戏要求:' : 'Continuity requirement: ') + p.note);
1934
+ if (!kindLine && !parts.length) return '';
1935
+ const head = zh ? '【衔接】' : '[Continuity] ';
1936
+ const end = zh
1937
+ ? '禁止重启姿态、禁止重新建立场景、禁止改变人物朝向与站位(除非剧情明确要求)'
1938
+ : 'Never reset posture, never re-establish the scene, never change facing or blocking unless the story demands it';
1939
+ const body = [kindLine].concat(parts, [end]).filter(Boolean);
1940
+ // 统一标点:拼接前去掉各自尾部标点,避免出现「。;」这种叠标点
1941
+ const clean = body.map((x) => String(x).replace(/[。;;.、\s]+$/, '')).filter(Boolean);
1942
+ return head + clean.join(zh ? ';' : '; ') + (zh ? '。' : '.');
1943
+ }
1944
+ /**
1945
+ * 首帧(上一段尾帧)的软锚定说明:把首帧图钉在"第 1 帧",模型只允许往后运动。
1946
+ * 接口不支持硬首帧时这是**提高权重**,不是锁像素——调用方要给用户可见的告警。
1947
+ */
1948
+ function frameAnchorText(opts) {
1949
+ const o = opts || {};
1950
+ const zh = o.zh !== false;
1951
+ if (!o.hasFrame) return '';
1952
+ const asFirst = o.role === 'first_frame';
1953
+ return zh
1954
+ ? (asFirst
1955
+ ? '【首帧锚定】本次输入的首帧图就是上一段视频的结尾画面:第 1 帧必须与该图完全一致(构图、人物位置、朝向、手持道具、光线),只允许从第 1 帧之后继续运动。'
1956
+ : '【首帧锚定】[图1] 是上一段视频的结尾画面(衔接参考,非风格参考):第 1 帧必须与 [图1] 的画面一致——构图、人物位置、朝向、手持道具、主光方向都不得改动,只允许往后延续运动。')
1957
+ : (asFirst
1958
+ ? '[First-frame anchor] The input first frame IS the last frame of the previous segment: frame 1 must match it exactly (framing, blocking, facing, handheld props, light) and motion starts only after frame 1.'
1959
+ : '[First-frame anchor] [Picture 1] is the last frame of the previous segment (a continuity reference, not a style reference): frame 1 must match it - framing, blocking, facing, handheld props and key light unchanged; only continue the motion.');
1960
+ }
1961
+ /**
1962
+ * 尾帧选帧评分:不是"取最后一帧"——最后一帧常落在动作顶点/运动模糊/遮挡/收敛抖动上。
1963
+ * 返回 0-1 分,越高越适合当下一段首帧(动作进行中、清晰、无遮挡、不抖)。
1964
+ * @param m { motion(相邻帧差 0-1), sharp(清晰度 0-1), coverage(前景占比 0-1), jitter(模型收敛抖动 0-1), complete(动作完成度 0-1) }
1965
+ */
1966
+ function tailFrameScore(m) {
1967
+ const c = (x, d) => (Number.isFinite(Number(x)) ? Math.max(0, Math.min(1, Number(x))) : d);
1968
+ const motion = c(m && m.motion, 0.5);
1969
+ const sharp = c(m && m.sharp, 0.5);
1970
+ const coverage = c(m && m.coverage, 1);
1971
+ const jitter = c(m && m.jitter, 0);
1972
+ const complete = c(m && m.complete, 0.5);
1973
+ // 动作"进行中"最好:太快(模糊/顶点)或完全静止(定格)都扣分
1974
+ const motionFit = 1 - Math.abs(motion - 0.45) / 0.55;
1975
+ // 动作完成度:留 30% 未完成最利于续接(完成度 0.7 附近满分,1=完成态扣分)
1976
+ const incomplete = 1 - Math.abs(complete - 0.7) / 0.7;
1977
+ const score = 0.34 * Math.max(0, motionFit) + 0.30 * sharp + 0.16 * coverage + 0.14 * Math.max(0, incomplete) - 0.22 * jitter;
1978
+ return Math.max(0, Math.min(1, score));
1979
+ }
1980
+ /**
1981
+ * 帧间相似度(接缝校验):只比较缩略灰度图,抗压缩噪声。
1982
+ * 指标:MAD(平均绝对差)、粗糙结构相关(16 宫格均值相关)——两者都要过才算接得上。
1983
+ * @param a/b { data: Uint8ClampedArray|number[](RGBA), w, h }(同尺寸,调用方先缩到 32×32 灰度或同等)
1984
+ */
1985
+ function frameSeamMetrics(a, b) {
1986
+ const ga = a && a.data, gb = b && b.data;
1987
+ if (!ga || !gb || !ga.length || ga.length !== gb.length) return null;
1988
+ const n = ga.length;
1989
+ let sad = 0;
1990
+ let ma = 0, mb = 0;
1991
+ for (let i = 0; i < n; i++) { sad += Math.abs(ga[i] - gb[i]); ma += ga[i]; mb += gb[i]; }
1992
+ ma /= n; mb /= n;
1993
+ let cov = 0, va = 0, vb = 0;
1994
+ for (let i = 0; i < n; i++) { const da = ga[i] - ma, db = gb[i] - mb; cov += da * db; va += da * da; vb += db * db; }
1995
+ const corr = (va > 1e-6 && vb > 1e-6) ? cov / Math.sqrt(va * vb) : (Math.abs(ma - mb) < 6 ? 1 : 0);
1996
+ // 宫格结构相似:把图切成 G×G 求每格均值,再做相关(对整体位移/缩放不敏感,抓"画面是否同一空间")
1997
+ const G = 16;
1998
+ const side = Math.round(Math.sqrt(n)) || 1;
1999
+ const cellA = new Array(G * G).fill(0), cellB = new Array(G * G).fill(0), cnt = new Array(G * G).fill(0);
2000
+ for (let i = 0; i < n; i++) {
2001
+ const x = i % side, y = Math.floor(i / side);
2002
+ const gx = Math.min(G - 1, Math.floor((x / side) * G));
2003
+ const gy = Math.min(G - 1, Math.floor((y / side) * G));
2004
+ const k = gy * G + gx;
2005
+ cellA[k] += ga[i]; cellB[k] += gb[i]; cnt[k]++;
2006
+ }
2007
+ let cm = 0;
2008
+ const ca = cellA.map((v, k) => (cnt[k] ? v / cnt[k] : 0)), cb = cellB.map((v, k) => (cnt[k] ? v / cnt[k] : 0));
2009
+ for (let k = 0; k < ca.length; k++) cm += ca[k] / ca.length;
2010
+ let cv = 0, cv2 = 0, cva = 0, cvb = 0;
2011
+ for (let k = 0; k < ca.length; k++) { const da = ca[k] - cm, db = cb[k] - cm; cv += da * db; cva += da * da; cvb += db * db; }
2012
+ const gridCorr = (cva > 1e-6 && cvb > 1e-6) ? cv / Math.sqrt(cva * cvb) : 0;
2013
+ return {
2014
+ mad: sad / n, // 0-255
2015
+ corr, // -1..1
2016
+ gridCorr, // -1..1
2017
+ sim: Math.max(0, Math.min(1, 0.35 * corr + 0.65 * gridCorr)), // 0-1 综合相似度
2018
+ };
2019
+ }
2020
+ /** 接缝判定:超过档位阈值才算"接得上",否则给出明确原因(供 UI 打回/告警)。 */
2021
+ function seamVerdict(metrics, join) {
2022
+ if (!metrics) return { ok: true, sim: null, note: '' };
2023
+ const thr = join === 'loose' ? 0.35 : join === 'cut' ? 0.15 : 0.55;
2024
+ const sim = metrics.sim;
2025
+ const ok = sim >= thr;
2026
+ return {
2027
+ ok, sim, thr,
2028
+ note: ok ? '' : ('接缝相似度 ' + sim.toFixed(2) + ' < 阈值 ' + thr.toFixed(2) + '(' + (join === 'loose' ? '跨场/换景' : join === 'cut' ? '特写插入' : '同场续帧') + '):画面可能跳变'),
2029
+ };
2030
+ }
2031
+
2032
+ // ---------- 视频「参考生成」结构化提示词(对齐 MiniMax H3 / 优云智算的优化后格式)----------
2033
+ // 为什么需要它:机械拼装(风格 + [图N] 绑定 + 镜头列表 + 设定集全文)会让模型把台词/场景判错;
2034
+ // 这个格式把「素材 → 主体 → 分镜 → 台词 → 声音」显式绑定,模型据此做音画同步与口型对齐。
2035
+ /** 参考图 → 主体信息:实体名(@引用或资产名解析)、品类、设定集描述。 */
2036
+ function subjectInfoFromRef(ref, bible) {
2037
+ const raw = String((ref && ref.name) || '').trim();
2038
+ let name = String((ref && ref.entity) || '').trim();
2039
+ let kind = ref && ref.category === 'character' ? 'character' : ref && ref.category === 'scene' ? 'scene' : ref && ref.category === 'prop' ? 'prop' : '';
2040
+ if (!name) {
2041
+ const m = /^(角色|场景|道具)[-_//]/.exec(raw);
2042
+ if (m) kind = m[1] === '角色' ? 'character' : m[1] === '场景' ? 'scene' : 'prop';
2043
+ name = raw
2044
+ .replace(/^(角色|场景|道具)[-_//]\s*/, '')
2045
+ .replace(/\s*\d{1,2}-\d{1,2}\s+\d{1,2}:\d{2}[\s\S]*$/, '')
2046
+ .replace(/\s*(设定图|参考图|多视图|三视图)[\s\S]*$/, '')
2047
+ .trim();
2048
+ }
2049
+ const ent = bible && name ? matchBibleEntity(name, bible) : null;
2050
+ return { name: ent ? ent.name : name, kind: (ent && ent.kind) || kind, desc: ent ? String(ent.desc || '') : '' };
2051
+ }
2052
+ /** 描述压缩成一句:优先「外观:…」片段,其次首句,最长 80 字。 */
2053
+ function condenseDesc(desc) {
2054
+ const s = String(desc || '').replace(/\s+/g, ' ').trim();
2055
+ if (!s) return '';
2056
+ const ap = /外观[::]\s*([^。;]+)/.exec(s);
2057
+ const seg = ap ? ap[1] : s.split(/[。;]/)[0];
2058
+ const one = String(seg || '').replace(/^[,,、\s]+/, '').trim();
2059
+ return one.length > 80 ? one.slice(0, 80) + '…' : one;
2060
+ }
2061
+ /** 设定集描述里的「声音:…」→ 说话人声音标注(台词的音色依据)。 */
2062
+ function voiceFromDesc(desc) {
2063
+ const m = /声音[::]\s*([^。;]+)/.exec(String(desc || ''));
2064
+ return m ? m[1].trim() : '';
2065
+ }
2066
+ /**
2067
+ * 由分镜表 + 参考图生成「参考生成」结构化提示词。
2068
+ * 段名保持英文(与厂商优化后的协议一致),正文用中文;没有参考图时返回 ''(调用方回退到普通提示词)。
2069
+ * @param shots 分镜镜头(visual/motion/duration/dialogue/sfx/title/characters/scene/props)
2070
+ * @param refs collectRefs 结果(顺序即 <Picture N>)
2071
+ * @param bible 设定集(desc 提供外观/声音信息)
2072
+ * @param opts { zh, prompt(节点上手写的补充说明), music(非叙事音乐,来自扩展参数) }
2073
+ */
2074
+ function buildStructuredVideoPrompt(shots, refs, bible, opts) {
2075
+ const o = opts || {};
2076
+ const zh = o.zh !== false;
2077
+ const list = (shots || []).filter((s) => s && (String(s.visual || '').trim() || String(s.motion || '').trim() || (s.dialogue || []).length));
2078
+ if (!list.length) return '';
2079
+ // 主体分配:一个实体一个 Subject,可对应多张 Picture;顺序 = 参考图中首次出现
2080
+ const subjects = [];
2081
+ const byKey = new Map();
2082
+ // 续帧降级模式(图1=上一段尾帧):请求侧 videoRefNote/frameAnchorText 都把尾帧编号为 [图1]、
2083
+ // 实体参考图为 [图2..N+1] —— <Picture N> 绑定必须整体偏移一位,否则同一提示词里
2084
+ // <Picture 1> 说实体、[图2] 才是实体,主体-素材绑定自相矛盾(参考生成格式赖以工作的锚点)。
2085
+ const picOffset = (o.hasFrame && o.frameRole === 'reference_image') ? 1 : 0;
2086
+ (refs || []).forEach((r, i) => {
2087
+ const info = subjectInfoFromRef(r, bible);
2088
+ if (!info || !info.name) return;
2089
+ const key = info.name.toLowerCase();
2090
+ let sub = byKey.get(key);
2091
+ if (!sub) {
2092
+ sub = { name: info.name, kind: info.kind, desc: info.desc, voice: voiceFromDesc(info.desc), pics: [], voiced: false };
2093
+ subjects.push(sub);
2094
+ byKey.set(key, sub);
2095
+ }
2096
+ if (!sub.pics.includes(i + 1 + picOffset)) sub.pics.push(i + 1 + picOffset);
2097
+ });
2098
+ if (!subjects.length) return '';
2099
+ const sid = (sub) => subjects.indexOf(sub) + 1;
2100
+ const pic = (n2) => '<Picture ' + n2 + '>';
2101
+ // 名称 → <Subject N>(长名优先,避免"瓦力"吃掉"瓦力的铃铛");顺带清掉 @引用@ 的包裹符号,
2102
+ // 否则会留下「@<Subject 2>@」这种残迹(@ 是画布内的引用语法,不该发给视频模型)
2103
+ const ordered = subjects.slice().sort((a, b) => b.name.length - a.name.length);
2104
+ const mentionRe = /@([^@\s,。;、,;:.!?!?「」()()]{1,24})@/g;
2105
+ const rewrite = (text) => {
2106
+ let out = String(text || '');
2107
+ if (out.includes('@')) {
2108
+ out = out.replace(mentionRe, (all, inner) => {
2109
+ const hit = subjects.find((sub) => sub.name.toLowerCase() === String(inner).trim().toLowerCase());
2110
+ if (hit) return '<Subject ' + sid(hit) + '>';
2111
+ return inner; // 未登记的 @名称@ 去掉包裹符号(不要再把画布语法发给模型)
2112
+ });
2113
+ }
2114
+ for (const sub of ordered) {
2115
+ if (!sub.name) continue;
2116
+ out = out.split(sub.name).join('<Subject ' + sid(sub) + '>');
2117
+ }
2118
+ return out;
2119
+ };
2120
+ // 说话人编号:跨镜台词按首次出现顺序 (S1)、(S2)…
2121
+ const speakers = [];
2122
+ const speakerId = (roleName) => {
2123
+ const nm = String(roleName || '').trim();
2124
+ if (!nm) return '';
2125
+ let i = speakers.findIndex((x) => x.name === nm);
2126
+ if (i < 0) { speakers.push({ name: nm, voiced: false }); i = speakers.length - 1; }
2127
+ return 'S' + (i + 1);
2128
+ };
2129
+ // 绝对时间轴
2130
+ let acc = 0;
2131
+ const starts = list.map((s) => { const st = acc; acc += Math.max(0.1, Number(s.duration) || 3); return st; });
2132
+ const total = acc;
2133
+ const fmtT = (sec) => {
2134
+ const ms = Math.max(0, Math.round(sec * 1000));
2135
+ const m = Math.floor(ms / 60000);
2136
+ const s2 = Math.floor((ms % 60000) / 1000);
2137
+ return String(m).padStart(2, '0') + ':' + String(s2).padStart(2, '0') + '.' + String(ms % 1000).padStart(3, '0');
2138
+ };
2139
+ const appearsIn = (sub) => list.map((_, i) => i + 1).filter((idx) => {
2140
+ const s = list[idx - 1];
2141
+ const hay = [s.visual, s.motion, s.title, (s.characters || []).join(' '), s.scene, (s.props || []).join(' '), (s.dialogue || []).map((d) => d && d.role).join(' ')].join(' ');
2142
+ return hay.includes(sub.name);
2143
+ });
2144
+ const outs = [];
2145
+ // ① 主体定义(素材绑定)
2146
+ outs.push('subject_definitions:');
2147
+ subjects.forEach((sub, i) => {
2148
+ const brief = condenseDesc(sub.desc);
2149
+ outs.push('<Subject ' + (i + 1) + '> 是 ' + sub.pics.map(pic).join('、') + ' 中的 ' + sub.name
2150
+ + (brief ? ':' + brief : (sub.kind ? '(' + entityLabelOf(sub.kind) + ')' : '')));
2151
+ });
2152
+ // ② 总述(只写事实,不臆造剧情)
2153
+ const first = String(list[0].title || '').trim();
2154
+ const last = String(list[list.length - 1].title || '').trim();
2155
+ outs.push('summary:');
2156
+ outs.push('[参考生成] 目标视频共 ' + list.length + ' 镜、总时长 ' + (Math.round(total * 10) / 10) + ' 秒'
2157
+ + (first || last ? ':从「' + (first || '起始镜') + '」开始,到「' + (last || '收束镜') + '」结束' : '')
2158
+ + '。主体:' + subjects.map((s) => '<Subject ' + sid(s) + '>').join('、') + '。'
2159
+ + (o.prompt ? ' ' + String(o.prompt).trim() : ''));
2160
+ // ③ 保真核对(每个主体出现在哪些镜)
2161
+ outs.push('retention_analysis:');
2162
+ subjects.forEach((sub) => {
2163
+ const where = appearsIn(sub);
2164
+ const brief = condenseDesc(sub.desc) || (sub.kind ? entityLabelOf(sub.kind) : sub.name);
2165
+ outs.push('<Subject ' + sid(sub) + '>(出现于 ' + (where.length ? where.map((i) => '[Shot ' + i + ']').join('、') : '未明确出现') + '):fully_preserved —— 保持' + brief + '。');
2166
+ });
2167
+ // ④ 逐镜描述(画面 + 运镜 + 台词 + 音效)
2168
+ outs.push('detailed_description:');
2169
+ const style = String((bible && bible.style) || '').trim();
2170
+ if (style) outs.push((zh ? '风格:' : 'Style: ') + style);
2171
+ const hasDialogue = list.some((s) => (s.dialogue || []).some((d) => String((d && d.line) || '').trim()));
2172
+ if (hasDialogue) {
2173
+ outs.push(zh
2174
+ ? '台词必须由画面内对应的主体当场开口说出(口型与台词同步、语气贴合情绪);严禁画外音/旁白。'
2175
+ : 'Dialogue must be spoken on screen by the matching subject with lip-sync; never voice-over.');
2176
+ }
2177
+ // 跨段续帧:首帧锚定(上一段尾帧作为本段第 1 帧)——放在逐镜之前,权重最高
2178
+ const anchor = frameAnchorText({ hasFrame: !!o.hasFrame, role: o.frameRole, zh });
2179
+ if (anchor) outs.push(anchor);
2180
+ list.forEach((s, i) => {
2181
+ const parts = [];
2182
+ // 镜头衔接:段类型 + 承接上镜尾态 + 运镜延续(只对**本分镜表内**的镜头生成;
2183
+ // 第 1 镜跨段承接由 frameAnchorText/调用方负责,避免同一句话说两遍)
2184
+ const plan = shotContinuityPlan(s, list[i - 1], i);
2185
+ if (i > 0) {
2186
+ const cb = shotContinuityBlock(plan, { zh });
2187
+ // 衔接块自带句末标点,作为段落级前置说明独立成句(去掉句末标点后由上层分隔符统一)
2188
+ if (cb) parts.push(rewrite(cb).replace(/[。.]+$/, ''));
2189
+ }
2190
+ const visual = rewrite(String(s.visual || '').trim());
2191
+ if (visual) parts.push(visual);
2192
+ const motion = rewrite(String(s.motion || '').trim());
2193
+ if (motion) parts.push((zh ? '运镜/动作:' : 'Camera/Action: ') + motion);
2194
+ const dls = (s.dialogue || [])
2195
+ .map((d) => ({ role: String((d && d.role) || '').trim(), line: String((d && d.line) || '').trim() }))
2196
+ .filter((d) => d.line);
2197
+ if (dls.length) {
2198
+ const txt = dls.map((d) => {
2199
+ const sp = speakerId(d.role);
2200
+ const sub = subjects.find((x) => x.name === d.role);
2201
+ const spk = speakers.find((x) => x.name === d.role);
2202
+ const useVoice = !!(sub && sub.voice && spk && !spk.voiced);
2203
+ if (useVoice) spk.voiced = true;
2204
+ const who = sub ? ('<Subject ' + sid(sub) + '>(' + sp + ')') : (d.role ? d.role + '(' + sp + ')' : sp);
2205
+ const lang = /[\u4e00-\u9fa5]/.test(d.line) ? 'Chinese' : 'English';
2206
+ const said = useVoice
2207
+ ? (zh ? ' 用' + sub.voice + '说' : ' says (voice: ' + sub.voice + ')')
2208
+ : (zh ? ' 说' : ' says');
2209
+ return who + said + ':<d>[' + lang + '] ' + d.line + '</d>';
2210
+ }).join(';');
2211
+ parts.push((zh ? '台词:' : 'Dialogue: ') + txt);
2212
+ }
2213
+ const sfx = rewrite(String(s.sfx || '').trim());
2214
+ if (sfx) parts.push((zh ? '音效:' : 'SFX: ') + sfx);
2215
+ const title = String(s.title || '').trim();
2216
+ outs.push('[Shot ' + (i + 1) + '] ' + fmtT(starts[i]) + (zh ? ' 起' : '')
2217
+ + (title ? '(' + title + ')' : '') + ':' + parts.join(zh ? ';' : '; '));
2218
+ });
2219
+ // ⑤ 声音景观(各镜音效合并)
2220
+ const sfxAll = [...new Set(list.map((s) => String(s.sfx || '').trim()).filter(Boolean))];
2221
+ outs.push('overall_soundscape:');
2222
+ outs.push(sfxAll.length ? sfxAll.join(';') : (zh ? '仅保留场景环境声,不添加额外音效' : 'Ambient scene sound only'));
2223
+ // ⑥ 非叙事音乐(仅在用户显式给出时输出,绝不臆造)
2224
+ const music = String(o.music || '').trim();
2225
+ if (music) {
2226
+ outs.push('non_diegetic_music:');
2227
+ outs.push(music);
2228
+ }
2229
+ // ⑦ 跨段接戏(仅当本段承接上一段:有首帧尾帧接力或分镜标了承接)
2230
+ const contLine = String(o.continuity || '').trim();
2231
+ if (contLine) {
2232
+ outs.push('continuity:');
2233
+ outs.push(contLine);
2234
+ }
2235
+ return outs.join('\n');
2236
+ }
1668
2237
  // <<<MENTION_PURE_END>>>
1669
2238
 
1670
2239
  /** 解析提示词里的 @引用:返回命中实体 + 其名下的参考图资产与**声音参考资产**(按实体出现顺序、各实体内按生成时间)。
@@ -1858,6 +2427,9 @@ window.__ModuleLoader__.load({
1858
2427
  const assetUrlCache = new Map();
1859
2428
  const assetNameCache = new Map(); // assetId -> name(连线绑定的同步名称查询)
1860
2429
  function fileUrlFor(rel) { return '/' + NS + '/api/file?rel=' + encodeURIComponent(rel); }
2430
+ /** 磁盘落盘扩展名(IC-B3 统一后唯一保留的 mime→ext 映射):mime 子类型直取,
2431
+ * 非法子类型回退 .bin。与 extForDownload 的区别:落盘时 mime 已由生成链路给出
2432
+ * (mp4-first 录制 / png 编码),不存在"从文件名猜"的场景——这里没有任何名字兜底。 */
1861
2433
  function extFromMime(mime) {
1862
2434
  const sub = String(mime || '').split(';')[0].split('/')[1] || 'bin';
1863
2435
  return /^[a-z0-9]{1,8}$/i.test(sub) ? sub : 'bin';
@@ -1905,7 +2477,8 @@ window.__ModuleLoader__.load({
1905
2477
  }
1906
2478
  async function saveAssetRecord(projectId, kind, blob, meta) {
1907
2479
  const id = uid('a');
1908
- const mime = blob.type || (kind === 'image' ? 'image/png' : 'video/webm');
2480
+ const mime = blob.type && blob.type !== 'application/octet-stream' ? blob.type
2481
+ : (kind === 'image' ? 'image/png' : 'video/mp4');
1909
2482
  const rec = {
1910
2483
  id, projectId, kind,
1911
2484
  mime,
@@ -1920,36 +2493,29 @@ window.__ModuleLoader__.load({
1920
2493
  if (meta && Array.isArray(meta.entities) && meta.entities.length) rec.entities = meta.entities;
1921
2494
  if (meta && meta.nodeLabel) rec.nodeLabel = meta.nodeLabel;
1922
2495
  if (meta && meta.effectivePrompt) rec.effectivePrompt = meta.effectivePrompt;
1923
- if (S.dataRoot) {
1924
- if (S.store === 'sqlite') {
1925
- // 0.18 主路径:上传 CAS 对象(内容寻址去重)→ 服务端建资产行(任意浏览器/远程立即可见)
1926
- const r = await fetch('/' + NS + '/api/object', { method: 'POST', body: blob });
1927
- if (!r.ok) throw new Error('资产上传失败: ' + (await r.text()));
1928
- const j = await r.json();
1929
- rec.sha = j.sha;
1930
- rec.size = j.size || blob.size;
1931
- rec.stored = 'cas';
1932
- const proj = S.projects.find((p) => p.id === projectId);
1933
- const saved = await hostRpc('assetCreate', { asset: assetIndexRecord(rec), projectName: (proj && proj.name) || '' });
1934
- const out = (saved && saved.asset) || rec;
1935
- assetMem.set(out.id, out);
1936
- assetNameCache.set(out.id, out.name);
1937
- return out;
1938
- }
1939
- // JSON 降级(Host 无 node:sqlite):旧落盘路径 + 索引镜像
1940
- const rel = `projects/${projectId}/${kind}/${assetFileBase(rec)}.${extFromMime(mime)}`;
1941
- const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
1942
- if (!r.ok) throw new Error('资产落盘失败: ' + (await r.text()));
1943
- rec.rel = rel;
1944
- rec.size = blob.size;
1945
- rec.stored = 'disk';
1946
- } else {
1947
- rec.blob = blob;
1948
- rec.stored = 'idb';
2496
+ // 【硬规则】资产文件一律落数据目录的项目文件夹(projects/<项目>/<类型>/<文件>),元数据进
2497
+ // canvas.db;IndexedDB 已废弃——未配置数据目录时明确报错,绝不静默塞进浏览器。
2498
+ if (!S.dataRoot) {
2499
+ throw new Error('未设置数据根目录:资产无法持久化(浏览器 IndexedDB 存储已废弃)。请在 画布 → 设置 → 数据根目录 里配置后重新生成。');
2500
+ }
2501
+ const rel = `projects/${projectId}/${kind}/${assetFileBase(rec)}.${extFromMime(mime)}`;
2502
+ const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
2503
+ if (!r.ok) throw new Error('资产落盘失败: ' + (await r.text()));
2504
+ rec.rel = rel;
2505
+ rec.size = blob.size;
2506
+ rec.stored = 'disk';
2507
+ const proj = S.projects.find((p) => p.id === projectId);
2508
+ if (S.store === 'sqlite') {
2509
+ // 主路径:文件落项目文件夹 + 元数据进 canvas.db(任意浏览器/远程立即可见)
2510
+ const saved = await hostRpc('assetCreate', { asset: assetIndexRecord(rec), projectName: (proj && proj.name) || '' });
2511
+ const out = (saved && saved.asset) || rec;
2512
+ assetMem.set(out.id, out);
2513
+ assetNameCache.set(out.id, out.name);
2514
+ return out;
1949
2515
  }
1950
- await idbPut(rec);
2516
+ await idbPut(rec); // JSON 降级模式(宿主缺 node:sqlite):本机元数据缓存
1951
2517
  assetNameCache.set(rec.id, rec.name);
1952
- if (S.dataRoot) mirrorAssetIndex(rec); // JSON 降级模式:元数据镜像进 assets.json
2518
+ mirrorAssetIndex(rec); // 元数据镜像进 assets.json
1953
2519
  return rec;
1954
2520
  }
1955
2521
  /** 资产元数据索引记录(去 blob;文本资产保留 text 以便导入端免读文件即可预览)。 */
@@ -1975,8 +2541,8 @@ window.__ModuleLoader__.load({
1975
2541
  projectId: rec.projectId, projectName: (proj && proj.name) || rec.projectId, asset: assetIndexRecord(rec),
1976
2542
  }).catch(() => { /* 索引失败不影响资产本身 */ });
1977
2543
  }
1978
- /** 数据根目录就绪后:把浏览器 IndexedDB 里的存量 blob 资产搬进服务端(CAS + 资产行),搬完即从本地退役。
1979
- * 0.17 旧版本的"落盘到项目文件夹 + assets.json 索引"由 Host 侧 v1→v2 迁移负责,这里只处理从未落盘的遗留。 */
2544
+ /** 数据根目录就绪后:把浏览器 IndexedDB 里的存量 blob 资产搬到数据目录的项目文件夹
2545
+ * (projects/<项目>/<类型>/<文件>),搬完从 IndexedDB 移除——**IndexedDB 只减不增,最终清零**。 */
1980
2546
  async function autoMigrateAssetsToRoot() {
1981
2547
  if (!S.dataRoot) return;
1982
2548
  try {
@@ -1986,16 +2552,15 @@ window.__ModuleLoader__.load({
1986
2552
  let done = 0;
1987
2553
  for (const rec of pending) {
1988
2554
  try {
1989
- const r = await fetch('/' + NS + '/api/object', { method: 'POST', body: rec.blob });
2555
+ const mime = rec.blob.type || rec.mime || (rec.kind === 'image' ? 'image/png' : 'video/mp4');
2556
+ const rel = `projects/${rec.projectId}/${rec.kind}/${assetFileBase(rec)}.${extFromMime(mime)}`;
2557
+ const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: rec.blob });
1990
2558
  if (!r.ok) continue;
1991
- const j = await r.json();
1992
- const meta = { ...rec, sha: j.sha, size: rec.size || rec.blob.size };
2559
+ const meta = { ...rec, rel, size: rec.size || rec.blob.size };
1993
2560
  delete meta.blob;
1994
- meta.stored = 'cas';
2561
+ meta.stored = 'disk';
1995
2562
  const proj = S.projects.find((p) => p.id === rec.projectId);
1996
- const saved = await hostRpc('assetCreate', { asset: assetIndexRecord(meta), projectName: (proj && proj.name) || '' });
1997
- const out = (saved && saved.asset) || meta;
1998
- assetMem.set(out.id, out);
2563
+ await hostRpc('assetCreate', { asset: assetIndexRecord(meta), projectName: (proj && proj.name) || '' });
1999
2564
  try { await idbDelete(rec.id); } catch { /* 本地清理失败不阻塞迁移 */ }
2000
2565
  done++;
2001
2566
  } catch { /* 单个失败继续下一个 */ }
@@ -2084,7 +2649,8 @@ window.__ModuleLoader__.load({
2084
2649
  const g = cv.getContext('2d');
2085
2650
  const rnd = mulberry32(hashStr(prompt || 'demo'));
2086
2651
  const hues = [0, 1, 2, 3].map(() => Math.floor(rnd() * 360));
2087
- const mimes = ['video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm', 'video/mp4'];
2652
+ // mp4 优先(现代 Chrome/Edge 原生支持 H.264 录制):webm 只是兜底,保证产物统一为 mp4
2653
+ const mimes = ['video/mp4;codecs=avc1.42E01E', 'video/mp4', 'video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'];
2088
2654
  const mime = (typeof MediaRecorder !== 'undefined') && mimes.find((m) => MediaRecorder.isTypeSupported(m));
2089
2655
  if (!mime) throw new Error('当前环境不支持 MediaRecorder,无法使用演示视频生成');
2090
2656
  const stream = cv.captureStream(30);
@@ -2272,21 +2838,49 @@ window.__ModuleLoader__.load({
2272
2838
  } catch { /* 解析失败返回 null */ }
2273
2839
  return null;
2274
2840
  }
2841
+ /** 魔数嗅探真实编码(IC-04 抽出的纯函数):只认 PNG/JPEG/WebP 三类平台可收的字节签名。
2842
+ * 返回 { mime, ext, kind };不匹配(改后缀的 AVIF/HEIC 等)返回 null,调用方给出可执行报错。 */
2843
+ function sniffImageEncoding(u8) {
2844
+ const isPng = u8 && u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4e && u8[3] === 0x47;
2845
+ const isJpg = u8 && u8[0] === 0xff && u8[1] === 0xd8 && u8[2] === 0xff;
2846
+ const isWebp = u8 && u8[0] === 0x52 && u8[1] === 0x49 && u8[2] === 0x46 && u8[3] === 0x46
2847
+ && u8[8] === 0x57 && u8[9] === 0x45 && u8[10] === 0x42 && u8[11] === 0x50;
2848
+ if (isPng) return { mime: 'image/png', ext: 'png', kind: 'png' };
2849
+ if (isJpg) return { mime: 'image/jpeg', ext: 'jpg', kind: 'jpeg' };
2850
+ if (isWebp) return { mime: 'image/webp', ext: 'webp', kind: 'webp' };
2851
+ return null;
2852
+ }
2853
+ /** 参考图登记(IC-04 抽出的纯流程):先用解码尺寸登记,被平台以 width/height
2854
+ * 口径拒绝(EXIF 旋转图:浏览器解码会交换宽高,平台读原始文件)时用文件原始尺寸重试一次。
2855
+ * api = async (method, path, body) => 响应;两次都失败时抛出带两次原因的错误。 */
2856
+ async function registerReferenceAsset(api, regPayload, meta) {
2857
+ try {
2858
+ return await api('POST', '/image/v1/assets', regPayload(meta.width, meta.height));
2859
+ } catch (e) {
2860
+ const msg = String((e && e.message) || e);
2861
+ const alt = meta.rawWidth && meta.rawHeight && (meta.rawWidth !== meta.width || meta.rawHeight !== meta.height);
2862
+ if (!/width\/?height/i.test(msg) || !alt) throw e;
2863
+ try {
2864
+ const reg = await api('POST', '/image/v1/assets', regPayload(meta.rawWidth, meta.rawHeight));
2865
+ console.info('[infinite-canvas] 参考图登记改用文件原始尺寸成功:' + meta.rawWidth + '×' + meta.rawHeight);
2866
+ return reg;
2867
+ } catch (e2) {
2868
+ throw new Error(msg + ';改用文件原始尺寸 ' + meta.rawWidth + '×' + meta.rawHeight + ' 重试仍失败:' + ((e2 && e2.message) || e2));
2869
+ }
2870
+ }
2871
+ }
2275
2872
  /** 参考图上传前置:魔数嗅探真实编码(平台仅收 JPG/PNG/WebP,且校验声明 MIME 与实际字节一致——
2276
2873
  * 改后缀的 AVIF/HEIC 会在登记时报 "Params [Width/Height] not available")+ 测像素尺寸
2277
2874
  * (同时给出解码尺寸与文件原始尺寸,登记不一致时用原始尺寸重试)。 */
2278
2875
  async function imageBlobMeta(input) {
2279
2876
  const blob = input.blob || input;
2280
2877
  const head = new Uint8Array(await blob.slice(0, 16).arrayBuffer());
2281
- const isPng = head[0] === 0x89 && head[1] === 0x50 && head[2] === 0x4e && head[3] === 0x47;
2282
- const isJpg = head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff;
2283
- const isWebp = head[0] === 0x52 && head[1] === 0x49 && head[2] === 0x46 && head[3] === 0x46
2284
- && head[8] === 0x57 && head[9] === 0x45 && head[10] === 0x42 && head[11] === 0x50;
2285
- if (!isPng && !isJpg && !isWebp) {
2878
+ const sniffed = sniffImageEncoding(head);
2879
+ if (!sniffed) {
2286
2880
  throw new Error('参考图编码不受支持:平台仅接受 JPG/PNG/WebP 原生编码(当前文件的实际字节不是这三类,常见于改后缀的 AVIF/HEIC)——请转成 JPG/PNG 后重新挂参考图');
2287
2881
  }
2288
- const mime = isPng ? 'image/png' : isJpg ? 'image/jpeg' : 'image/webp';
2289
- const ext = isPng ? 'png' : isJpg ? 'jpg' : 'webp';
2882
+ const mime = sniffed.mime;
2883
+ const ext = sniffed.ext;
2290
2884
  let raw = null;
2291
2885
  try { raw = rawImageDims(new Uint8Array(await blob.slice(0, 262144).arrayBuffer())); } catch { /* 忽略 */ }
2292
2886
  let width = 0;
@@ -2298,7 +2892,7 @@ window.__ModuleLoader__.load({
2298
2892
  try { bmp.close && bmp.close(); } catch { /* 忽略 */ }
2299
2893
  } catch { /* 解码失败时退回文件头尺寸 */ }
2300
2894
  if (!width || !height) { if (raw) { width = raw.w; height = raw.h; } }
2301
- if (!width || !height) throw new Error('参考图尺寸测量失败');
2895
+ if (!width || !height) throw new Error('参考图尺寸测量失败(' + mime + ',文件头与浏览器解码都没读出宽高——文件可能已损坏)');
2302
2896
  return { blob, mime, ext, width, height, rawWidth: raw ? raw.w : width, rawHeight: raw ? raw.h : height };
2303
2897
  }
2304
2898
  async function compshareImageTask(prompt, refs, s, opts) {
@@ -2308,6 +2902,7 @@ window.__ModuleLoader__.load({
2308
2902
  if (!base) throw new Error('未配置图片 API 地址(优云智算:https://cp.compshare.cn)');
2309
2903
  const headers = { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: 'Bearer ' + String(s.apiKey || '').trim() };
2310
2904
  const extra = parseExtraParams(s.extra); // 透传:optimize_prompt / use_context_ir 等任务级字段
2905
+ if (o.warn && Array.isArray(refs) && refs.length > 10) o.warn('参考图 ' + refs.length + ' 张超过优云上限 10 张,本次只使用前 10 张');
2311
2906
  // 报错附带实际发出的参数:登记/提交被拒时一眼看到缺什么、值是多少(不再靠猜)
2312
2907
  const bodySummary = (b) => {
2313
2908
  if (!b || typeof b !== 'object') return '';
@@ -2352,22 +2947,15 @@ window.__ModuleLoader__.load({
2352
2947
  });
2353
2948
  if (!put.ok) throw new Error('参考图上传失败 HTTP ' + put.status + ':' + put.text.slice(0, 200));
2354
2949
  // 登记:宽高必须与平台解析出的文件尺寸一致。浏览器解码尺寸与文件原始尺寸在
2355
- // EXIF 旋转图上不同 → 先用解码尺寸,被拒(Params [Width/Height] not available)时用原始尺寸重试一次
2950
+ // EXIF 旋转图上不同 → 先用解码尺寸,被拒(Params [Width/Height] not available)时用原始尺寸重试一次。
2951
+ // 重试发生过 = 这张图带 EXIF 旋转,节点上给一条可见提示(用户下次换图心里有数)
2356
2952
  const regPayload = (w, h) => ({ object_key: up.object_key, file_name: fileName, content_type: meta.mime, file_size: meta.blob.size, width: w, height: h });
2357
- let reg = null;
2358
- try {
2359
- reg = await api('POST', '/image/v1/assets', regPayload(meta.width, meta.height));
2360
- } catch (e) {
2361
- const msg = String((e && e.message) || e);
2362
- const alt = meta.rawWidth && meta.rawHeight && (meta.rawWidth !== meta.width || meta.rawHeight !== meta.height);
2363
- if (!/width\/?height/i.test(msg) || !alt) throw e;
2364
- try {
2365
- reg = await api('POST', '/image/v1/assets', regPayload(meta.rawWidth, meta.rawHeight));
2366
- console.info('[infinite-canvas] 参考图登记改用文件原始尺寸成功:' + meta.rawWidth + '×' + meta.rawHeight);
2367
- } catch (e2) {
2368
- throw new Error(msg + ';改用文件原始尺寸 ' + meta.rawWidth + '×' + meta.rawHeight + ' 重试仍失败:' + ((e2 && e2.message) || e2));
2369
- }
2370
- }
2953
+ let retried = false;
2954
+ const reg = await registerReferenceAsset(api, (w, h) => {
2955
+ if (w === meta.rawWidth && h === meta.rawHeight && (meta.rawWidth !== meta.width || meta.rawHeight !== meta.height)) retried = true;
2956
+ return regPayload(w, h);
2957
+ }, meta);
2958
+ if (retried && o.warn) o.warn('参考图带 EXIF 旋转:平台按文件原始尺寸 ' + meta.rawWidth + '×' + meta.rawHeight + ' 登记成功(浏览器解码是 ' + meta.width + '×' + meta.height + ')');
2371
2959
  const aid = reg && reg.reference && reg.reference.asset_id;
2372
2960
  if (!aid) throw new Error('参考图登记失败:响应中没有 reference.asset_id');
2373
2961
  if (ref.assetId) compAssetIdCache.set(ref.assetId, aid);
@@ -2551,6 +3139,8 @@ window.__ModuleLoader__.load({
2551
3139
  const body = {
2552
3140
  model: s.model,
2553
3141
  content,
3142
+ // 声音参考与首次提交保持同一口径:降级换格式不能静默丢掉音色对齐(否则无任何告警)
3143
+ ...((o.audioRefs || []).length ? { audios: await Promise.all((o.audioRefs || []).slice(0, 3).map((b) => blobToDataUrl(b))) } : {}),
2554
3144
  metadata: {
2555
3145
  duration: Math.round(Number(o.duration)) || 5,
2556
3146
  ratio: o.ratio || '16:9',
@@ -3149,6 +3739,18 @@ window.__ModuleLoader__.load({
3149
3739
  return await downloadVideo(url, {});
3150
3740
  },
3151
3741
  };
3742
+ /** 各视频厂商的参考素材上限(与各 runner 内部的截断口径一致):
3743
+ * 超出上限会被截断——必须显式告警(o.warn → 节点 genWarn),绝不静默丢素材。 */
3744
+ const VIDEO_REF_CAPS = {
3745
+ wan: { img: 10 },
3746
+ minimaxv2: { img: 9, audio: 3 },
3747
+ openai: { img: 8 },
3748
+ seedance: { img: 8 },
3749
+ // img:0 = 该厂商的协议**没有任何实体参考图入口**(已核对各自 runner:只收首帧/提示词)——
3750
+ // 曾误标 img:1:恰 1 张时静默弃用不告警,≥2 张时告警"用前 1 张"实际 0 张,全部误导
3751
+ kling: { img: 0 }, vidu: { img: 0 }, runway: { img: 0 }, veo: { img: 0 },
3752
+ luma: { img: 0 }, pixverse: { img: 0 }, minimax: { img: 0 }, stability: { img: 0 },
3753
+ };
3152
3754
  async function apiVideo(prompt, frameBlob, s, opts) {
3153
3755
  const o = opts || {};
3154
3756
  // 节点级扩展参数(手动参数框)与供应商扩展参数合并(节点优先),各 runner 的 mergeExtra 自动生效
@@ -3165,10 +3767,30 @@ window.__ModuleLoader__.load({
3165
3767
  if (o.frameMode === 'hard' && frameBlob) o.hardFrame = true;
3166
3768
  // 厂商预设层:显式选择了厂商格式(或旧配置自动识别)→ 走该厂商的官方协议适配器
3167
3769
  const vendor = detectVideoVendor(s);
3770
+ let vendorDowngraded = false; // IC-03:识别到厂商但无适配器 → 降级走 OpenAI 兼容通用链(节点上可见)
3771
+ // 参考素材上限检查:超出即告警(截断发生在各 runner 内,这里先把事实说清楚)。
3772
+ // 未知厂商兜底 8 = IC-03 降级后的 OpenAI 兼容链实际发送上限。
3773
+ if (o.warn) {
3774
+ const caps = VIDEO_REF_CAPS[vendor] || { img: 8 };
3775
+ const refCount = o.hardFrame ? 0 : (Array.isArray(o.refs) ? o.refs.length : 0);
3776
+ if (refCount > (caps.img || 0)) {
3777
+ o.warn(caps.img
3778
+ ? ('参考图 ' + refCount + ' 张超过该厂商上限 ' + caps.img + ' 张,本次只使用前 ' + caps.img + ' 张')
3779
+ : ('该厂商不支持实体参考图,本次将忽略 ' + refCount + ' 张参考图(仅保留首帧与提示词;角色/场景一致性请改用支持参考图的厂商)'));
3780
+ }
3781
+ const audCount = Array.isArray(o.audioRefs) ? o.audioRefs.length : 0;
3782
+ if (audCount > (caps.audio || 0)) o.warn('参考音频 ' + audCount + ' 条超过该厂商上限 ' + (caps.audio || 0) + ' 条,本次只使用前 ' + (caps.audio || 0) + ' 条');
3783
+ }
3168
3784
  if (vendor !== 'openai' && vendor !== 'seedance') {
3169
3785
  const run = VIDEO_VENDOR_RUNNERS[vendor];
3170
- if (!run) throw new Error('暂未适配的视频厂商格式:' + vendor);
3171
- return await run(prompt, frameBlob, s, o);
3786
+ if (!run) {
3787
+ // IC-03:识别到的厂商没有官方适配器 → 不再硬失败(历史:`暂未适配的视频厂商格式:X` 直接炸),
3788
+ // 降级走下方 OpenAI 兼容通用链,并把降级事实放节点上(绝不静默换协议)。
3789
+ if (o.warn) o.warn('未识别到「' + vendor + '」的官方适配器,本次按 OpenAI 兼容格式发送(/v1/video/generations,失败自动降级 Seedance content[] / Sora /videos)——若该厂商协议不同,请在供应商设置里显式选择厂商');
3790
+ vendorDowngraded = true; // 源码级契约锚点(test-vendor-detect 断言):降级必须留痕
3791
+ } else {
3792
+ return await run(prompt, frameBlob, s, o);
3793
+ }
3172
3794
  }
3173
3795
  // OpenAI 风格视频接口(JUAI 等:/v1/video/generations)优先识别——
3174
3796
  // 即使误把协议设成 seedance 也不走火山官方协议(两者状态/结果字段不同)
@@ -3396,36 +4018,250 @@ window.__ModuleLoader__.load({
3396
4018
 
3397
4019
  /** 从视频 Blob 提取最后一帧为 PNG(通用:任何 API 生成的视频都能取尾帧)。 */
3398
4020
  async function extractLastFrame(videoBlob) {
4021
+ const r = await extractTailFrames(videoBlob, { count: 2, span: 0.2 });
4022
+ if (!r || !r.last) throw new Error('尾帧提取失败');
4023
+ return r.last;
4024
+ }
4025
+ /**
4026
+ * 尾帧候选抽取(续帧接力的关键):**不取最后一帧**。
4027
+ * 最后一帧常落在动作顶点、运动模糊、遮挡、模型收敛抖动上——用它当下一段首帧,接缝必然跳。
4028
+ * 做法:取末尾若干帧做候选,给每帧打「动作进行中 / 清晰 / 无遮挡 / 不抖」分,取最高分那帧。
4029
+ * @returns { blobs: Blob[], picks: Array<{ t, blob, score, metrics }>, best: { blob, t, score } | null }
4030
+ */
4031
+ async function extractTailFrames(videoBlob, opts) {
4032
+ const o = opts || {};
4033
+ const count = Math.max(2, Math.min(24, Number(o.count) || 9)); // 候选帧数(末段采样)
4034
+ const span = Math.max(0.1, Math.min(2, Number(o.span) || 0.8)); // 采样窗口:结尾前 0.8s
3399
4035
  const url = URL.createObjectURL(videoBlob);
4036
+ let v = null;
3400
4037
  try {
3401
- const v = document.createElement('video');
4038
+ v = document.createElement('video');
3402
4039
  v.src = url; v.muted = true; v.preload = 'auto';
3403
4040
  await new Promise((res, rej) => {
3404
4041
  v.onloadedmetadata = () => res();
3405
4042
  v.onerror = () => rej(new Error('视频解码失败'));
3406
- setTimeout(res, 10000);
4043
+ setTimeout(res, 12000);
4044
+ });
4045
+ const dur = Number(v.duration);
4046
+ if (!isFinite(dur) || dur <= 0) throw new Error('视频时长不可用');
4047
+ const canvas = document.createElement('canvas');
4048
+ canvas.width = v.videoWidth || 1280;
4049
+ canvas.height = v.videoHeight || 720;
4050
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
4051
+ // 采样时刻:结尾前 span 秒内均匀取 count 帧,最后一帧贴近片尾
4052
+ const t0 = Math.max(0, dur - span);
4053
+ const times = [];
4054
+ for (let i = 0; i < count; i++) times.push(Math.min(dur - 0.02, t0 + (span * i) / Math.max(1, count - 1)));
4055
+ times[times.length - 1] = Math.max(0, dur - 0.04);
4056
+ const grabs = [];
4057
+ for (const t of times) {
4058
+ await new Promise((res) => {
4059
+ let done = false;
4060
+ const fin = () => { if (!done) { done = true; res(); } };
4061
+ v.onseeked = fin;
4062
+ try { v.currentTime = t; } catch { fin(); }
4063
+ setTimeout(fin, 4000);
4064
+ });
4065
+ await new Promise((res) => {
4066
+ if (v.readyState >= 2) return res();
4067
+ v.oncanplay = res;
4068
+ setTimeout(res, 2000);
4069
+ });
4070
+ try {
4071
+ ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
4072
+ } catch { continue; }
4073
+ // 缩略灰度(64×36):算清晰度/运动/结构用,够用且极快
4074
+ const tw = 64, th = Math.max(1, Math.round((64 * canvas.height) / Math.max(1, canvas.width)));
4075
+ const small = document.createElement('canvas');
4076
+ small.width = tw; small.height = th;
4077
+ const sctx = small.getContext('2d', { willReadFrequently: true });
4078
+ sctx.drawImage(canvas, 0, 0, tw, th);
4079
+ const img = sctx.getImageData(0, 0, tw, th);
4080
+ const gray = new Uint8ClampedArray(tw * th);
4081
+ let sum = 0;
4082
+ for (let i = 0; i < gray.length; i++) {
4083
+ const r = img.data[i * 4], g = img.data[i * 4 + 1], b = img.data[i * 4 + 2];
4084
+ const y = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
4085
+ gray[i] = y; sum += y;
4086
+ }
4087
+ // 清晰度:拉普拉斯响应均值(越高越锐利)
4088
+ let lap = 0, lapN = 0;
4089
+ for (let y2 = 1; y2 < th - 1; y2++) {
4090
+ for (let x2 = 1; x2 < tw - 1; x2++) {
4091
+ const i = y2 * tw + x2;
4092
+ const l = 4 * gray[i] - gray[i - 1] - gray[i + 1] - gray[i - tw] - gray[i + tw];
4093
+ lap += Math.abs(l); lapN++;
4094
+ }
4095
+ }
4096
+ const sharpRaw = lapN ? lap / lapN : 0;
4097
+ // 前景占比:与画面均值的偏离度(粗估有人/物主体的程度)
4098
+ let dev = 0;
4099
+ const mean = sum / gray.length;
4100
+ for (let i = 0; i < gray.length; i++) dev += Math.abs(gray[i] - mean);
4101
+ const coverageRaw = gray.length ? dev / gray.length : 0;
4102
+ grabs.push({ t, gray, sharpRaw, coverageRaw, full: null });
4103
+ // 全分辨率 PNG 只对最终入选帧生成(省内存)
4104
+ }
4105
+ if (!grabs.length) throw new Error('尾帧采样失败');
4106
+ // 帧间差(运动幅度)+ 收敛抖动(相邻帧差突然变小 = 定格;突然变大 = 抖)
4107
+ const diffs = [];
4108
+ for (let i = 1; i < grabs.length; i++) {
4109
+ const a = grabs[i - 1].gray, b = grabs[i].gray;
4110
+ let s2 = 0;
4111
+ for (let k = 0; k < a.length; k++) s2 += Math.abs(a[k] - b[k]);
4112
+ diffs.push(a.length ? s2 / a.length / 255 : 0); // 0-1
4113
+ }
4114
+ const maxSharp = Math.max(...grabs.map((g) => g.sharpRaw), 1e-6);
4115
+ const picks = grabs.map((g, i) => {
4116
+ const prevDiff = i > 0 ? diffs[i - 1] : (diffs[0] || 0);
4117
+ const nextDiff = i < diffs.length ? diffs[i] : prevDiff;
4118
+ const motion = Math.max(0, Math.min(1, (prevDiff * 6))); // 归一:0.17/帧 ≈ 满运动
4119
+ const sharp = Math.max(0, Math.min(1, g.sharpRaw / maxSharp));
4120
+ const coverage = Math.max(0, Math.min(1, g.coverageRaw / 60));
4121
+ // 收敛抖动:自身运动相对邻域突变(模型末端常见突变)
4122
+ const jitter = Math.min(1, Math.abs(prevDiff - nextDiff) * 8);
4123
+ // 动作完成度:越接近片尾越可能"演完了"(末 3 帧按时间梯度推断)
4124
+ const complete = Math.max(0, Math.min(1, 0.35 + (i / Math.max(1, grabs.length - 1)) * 0.65));
4125
+ const score = tailFrameScore({ motion, sharp, coverage, jitter, complete });
4126
+ return { t: g.t, score, metrics: { motion, sharp, coverage, jitter, complete }, gray: g.gray, idx: i };
4127
+ });
4128
+ const best = picks.slice().sort((a, b) => b.score - a.score)[0];
4129
+ // 为入选帧 + 末帧各生成一张全分辨率 PNG(末帧始终产出,保证 last 端口行为不变)
4130
+ const wanted = new Set([best.idx, grabs.length - 1]);
4131
+ const blobs = new Map();
4132
+ for (const gi of [...wanted].sort((a, b) => a - b)) {
4133
+ const g = grabs[gi];
4134
+ await new Promise((res) => {
4135
+ let done = false;
4136
+ const fin = () => { if (!done) { done = true; res(); } };
4137
+ v.onseeked = fin;
4138
+ try { v.currentTime = g.t; } catch { fin(); }
4139
+ setTimeout(fin, 4000);
4140
+ });
4141
+ await new Promise((res) => { if (v.readyState >= 2) return res(); v.oncanplay = res; setTimeout(res, 2000); });
4142
+ try { ctx.drawImage(v, 0, 0, canvas.width, canvas.height); } catch { continue; }
4143
+ const blob = await new Promise((res) => canvas.toBlob(res, 'image/png'));
4144
+ if (blob) blobs.set(gi, blob);
4145
+ }
4146
+ const lastBlob = blobs.get(grabs.length - 1) || blobs.get(best.idx) || null;
4147
+ const bestBlob = blobs.get(best.idx) || lastBlob;
4148
+ return {
4149
+ last: lastBlob,
4150
+ best: bestBlob,
4151
+ bestT: best.t,
4152
+ bestScore: best.score,
4153
+ picks: picks.map((p) => ({ t: p.t, score: p.score, metrics: p.metrics })),
4154
+ };
4155
+ } finally {
4156
+ URL.revokeObjectURL(url);
4157
+ }
4158
+ }
4159
+ /** 缩略灰度(接缝校验用):任意图 Blob → 32×32 RGBA。 */
4160
+ async function grayThumb(blob, size) {
4161
+ const n = Math.max(8, Math.min(128, Number(size) || 32));
4162
+ const url = URL.createObjectURL(blob);
4163
+ try {
4164
+ const img = new Image();
4165
+ img.src = url;
4166
+ await new Promise((res, rej) => {
4167
+ img.onload = () => res();
4168
+ img.onerror = () => rej(new Error('图片解码失败'));
4169
+ setTimeout(res, 8000);
3407
4170
  });
3408
- if (!isFinite(v.duration) || v.duration <= 0) throw new Error('视频时长不可用');
4171
+ const cv = document.createElement('canvas');
4172
+ cv.width = n; cv.height = n;
4173
+ const ctx = cv.getContext('2d', { willReadFrequently: true });
4174
+ // 保持比例居中裁切(letterbox 会稀释位移信号,直接拉伸更敏感)
4175
+ ctx.drawImage(img, 0, 0, n, n);
4176
+ const im = ctx.getImageData(0, 0, n, n);
4177
+ const gray = new Uint8ClampedArray(n * n);
4178
+ for (let i = 0; i < gray.length; i++) {
4179
+ gray[i] = Math.round(0.299 * im.data[i * 4] + 0.587 * im.data[i * 4 + 1] + 0.114 * im.data[i * 4 + 2]);
4180
+ }
4181
+ return { data: gray, w: n, h: n };
4182
+ } finally {
4183
+ URL.revokeObjectURL(url);
4184
+ }
4185
+ }
4186
+ /** 首帧抽取(末段续帧时校验用):视频开头第一帧 PNG。 */
4187
+ async function extractFirstFrame(videoBlob) {
4188
+ const url = URL.createObjectURL(videoBlob);
4189
+ try {
4190
+ const v = document.createElement('video');
4191
+ v.src = url; v.muted = true; v.preload = 'auto';
3409
4192
  await new Promise((res) => {
3410
- v.onseeked = res;
3411
- try { v.currentTime = Math.max(0, v.duration - 0.05); } catch { res(); }
3412
- setTimeout(res, 5000);
4193
+ let done = false;
4194
+ const fin = () => { if (!done) { done = true; res(); } };
4195
+ v.onloadedmetadata = fin;
4196
+ v.onerror = fin;
4197
+ setTimeout(fin, 10000);
3413
4198
  });
3414
4199
  await new Promise((res) => {
3415
- if (v.readyState >= 2) return res();
3416
- v.oncanplay = res;
3417
- setTimeout(res, 3000);
4200
+ let done = false;
4201
+ const fin = () => { if (!done) { done = true; res(); } };
4202
+ v.onseeked = fin;
4203
+ try { v.currentTime = 0.02; } catch { fin(); }
4204
+ setTimeout(fin, 4000);
3418
4205
  });
4206
+ await new Promise((res) => { if (v.readyState >= 2) return res(); v.oncanplay = res; setTimeout(res, 2000); });
3419
4207
  const cv = document.createElement('canvas');
3420
- cv.width = v.videoWidth; cv.height = v.videoHeight;
4208
+ cv.width = v.videoWidth || 1280; cv.height = v.videoHeight || 720;
3421
4209
  cv.getContext('2d').drawImage(v, 0, 0);
3422
- const blob = await new Promise((res) => cv.toBlob(res, 'image/png'));
3423
- if (!blob) throw new Error('尾帧提取失败');
3424
- return blob;
4210
+ return await new Promise((res) => cv.toBlob(res, 'image/png'));
3425
4211
  } finally {
3426
4212
  URL.revokeObjectURL(url);
3427
4213
  }
3428
4214
  }
4215
+ /** 续帧链:找到「上一个视频生成节点」。按可靠性依次尝试:
4216
+ * ① frame 口连线的来源视频(用户显式指定;也支持从视频资产节点回溯);
4217
+ * ② 同一上游分镜表连出的其它视频节点里、已生成且位置更靠上的那一个(一个分镜表铺多个视频段);
4218
+ * ③ **沿分镜表的 prev 承接链向上游找**:上游分镜表所连的视频节点。
4219
+ * 标准短剧布局是「一场一个分镜表 + 一场一个视频」,此时每个视频节点的上游分镜表都不同,
4220
+ * ①②永远找不到——这是主用法,必须支持。逐级向上并防环。 */
4221
+ function autoPrevVideoNode(n) {
4222
+ const frameEdge = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'frame');
4223
+ if (frameEdge) {
4224
+ const src = S.graph.nodes[frameEdge.fromNode];
4225
+ if (src && src.type === 'video-gen' && src.data.assetId) return src;
4226
+ if (src && src.type === 'video-asset') {
4227
+ const up = S.graph.edges.find((e) => e.toNode === src.id && e.toPort === 'in');
4228
+ const pv = up && S.graph.nodes[up.fromNode];
4229
+ if (pv && pv.type === 'video-gen' && pv.data.assetId) return pv;
4230
+ }
4231
+ }
4232
+ const promptEdge = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'prompt');
4233
+ if (!promptEdge) return null;
4234
+ const sbId = promptEdge.fromNode;
4235
+ const sb = S.graph.nodes[sbId];
4236
+ // ② 同一分镜表内的兄弟视频段
4237
+ const sibs = S.graph.edges
4238
+ .filter((x) => x.fromNode === sbId && x.toPort === 'prompt')
4239
+ .map((x) => S.graph.nodes[x.toNode])
4240
+ .filter((v) => v && v.id !== n.id && v.type === 'video-gen' && v.data.assetId && (v.y < n.y || (v.y === n.y && v.x < n.x)));
4241
+ if (sibs.length) {
4242
+ sibs.sort((a, b) => (b.y - a.y) || (b.x - a.x)); // 最近的一段(同列/同排里最靠近的上一段)
4243
+ return sibs[0];
4244
+ }
4245
+ // ③ 沿 prev 链向上游分镜表找它连出的视频段(跨分镜表续帧 = 标准用法)
4246
+ let curSb = sb;
4247
+ const seen = new Set([sbId]);
4248
+ for (let hop = 0; hop < 32 && curSb; hop++) {
4249
+ const prevEdge = S.graph.edges.find((x) => x.toNode === curSb.id && x.toPort === 'prev');
4250
+ const upSb = prevEdge && S.graph.nodes[prevEdge.fromNode];
4251
+ if (!upSb || upSb.type !== 'storyboard' || seen.has(upSb.id)) break;
4252
+ seen.add(upSb.id);
4253
+ const vids = S.graph.edges
4254
+ .filter((x) => x.fromNode === upSb.id && x.toPort === 'prompt')
4255
+ .map((x) => S.graph.nodes[x.toNode])
4256
+ .filter((v) => v && v.type === 'video-gen' && v.data.assetId);
4257
+ if (vids.length) {
4258
+ vids.sort((a, b) => (b.y - a.y) || (b.x - a.x));
4259
+ return vids[0];
4260
+ }
4261
+ curSb = upSb; // 上一场还没生成则继续向上找更早的已成片段
4262
+ }
4263
+ return null;
4264
+ }
3429
4265
  /** 链式下一镜:以本镜尾帧为首帧,克隆一个视频生成节点并自动连线。 */
3430
4266
  function chainNext(n) {
3431
4267
  const c = { x: n.x + 320, y: n.y + 60 };
@@ -3438,8 +4274,12 @@ window.__ModuleLoader__.load({
3438
4274
  nd2.data.resolution = n.data.resolution || '720P';
3439
4275
  nd2.data.ratio = n.data.ratio || '16:9';
3440
4276
  nd2.data.videoModel = nodeVideoModelRef(n);
4277
+ // 与自动续帧保持同一默认(auto = 尾帧降级为首张参考图,保留实体参考图,衔接为软约束)。
4278
+ // 硬首帧会放弃角色/场景参考图,不该作为默认——用户需要绝对承接时自己在下拉里选。
4279
+ nd2.data.frameMode = 'auto';
3441
4280
  }
3442
- s.graph.edges.push({ id: uid('e'), fromNode: n.id, fromPort: 'last', toNode: nn.id, toPort: 'frame' });
4281
+ // 尾帧接力:优先「最佳选帧」(动作进行中/清晰),退到真·尾帧
4282
+ s.graph.edges.push({ id: uid('e'), fromNode: n.id, fromPort: (n.data.lastPickAssetId ? 'tailpick' : 'last'), toNode: nn.id, toPort: 'frame' });
3443
4283
  });
3444
4284
  pushHistory();
3445
4285
  toast(T('lastFrame') + ' → ' + T('portFrame') + ' ✓');
@@ -3493,18 +4333,35 @@ window.__ModuleLoader__.load({
3493
4333
  for (const seg of segments) {
3494
4334
  const v = document.createElement('video');
3495
4335
  v.src = seg.url; v.preload = 'auto'; v.playsInline = true;
3496
- await new Promise((res, rej) => {
3497
- v.onloadedmetadata = () => res();
3498
- v.onerror = () => rej(new Error('视频加载失败: ' + seg.name));
3499
- setTimeout(res, 15000);
4336
+ // 加载失败必须留下来而不是静默丢弃:静默丢弃 = 用户以为全片都合进去了
4337
+ let loadErr = null;
4338
+ await new Promise((res) => {
4339
+ let settled = false;
4340
+ const fin = () => { if (!settled) { settled = true; res(); } };
4341
+ v.onloadedmetadata = fin;
4342
+ v.onerror = () => { loadErr = '视频解码失败(格式不支持或文件损坏)'; fin(); };
4343
+ setTimeout(fin, 15000); // 超时也继续:时长拿不到时按 8s 兜底
4344
+ });
4345
+ if (loadErr && !(isFinite(v.duration) && v.duration > 0)) {
4346
+ throw new Error((seg.name || '片段') + ':' + loadErr + '——请确认该段视频能正常播放(可在节点上预览)');
4347
+ }
4348
+ const clipDur = isFinite(v.duration) && v.duration > 0 ? v.duration : 0;
4349
+ // 编辑时长(分镜时长)用于裁剪:不能超过真实素材时长
4350
+ const trimSec = seg.dur && seg.dur > 0 ? Math.min(seg.dur, clipDur || seg.dur) : 0;
4351
+ totalMs += (trimSec || clipDur || 8) * 1000;
4352
+ vids.push({
4353
+ v, name: seg.name, sub: seg.sub || '', withAudio: seg.withAudio !== false,
4354
+ ttsUrl: seg.ttsUrl || null, ttsEl: null, ttsSrc: null, started: false, srcNode: null,
4355
+ trim: trimSec || null, // 裁剪到该秒数(0/null = 用完整片段)
4356
+ clipDur, // 素材真实时长(可能为 0:元数据没拿到)
4357
+ startAt: 0,
3500
4358
  });
3501
- const trimSec = seg.dur && seg.dur > 0 ? Math.min(seg.dur, (isFinite(v.duration) && v.duration > 0 ? v.duration : seg.dur)) : 0;
3502
- totalMs += trimSec * 1000 || (isFinite(v.duration) && v.duration > 0 ? v.duration * 1000 : 8000);
3503
- vids.push({ v, name: seg.name, sub: seg.sub || '', withAudio: seg.withAudio !== false, ttsUrl: seg.ttsUrl || null, ttsEl: null, ttsSrc: null, started: false, srcNode: null, dur: seg.dur || null, startAt: 0 });
3504
4359
  }
4360
+ if (!vids.length) throw new Error('没有可合成的片段');
3505
4361
  const cv = document.createElement('canvas'); cv.width = W; cv.height = H;
3506
4362
  const g = cv.getContext('2d');
3507
- const mimes = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'];
4363
+ // mp4 优先(含音轨:H.264 + AAC):webm 只是兜底,保证成片统一为 mp4
4364
+ const mimes = ['video/mp4;codecs=avc1.42E01E,mp4a.40.2', 'video/mp4', 'video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm'];
3508
4365
  const mime = (typeof MediaRecorder !== 'undefined') && mimes.find((m) => MediaRecorder.isTypeSupported(m));
3509
4366
  if (!mime) throw new Error('当前环境不支持视频录制');
3510
4367
  const stream = cv.captureStream(30);
@@ -3566,67 +4423,102 @@ window.__ModuleLoader__.load({
3566
4423
  }
3567
4424
  let idx = 0;
3568
4425
  let done = false;
4426
+ let failed = null;
3569
4427
  let blackUntil = 0;
3570
4428
  let lastProgressIdx = -1;
4429
+ let lastTickAt = 0;
4430
+ // 看门狗:即使某段 video 元素永远不触发 ended/timeupdate(解码卡住、seek 失效),
4431
+ // 也按"本段应有时长"强制推进——否则用户只看到"运行中"永远不结束。
4432
+ const startedAt = performance.now();
3571
4433
  startPlayback(vids[0]);
3572
4434
  await new Promise((resolve) => {
4435
+ function finish(err) {
4436
+ if (done) return;
4437
+ done = true;
4438
+ failed = err || null;
4439
+ resolve();
4440
+ }
3573
4441
  function frame() {
3574
- if (done) return resolve();
3575
- const now = performance.now();
3576
- const item = vids[idx];
3577
- const v = item.v;
3578
- // 黑场过渡窗口
3579
- if (blackUntil && now < blackUntil) {
4442
+ if (done) return;
4443
+ try {
4444
+ const now = performance.now();
4445
+ const item = vids[idx];
4446
+ if (!item) return finish();
4447
+ const v = item.v;
4448
+ // 每段超时保护:单段最多等到 应有+10s,超过即视为该段已结束(不卡死整片)
4449
+ if (item.startAt && item.hardDeadline && now > item.hardDeadline) {
4450
+ stopItem(item);
4451
+ idx++;
4452
+ if (idx >= vids.length) return finish();
4453
+ if (transition === 'black') blackUntil = now + 350;
4454
+ }
4455
+ // 黑场过渡窗口
4456
+ if (blackUntil && now < blackUntil) {
4457
+ g.fillStyle = '#000'; g.fillRect(0, 0, W, H);
4458
+ requestAnimationFrame(frame);
4459
+ return;
4460
+ }
4461
+ blackUntil = 0;
4462
+ startPlayback(item);
4463
+ if (lastProgressIdx !== idx) { lastProgressIdx = idx; if (onProgress) onProgress(idx / Math.max(1, vids.length), item.name); }
4464
+ const clipDur = isFinite(v.duration) && v.duration > 0 ? v.duration : item.clipDur;
4465
+ // 有效时长 = 裁剪时长(若有)与素材真实时长的较小值
4466
+ const effDur = item.trim && item.trim > 0 ? Math.min(item.trim, clipDur || item.trim) : clipDur;
4467
+ if (!item.hardDeadline && item.startAt) item.hardDeadline = item.startAt + (effDur > 0 ? effDur * 1000 : 8000) + 10000;
4468
+ const elapsed = item.startAt ? (now - item.startAt) / 1000 : 0;
4469
+ // 叠化窗口:距本段结束还剩多少毫秒(effDur 为 0 时视为无限,不会误触发)
4470
+ const remainMs = effDur > 0 ? (effDur - (v.currentTime || 0)) * 1000 : Infinity;
4471
+ // 叠化:临近结束时提前启动下一片段
4472
+ let next = null;
4473
+ if (transition === 'fade' && idx < vids.length - 1 && remainMs <= td && Number.isFinite(remainMs)) {
4474
+ next = vids[idx + 1];
4475
+ startPlayback(next);
4476
+ }
4477
+ // 绘制当前片段
3580
4478
  g.fillStyle = '#000'; g.fillRect(0, 0, W, H);
3581
- requestAnimationFrame(frame);
3582
- return;
3583
- }
3584
- blackUntil = 0;
3585
- startPlayback(item);
3586
- if (lastProgressIdx !== idx) { lastProgressIdx = idx; if (onProgress) onProgress(idx / Math.max(1, vids.length), item.name); }
3587
- const clipDur = isFinite(v.duration) && v.duration > 0 ? v.duration : 0;
3588
- const effDur = item.dur && item.dur > 0 ? Math.min(item.dur, clipDur || item.dur) : clipDur;
3589
- const elapsed = item.startAt ? (now - item.startAt) / 1000 : 0;
3590
- const remainMs = dur ? (dur - v.currentTime) * 1000 : Infinity;
3591
- // 叠化:临近结束时提前启动下一片段
3592
- let next = null;
3593
- if (transition === 'fade' && idx < vids.length - 1 && remainMs <= td) {
3594
- next = vids[idx + 1];
3595
- startPlayback(next);
3596
- }
3597
- // 绘制当前片段
3598
- g.fillStyle = '#000'; g.fillRect(0, 0, W, H);
3599
- letterbox(v);
3600
- let fadeT = 0;
3601
- if (next && next.started) {
3602
- fadeT = clamp(1 - remainMs / td, 0, 1);
3603
- if (next.v.videoWidth) {
3604
- g.save(); g.globalAlpha = fadeT; letterbox(next.v); g.restore();
4479
+ letterbox(v);
4480
+ let fadeT = 0;
4481
+ if (next && next.started) {
4482
+ fadeT = clamp(1 - remainMs / td, 0, 1);
4483
+ if (next.v.videoWidth) {
4484
+ g.save(); g.globalAlpha = fadeT; letterbox(next.v); g.restore();
4485
+ }
3605
4486
  }
4487
+ // 字幕随叠化淡出/淡入
4488
+ if (next && fadeT > 0.5) {
4489
+ g.save(); g.globalAlpha = fadeT; drawSubtitle(g, W, H, next.sub); g.restore();
4490
+ } else {
4491
+ g.save(); g.globalAlpha = 1 - fadeT; drawSubtitle(g, W, H, item.sub); g.restore();
4492
+ }
4493
+ // 推进(三判据:播放器自报结束 / 到达有效时长 / 墙钟超时)
4494
+ const ended = v.ended || (effDur > 0 && elapsed >= effDur - 0.03);
4495
+ if (ended) {
4496
+ stopItem(item);
4497
+ idx++;
4498
+ if (idx >= vids.length) return finish();
4499
+ if (transition === 'black') blackUntil = now + 350;
4500
+ }
4501
+ lastTickAt = now;
4502
+ requestAnimationFrame(frame);
4503
+ } catch (e) {
4504
+ // 帧循环内异常必须转成 reject:静默卡住比报错更糟
4505
+ try { stopItem(vids[idx] || {}); } catch { /* 忽略 */ }
4506
+ if (lastTickAt) void lastTickAt;
4507
+ finish(e);
3606
4508
  }
3607
- // 字幕随叠化淡出/淡入
3608
- if (next && fadeT > 0.5) {
3609
- g.save(); g.globalAlpha = fadeT; drawSubtitle(g, W, H, next.sub); g.restore();
3610
- } else {
3611
- g.save(); g.globalAlpha = 1 - fadeT; drawSubtitle(g, W, H, item.sub); g.restore();
3612
- }
3613
- // 推进
3614
- const ended = v.ended || (effDur > 0 && elapsed >= effDur - 0.03);
3615
- if (ended) {
3616
- stopItem(item);
3617
- idx++;
3618
- if (idx >= vids.length) { done = true; resolve(); return; }
3619
- if (transition === 'black') blackUntil = now + 350;
3620
- }
3621
- requestAnimationFrame(frame);
3622
4509
  }
3623
4510
  requestAnimationFrame(frame);
3624
- setTimeout(() => { done = true; resolve(); }, totalMs + 30000);
4511
+ // 全局兜底:总时长 + 30s(含看门狗余量)后强制收尾,并明确报告超时而不是假装成功
4512
+ setTimeout(() => {
4513
+ if (!done) finish(new Error('成片合成超时(已等待 ' + Math.round((performance.now() - startedAt) / 1000) + 's):请检查所选片段是否都能正常播放'));
4514
+ }, totalMs + 30000);
3625
4515
  });
3626
4516
  vids.forEach(stopItem);
3627
4517
  rec.stop();
3628
4518
  await stopped;
3629
4519
  if (audio) { try { audio.ac.close(); } catch { /* 忽略 */ } }
4520
+ if (failed) throw failed;
4521
+ if (!chunks.length) throw new Error('成片录制未产出数据(浏览器录制失败,请重试或降低分辨率)');
3630
4522
  return new Blob(chunks, { type: mime.split(';')[0] });
3631
4523
  }
3632
4524
 
@@ -3663,10 +4555,12 @@ window.__ModuleLoader__.load({
3663
4555
  const craft = {
3664
4556
  status: async () => { const r = await craftFetch('/status', {}, 20000, true); return await r.json(); },
3665
4557
  projects: async () => { const r = await craftFetch('/projects'); return await r.json(); },
3666
- write: async (rel, blob) => { const r = await craftFetch('/fs?action=write&rel=' + encodeURIComponent(rel), { method: 'POST', body: blob }, 180000); return await r.json(); },
3667
- writeText: async (rel, text) => { const r = await craftFetch('/fs?action=write&rel=' + encodeURIComponent(rel), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ rel, text }) }, 60000); return await r.json(); },
3668
- readText: async (rel) => { const r = await craftFetch('/fs?op=readtext&rel=' + encodeURIComponent(rel)); return (await r.json()).text; },
3669
- file: async (rel) => { const r = await craftFetch('/file?rel=' + encodeURIComponent(rel), {}, 180000); return await r.blob(); },
4558
+ // BLK-02:所有文件类调用一律 base(项目绝对目录) + path(相对),绝不发「无 base 的相对 rel」——
4559
+ // 无 base 时 ArtCraft 按插件进程 cwd 解析,会 200 静默写到 DSH profile 目录(现场证据见 _qa/BLK-02)。
4560
+ write: async (base, rel, blob) => { const r = await craftFetch('/fs?action=write&base=' + encodeURIComponent(base) + '&path=' + encodeURIComponent(rel), { method: 'POST', body: blob }, 180000); return await r.json(); },
4561
+ writeText: async (base, rel, text) => { const r = await craftFetch('/fs?action=write&base=' + encodeURIComponent(base) + '&path=' + encodeURIComponent(rel), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ base, path: rel, text }) }, 60000); return await r.json(); },
4562
+ readText: async (base, rel) => { const r = await craftFetch('/fs?op=readtext&base=' + encodeURIComponent(base) + '&path=' + encodeURIComponent(rel)); return (await r.json()).text; },
4563
+ file: async (base, rel) => { const r = await craftFetch('/file?base=' + encodeURIComponent(base) + '&path=' + encodeURIComponent(rel), {}, 180000); return await r.blob(); },
3670
4564
  jobStart: async (p) => { const r = await craftFetch('/job', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(p) }, 30000); return await r.json(); },
3671
4565
  jobGet: async (id, tail) => { const r = await craftFetch('/job?id=' + encodeURIComponent(id) + '&tail=' + (tail || 12)); return await r.json(); },
3672
4566
  jobCancel: async (id) => { const r = await craftFetch('/job/cancel', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ id }) }); return await r.json(); },
@@ -3681,6 +4575,41 @@ window.__ModuleLoader__.load({
3681
4575
  update((s) => { s.craftStatus = { at: Date.now(), data }; });
3682
4576
  return data;
3683
4577
  }
4578
+ // ---- BLK-02:ArtCraft 调用的目录基准 ----
4579
+ // ArtCraft 是纯桥接、**没有自己的数据根**(0.3.x 起 /api/status 也不再返回 dataRoot)。
4580
+ // 因此画布必须自己给出绝对基准:每个项目的目录 = <画布数据根>/projects/<物理文件夹>,
4581
+ // 由 Host 的 projectDirGet 解析(meta 无条目时回退 id),再以 base + 相对 path 调 ArtCraft。
4582
+ // 绝不发「无 base 的相对 rel」——那样会被按插件进程 cwd 解析、200 却写错目录(见 _qa/BLK-02)。
4583
+ const craftBaseCache = new Map(); // projectId -> { at, abs }
4584
+ async function craftBaseDir(pid) {
4585
+ const id = String(pid || S.activeId || '');
4586
+ if (!id) return null;
4587
+ const hit = craftBaseCache.get(id);
4588
+ if (hit && Date.now() - hit.at < 60000) return hit.abs;
4589
+ let r = null;
4590
+ try { r = await hostRpc('projectDirGet', { projectId: id }); } catch { r = null; }
4591
+ if (r && r.ok && r.abs) { craftBaseCache.set(id, { at: Date.now(), abs: r.abs }); return r.abs; }
4592
+ return null;
4593
+ }
4594
+ /** 取不到项目目录时明确失败(未配数据根/宿主过旧),不做任何相对路径兜底。 */
4595
+ async function craftBaseDirRequired(pid) {
4596
+ const abs = await craftBaseDir(pid);
4597
+ if (!abs) throw new Error('未设置画布数据根目录:ArtCraft 调用需要绝对的项目目录作为基准(设置 → 数据根目录)');
4598
+ return abs;
4599
+ }
4600
+ /** IC-B2:项目删除时清掉其 craft/ 后期目录(Host RPC projectCraftRemove,幂等、失败可见)。 */
4601
+ async function craftProjectCleanup(pid) {
4602
+ try {
4603
+ const r = await hostRpc('projectCraftRemove', { projectId: pid });
4604
+ if (r && r.ok === false && r.error) toast('Craft 目录清理未完成:' + r.error);
4605
+ } catch { /* 旧 Host 无该 RPC → 静默(目录残留不影响功能) */ }
4606
+ }
4607
+ /** 项目目录下的相对路径 → 本机绝对路径(ArtCraft 的 args/FCP7 XML 都用它)。 */
4608
+ function joinLocal(absDir, rel) {
4609
+ const s = String(absDir || '').replace(/\//g, '\\').replace(/\\+$/, '');
4610
+ const r = String(rel || '').replace(/\\/g, '/').replace(/^\/+/, '');
4611
+ return s + '\\' + r.replace(/\//g, '\\');
4612
+ }
3684
4613
  async function craftStatusSafe() {
3685
4614
  try { return await craft.status(); } catch { toast(T('craftUnreachable')); return null; }
3686
4615
  }
@@ -3689,25 +4618,14 @@ window.__ModuleLoader__.load({
3689
4618
  if (!cs || !cs.at || Date.now() - cs.at > 120000 || !cs.data || !cs.data.bins) return false;
3690
4619
  return Object.keys(cs.data.bins).some((k) => cs.data.bins[k] && cs.data.bins[k].found);
3691
4620
  }
3692
- function slugifyName(name) {
3693
- return String(name || '').replace(/[\\/:*?"<>|]/g, '').trim() || 'project';
3694
- }
3695
- function craftProjectSlug() {
3696
- const p = S.projects.find((x) => x.id === S.activeId);
3697
- if (!p) return 'project';
3698
- if (!p.slug) {
3699
- let base = slugifyName(p.name);
3700
- const taken = new Set(S.projects.filter((x) => x.id !== p.id && x.slug).map((x) => x.slug));
3701
- if (taken.has(base)) base = base + '-' + p.id.slice(-4);
3702
- p.slug = base;
3703
- lsSet(LS_PROJECTS, S.projects);
3704
- }
3705
- return p.slug;
3706
- }
3707
- // 资产 assetId → dataRoot 相对路径 映射(项目级元数据,localStorage)
4621
+ // 资产 assetId → 项目内相对路径 映射(项目级元数据,localStorage)
3708
4622
  function craftMapLoad(pid) { return lsGet('dsh-ic:craftmap:' + pid, {}) || {}; }
3709
4623
  function craftMapSave(pid, map) { lsSet('dsh-ic:craftmap:' + pid, map); }
3710
4624
  function craftMapSet(pid, assetId, rel) { const m = craftMapLoad(pid); m[assetId] = rel; craftMapSave(pid, m); }
4625
+ /** Craft 落盘/产物路径的扩展名(IC-B3 统一):**文件名优先**——Craft 侧复用资产的
4626
+ * 既有名字与扩展名(已登记的 media/graded 文件靠名字对回 ArtCraft 工程),mime 只兜底。
4627
+ * 与 extForDownload(mime 优先)方向相反是有意的:下载侧扩展名必须反映真实容器,
4628
+ * 落盘侧名字才是资产在 ArtCraft 工程里的稳定标识。 */
3711
4629
  function extForMime(mime, name) {
3712
4630
  const fromName = String(name || '').match(/(\.[a-z0-9]{2,5})$/i);
3713
4631
  if (fromName) return fromName[1].toLowerCase();
@@ -3721,6 +4639,36 @@ window.__ModuleLoader__.load({
3721
4639
  if (m.includes('wav')) return '.wav';
3722
4640
  return '.bin';
3723
4641
  }
4642
+ /** IC-01/IC-B3:下载侧扩展名判定(**mime 优先** → 文件名白名单兜底 → kind 兜底)。
4643
+ * 三套映射的分工(IC-B3 统一结论,各自单一职责):
4644
+ * - extFromMime:磁盘落盘(mime 子类型直取,无名字兜底——落盘时 mime 由生成链路给出)
4645
+ * - extForMime:Craft 落盘/产物路径(文件名优先——名字是 ArtCraft 工程里的稳定标识)
4646
+ * - extForDownload:用户下载(mime 优先——扩展名必须反映真实容器,`final.2026`
4647
+ * 这类带点号的资产名绝不能得到 `.2026`,所以名字兜底只认白名单) */
4648
+ const DOWNLOAD_EXT_BY_MIME = [
4649
+ [/^image\/png$/, '.png'], [/^image\/jpe?g$/, '.jpg'], [/^image\/webp$/, '.webp'], [/^image\/gif$/, '.gif'],
4650
+ [/^video\/mp4$/, '.mp4'], [/^video\/quicktime$/, '.mov'], [/^video\/webm$/, '.webm'], [/^video\/x-matroska$/, '.mkv'],
4651
+ [/^audio\/mpeg$/, '.mp3'], [/^audio\/mp4$/, '.m4a'], [/^audio\/(x-)?wav$/, '.wav'],
4652
+ [/^text\/markdown$/, '.md'], [/^text\/plain$/, '.txt'],
4653
+ ];
4654
+ const DOWNLOAD_EXT_BY_NAME = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.mp4', '.mov', '.webm', '.mkv', '.mp3', '.m4a', '.wav', '.md', '.txt']);
4655
+ function extForDownload(mime, name, kind) {
4656
+ const m = String(mime || '').split(';')[0].trim().toLowerCase();
4657
+ if (m) { for (const [re, ext] of DOWNLOAD_EXT_BY_MIME) if (re.test(m)) return ext; }
4658
+ const nm = String(name || '').match(/\.([a-z0-9]{2,5})$/i);
4659
+ if (nm) { const ext = '.' + nm[1].toLowerCase(); if (DOWNLOAD_EXT_BY_NAME.has(ext)) return ext; }
4660
+ if (kind === 'image') return '.png';
4661
+ if (kind === 'video') return '.mp4';
4662
+ if (kind === 'audio') return '.mp3';
4663
+ if (kind === 'text') return '.md';
4664
+ return '.bin';
4665
+ }
4666
+ /** 下载名 = 可读前缀 + 真实扩展名。assetId 只为取 mime/kind;记录取不到时按 kind 兜底。 */
4667
+ async function downloadNameForAsset(assetId, base, kind) {
4668
+ let rec = null;
4669
+ try { rec = assetId ? await idbGet(assetId) : null; } catch { rec = null; }
4670
+ return base + extForDownload(rec && rec.mime, rec && rec.name, (rec && rec.kind) || kind);
4671
+ }
3724
4672
  /** 资产落盘到 <项目>/media/(force=false 时已有映射则复用;force=true 覆盖写)。 */
3725
4673
  /** ArtCraft 工作区里的文件名:资产名(人工可读,如「角色-张山 多视图」)+ id 尾号(唯一,避免同名覆盖)。 */
3726
4674
  function assetFileBase(rec) {
@@ -3731,16 +4679,29 @@ window.__ModuleLoader__.load({
3731
4679
  }
3732
4680
  async function syncAssetToDisk(rec, opts = {}) {
3733
4681
  const pid = S.activeId;
4682
+ const base = await craftBaseDirRequired(pid);
3734
4683
  const map = craftMapLoad(pid);
3735
- if (!opts.force && map[rec.id]) return map[rec.id];
3736
- // Craft 后期数据统一放在**画布数据根目录**的项目目录下:projects/<项目>/<slug 前缀去掉>/craft/…
3737
- // (ArtCraft 数据根目录 = 画布数据根目录时,即落在 <root>/projects/<项目>/craft/media/)
3738
- const rel = 'projects/' + craftProjectSlug() + '/craft/media/' + assetFileBase(rec) + extForMime(rec.mime, rec.name);
4684
+ // 陈旧映射防护(旧版本升级边界):映射存的是写入时刻的逻辑路径
4685
+ // 'projects/<当时的物理文件夹>/craft/…'。BLK-02 后物理文件夹名来自数据根 meta,
4686
+ // 旧版 slug-era 的映射前缀几乎必然 ≠ 现文件夹——直接复用会让 FilmCraft 导出/
4687
+ // PhotoCraft 调色引用到不存在的媒体("文件不存在")。前缀不符 = 过期,强制重写。
4688
+ const curPrefix = 'projects/' + craftFolderOf(base) + '/';
4689
+ if (!opts.force && map[rec.id] && String(map[rec.id]).startsWith(curPrefix)) return map[rec.id];
4690
+ // BLK-02:写入基准 = 项目绝对目录(base),路径只用 craft/ 下的相对段。
4691
+ // ArtCraft 侧解析后落点 = <数据根>/projects/<物理文件夹>/craft/media/…;不再依赖 slug 与它的 cwd。
4692
+ const rel = 'craft/media/' + assetFileBase(rec) + extForMime(rec.mime, rec.name);
3739
4693
  const blob = rec.blob || await assetBlob(rec.id);
3740
4694
  if (!blob) throw new Error('资产内容不可用: ' + rec.id);
3741
- await craft.write(rel, blob);
3742
- craftMapSet(pid, rec.id, rel);
3743
- return rel;
4695
+ await craft.write(base, rel, blob);
4696
+ const stored = 'projects/' + craftFolderOf(base) + '/' + rel; // 返回给 Host 侧取用(逻辑路径)
4697
+ craftMapSet(pid, rec.id, stored);
4698
+ return stored;
4699
+ }
4700
+ /** 从项目绝对目录里取物理文件夹名(base 的末段),用于拼 Host 可识别的逻辑路径。 */
4701
+ function craftFolderOf(abs) {
4702
+ const s = String(abs || '').replace(/[\\/]+$/, '');
4703
+ const i = Math.max(s.lastIndexOf('\\'), s.lastIndexOf('/'));
4704
+ return i >= 0 ? s.slice(i + 1) : s;
3744
4705
  }
3745
4706
  async function ensureAssetOnDisk(rec) { return syncAssetToDisk(rec, { force: false }); }
3746
4707
  async function videoDurationOf(blob) {
@@ -3835,8 +4796,9 @@ window.__ModuleLoader__.load({
3835
4796
  });
3836
4797
  return out.join('\n');
3837
4798
  }
3838
- /** FilmCraft 任务后台轮询:状态写回节点,done 后回收产物入资产库。 */
3839
- async function pollFilmcraftJob(nodeId, jobId, outRel) {
4799
+ /** FilmCraft 任务后台轮询:状态写回节点,done 后回收产物入资产库。
4800
+ * base = 项目绝对目录(BLK-02:取产物也必须带 base,否则读到的是插件 cwd 下的同名文件)。 */
4801
+ async function pollFilmcraftJob(nodeId, jobId, outRel, base) {
3840
4802
  const deadline = Date.now() + 30 * 60 * 1000; // 与 jobStart 的 timeoutMs 对齐:绝不无限轮询
3841
4803
  for (;;) {
3842
4804
  if (Date.now() > deadline) {
@@ -3856,7 +4818,7 @@ window.__ModuleLoader__.load({
3856
4818
  });
3857
4819
  if (snap.status === 'done') {
3858
4820
  try {
3859
- const blob = await craft.file(outRel);
4821
+ const blob = await craft.file(base, outRel);
3860
4822
  const rec = await saveAssetRecord(S.activeId, 'video', blob, {
3861
4823
  name: T('finalCutDone') + '-FC-' + new Date().toISOString().slice(5, 16).replace('T', ' '),
3862
4824
  prompt: 'filmcraft export', source: 'filmcraft', category: 'finalcut',
@@ -4013,9 +4975,8 @@ window.__ModuleLoader__.load({
4013
4975
  s.projects = s.projects.filter((p) => !gone.has(p.id));
4014
4976
  if (gone.has(s.activeId)) {
4015
4977
  if (s.projects.length === 0) {
4016
- const p = { id: uid('p'), name: T('defaultProject'), createdAt: Date.now(), updatedAt: Date.now() };
4017
- s.projects.push(p);
4018
- s.activeId = p.id;
4978
+ // OBS-UI-1:删光后进入空态,不再自动建「我的画布」占位(同 initStore 口径)
4979
+ s.activeId = null;
4019
4980
  s.graph = emptyGraph();
4020
4981
  } else {
4021
4982
  s.activeId = s.projects[0].id;
@@ -4157,6 +5118,8 @@ window.__ModuleLoader__.load({
4157
5118
  /** 启动时把本地全部项目推给 Host:让对话能看到并操作画布上已有的项目(客户端为内容权威)。 */
4158
5119
  async function pushAllProjectsToHost() {
4159
5120
  if (hostSyncStopped) return;
5121
+ // OBS-UI-1:空态(用户还没建任何项目)不推送——否则空态下的占位也会写进 canvas.db
5122
+ if (!S.projects.length) return;
4160
5123
  for (const p of [...S.projects]) {
4161
5124
  try {
4162
5125
  const doc = loadGraphDoc(p.id) || emptyGraph();
@@ -4444,14 +5407,25 @@ window.__ModuleLoader__.load({
4444
5407
  }
4445
5408
  return out;
4446
5409
  }
4447
- async function aiStoryboardShots(scriptText, requirement, count, model, onDelta, prevTail) {
5410
+ async function aiStoryboardShots(scriptText, requirement, count, model, onDelta, prevTail, opts) {
4448
5411
  const settings = loadSettings();
4449
5412
  const bible = bibleBlock(loadBible(S.activeId));
4450
5413
  const zh = effLang() !== 'en';
5414
+ const contOn = !(opts && opts.cont === false); // 分镜衔接开关(默认开)
5415
+ const contFields = (() => {
5416
+ if (zh) {
5417
+ return contOn
5418
+ ? '{"title":"…","visual":"…","motion":"…","tail":"…","cam":"…","start":"…","seg":"continuation","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"陈拾","line":"收件时间,1999……"}],"sfx":"邮包搭扣声、撕纸声"}'
5419
+ : '{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"陈拾","line":"收件时间,1999……"}],"sfx":"邮包搭扣声、撕纸声"}';
5420
+ }
5421
+ return contOn
5422
+ ? '{"title":"…","visual":"…","motion":"…","tail":"…","cam":"…","start":"…","seg":"continuation","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"Chen","line":"…"}],"sfx":"zipper snap, paper tear"}'
5423
+ : '{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"Chen","line":"…"}],"sfx":"zipper snap, paper tear"}';
5424
+ })();
4451
5425
  const jsonContract = zh
4452
5426
  ? [
4453
5427
  '把剧本拆解为「多镜头一次性视频生成」的分镜脚本:铺开后由**一个视频生成节点**把全部镜头按顺序与时长写进同一条提示词,由视频模型一次性生成整段视频(镜头间由模型直接硬切,不是一镜一视频再拼接)。',
4454
- '只输出一个 JSON 对象:{"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"陈拾","line":"收件时间,1999……"}],"sfx":"邮包搭扣声、撕纸声"}]}。',
5428
+ '只输出一个 JSON 对象:{"shots":[' + contFields + ']}。',
4455
5429
  '规则:',
4456
5430
  '- visual 是本镜画面描述(景别、人物外观与站位、场景、光线、构图,60 字内);',
4457
5431
  '- **visual 与 motion 里的角色/场景/道具必须写成 @资产名@ 引用形式**(前后各一个 @ 精确包裹,如「@陈拾@ 在 @城市街头@ 的围挡旁拿起 @奇怪快递件@」);名称必须与设定集/资产**逐字一致**;剧本原文里已出现的 @资产名@ 锚点要在镜头中原样保留(名称逐字一致);名称内不得含 @、空格或标点;@ 后绝不允许直接跟其他文字(必须以 @ 闭合)——系统会隐藏两个 @ 并把名称显示为蓝色加粗,未登记的 @名称@ 会被一致性检查提示补充;',
@@ -4467,10 +5441,21 @@ window.__ModuleLoader__.load({
4467
5441
  '- **按情绪选景别**:孤独/绝望/宏大危机=远景;对峙/动作/关系建立=全景;对话/试探/信息交换=中景;震惊/怀疑/内心动摇=近景;崩溃/嘶吼/情绪爆发=特写;关键细节/道具揭示=大特写——冲突用全景、揭秘转近景、爆发给特写、反转可拉远,景别变化本身在讲情绪;',
4468
5442
  '- **快切镜头(<1.5s)的 visual/motion 要极短**(一个动作、一句画内信息),信息量大的镜头才配长时长——时长与内容量必须匹配;',
4469
5443
  '- 只输出 JSON,不要输出任何其他内容。',
4470
- ].join('\n')
5444
+ ].concat(contOn ? [
5445
+ '',
5446
+ '**【分镜衔接·硬性】成片是分段生成的,每段模型看不到上一段画面——衔接只能靠写在分镜里的"接点"。除末镜外,每镜都必须写 tail 与 cam:**',
5447
+ '- **tail(接点尾态)**:本镜**最后一刻**的状态,一行写清——人物姿势停在哪个动作的中途、朝向、视线落点、手持物位置、位置关系(如「右手抬到胸口一半,视线仍锁在 @陈拾@ 脸上,@糯糯@ 停在他左后方半步」「已侧身转到 3/4 面,还没完全转身面对对方」)。**必须是"动作进行中"的状态,不能写成"已经完成/站定/看向"**——切点停在静止定格,下一镜就没有动作可接,接缝必然暴露;',
5448
+ '- **cam(运镜延续)**:本镜运镜的方向与速度,写成下一镜可以直接"接着走"的形式(如「右移横移,匀速,未停」「缓推向面部,接近停住」);末镜写「收束静止」;**全片避免相邻镜反向运镜**(上一镜右移、下一镜突然左移 = 观众立刻感觉跳),需要反向时把切点设计成遮挡/过肩/特写插入;',
5449
+ '- **start(仅第 1 镜或 seg 非续帧的镜头需要)**:本镜起始状态;续帧镜头不写 start(它会自动承接上一镜 tail);',
5450
+ '- **seg(段落类型)**:`continuation` 续帧(默认,绝大多数镜头)、`scene_change` 换场/切换地点时间、`new_character` 新角色首次出场、`closeup` 特写/细节插入(允许跳切)、`opening` 仅第 1 镜且无上游承接时;',
5451
+ '- **一镜只做一个主动作**:转身、走路、推镜、换表情不要塞进同一镜——模型只会执行前半段,下一镜就对不上;',
5452
+ '- **一段一运镜**:不要又推又横移又摇;',
5453
+ '- **大位移拆镜**:人物横移超过画面 1/4、或场景大换位,就再拆一镜(用不同景别接,而不是一镜硬走);',
5454
+ '- **轴线一致**:两人对话,同一轴线关系(如男左女右)在相邻镜必须保持一致,不得越轴。',
5455
+ ] : []).join('\n')
4471
5456
  : [
4472
5457
  'Break the script into shots for ONE multi-shot video generation: after expansion, a single video-gen node composes ALL shots (in order, with durations) into one prompt, and the video model generates the whole clip in one call (hard cuts inside the model — not one video per shot stitched together).',
4473
- 'Output only one JSON object: {"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"Chen","line":"…"}],"sfx":"zipper snap, paper tear"}]}.',
5458
+ 'Output only one JSON object: {"shots":[' + contFields + ']}.',
4474
5459
  'Rules:',
4475
5460
  '- visual = the shot\'s frame description (shot size, wardrobe, staging, light, framing, <=60 words);',
4476
5461
  '- **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;',
@@ -4486,7 +5471,18 @@ window.__ModuleLoader__.load({
4486
5471
  '- **Pick the shot size by emotion**: loneliness/despair/grand crisis = wide; confrontation/action/relationship = full; dialogue/probing/info exchange = medium; shock/suspicion/inner shake = close-up; breakdown/screaming/emotional burst = extreme close-up; key detail/prop reveal = big close-up — conflicts play wide, reveals move close, bursts go extreme, reversals can pull wide; the changing distance itself tells the emotion;',
4487
5472
  '- **Rapid cuts (<1.5s) get minimal visual/motion** (one action, one piece of in-frame information); long durations are earned by information density — duration must match content;',
4488
5473
  '- Output JSON only.',
4489
- ].join('\n');
5474
+ ].concat(contOn ? [
5475
+ '',
5476
+ '**[CONTINUITY - HARD] the film is generated in segments and each segment cannot see the previous one, so the "hand-off point" must be written into the storyboard. Every shot except the last MUST carry tail and cam:**',
5477
+ '- **tail (hand-off tail state)**: the state at the LAST instant of this shot, in one line - where the posture stops mid-motion, facing, gaze target, hand/prop position, blocking (e.g. "right hand half-way up to the chest, eyes still locked on @Chen@, @Nuonuo@ half a step behind his left shoulder"). It MUST be a mid-motion state, never a settled/completed pose: a cut on a static freeze leaves nothing for the next shot to continue, which is exactly what makes the seam visible;',
5478
+ '- **cam (camera carry-over)**: the move direction and speed of this shot, written so the next shot can continue it (e.g. "lateral right, constant speed, never stops"; "slow push-in, almost settled"). Last shot: "settle to static". Avoid reversing direction on adjacent shots (right pan then sudden left pan reads as a jump); if you need a reversal, make the cut a wipe/over-shoulder/insert close-up;',
5479
+ '- **start**: the start state, needed only for shot 1 or shots whose seg is not a continuation; continuation shots omit it (they inherit the previous tail);',
5480
+ '- **seg (segment type)**: `continuation` (default, most shots), `scene_change` (new place/time), `new_character` (a character appears for the first time), `closeup` (insert/detail, jump cut allowed), `opening` (only shot 1 with no upstream continuation);',
5481
+ '- **one primary action per shot**: never pack a turn + walk + push-in + expression change into one shot - the model only executes the first half and the next shot cannot match it;',
5482
+ '- **one camera move per shot**;',
5483
+ '- **split big displacements**: if a character crosses more than 1/4 of the frame, or the staging changes a lot, break it into another shot (different shot size) instead of one long move;',
5484
+ '- **keep the axis**: in a dialogue, the same side relationship (e.g. man left / woman right) must hold across adjacent shots - never cross the line.',
5485
+ ] : []).join('\n');
4490
5486
  const methodology = await loadSkillRules(settings.skillStoryboard, zh)
4491
5487
  || await loadSkillRules(CRAFT_SKILL, zh); // 未选技能时默认注入内置创作方法论
4492
5488
  const system = [
@@ -4514,27 +5510,35 @@ window.__ModuleLoader__.load({
4514
5510
  const sh = prevTail.shot;
4515
5511
  if (!sh) return null;
4516
5512
  const dl = (sh.dialogue || []).map((d) => d.role + (zh ? ':' : ': ') + d.line).join(zh ? ';' : '; ');
5513
+ const tailState = String(sh.tail || '').trim();
5514
+ const camState = String(sh.cam || '').trim();
4517
5515
  return zh
4518
5516
  ? `【承接约束·硬性】本分镜承接「${label}」的最后一镜——**只承接它的结尾瞬间,不要重演它**:\n`
4519
5517
  + ` 尾镜画面:${String(sh.visual || '').trim()}\n`
4520
5518
  + ` 尾镜最后动作:${String(sh.motion || '').trim()}\n`
5519
+ + (tailState ? ` 尾镜接点尾态(本分镜第 1 镜必须从这里继续,动作处于进行中):${tailState}\n` : '')
5520
+ + (camState ? ` 尾镜运镜(第 1 镜须同向延续,不得反向、不得突变):${camState}\n` : '')
4521
5521
  + (dl ? ` 尾镜台词:${dl}\n` : '')
4522
5522
  + (String(sh.sfx || '').trim() ? ` 尾镜音效/环境声:${String(sh.sfx).trim()}\n` : '')
4523
5523
  + `要求:\n`
4524
5524
  + ` 1) 第 1 镜从上一镜的**最后一个动作**继续(人物位置、朝向、情绪、光线保持一致),不得重新建立场景、不得跳回远景交代、不得切换地点;\n`
4525
5525
  + ` 2) 与上一镜的画面重叠**不超过 0.2 秒**(不要重演上一镜的内容);\n`
4526
5526
  + ` 3) **背景声连续**:环境音/音效自然延续(风声、电流声、街道底噪等),不得突然静音或更换环境声;\n`
4527
- + ` 4) 第 1 镜的画面描述里明确写出承接关系(如「承接上一镜,<角色>保持<姿态>,继续…」)。`
5527
+ + ` 4) 第 1 镜的画面描述里明确写出承接关系(如「承接上一镜,<角色>保持<姿态>,继续…」);\n`
5528
+ + ` 5) 第 1 镜的 seg 标 continuation,并**不要写 start**(自动承接上一镜 tail)。`
4528
5529
  : `[CONTINUITY CONSTRAINT - HARD] This storyboard continues the LAST SHOT of "${label}" - continue only its final instant, do NOT replay it:\n`
4529
5530
  + ` Tail frame: ${String(sh.visual || '').trim()}\n`
4530
5531
  + ` Tail last action: ${String(sh.motion || '').trim()}\n`
5532
+ + (tailState ? ` Tail hand-off state (shot 1 must continue from here, mid-motion): ${tailState}\n` : '')
5533
+ + (camState ? ` Tail camera (shot 1 continues the same direction, no reversal, no sudden change): ${camState}\n` : '')
4531
5534
  + (dl ? ` Tail dialogue: ${dl}\n` : '')
4532
5535
  + (String(sh.sfx || '').trim() ? ` Tail SFX/ambience: ${String(sh.sfx).trim()}\n` : '')
4533
5536
  + `Requirements:\n`
4534
5537
  + ` 1) Shot 1 continues from the LAST ACTION of the previous shot (same position, facing, emotion, light) - no re-establishing, no wide reset, no location change;\n`
4535
5538
  + ` 2) overlap with the previous shot must be <= 0.2s (do not replay the previous shot);\n`
4536
5539
  + ` 3) ambient background sound continues naturally (wind, electrical hum, street noise) - never cut to silence or swap ambience;\n`
4537
- + ` 4) state the continuity explicitly in shot 1's frame description.`;
5540
+ + ` 4) state the continuity explicitly in shot 1's frame description;\n`
5541
+ + ` 5) shot 1 uses seg=continuation and omits start (it inherits the previous tail).`;
4538
5542
  })();
4539
5543
  const prevLine = cont || '';
4540
5544
  const user = [
@@ -4569,6 +5573,11 @@ window.__ModuleLoader__.load({
4569
5573
  title: String(s.title || (T('shotPrefix') + (i + 1))).slice(0, 24),
4570
5574
  visual: String(s.visual || ''),
4571
5575
  motion: String(s.motion || ''),
5576
+ // 分镜衔接(分段生成的接戏依据):接点尾态 / 运镜延续 / 起始态 / 段落类型
5577
+ tail: String(s.tail || '').trim().slice(0, 120),
5578
+ cam: String(s.cam || '').trim().slice(0, 80),
5579
+ start: String(s.start || '').trim().slice(0, 120),
5580
+ seg: String(s.seg || '').trim().toLowerCase().slice(0, 20),
4572
5581
  characters: Array.isArray(s.characters) ? s.characters.map(String).slice(0, 6) : [],
4573
5582
  scene: String(s.scene || ''),
4574
5583
  props: Array.isArray(s.props) ? s.props.map(String).slice(0, 6) : [],
@@ -4660,35 +5669,27 @@ window.__ModuleLoader__.load({
4660
5669
  source: (meta && meta.source) || 'ai',
4661
5670
  category: (meta && meta.category) || 'script',
4662
5671
  };
4663
- // 文本资产:0.18 起正文随元数据进 canvas.db(预览/下载不必回读文件);
4664
- // JSON 降级落盘 .md 文件;无数据目录降级为仅 IndexedDB。绝不阻塞生成主流程。
4665
- if (S.dataRoot && S.store === 'sqlite') {
4666
- try {
4667
- const proj = S.projects.find((p) => p.id === projectId);
4668
- const saved = await hostRpc('assetCreate', { asset: assetIndexRecord(rec), projectName: (proj && proj.name) || '' });
4669
- const out = (saved && saved.asset) || rec;
4670
- assetMem.set(out.id, out);
4671
- assetNameCache.set(out.id, out.name);
4672
- return out;
4673
- } catch (err) {
4674
- console.warn('[infinite-canvas] 文本资产入库失败(降级为浏览器存储):', String((err && err.message) || err));
4675
- }
4676
- } else if (S.dataRoot) {
4677
- try {
4678
- const rel = `projects/${projectId}/text/${assetFileBase(rec)}.md`;
4679
- const blob = new Blob([rec.text], { type: 'text/markdown' });
4680
- const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
4681
- if (!r.ok) throw new Error(await r.text());
4682
- rec.rel = rel;
4683
- rec.size = blob.size;
4684
- rec.stored = 'disk';
4685
- } catch (err) {
4686
- console.warn('[infinite-canvas] 文本资产落盘失败(降级为浏览器存储):', String((err && err.message) || err));
4687
- }
5672
+ // 文本资产:文件(.md)落数据目录的项目文件夹,正文同时进 canvas.db(预览/下载不必回读文件)。
5673
+ // 未设数据目录时明确报错(IndexedDB 已废弃),绝不静默。
5674
+ if (!S.dataRoot) throw new Error('未设置数据根目录:文本资产无法持久化(IndexedDB 已废弃)。请在设置里配置数据根目录。');
5675
+ const rel = `projects/${projectId}/text/${assetFileBase(rec)}.md`;
5676
+ const blob = new Blob([rec.text], { type: 'text/markdown' });
5677
+ const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
5678
+ if (!r.ok) throw new Error('文本资产落盘失败: ' + (await r.text()));
5679
+ rec.rel = rel;
5680
+ rec.size = blob.size;
5681
+ rec.stored = 'disk';
5682
+ const proj = S.projects.find((p) => p.id === projectId);
5683
+ if (S.store === 'sqlite') {
5684
+ const saved = await hostRpc('assetCreate', { asset: assetIndexRecord(rec), projectName: (proj && proj.name) || '' });
5685
+ const out = (saved && saved.asset) || rec;
5686
+ assetMem.set(out.id, out);
5687
+ assetNameCache.set(out.id, out.name);
5688
+ return out;
4688
5689
  }
4689
5690
  await idbPut(rec);
4690
5691
  assetNameCache.set(rec.id, rec.name);
4691
- if (S.dataRoot) mirrorAssetIndex(rec); // JSON 降级模式:镜像进 assets.json
5692
+ mirrorAssetIndex(rec); // JSON 降级模式:镜像进 assets.json
4692
5693
  return rec;
4693
5694
  }
4694
5695
 
@@ -4718,6 +5719,8 @@ window.__ModuleLoader__.load({
4718
5719
  updateBusy: false,
4719
5720
  updateMsg: null,
4720
5721
  updateDismissed: false,
5722
+ keysOpen: false, // 左上角快捷键面板是否展开(纯 UI 状态,不持久化)
5723
+ canvasHost: null, // 画布当前宿主:'main'(全屏面板)| 'pane'(会话右栏页签)| null——同一时刻只允许一处实例
4721
5724
  };
4722
5725
  const listeners = new Set();
4723
5726
  let saveTimer = null;
@@ -4774,9 +5777,16 @@ window.__ModuleLoader__.load({
4774
5777
  }
4775
5778
  function initStore() {
4776
5779
  S.projects = lsGet(LS_PROJECTS, []);
4777
- if (!Array.isArray(S.projects) || S.projects.length === 0) {
4778
- S.projects = [{ id: uid('p'), name: T('defaultProject'), createdAt: Date.now(), updatedAt: Date.now() }];
4779
- lsSet(LS_PROJECTS, S.projects);
5780
+ if (!Array.isArray(S.projects)) S.projects = [];
5781
+ // OBS-UI-1:不再自动创建「我的画布」空项目——干净客户端每次打开面板都会
5782
+ // 经 pushAllProjectsToHost 把它写进 canvas.db(QA 实测 13→14 持续累积)。
5783
+ // 空列表 = 空态:画布显示"新建项目"引导,用户显式点击才真正建项目。
5784
+ if (S.projects.length === 0) {
5785
+ S.activeId = null;
5786
+ S.graph = emptyGraph();
5787
+ V.x = 60; V.y = 40; V.scale = 1;
5788
+ S.ready = true;
5789
+ return;
4780
5790
  }
4781
5791
  const active = lsGet(LS_ACTIVE, null);
4782
5792
  S.activeId = S.projects.some((p) => p.id === active) ? active : S.projects[0].id;
@@ -5170,6 +6180,8 @@ window.__ModuleLoader__.load({
5170
6180
  function servedAssetId(src, fromPort) {
5171
6181
  if (!src || !src.data) return null;
5172
6182
  if (fromPort === 'last') return src.data.lastAssetId || null;
6183
+ // tailpick = 尾帧「最佳选帧」(续帧接力首选:动作进行中/清晰/不抖)
6184
+ if (fromPort === 'tailpick') return src.data.lastPickAssetId || src.data.lastAssetId || null;
5173
6185
  return src.data.assetId || null;
5174
6186
  }
5175
6187
  async function resolveAssetBlob(nodeId, portId) {
@@ -5324,7 +6336,7 @@ window.__ModuleLoader__.load({
5324
6336
  flushSave();
5325
6337
  try {
5326
6338
  const prev = prevSceneTail(n); // 场 N-1 的尾镜 → 作为本场第 1 镜的硬性承接约束
5327
- const shots = await aiStoryboardShots(st, n.data.requirement, n.data.count || 6, n.data.model, (kind, d) => aiStreamAppend(n.id, kind, d), prev);
6339
+ const shots = await aiStoryboardShots(st, n.data.requirement, n.data.count || 6, n.data.model, (kind, d) => aiStreamAppend(n.id, kind, d), prev, { cont: n.data.cont !== false });
5328
6340
  // 未闭合引用自愈:句尾丢闭合 @ 的引用按设定集名补全(否则参考图静默丢失)
5329
6341
  const _known = bibleNames(loadBible(S.activeId));
5330
6342
  for (const sh of shots) {
@@ -5377,21 +6389,36 @@ window.__ModuleLoader__.load({
5377
6389
  return v != null ? v : defSub;
5378
6390
  };
5379
6391
  const metas = [];
6392
+ const skipped = []; // 被跳过的片段必须让用户看见:静默少段 = 用户以为全片都合进去了
5380
6393
  for (const x of segs) {
5381
- if (!x.nd.data.assetId) continue;
6394
+ const segName = String((x.nd.data && x.nd.data.label) || TYPE_LABEL()[x.nd.type] || '片段');
6395
+ if (!x.nd.data.assetId) { skipped.push(segName + '(还没有生成结果)'); continue; }
5382
6396
  try {
5383
6397
  const rec = await idbGet(x.nd.data.assetId);
5384
6398
  const blob = await assetBlob(x.nd.data.assetId);
5385
- if (blob) {
5386
- const ttsId = n.data.tts && n.data.tts[x.e.id] && n.data.tts[x.e.id].assetId;
5387
- const ttsUrl = ttsId ? await assetUrl(ttsId) : null;
5388
- // 分镜的编辑时长(1-3s 级)作为成片裁剪依据:API 生成的片段偏长,成片时裁到分镜时长
5389
- const editDur = Number(x.nd.data.editDuration) || null;
5390
- metas.push({ url: URL.createObjectURL(blob), name: (rec && rec.name) || 'seg', sub: getSub(x), ttsUrl, dur: editDur || undefined });
5391
- }
5392
- } catch { /* 跳过加载失败的片段 */ }
6399
+ if (!blob) { skipped.push(segName + '(资产读不到,可能是文件已被删除)'); continue; }
6400
+ if (!blob.size) { skipped.push(segName + '(资产为空文件)'); continue; }
6401
+ const ttsId = n.data.tts && n.data.tts[x.e.id] && n.data.tts[x.e.id].assetId;
6402
+ const ttsUrl = ttsId ? await assetUrl(ttsId) : null;
6403
+ // 分镜的编辑时长(1-3s 级)作为成片裁剪依据:API 生成的片段偏长,成片时裁到分镜时长
6404
+ const editDur = Number(x.nd.data.editDuration) || null;
6405
+ metas.push({ url: URL.createObjectURL(blob), name: (rec && rec.name) || 'seg', sub: getSub(x), ttsUrl, dur: editDur || undefined });
6406
+ } catch (e) {
6407
+ skipped.push(segName + '(读取失败:' + String((e && e.message) || e) + ')');
6408
+ }
6409
+ }
6410
+ if (!metas.length) {
6411
+ throw new Error(skipped.length
6412
+ ? ('没有可合成的片段——' + skipped.join(';'))
6413
+ : T('noSegments'));
6414
+ }
6415
+ if (skipped.length) {
6416
+ // 不阻断合成,但必须在节点上留痕(否则"少了一段"会被当成 bug 却查不到原因)
6417
+ update((s) => {
6418
+ const nd = s.graph.nodes[n.id];
6419
+ if (nd) nd.data.genWarn = '已跳过 ' + skipped.length + ' 个片段:' + skipped.join(';');
6420
+ });
5393
6421
  }
5394
- if (!metas.length) throw new Error(T('noSegments'));
5395
6422
  update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.status = 'running'; nd.data.error = null; nd.data.progress = '0%'; } });
5396
6423
  flushSave();
5397
6424
  try {
@@ -5431,9 +6458,20 @@ window.__ModuleLoader__.load({
5431
6458
  const ctl = new AbortController();
5432
6459
  genAbort.ctl = ctl;
5433
6460
  genActive++;
6461
+ // 生成告警收集器:参考素材截断、厂商参数落空等"需要用户知道"的事实,一律挂到节点上(绝不静默)
6462
+ const addWarn = (msg) => {
6463
+ if (!msg) return;
6464
+ update((s) => {
6465
+ const nd = s.graph.nodes[n.id];
6466
+ if (!nd) return;
6467
+ const prev = String(nd.data.genWarn || '').trim();
6468
+ if (prev.includes(msg)) return;
6469
+ nd.data.genWarn = prev ? (prev + ';' + msg) : String(msg);
6470
+ });
6471
+ };
5434
6472
  update((s) => {
5435
6473
  const nd = s.graph.nodes[n.id];
5436
- if (nd) { nd.data.status = 'running'; nd.data.error = null; nd.data.stopNote = null; }
6474
+ if (nd) { nd.data.status = 'running'; nd.data.error = null; nd.data.stopNote = null; nd.data.genWarn = null; }
5437
6475
  s.running = true; // 单节点触发(对话/节点按钮)也显示停止按钮
5438
6476
  });
5439
6477
  flushSave(); // 立刻落盘 + 推送 Host:切走画面/对话查询都能看到"运行中"
@@ -5455,7 +6493,7 @@ window.__ModuleLoader__.load({
5455
6493
  // 模型解析:节点选择优先,未指定路由到星标默认模型;都没有 = 明确报错(不再静默走演示模式)
5456
6494
  const mcfg = resolveImageModelCfg(settings, nodeImageModelRef(n));
5457
6495
  if (!mcfg) throw new Error(T('errNoImageModel'));
5458
- const blob = await apiImage(p, metas, mcfg, { size: n.data.size, ratio: n.data.ratio, nodeExtra: n.data.imgExtra });
6496
+ const blob = await apiImage(p, metas, mcfg, { size: n.data.size, ratio: n.data.ratio, nodeExtra: n.data.imgExtra, warn: addWarn });
5459
6497
  const rec = await saveAssetRecord(S.activeId, 'image', blob, {
5460
6498
  name: assetNameFor(n, entities, 'image'),
5461
6499
  prompt: stripMentionMarks(prompt), effectivePrompt: p,
@@ -5468,17 +6506,115 @@ window.__ModuleLoader__.load({
5468
6506
  });
5469
6507
  commitGenResult(n.id, rec.id, false, 'image');
5470
6508
  } else {
5471
- const frameBlob = await resolveAssetBlob(n.id, 'frame');
5472
6509
  const { refs, entities, audioBlobs } = await collectRefs(n, prompt, bible);
5473
- const p = buildEffectivePrompt(prompt, refs, entities, bible);
6510
+ // 上游是分镜表 → 走厂商「参考生成」结构化提示词(主体/分镜/台词/声音显式绑定,音画同步;
6511
+ // 机械拼装会让模型把台词与场景判错);节点上已手写结构化提示词则原样尊重。
6512
+ const sbUp = (() => {
6513
+ const e2 = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'prompt');
6514
+ const src = e2 && S.graph.nodes[e2.fromNode];
6515
+ return src && src.type === 'storyboard' ? src : null;
6516
+ })();
6517
+ const shotList = sbUp && Array.isArray(sbUp.data.shots) ? sbUp.data.shots : [];
6518
+ const handWritten = String(n.data.prompt || '').trim();
6519
+ // 分镜衔接规划:段落类型(续帧/换场/新角色/特写插入)+ 首镜承接口 + 是否接力尾帧
6520
+ const zh = effLang() !== 'en';
6521
+ const plan = (() => {
6522
+ if (!shotList.length) return { first: null, chain: false, join: 'loose', seg: 'opening', fromShot: false };
6523
+ const first = shotContinuityPlan(shotList[0], null, 0);
6524
+ // 分镜衔接总开关(分镜表 cont):关掉 = 不做跨段承接、不自动取尾帧、不写衔接块
6525
+ const contOn = sbUp ? sbUp.data.cont !== false : true;
6526
+ // 跨分镜表承接:上游分镜表「衔接上一镜」输入口 ← 上一个分镜表「尾镜」输出口
6527
+ const prevSb = (() => {
6528
+ const e2 = S.graph.edges.find((x) => x.toNode === (sbUp && sbUp.id) && x.toPort === 'prev');
6529
+ const src = e2 && S.graph.nodes[e2.fromNode];
6530
+ return src && src.type === 'storyboard' ? src : null;
6531
+ })();
6532
+ const prevNote = prevSb ? storyboardContinuityNote(prevSb.data.shots || [], String(prevSb.data.label || ''), zh) : null;
6533
+ let seg = first.seg;
6534
+ // 第 1 镜没有表内上一镜时,可承接上一个分镜表 → 实质是续帧
6535
+ if (seg === 'opening' && prevNote) seg = 'continuation';
6536
+ const join = (seg === 'scene_change' || seg === 'new_character') ? 'loose' : seg === 'closeup' ? 'cut' : 'strict';
6537
+ // 只有续帧段才自动取上一段尾帧;其余段落没有可承接的画面
6538
+ const chain = contOn && seg === 'continuation';
6539
+ return { first, chain, seg, join, fromShot: contOn && !!prevNote, contOn };
6540
+ })();
6541
+ // 首帧来源:① frame 口连线(用户显式)② 续帧链自动取「上一个视频」的尾帧
6542
+ let frameBlob = await resolveAssetBlob(n.id, 'frame');
6543
+ let frameAuto = false;
6544
+ if (!frameBlob && plan.chain) {
6545
+ const prevVid = autoPrevVideoNode(n);
6546
+ if (prevVid && prevVid.data.assetId) {
6547
+ try {
6548
+ const vb = await assetBlob(prevVid.data.assetId);
6549
+ if (vb) {
6550
+ // 优先复用它已选好的最佳尾帧(避免重复解码整段视频);没有再现场选帧
6551
+ if (prevVid.data.lastPickAssetId && prevVid.data.lastAssetVideo === prevVid.data.assetId) {
6552
+ frameBlob = await assetBlob(prevVid.data.lastPickAssetId).catch(() => null);
6553
+ }
6554
+ if (!frameBlob) {
6555
+ const tail = await extractTailFrames(vb, { count: 9, span: 0.8 }).catch(() => null);
6556
+ frameBlob = (tail && (tail.best || tail.last)) || null;
6557
+ }
6558
+ if (frameBlob) {
6559
+ frameAuto = true;
6560
+ addWarn('已自动取「' + String(prevVid.data.label || '上一段视频') + '」的尾帧作为本段首帧(续帧接力)');
6561
+ }
6562
+ }
6563
+ } catch { /* 取不到尾帧则降级为纯参考图生成 */ }
6564
+ }
6565
+ }
6566
+ const frameMode = n.data.frameMode || 'auto';
6567
+ const useFrame = frameMode !== 'off' && !!frameBlob;
6568
+ // 硬首帧只在"不挂实体参考图"时可用(多数厂商 first_frame 与 reference_image 互斥);
6569
+ // 续帧段若还要实体参考图 → 尾帧降级为首张参考图 + 软锚定(frameAnchorText 会写明)
6570
+ const hardFrame = useFrame && frameMode === 'hard';
6571
+ const anchorRole = (useFrame && !hardFrame && refs.length) ? 'reference_image' : 'first_frame';
6572
+ let music = '';
6573
+ try { const ex2 = parseExtraParams(n.data.vidExtra); if (ex2 && typeof ex2.music === 'string') music = ex2.music; } catch { /* 扩展参数非法时忽略音乐提示 */ }
6574
+ // 跨段承接说明:本分镜第 1 镜承接上一个分镜表尾镜(续帧时首帧图已带画面,这里补文字约束)
6575
+ const continuity = (() => {
6576
+ if (!plan.fromShot) return '';
6577
+ const st = shotList[0] || {};
6578
+ const tail = String(st.tail || '').trim();
6579
+ const bits = [];
6580
+ if (plan.seg === 'continuation') {
6581
+ bits.push(zh ? '本段是上一段的直接续帧:第 1 帧从上一段结尾接着演,不重演、不重新建立场景、不切换机位' : 'This segment continues the previous one: start from its last frame, no replay, no re-establishing, no camera reset');
6582
+ if (tail) bits.push((zh ? '起始状态:' : 'Start state: ') + tail);
6583
+ } else if (plan.seg === 'scene_change') {
6584
+ bits.push(zh ? '本段换场:不承接上一段画面,但环境声/情绪可延续' : 'Scene change: do not continue the previous frame; ambience and mood may carry over');
6585
+ } else if (plan.seg === 'new_character') {
6586
+ bits.push(zh ? '本段有新角色入场:新角色必须从画面内自然进入(走入/被带入),不得凭空出现' : 'A new character enters: they must enter naturally from within the frame, never pop in');
6587
+ }
6588
+ return bits.join(zh ? ';' : '; ');
6589
+ })();
6590
+ let p;
6591
+ // 图1=尾帧(降级模式)时实体参考图为图2..N+1:两条提示词组装路径都要同步偏移
6592
+ const effOpts = { picOffset: (useFrame && anchorRole === 'reference_image') ? 1 : 0 };
6593
+ if (handWritten.includes('subject_definitions')) {
6594
+ p = handWritten; // 高级用法:手写结构化提示词,原样发送
6595
+ } else if (shotList.length) {
6596
+ p = buildStructuredVideoPrompt(shotList, refs, bible, {
6597
+ zh, prompt: handWritten, music, hasFrame: useFrame, frameRole: anchorRole, continuity,
6598
+ });
6599
+ if (!p) p = buildEffectivePrompt(prompt, refs, entities, bible, false, effOpts); // 无参考图时回退
6600
+ } else {
6601
+ p = buildEffectivePrompt(prompt, refs, entities, bible, false, effOpts);
6602
+ }
6603
+ // 非结构化路径(无参考图/手写提示词)也要有首帧锚定与承接说明
6604
+ if (!shotList.length || !p.includes('subject_definitions')) {
6605
+ const pre = [frameAnchorText({ hasFrame: useFrame, role: anchorRole, zh }), continuity].filter(Boolean).join(zh ? ';' : '; ');
6606
+ if (pre) p = pre + '\n' + p;
6607
+ }
5474
6608
  const mcfg = resolveVideoModelCfg(settings, nodeVideoModelRef(n));
5475
6609
  if (!mcfg) throw new Error(T('errNoVideoModel'));
5476
- const blob = await apiVideo(p, frameBlob, mcfg, {
6610
+ const blob = await apiVideo(p, useFrame ? frameBlob : null, mcfg, {
5477
6611
  duration: n.data.duration, resolution: n.data.resolution, ratio: n.data.ratio,
5478
- frameMode: n.data.frameMode || 'auto', // auto=尾帧降级为参考图 / hard=硬首帧(放弃参考图) / off=忽略首帧
6612
+ frameMode, // auto=尾帧降级为参考图(保留实体参考图)/ hard=硬首帧(放弃参考图)/ off=忽略首帧
6613
+ hardFrame,
5479
6614
  refs: refs.map((r) => r.blob),
5480
6615
  refNames: refs.map((r) => r.entity || r.name || ''), // 跨镜衔接标注用(图2..N 的实体名)
5481
6616
  nodeExtra: n.data.vidExtra, // 节点级扩展参数(手动参数框,如 {"use_context_ir":true})
6617
+ warn: addWarn, // 参考素材超限等告警回传(节点上可见)
5482
6618
  // 任务提交成功即持久化 task id:轮询中断/页面关闭后仍可凭 id 回查结果(钱花了就必须能找回)
5483
6619
  onTask: (tid, qurl) => {
5484
6620
  update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.taskId = String(tid || ''); nd.data.taskQueryUrl = String(qurl || ''); } });
@@ -5495,6 +6631,13 @@ window.__ModuleLoader__.load({
5495
6631
  nodeLabel: (n.data && n.data.label) || '',
5496
6632
  category: 'finalcut', // 节点生成的视频 = 成片(实体归属由 entities 字段承担,不占用分类)
5497
6633
  });
6634
+ // 记录续帧来源(节点上可追溯:本段是从哪一段接过来的)
6635
+ if (useFrame) {
6636
+ update((s) => {
6637
+ const nd = s.graph.nodes[n.id];
6638
+ if (nd) { nd.data.chainFrom = frameAuto ? (autoPrevVideoNode(n) || {}).id || null : 'wired'; }
6639
+ });
6640
+ }
5498
6641
  commitGenResult(n.id, rec.id, false, 'video');
5499
6642
  // 实测结果与设置不一致时留可见提示(厂商忽略 size/duration 是常见情况,必须让人看见而不是静默)
5500
6643
  try {
@@ -5507,22 +6650,55 @@ window.__ModuleLoader__.load({
5507
6650
  else if (wantRatio === '9:16' && gotLandscape) notes.push('厂商返回 ' + meta.w + '×' + meta.h + '(横屏),与设置的 9:16 不符');
5508
6651
  const wantDur = Number(n.data && n.data.duration) || 0;
5509
6652
  if (wantDur && meta.d && Math.abs(meta.d - wantDur) > 1.5) notes.push('实际时长 ' + meta.d.toFixed(1) + 's ≠ 设置 ' + wantDur + 's(厂商单次上限或已钳制)');
5510
- if (notes.length) update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.genWarn = notes.join(';'); });
6653
+ if (notes.length) addWarn(notes.join(';')); // 追加而非覆盖:本轮更早的告警(自动续帧/参考图超限/厂商降级)不能被抹掉
5511
6654
  }
5512
6655
  } catch { /* 探测失败不影响生成 */ }
5513
- // 尾帧提取(通用):供 last 端口输出与链式下一镜使用
6656
+ // 尾帧抽取(续帧接力的关键):末段多帧候选里选「动作进行中/清晰/不抖」的一帧,
6657
+ // 而不是机械取最后一帧(最后一帧常在动作顶点/模糊/抖动上)。
5514
6658
  try {
5515
- const fBlob = await extractLastFrame(blob);
5516
- const fRec = await saveAssetRecord(S.activeId, 'image', fBlob, {
5517
- name: T('lastFrame') + '-' + (n.data.label || n.id.slice(-4)),
5518
- prompt: 'last frame of ' + prompt.slice(0, 60), source: 'api', category: 'image',
5519
- });
5520
- update((s) => {
5521
- const nd = s.graph.nodes[n.id];
5522
- if (nd) { nd.data.lastAssetId = fRec.id; nd.data.lastAssetVideo = rec.id; }
5523
- s.assetsTick++;
5524
- });
6659
+ const tail = await extractTailFrames(blob, { count: 9, span: 0.8 });
6660
+ if (tail && tail.last) {
6661
+ const fRec = await saveAssetRecord(S.activeId, 'image', tail.last, {
6662
+ name: T('lastFrame') + '-' + (n.data.label || n.id.slice(-4)),
6663
+ prompt: 'last frame of ' + prompt.slice(0, 60), source: 'api', category: 'image',
6664
+ });
6665
+ // 最佳选帧单独存一份(last 口保持"真·最后一帧"语义,接力用"最佳帧")
6666
+ let pickRec = null;
6667
+ if (tail.best && tail.best !== tail.last) {
6668
+ pickRec = await saveAssetRecord(S.activeId, 'image', tail.best, {
6669
+ name: T('tailPick') + '-' + (n.data.label || n.id.slice(-4)),
6670
+ prompt: 'continuity tail pick t=' + Number(tail.bestT || 0).toFixed(2) + 's; ' + prompt.slice(0, 40),
6671
+ source: 'api', category: 'image',
6672
+ });
6673
+ }
6674
+ update((s) => {
6675
+ const nd = s.graph.nodes[n.id];
6676
+ if (nd) {
6677
+ nd.data.lastAssetId = fRec.id; nd.data.lastAssetVideo = rec.id;
6678
+ nd.data.lastPickAssetId = pickRec ? pickRec.id : fRec.id;
6679
+ nd.data.tailPickMeta = { t: Number(tail.bestT || 0), score: Math.round((tail.bestScore || 0) * 100) / 100 };
6680
+ }
6681
+ s.assetsTick++;
6682
+ });
6683
+ }
5525
6684
  } catch { /* 尾帧提取失败不影响主流程 */ }
6685
+ // 接缝校验:本段首帧 vs 上一段尾帧(续帧段才有意义)。不达标 → 节点上可见告警(不静默)
6686
+ if (useFrame && plan.chain) {
6687
+ try {
6688
+ const first = await extractFirstFrame(blob);
6689
+ if (first) {
6690
+ const ga = await grayThumb(frameBlob, 32);
6691
+ const gb = await grayThumb(first, 32);
6692
+ const met = frameSeamMetrics(ga, gb);
6693
+ const v = seamVerdict(met, plan.join);
6694
+ update((s) => {
6695
+ const nd = s.graph.nodes[n.id];
6696
+ if (nd) nd.data.seam = { ok: v.ok, sim: v.sim == null ? null : Math.round(v.sim * 100) / 100, thr: v.thr, note: v.note, at: Date.now() };
6697
+ });
6698
+ if (!v.ok) addWarn('接缝校验未通过:' + v.note + '(建议:调低运动幅度重生成,或改用"硬首帧"模式)');
6699
+ }
6700
+ } catch { /* 校验失败不影响产物 */ }
6701
+ }
5526
6702
  }
5527
6703
  return true;
5528
6704
  } catch (err) {
@@ -5618,7 +6794,9 @@ window.__ModuleLoader__.load({
5618
6794
  // ============================================================
5619
6795
  // 画布交互(平移 / 缩放 / 拖动 / 连线 / 框选)
5620
6796
  // ============================================================
5621
- const drag = { mode: null, sx: 0, sy: 0, moved: false, nodeStarts: null, marquee: null, linkFrom: null, shift: false };
6797
+ // altNoPush:本次拖动是否处于「按住 Alt 自由摆放」模式(不推挤邻居、也不吸附加固)。
6798
+ // 注意每次 pointermove 都重新读 e.altKey,所以拖动中途按下/松开 Alt 会实时切换。
6799
+ const drag = { mode: null, sx: 0, sy: 0, moved: false, nodeStarts: null, marquee: null, linkFrom: null, shift: false, altNoPush: false };
5622
6800
 
5623
6801
  function onCanvasPointerDown(e) {
5624
6802
  const target = e.target;
@@ -5673,6 +6851,7 @@ window.__ModuleLoader__.load({
5673
6851
  drag.mode = 'node';
5674
6852
  drag.sx = e.clientX; drag.sy = e.clientY;
5675
6853
  drag.moved = false;
6854
+ drag.altNoPush = !!e.altKey; // 起手就按住 Alt → 整段拖动自由摆放
5676
6855
  const ids = S.sel.has(n.id) ? [...S.sel] : [n.id];
5677
6856
  // 分组拖动时携带其成员
5678
6857
  const allIds = [];
@@ -5726,10 +6905,15 @@ window.__ModuleLoader__.load({
5726
6905
  } else if (drag.mode === 'node') {
5727
6906
  if (Math.abs(dxs) + Math.abs(dys) > 2) drag.moved = true;
5728
6907
  let dxw = dxs / V.scale, dyw = dys / V.scale;
6908
+ // 按住 Alt = 自由摆放:跳过对齐吸附 + 跳过碰撞推挤(允许穿过/覆盖其他节点)。
6909
+ // 典型场景:矩阵布局里挪中间的节点,普通拖动会把整片邻居推开打乱布局,Alt 拖动只挪它自己。
6910
+ // 实时读 e.altKey(而不是只看起手那一刻):拖动途中按下/松开立即生效。
6911
+ const noPush = !!e.altKey;
6912
+ drag.altNoPush = noPush;
5729
6913
  // 对齐吸附:以首个拖动节点为基准,与其他节点左/中/右、上/中/下对齐(容差 6px)
5730
6914
  const prim = drag.nodeStarts[0];
5731
6915
  const ndP = prim && S.graph.nodes[prim.id];
5732
- if (ndP && !e.shiftKey && drag.nodeStarts.length) {
6916
+ if (ndP && !e.shiftKey && !noPush && drag.nodeStarts.length) {
5733
6917
  const snap = 6 / V.scale;
5734
6918
  const ph = ndP.h || 96;
5735
6919
  const px = prim.x + dxw, py = prim.y + dyw;
@@ -5751,6 +6935,15 @@ window.__ModuleLoader__.load({
5751
6935
  if (bestV) dxw += bestV.delta;
5752
6936
  if (bestH) dyw += bestH.delta;
5753
6937
  showGuides(bestV ? bestV.line : null, bestH ? bestH.line : null);
6938
+ } else if (noPush) {
6939
+ hideGuides(); // Alt 自由摆放:吸附线一并隐藏,避免"看着被吸住其实没吸"
6940
+ }
6941
+ // Alt 自由摆放的可见反馈:被拖节点加虚线轮廓,用户能立刻分清"现在没在推挤"
6942
+ for (const st of drag.nodeStarts) {
6943
+ const el = nodeEls.get(st.id);
6944
+ if (!el) continue;
6945
+ if (noPush) el.classList.add('is-free-drag');
6946
+ else el.classList.remove('is-free-drag');
5754
6947
  }
5755
6948
  const movedIds = new Set();
5756
6949
  for (const st of drag.nodeStarts) {
@@ -5765,7 +6958,9 @@ window.__ModuleLoader__.load({
5765
6958
  }
5766
6959
  // —— 实时碰撞推挤(物理感):拖动节点碰到谁,谁沿远离方向实时让位;被推者再推别人(级联)。
5767
6960
  // 上下左右同理:下节点上移碰到上节点 → 上节点实时上移;全程无弹跳,松手即定格。
5768
- resolvePushNow(movedIds);
6961
+ // 按住 Alt 时**整段禁用**(不推挤 = 允许覆盖穿过),松手后位置照旧保留,不会弹回。
6962
+ if (!noPush) resolvePushNow(movedIds);
6963
+
5769
6964
  if (movedIds.size) {
5770
6965
  for (const e2 of S.graph.edges) {
5771
6966
  if (!movedIds.has(e2.fromNode) && !movedIds.has(e2.toNode)) continue;
@@ -5794,6 +6989,14 @@ window.__ModuleLoader__.load({
5794
6989
  function onCanvasPointerUp(e) {
5795
6990
  if (!drag.mode) return;
5796
6991
  const mode = drag.mode;
6992
+ // 收尾:清掉 Alt 自由摆放的视觉标记(无论是否发生过移动,避免虚线轮廓残留在节点上)
6993
+ if (mode === 'node') {
6994
+ for (const st of (drag.nodeStarts || [])) {
6995
+ const el = nodeEls.get(st.id);
6996
+ if (el) el.classList.remove('is-free-drag');
6997
+ }
6998
+ drag.altNoPush = false;
6999
+ }
5797
7000
  if (mode === 'node' && drag.moved) {
5798
7001
  // 拖动期间直接写了模型坐标,这里只需通知 + 保存
5799
7002
  notify();
@@ -6294,7 +7497,7 @@ window.__ModuleLoader__.load({
6294
7497
  function portLabel(pid) {
6295
7498
  const map = {
6296
7499
  prompt: T('portPrompt'), ref: T('portRef'), frame: T('portFrame'),
6297
- in: T('portIn'), out: T('portOut'), last: T('lastFrame'),
7500
+ in: T('portIn'), out: T('portOut'), last: T('lastFrame'), tailpick: T('tailPick'),
6298
7501
  char: T('portChar'), scene: T('portScene'), prop: T('portProp'),
6299
7502
  visual: T('portVisual'), motion: T('portMotion'), script: T('portScript'),
6300
7503
  prev: T('portPrev'), tail: T('portTail'),
@@ -6744,7 +7947,9 @@ window.__ModuleLoader__.load({
6744
7947
  ? h('div', { key: 'ref', className: 'dshic-hint' },
6745
7948
  T('portFrame') + ' × ' + refEdges.length + ' · ' + T('connected'))
6746
7949
  : null,
6747
- kind === 'video' && S.graph.edges.some((x) => x.toNode === n.id && x.toPort === 'frame')
7950
+ // 「衔接」下拉:视频节点**始终**显示——自动续帧(无 frame 线)也需要它来关闭接力或改硬首帧。
7951
+ // 若有 frame 连线则额外说明连线数量。
7952
+ kind === 'video'
6748
7953
  ? h('div', { key: 'fmode', className: 'dshic-node-row' },
6749
7954
  h('span', { className: 'dshic-shot-label', style: { flex: 'none' } }, T('frameModeLabel')),
6750
7955
  h('select', {
@@ -6764,6 +7969,23 @@ window.__ModuleLoader__.load({
6764
7969
  n.data.genWarn ? h('div', { key: 'gw', className: 'dshic-hint', style: { color: 'var(--dsw-alias-state-warning-primary, #d08700)' } }, '⚠ ' + n.data.genWarn) : null,
6765
7970
  n.data.stopNote ? h('div', { key: 'sn', className: 'dshic-hint' }, '⏹ ' + n.data.stopNote) : null,
6766
7971
  n.data.error ? h('div', { key: 'err', className: 'dshic-err' }, n.data.error) : null,
7972
+ // 续帧状态:本段首帧来自哪里(上一段尾帧 / 连线)+ 接缝校验结果(不达标必须让人看见)
7973
+ kind === 'video' && (n.data.chainFrom || n.data.seam)
7974
+ ? h('div', { key: 'chain', className: 'dshic-hint', style: { display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' } },
7975
+ n.data.chainFrom
7976
+ ? h('span', { title: T('chainInfo') }, '🔗 ' + (n.data.chainFrom === 'wired' ? T('chainManual') : T('chainAuto')))
7977
+ : null,
7978
+ n.data.seam
7979
+ ? h('span', {
7980
+ title: T('seamHint'),
7981
+ style: { color: n.data.seam.ok ? 'var(--dsw-alias-state-success-primary, #2f9e44)' : 'var(--dsw-alias-state-warning-primary, #d08700)' },
7982
+ }, (n.data.seam.ok ? '✓ ' : '⚠ ') + (n.data.seam.ok ? T('seamOk') : T('seamFail')) + (n.data.seam.sim != null ? ' ' + n.data.seam.sim : ''))
7983
+ : null,
7984
+ n.data.tailPickMeta
7985
+ ? h('span', { title: T('tailPickHint') }, '✂ ' + T('tailPick') + ' ' + Number(n.data.tailPickMeta.t || 0).toFixed(1) + 's')
7986
+ : null,
7987
+ )
7988
+ : null,
6767
7989
  assetUrlV && kind === 'image'
6768
7990
  ? h('img', { key: 'img', className: 'dshic-preview', src: assetUrlV, draggable: false, 'data-no-drag': '1', style: { cursor: 'zoom-in' }, onPointerDown: (e) => e.stopPropagation(), onClick: (e) => { e.stopPropagation(); openLightbox('image', assetUrlV, n.data.label); } })
6769
7991
  : null,
@@ -6775,7 +7997,7 @@ window.__ModuleLoader__.load({
6775
7997
  h('button', {
6776
7998
  type: 'button', className: 'dshic-mini is-ghost',
6777
7999
  onPointerDown: (e) => e.stopPropagation(),
6778
- onClick: () => downloadBlobFromUrl(assetUrlV, (kind === 'image' ? 'image' : 'video') + '-' + n.id.slice(-5) + (kind === 'image' ? '.png' : '.webm')),
8000
+ onClick: async () => downloadBlobFromUrl(assetUrlV, await downloadNameForAsset(n.data.assetId, (kind === 'image' ? 'image' : 'video') + '-' + n.id.slice(-5), kind)),
6779
8001
  }, Ic('download', 11), T('download')),
6780
8002
  h('button', {
6781
8003
  type: 'button', className: 'dshic-mini is-ghost', disabled: S.running || n.data.status === 'running',
@@ -7335,6 +8557,18 @@ window.__ModuleLoader__.load({
7335
8557
  try { await genStoryboardNode(n); } catch (e) { toast(String((e && e.message) || e)); }
7336
8558
  };
7337
8559
  const shots = n.data.shots || [];
8560
+ // 一致性告警的手动重校验:告警是**生成时刻**算的,之后补全设定集不会让它们自动消失——
8561
+ // 点刷新按当前设定集重算(checkShotConsistency 读的是实时 bible/资产名索引)
8562
+ const [wvChecking, setWvChecking] = useState(false);
8563
+ const revalidate = async () => {
8564
+ if (wvChecking || !shots.length) return;
8565
+ setWvChecking(true);
8566
+ try {
8567
+ const warnings = await checkShotConsistency(shots);
8568
+ update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.warnings = warnings; });
8569
+ } catch { /* 校验失败保留旧告警 */ }
8570
+ setWvChecking(false);
8571
+ };
7338
8572
  return [
7339
8573
  h('div', { key: 'mrow', className: 'dshic-node-row' },
7340
8574
  h(ModelSelect, { value: n.data.model, title: T('llmLabel'), onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.model = v; }) }),
@@ -7359,6 +8593,14 @@ window.__ModuleLoader__.load({
7359
8593
  options: [{ value: '0', label: T('autoShots') }].concat([3, 4, 6, 8, 10, 12, 16].map((x) => ({ value: String(x), label: x + ' ' + T('shotsUnit') }))),
7360
8594
  onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.count = Number(v); }),
7361
8595
  }),
8596
+ // 分镜衔接:写作时强制每镜带「承接上镜尾态 / 接点尾态 / 运镜延续」,并可标记段落类型
8597
+ h('button', {
8598
+ key: 'cont', type: 'button',
8599
+ className: 'dshic-mini' + (n.data.cont !== false ? '' : ' is-ghost'),
8600
+ title: T('contToggleHint'),
8601
+ onPointerDown: (e) => e.stopPropagation(),
8602
+ onClick: () => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.cont = nd.data.cont === false; }),
8603
+ }, (n.data.cont !== false ? '✓ ' : '') + T('contToggle')),
7362
8604
  h('div', { className: 'dshic-flex' }),
7363
8605
  ),
7364
8606
  h('button', { key: 'gen', type: 'button', className: 'dshic-gen', disabled: running, onClick: generate, onPointerDown: (e) => e.stopPropagation() },
@@ -7373,22 +8615,43 @@ window.__ModuleLoader__.load({
7373
8615
  n.data.aiError ? h('div', { key: 'err', className: 'dshic-err' }, n.data.aiError) : null,
7374
8616
  (n.data.warnings || []).length
7375
8617
  ? h('div', { key: 'warn', className: 'dshic-card', style: { padding: 6, gap: 3 } },
7376
- h('div', { className: 'dshic-shot-label' }, T('consistencyTitle')),
8618
+ h('div', { key: 'thead', className: 'dshic-node-row', style: { gap: 6 } },
8619
+ h('div', { className: 'dshic-shot-label', style: { flex: 1, minWidth: 0 } }, T('consistencyTitle')),
8620
+ // 手动重校验:生成时刻的告警在设定集补全后不会自动变绿——点一下按**当前**设定集重算
8621
+ h('button', {
8622
+ type: 'button', className: 'dshic-mini is-ghost', title: T('refreshCheckHint'),
8623
+ disabled: wvChecking,
8624
+ onPointerDown: (e) => e.stopPropagation(),
8625
+ onClick: revalidate,
8626
+ }, wvChecking ? h('span', { className: 'dshic-spin' }) : '↻', T('refreshCheck')),
8627
+ ),
7377
8628
  n.data.warnings.map((w, i) => h('div', { key: i, className: 'dshic-err', style: { color: 'var(--dsw-alias-state-warn-primary)' } }, '· ' + w)),
7378
8629
  )
7379
8630
  : null,
7380
8631
  shots.length
7381
8632
  ? h('div', { key: 'listwrap', style: { position: 'relative', alignSelf: 'stretch' } },
7382
8633
  h('div', { key: 'list', className: 'dshic-shot-list', style: { maxHeight: listH + 'px' } },
7383
- shots.map((sh, i) => h('div', { key: i, className: 'dshic-shot-item', onPointerDown: (e) => e.stopPropagation() },
8634
+ shots.map((sh, i) => {
8635
+ const pl = shotContinuityPlan(sh, shots[i - 1], i);
8636
+ return h('div', { key: i, className: 'dshic-shot-item', onPointerDown: (e) => e.stopPropagation() },
7384
8637
  h('strong', {}, `${sh.title} · ${sh.duration}s `), ...mentionNodes(sh.visual),
8638
+ pl.seg !== 'continuation'
8639
+ ? h('div', { className: 'dshic-shot-label', style: { marginTop: 2, opacity: 0.9 } }, '〔' + T('seg' + pl.seg.charAt(0).toUpperCase() + pl.seg.slice(1)) + '〕')
8640
+ : null,
8641
+ String(sh.tail || '').trim()
8642
+ ? h('div', { className: 'dshic-shot-label', style: { marginTop: 2, opacity: 0.9 } }, '↳ ' + T('shotTail') + ':' + String(sh.tail).trim())
8643
+ : null,
8644
+ String(sh.cam || '').trim()
8645
+ ? h('div', { className: 'dshic-shot-label', style: { marginTop: 2, opacity: 0.85 } }, '🎥 ' + String(sh.cam).trim())
8646
+ : null,
7385
8647
  (sh.dialogue || []).length
7386
8648
  ? h('div', { className: 'dshic-shot-label', style: { marginTop: 3 } }, '台词 ' + sh.dialogue.map((d) => `${d.role}:${d.line}`).join(';'))
7387
8649
  : null,
7388
8650
  String(sh.sfx || '').trim()
7389
8651
  ? h('div', { className: 'dshic-shot-label', style: { marginTop: 2, opacity: 0.85 } }, '音效 ' + String(sh.sfx).trim())
7390
8652
  : null,
7391
- )),
8653
+ );
8654
+ }),
7392
8655
  ),
7393
8656
  h('div', { key: 'grip', className: 'dshic-shot-grip', title: T('resizeList'), onPointerDown: startResize }, '⌟'),
7394
8657
  )
@@ -7486,6 +8749,14 @@ window.__ModuleLoader__.load({
7486
8749
  });
7487
8750
  linkRaw(n, 'out', videoGen, 'prompt'); // 多镜头提示词来自上游分镜表(不复制文本)
7488
8751
  linkRaw(videoGen, 'out', videoAsset, 'in');
8752
+ // 把「上一段视频」的尾帧接到本段的 frame:铺开时就建好,用户按顺序生成即可自动接力。
8753
+ // 找上一段的规则与 autoPrevVideoNode 一致(同分镜表兄弟段 / 沿 prev 链上溯的上游分镜表所连视频)。
8754
+ (() => {
8755
+ const prevVid = autoPrevVideoNode(videoGen);
8756
+ if (!prevVid || !prevVid.id) return;
8757
+ const fromPort = prevVid.data && prevVid.data.lastPickAssetId ? 'tailpick' : 'last';
8758
+ linkRaw(prevVid, fromPort, videoGen, 'frame');
8759
+ })();
7489
8760
  s.sel = new Set([videoGen.id]);
7490
8761
  });
7491
8762
  pushHistory();
@@ -7542,6 +8813,9 @@ window.__ModuleLoader__.load({
7542
8813
  const st = await craftStatusSafe();
7543
8814
  if (!st) return;
7544
8815
  if (!(st.bins && st.bins.filmcraft && st.bins.filmcraft.found)) { toast(T('craftCliMissing').replace('{name}', 'filmcraft')); return; }
8816
+ // BLK-02:先取项目绝对目录(ArtCraft 无数据根,必须由画布给 base)
8817
+ let base;
8818
+ try { base = await craftBaseDirRequired(S.activeId); } catch (e) { toast('Craft: ' + ((e && e.message) || e)); return; }
7545
8819
  const items = [];
7546
8820
  for (const x of segs) {
7547
8821
  if (!x.nd.data.assetId) continue;
@@ -7552,31 +8826,34 @@ window.__ModuleLoader__.load({
7552
8826
  const rel = await ensureAssetOnDisk({ ...(rec || {}), id: x.nd.data.assetId, blob });
7553
8827
  const vd = await videoDurationOf(blob);
7554
8828
  const ed = Number(x.nd.data.editDuration) || 0;
7555
- items.push({ rel, name: (rec && rec.name) || 'seg', sub: getSub(x), dur: ed > 0 ? Math.min(ed, vd) : vd });
8829
+ // items.rel 用「项目目录下的相对路径」,FCP7 XML 里再拼成绝对路径
8830
+ items.push({ rel: rel.slice(rel.indexOf('/craft/') + 1), name: (rec && rec.name) || 'seg', sub: getSub(x), dur: ed > 0 ? Math.min(ed, vd) : vd });
7556
8831
  } catch (e) { toast('Craft: ' + (e.message || e)); return; }
7557
8832
  }
7558
8833
  if (!items.length) { toast(T('noSegments')); return; }
7559
- const slug = craftProjectSlug();
7560
8834
  const dims = videoDimsFor({ resolution: n.data.resolution, ratio: n.data.ratio });
7561
8835
  const xml = buildFcp7Xml(items, {
7562
8836
  name: (S.projects.find((p) => p.id === S.activeId) || {}).name || 'project',
7563
- dataRoot: (st.dataRoot && st.dataRoot.path) || '',
8837
+ dataRoot: base, // BLK-02:媒体绝对路径 = 项目目录 + items.rel(原读 ArtCraft 的 dataRoot 字段恒为空)
7564
8838
  width: dims.w, height: dims.h,
7565
8839
  });
8840
+ const outRel = 'craft/out/final.mp4'; // 相对 base
7566
8841
  try {
7567
- await craft.writeText('projects/' + slug + '/craft/timeline.xml', xml);
7568
- await craft.writeText('projects/' + slug + '/craft/subs.srt', buildSrt(items));
8842
+ await craft.writeText(base, 'craft/timeline.xml', xml);
8843
+ await craft.writeText(base, 'craft/subs.srt', buildSrt(items));
7569
8844
  } catch (e) { return; /* craftFetch 已提示 */ }
7570
8845
  setFcDialog({
7571
- args: JSON.stringify(['--project', 'projects/' + slug + '/craft/drama.fcproj', '--import', 'projects/' + slug + '/craft/timeline.xml', '--export', 'projects/' + slug + '/craft/out/final.mp4'], null, 1),
7572
- outRel: 'projects/' + slug + '/craft/out/final.mp4',
8846
+ // args 里一律绝对路径(job 的 cwd 语义不依赖)
8847
+ args: JSON.stringify(['--project', joinLocal(base, 'craft/drama.fcproj'), '--import', joinLocal(base, 'craft/timeline.xml'), '--export', joinLocal(base, outRel)], null, 1),
8848
+ outRel,
8849
+ base,
7573
8850
  });
7574
8851
  };
7575
- const submitFc = (args, outRel) => {
7576
- craft.jobStart({ bin: 'filmcraft', args, outputs: [outRel], timeoutMs: 30 * 60 * 1000 }).then((job) => {
8852
+ const submitFc = (args, outRel, base) => {
8853
+ craft.jobStart({ bin: 'filmcraft', cwd: base, args, outputs: [outRel], timeoutMs: 30 * 60 * 1000 }).then((job) => {
7577
8854
  update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.fcJobId = job.id; nd.data.fcStatus = 'running'; } });
7578
8855
  setFcDialog(null);
7579
- pollFilmcraftJob(n.id, job.id, outRel);
8856
+ pollFilmcraftJob(n.id, job.id, outRel, base);
7580
8857
  }).catch(() => { /* craftFetch 已提示 */ });
7581
8858
  };
7582
8859
  return [
@@ -7686,7 +8963,7 @@ window.__ModuleLoader__.load({
7686
8963
  h('button', {
7687
8964
  type: 'button', className: 'dshic-mini is-ghost',
7688
8965
  onPointerDown: (e) => e.stopPropagation(),
7689
- onClick: () => downloadBlobFromUrl(fcUrl, 'finalcut-fc-' + n.id.slice(-5) + '.mp4'),
8966
+ onClick: async () => downloadBlobFromUrl(fcUrl, await downloadNameForAsset(n.data.fcAssetId, 'finalcut-fc-' + n.id.slice(-5), 'video')),
7690
8967
  }, Ic('download', 11), T('download')),
7691
8968
  h('button', {
7692
8969
  type: 'button', className: 'dshic-mini is-ghost', title: T('voiceExtractBtn'),
@@ -7705,7 +8982,7 @@ window.__ModuleLoader__.load({
7705
8982
  h('button', {
7706
8983
  type: 'button', className: 'dshic-mini is-ghost',
7707
8984
  onPointerDown: (e) => e.stopPropagation(),
7708
- onClick: () => downloadBlobFromUrl(assetUrlV, 'finalcut-' + n.id.slice(-5) + '.webm'),
8985
+ onClick: async () => downloadBlobFromUrl(assetUrlV, await downloadNameForAsset(n.data.assetId, 'finalcut-' + n.id.slice(-5), 'video')),
7709
8986
  }, Ic('download', 11), T('download')),
7710
8987
  h('div', { className: 'dshic-flex' }),
7711
8988
  ),
@@ -7843,6 +9120,8 @@ window.__ModuleLoader__.load({
7843
9120
  h(Btn, { onClick: () => zoomStep(1.18), title: T('zoomIn') }, Ic('zoomIn', 12)),
7844
9121
  ),
7845
9122
  h('div', { className: 'dshic-toolbar-sep' }),
9123
+ h(ShortcutsPanel), // 快捷键入口(工具栏右侧,缩放控件与项目名之间)
9124
+ h('div', { className: 'dshic-toolbar-sep' }),
7846
9125
  naming
7847
9126
  ? h('input', {
7848
9127
  className: 'dshic-input', style: { width: 160 }, autoFocus: true, defaultValue: proj ? proj.name : '',
@@ -7921,9 +9200,8 @@ window.__ModuleLoader__.load({
7921
9200
  try { localStorage.removeItem(LS_BIBLE + pid); } catch { /* 忽略 */ }
7922
9201
  if (s.activeId === pid) {
7923
9202
  if (s.projects.length === 0) {
7924
- const p = { id: uid('p'), name: T('defaultProject'), createdAt: Date.now(), updatedAt: Date.now() };
7925
- s.projects.push(p);
7926
- s.activeId = p.id;
9203
+ // OBS-UI-1:删光后进入空态,不再自动建「我的画布」占位(同 initStore 口径)
9204
+ s.activeId = null;
7927
9205
  s.graph = emptyGraph();
7928
9206
  } else {
7929
9207
  s.activeId = s.projects[0].id;
@@ -7938,7 +9216,17 @@ window.__ModuleLoader__.load({
7938
9216
  });
7939
9217
  lsSet(LS_PROJECTS, S.projects);
7940
9218
  lsSet(LS_ACTIVE, S.activeId);
7941
- try { hostRpc('projectsDelete', { id: pid }).catch(() => { /* 静默 */ }); } catch { /* 忽略 */ }
9219
+ // IC-B2:先清 ArtCraft 后期目录**再**删项目——projectCraftRemove 靠 meta 条目解析物理文件夹,
9220
+ // 若先删项目(meta 条目随之被清),清理会解析不到目录而"伪成功",craft 残留无人知晓。
9221
+ // 失败只 toast 不阻断删除(目录残留可手动清)。
9222
+ await craftProjectCleanup(pid);
9223
+ try {
9224
+ const del = await hostRpc('projectsDelete', { id: pid });
9225
+ // BLK-01 的清理结果必须可见:rmrf 部分失败(Windows 文件锁)时残留在盘上,
9226
+ // 同名重建的项目会继承旧 assets.json 条目——用户至少要知道发生过什么。
9227
+ const diskErrs = del && del.disk && Array.isArray(del.disk.errors) ? del.disk.errors : [];
9228
+ if (diskErrs.length) toast('项目已删除,但磁盘清理有 ' + diskErrs.length + ' 项失败:' + diskErrs.join(';'));
9229
+ } catch (e) { toast('删除请求失败(本地已移除,刷新后可能复现):' + String((e && e.message) || e)); }
7942
9230
  if (!S.store) {
7943
9231
  // IndexedDB 模式(未设数据目录):本地逐个清理
7944
9232
  try {
@@ -7969,6 +9257,10 @@ window.__ModuleLoader__.load({
7969
9257
  h('button', { type: 'button', className: 'dshic-mini is-ghost', title: T('cancel'), onClick: () => setCreating(false) }, '✕'),
7970
9258
  )
7971
9259
  : h('button', { key: 'newbtn', type: 'button', className: 'dshic-mini', style: { alignSelf: 'flex-start' }, onClick: () => { setCreating(true); setName(''); } }, Ic('plus', 11), T('newProject')),
9260
+ !S.projects.length && !creating
9261
+ ? h('div', { key: 'emptystate', className: 'dshic-hint', style: { alignSelf: 'flex-start', padding: '6px 2px', maxWidth: 260 } },
9262
+ T('emptyProjectHint'))
9263
+ : null,
7972
9264
  ...S.projects.map((p) => {
7973
9265
  const editing = editId === p.id;
7974
9266
  return h('div', {
@@ -8328,7 +9620,7 @@ window.__ModuleLoader__.load({
8328
9620
  onPointerDown: (e) => e.stopPropagation(),
8329
9621
  onClick: startRename,
8330
9622
  }, Ic('pencil', 11));
8331
- const downloadName = (a.name || 'asset') + (a.kind === 'image' ? '.png' : a.kind === 'video' ? '.webm' : a.kind === 'audio' ? '.mp3' : '.md');
9623
+ const downloadName = (a.name || 'asset') + extForDownload(a.mime, a.name, a.kind);
8332
9624
  return h('div', { className: 'dshic-asset', onClick: a.kind === 'audio' ? undefined : () => onAdd(a), title: a.kind === 'text' ? T('textAssetHint') : a.kind === 'audio' ? '' : T('addAssetNode') },
8333
9625
  a.kind === 'text'
8334
9626
  ? h('div', { className: 'dshic-textcard is-title' },
@@ -8798,12 +10090,16 @@ window.__ModuleLoader__.load({
8798
10090
  useEffect(() => { probeCraft().then(() => force((x) => x + 1)); }, []);
8799
10091
  const cs = S.craftStatus;
8800
10092
  const data = cs && cs.data;
8801
- const slug = craftProjectSlug();
8802
- // Craft 后期目录 = **画布数据根目录** + 项目目录 + \craft(不再有 ArtCraft 独立数据目录的 second 口径)
8803
- const craftDir = S.dataRoot ? (S.dataRoot.replace(/[\\/]+$/, '') + '\\projects\\' + slug + '\\craft') : '';
8804
- // ArtCraft 自身的数据目录若与画布数据根目录不一致 → 后期文件会落到两处,必须提醒
8805
- const artRoot = data && data.dataRoot ? data.dataRoot.path : '';
8806
- const rootMismatch = !!(S.dataRoot && artRoot && artRoot.replace(/[\\/]+$/, '') !== S.dataRoot.replace(/[\\/]+$/, ''));
10093
+ const [craftBase, setCraftBase] = useState('');
10094
+ // BLK-02:后期目录由画布自己算 —— <数据根>/projects/<物理文件夹>/craft。
10095
+ // ArtCraft 0.3.x 已无「自有数据根」概念(/status 无 dataRoot),故不再做两边对比,
10096
+ // 改为把实际基准显示出来,供 QA/用户核对落点。
10097
+ useEffect(() => {
10098
+ let ok = true;
10099
+ craftBaseDir(S.activeId).then((abs) => { if (ok) setCraftBase(abs || ''); }).catch(() => { /* 未配置数据根 */ });
10100
+ return () => { ok = false; };
10101
+ }, [S.activeId, S.dataRoot]);
10102
+ const craftDir = craftBase ? (craftBase.replace(/[\\/]+$/, '') + '\\craft') : '';
8807
10103
  // Craft 家族全量动态渲染:ArtCraft /status 返回几款 bins 就渲染几款(当前 12 款),不再写死
8808
10104
  const binKeys = (data && data.bins ? Object.keys(data.bins) : []).sort();
8809
10105
  const readyCount = binKeys.filter((k) => data.bins[k] && data.bins[k].found).length;
@@ -8831,7 +10127,6 @@ window.__ModuleLoader__.load({
8831
10127
  ),
8832
10128
  open ? [
8833
10129
  craftDir ? h('div', { className: 'dshic-hint' }, T('craftDirLabel') + ':' + craftDir) : h('div', { className: 'dshic-err' }, T('craftDataRootNeed')),
8834
- rootMismatch ? h('div', { className: 'dshic-err' }, T('craftRootMismatch')) : null,
8835
10130
  ...(data ? binKeys.map((k) => binRow(k)) : []),
8836
10131
  ] : null,
8837
10132
  );
@@ -8861,11 +10156,15 @@ window.__ModuleLoader__.load({
8861
10156
  if (!rec || (!rec.blob && !rec.rel)) { toast(T('noResult')); return; }
8862
10157
  setRunning(true); setErr(null); setProgress('…');
8863
10158
  try {
10159
+ // BLK-02:PhotoCraft 的输入/输出也一律 base(项目绝对目录) + 相对路径
10160
+ const base = await craftBaseDirRequired(S.activeId);
8864
10161
  const mediaRel = await ensureAssetOnDisk(rec);
8865
- const outRel = 'projects/' + craftProjectSlug() + '/craft/out/graded/' + rec.id + '-graded' + extForMime(rec.mime, rec.name);
10162
+ const mediaUnderBase = mediaRel.slice(mediaRel.indexOf('/craft/') + 1); // craft/media/…
10163
+ const outRel = 'craft/out/graded/' + rec.id + '-graded' + extForMime(rec.mime, rec.name);
8866
10164
  const job = await craft.jobStart({
8867
10165
  bin: 'photocraft',
8868
- args: ['run', mediaRel, '--cmd', cmd, '--params', JSON.stringify(paramsObj), '--out', outRel],
10166
+ cwd: base,
10167
+ args: ['run', joinLocal(base, mediaUnderBase), '--cmd', cmd, '--params', JSON.stringify(paramsObj), '--out', joinLocal(base, outRel)],
8869
10168
  outputs: [outRel], timeoutMs: 600000,
8870
10169
  });
8871
10170
  setJobId(job.id);
@@ -8881,7 +10180,7 @@ window.__ModuleLoader__.load({
8881
10180
  throw new Error('PhotoCraft ' + status + ':' + ((snap.stderr || '').split('\n').filter(Boolean).slice(-3).join('\n') || ''));
8882
10181
  }
8883
10182
  if (!(snap.outputs && snap.outputs[0] && snap.outputs[0].exists)) throw new Error('任务完成但未找到输出文件');
8884
- const blob = await craft.file(outRel);
10183
+ const blob = await craft.file(base, outRel);
8885
10184
  const rec2 = await saveAssetRecord(S.activeId, 'image', blob, {
8886
10185
  name: (rec.name || 'image') + '-graded',
8887
10186
  prompt: 'photocraft ' + cmd, source: 'craft', category: rec.category || 'general',
@@ -8946,7 +10245,7 @@ window.__ModuleLoader__.load({
8946
10245
  let args;
8947
10246
  try { args = JSON.parse(argsText); } catch { setErr(T('craftInvalidJson')); return; }
8948
10247
  if (!Array.isArray(args)) { setErr(T('craftInvalidJson')); return; }
8949
- onSubmit(args, String(outRel || '').trim());
10248
+ onSubmit(args, String(outRel || '').trim(), info.base);
8950
10249
  };
8951
10250
  return h('div', { className: 'dshic-mask-overlay', onPointerDown: (e) => { if (e.target === e.currentTarget) onClose(); } },
8952
10251
  h('div', { className: 'dshic-mask-card' },
@@ -8970,7 +10269,10 @@ window.__ModuleLoader__.load({
8970
10269
  const [val, setVal] = useState('');
8971
10270
  const [busy, setBusy] = useState(false);
8972
10271
  const [picking, setPicking] = useState(false);
10272
+ const [stats, setStats] = useState(null); // 存储实况:是否真在用 SQLite、库在哪、多少数据
10273
+ const loadStats = () => { hostRpc('dbStats').then((r) => setStats(r || null)).catch(() => setStats({ error: true })); };
8973
10274
  useEffect(() => { if (S.dataRoot) setVal(S.dataRoot); }, [S.dataRoot]);
10275
+ useEffect(() => { loadStats(); }, [S.dataRoot, S.assetsTick]);
8974
10276
  const save = async () => {
8975
10277
  if (busy) return;
8976
10278
  setBusy(true);
@@ -9057,6 +10359,21 @@ window.__ModuleLoader__.load({
9057
10359
  h('button', { type: 'button', className: 'dshic-mini', disabled: busy || picking, onPointerDown: (e) => e.stopPropagation(), onClick: save }, T('dataRootSave')),
9058
10360
  ),
9059
10361
  picking ? h('div', { className: 'dshic-hint', key: 'picking' }, T('dataRootPicking')) : null,
10362
+ h('div', { className: 'dshic-row', key: 'stats', style: { alignItems: 'center', gap: 6 } },
10363
+ h('span', { className: 'dshic-hint', style: { flex: 1, minWidth: 0 } },
10364
+ !stats ? '存储实况:读取中…'
10365
+ : stats.error ? '存储实况:无法读取(后端未提供该接口)'
10366
+ : (stats.store === 'sqlite'
10367
+ ? '存储实况:SQLite 库' + (stats.dbExists ? '(' + (stats.dbBytes / 1048576).toFixed(2) + ' MB' + (stats.walBytes ? ' + WAL ' + (stats.walBytes / 1048576).toFixed(2) + ' MB' : '') + ')' : '(未创建)') + ' · ' + stats.projects + ' 项目 · ' + stats.assets + ' 资产 · 检索 ' + (stats.fts || '-')
10368
+ : stats.store === 'json'
10369
+ ? '存储实况:JSON 降级模式(宿主运行时缺 node:sqlite,数据仍在数据目录的文件里)'
10370
+ : '存储实况:未设置数据目录(资产存浏览器本地 IndexedDB)')),
10371
+ h('button', {
10372
+ type: 'button', className: 'dshic-mini is-ghost', title: '重新检测存储实况',
10373
+ onPointerDown: (e) => e.stopPropagation(), onClick: loadStats,
10374
+ }, '↻'),
10375
+ ),
10376
+ stats && stats.dbPath ? h('div', { key: 'dbpath', className: 'dshic-hint' }, '库文件:' + stats.dbPath) : null,
9060
10377
  h('div', { className: 'dshic-row', key: 'row2' },
9061
10378
  S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini', onPointerDown: (e) => e.stopPropagation(), onClick: openDir }, T('dataRootOpen')) : null,
9062
10379
  h('button', { type: 'button', className: 'dshic-mini is-ghost', disabled: busy || picking, title: T('dataRootImportHint'), onPointerDown: (e) => e.stopPropagation(), onClick: importFromRoot }, Ic('folder', 11), T('dataRootImportBtn')),
@@ -9663,6 +10980,81 @@ window.__ModuleLoader__.load({
9663
10980
  );
9664
10981
  }
9665
10982
 
10983
+ // ============================================================
10984
+ // 快捷键面板(画布左上角浮层):按钮点开 → 下方展开半透明列表
10985
+ // 纯展示、不持久化;点面板外或再点按钮或 Esc 收起。
10986
+ // 内容与代码里**真实存在**的快捷键一一对应(改快捷键时同步这里)。
10987
+ // ============================================================
10988
+ function ShortcutsPanel() {
10989
+ const open = !!S.keysOpen;
10990
+ const wrapRef = useRef(null);
10991
+ // 打开时:Esc 关闭 + 点击面板外关闭(都只在打开期间挂监听,避免常年占用全局事件)
10992
+ useEffect(() => {
10993
+ if (!open) return undefined;
10994
+ const onKey = (e) => { if (e.key === 'Escape') { update((s) => { s.keysOpen = false; }); } };
10995
+ // 用捕获阶段:画布/工具栏上的 handler 可能 stopPropagation,冒泡阶段会收不到
10996
+ const onDown = (e) => {
10997
+ const el = wrapRef.current;
10998
+ if (el && e.target && el.contains && el.contains(e.target)) return; // 点面板内或按钮本身:交给按钮自己的 onClick 处理
10999
+ update((s) => { s.keysOpen = false; });
11000
+ };
11001
+ window.addEventListener('keydown', onKey);
11002
+ window.addEventListener('pointerdown', onDown, true);
11003
+ return () => {
11004
+ window.removeEventListener('keydown', onKey);
11005
+ window.removeEventListener('pointerdown', onDown, true);
11006
+ };
11007
+ }, [open]);
11008
+ // row(id, keyLabel, text):id 仅作 React key;此前误收两参导致第三参(说明文案)被丢弃,
11009
+ // 键位栏渲染成 "Alt + 拖动|0"、说明栏重复键名——整张面板的描述全部失效。
11010
+ const row = (id, keyLabel, text) => h('div', { className: 'dshic-keys-row', key: id },
11011
+ h('span', { className: 'dshic-keys-key' }, keyLabel),
11012
+ h('span', { className: 'dshic-keys-text' }, text));
11013
+ const group = (title, items) => h('div', { className: 'dshic-keys-group', key: title },
11014
+ h('div', { className: 'dshic-keys-group-title' }, title),
11015
+ ...items.map(([k, v], i) => row(k + '|' + i, k, v)));
11016
+ return h('div', { className: 'dshic-keys-wrap', ref: wrapRef },
11017
+ h('button', {
11018
+ type: 'button',
11019
+ className: cls('dshic-keys-btn', open && 'is-open'),
11020
+ title: T('keysBtn'),
11021
+ onPointerDown: (e) => e.stopPropagation(), // 不触发放大/拖拽等工具栏行为
11022
+ onClick: (e) => { e.stopPropagation(); update((s) => { s.keysOpen = !s.keysOpen; }); },
11023
+ }, h('span', { className: 'dshic-keys-btn-label' }, T('keysBtn')), h('span', { className: 'dshic-keys-caret' }, open ? '▴' : '▾')),
11024
+ open
11025
+ ? h('div', {
11026
+ className: 'dshic-keys-pop',
11027
+ onPointerDown: (e) => e.stopPropagation(), // 面板内点击不进画布
11028
+ onWheel: (e) => e.stopPropagation(),
11029
+ },
11030
+ h('div', { className: 'dshic-keys-title' }, T('keysTitle')),
11031
+ group(T('keysDrag'), [
11032
+ ['Alt + 拖动', T('kAltDrag')],
11033
+ ['拖动', T('kDrag')],
11034
+ ['Shift + 拖动', T('kShiftDrag')],
11035
+ ['Shift + 点击', T('kShiftClick')],
11036
+ ['Shift + 框选', T('kMarquee')],
11037
+ ['中键拖动', T('kMiddle')],
11038
+ ['滚轮', T('kWheel')],
11039
+ ['双击空白', T('kDblClick')],
11040
+ ['右键', T('kRightClick')],
11041
+ ]),
11042
+ group(T('keysEdit'), [
11043
+ ['Ctrl/⌘ Z', T('kUndo')],
11044
+ ['Ctrl/⌘ ⇧ Z', T('kRedo')],
11045
+ ['Ctrl/⌘ D', T('kCopy')],
11046
+ ['Delete', T('kDel')],
11047
+ ['Esc', T('kEsc')],
11048
+ ]),
11049
+ group(T('keysView'), [
11050
+ ['双击项目名', T('kZoomKeys')],
11051
+ ]),
11052
+ h('div', { className: 'dshic-keys-hint' }, T('keysHint')),
11053
+ )
11054
+ : null,
11055
+ );
11056
+ }
11057
+
9666
11058
  // ============================================================
9667
11059
  // 帮助与反馈:GitHub 仓库/Issue + 微信交流群二维码(插件静态资源)
9668
11060
  // ============================================================
@@ -9729,7 +11121,7 @@ window.__ModuleLoader__.load({
9729
11121
  // 插件入口
9730
11122
  // ============================================================
9731
11123
  return {
9732
- inject: ['slots', 'locale', 'layout'],
11124
+ inject: ['slots', 'locale', 'layout', 'sidebarRightTabs'],
9733
11125
  apply(ctx) {
9734
11126
  CTX = ctx;
9735
11127
  ctx.effect(() => ctx.locale.register(NS, { zh: DICT_ZH, en: DICT_EN }), 'infinite-canvas: dictionaries');
@@ -9772,23 +11164,112 @@ window.__ModuleLoader__.load({
9772
11164
  // 面板根:包一层错误边界——任何渲染期崩溃都不再白屏,而是显示可复制的错误卡片
9773
11165
  PanelErrorBoundary.__dshicClass = true; // 冒烟 walker 标记
9774
11166
  function PanelRoot() {
9775
- return h(PanelErrorBoundary, null, h(CanvasPage));
11167
+ return h(CanvasHostedApp, { host: 'main' });
9776
11168
  }
9777
11169
  ctx.slots.inject('main', () => ctx.slots.register({
9778
11170
  name: 'main',
9779
11171
  key: PANEL_ID,
9780
11172
  locale: NS,
9781
11173
  }, PanelRoot));
11174
+ // —— 会话右侧面板:画布作为右栏页签(对话与画布同屏工作)——
11175
+ // 契约照抄 dsh-client-ui-sidebar-terminal:**注册 id、三个槽位的 key、guide 条目 id 全部同一口径**。
11176
+ // 关键在壳的 TabSlot:`renderSlot(seat, {}, { entryKey: definition?.id ?? tab.kind })` ——
11177
+ // 插槽调度用的是注册表里的 definition.id,只有 kind 没进注册表时才回退到 kind。
11178
+ // 上一次 body 槽位 key 写的是 kind('infinite-canvas')而注册 id 是包名,
11179
+ // 于是永远命中 fallback:「这类内容还没有可用的查看方式」。
11180
+ const CANVAS_TAB_KIND = 'infinite-canvas';
11181
+ const CANVAS_TAB_ID = '@ninkch/dsh-infinite-canvas'; // = 注册 id = 槽位 key
11182
+ function CanvasHostedApp({ host }) {
11183
+ useApp();
11184
+ useEffect(() => {
11185
+ // 挂载即声明宿主(后开者接管):在窄栏宿主里顺手收起画布自带的右侧栏(宽度不够)
11186
+ const takeover = S.canvasHost !== host;
11187
+ S.canvasHost = host;
11188
+ if (takeover && host === 'pane' && S.rightOpen) S.rightOpen = false;
11189
+ notify();
11190
+ return () => { if (S.canvasHost === host) S.canvasHost = null; };
11191
+ }, [host]);
11192
+ if (S.canvasHost && S.canvasHost !== host) {
11193
+ return h('div', { className: 'dshic-host-taken' },
11194
+ h('div', { className: 'dshic-host-taken-card' },
11195
+ h('div', { className: 'dshic-host-taken-title' }, T('hostTakenTitle')),
11196
+ h('div', { className: 'dshic-host-taken-hint' }, T('hostTakenHint')),
11197
+ h('button', {
11198
+ type: 'button', className: 'dshic-mini',
11199
+ onPointerDown: (e) => e.stopPropagation(),
11200
+ onClick: () => update((s) => { s.canvasHost = host; if (host === 'pane' && s.rightOpen) s.rightOpen = false; }),
11201
+ }, T('hostTakeOver')),
11202
+ ));
11203
+ }
11204
+ return h(PanelErrorBoundary, null, h(CanvasPage, { host }));
11205
+ }
11206
+ // 页签 kind 注册(**开始页卡片的真正来源**):壳的 GuideBody 遍历注册表里每个 kind 的
11207
+ // guide 数组渲染标准卡——只注册槽位不进注册表时,页签体存在但开始页永远不显示入口
11208
+ //(首次上线时漏了这一步,卡片不出现)。DEFAULT_BAND='extension':插件默认档,与
11209
+ // builtin 一次性配对共存,无需显式 priority。
11210
+ // 守卫:旧 runner 未提供 sidebarRightTabs 服务时跳过(保持全屏面板可用,不让整个插件挂掉)。
11211
+ if (ctx.sidebarRightTabs && typeof ctx.sidebarRightTabs.register === 'function') {
11212
+ ctx.effect(() => ctx.sidebarRightTabs.register({
11213
+ id: CANVAS_TAB_ID, // ← 壳用这个 id 作为 slot entryKey 调度 body/title
11214
+ kind: CANVAS_TAB_KIND,
11215
+ multiple: false, // 一个页签足够(同 kind 再开 = 聚焦已有页签,壳的 page uniqueness 语义)
11216
+ title: () => T('panel'), // chip 文本(未注册 title 槽位时的兜底口径)
11217
+ guide: [{
11218
+ id: 'open',
11219
+ order: 30,
11220
+ title: () => T('panel'),
11221
+ description: () => T('guideDesc'),
11222
+ icon: PanelIcon,
11223
+ }],
11224
+ }), 'infinite-canvas: tab type');
11225
+ }
11226
+ // 槽位 key = 注册 id(不是 kind):与 TabSlot 的 entryKey 口径一致,否则永远落到 fallback
11227
+ ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
11228
+ name: 'sidebar.right.pane.tab',
11229
+ key: CANVAS_TAB_ID,
11230
+ locale: NS,
11231
+ }, () => h(CanvasHostedApp, { host: 'pane' })));
11232
+ // 标题槽位同样用注册 id;缺了它 chip 显示注册表 title(address) 抓到的开页标题(也够用)
11233
+ ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
11234
+ name: 'sidebar.right.pane.tab.title',
11235
+ key: CANVAS_TAB_ID,
11236
+ locale: NS,
11237
+ }, function CanvasPaneTitle() {
11238
+ return h('span', { className: 'dshic-pane-tab-title' }, T('panel'));
11239
+ }));
9782
11240
  // 冒烟测试专用缝隙(仅在 smoke-render.mjs 设置了该标记时存在):
9783
11241
  // 直接暴露各页签组件与同步入口,让测试能逐个渲染/驱动,抓白屏与同步断链问题
9784
11242
  if (typeof window !== 'undefined' && window.__DSH_IC_SMOKE__) {
9785
- window.__DSH_IC_TABS__ = { ProjectsTab, AssetsTab, BibleTab, SettingsTab, CanvasPage };
11243
+ window.__DSH_IC_TABS__ = { ProjectsTab, AssetsTab, BibleTab, SettingsTab, CanvasPage, ShortcutsPanel, CanvasHostedApp };
11244
+ // 成片合成(composeFinalCut)纯函数级出口:浏览器录制链路无法在 Node 端跑,
11245
+ // 但合成主循环(分段推进/裁剪/转场/字幕)可以用假的 video/MediaRecorder 真实执行——
11246
+ // 这正是历史上一处 "dur is not defined" 在真机上才炸的漏洞所在。
11247
+ window.__DSH_IC_COMPOSE__ = {
11248
+ composeFinalCut,
11249
+ videoDimsFor,
11250
+ drawSubtitle,
11251
+ extractTailFrames,
11252
+ grayThumb,
11253
+ frameSeamMetrics,
11254
+ seamVerdict,
11255
+ tailFrameScore,
11256
+ };
9786
11257
  window.__DSH_IC_SYNC__ = {
9787
11258
  hostSyncTick, mergeHostDoc, loadBible, loadGraphDoc,
9788
11259
  removeNodes, ungroupNode, undo, redo, pushHistory,
9789
11260
  graph: () => JSON.parse(JSON.stringify(S.graph)),
11261
+ // 冒烟测试用:切换「快捷键面板」展开态,让展开后的列表也走一次真实渲染
11262
+ setKeysOpen: (v) => { S.keysOpen = !!v; },
9790
11263
  state: () => ({ stopped: hostSyncStopped, busy: hostSyncBusy, projects: S.projects.map((p) => p.id), activeId: S.activeId, ready: S.ready }),
9791
11264
  };
11265
+ // IC-01:下载侧扩展名判定的纯函数出口(供 scripts/test-download-ext.mjs 直接断言)
11266
+ window.__DSH_IC_MEDIA__ = { extForDownload };
11267
+ // IC-04:参考图上传三件套的纯函数出口(供 scripts/test-image-upload.mjs 直接断言)
11268
+ window.__DSH_IC_IMGREF__ = { rawImageDims, sniffImageEncoding, registerReferenceAsset };
11269
+ // IC-03:视频厂商识别/前缀归一化的纯函数出口(供 scripts/test-vendor-detect.mjs 直接断言)
11270
+ window.__DSH_IC_VENDOR__ = { detectVideoVendor, gatewayPrefixFor, wanEndpoints, vendorBase, VIDEO_REF_CAPS };
11271
+ // BLK-02:ArtCraft 目录基准相关的纯函数出口(供 scripts/test-craft-base.mjs 断言)
11272
+ window.__DSH_IC_CRAFT__ = { craftBaseDir, craftBaseDirRequired, joinLocal, craftFolderOf, craft };
9792
11273
  }
9793
11274
  },
9794
11275
  };