@ninkch/dsh-infinite-canvas 0.18.4 → 0.18.6

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': '忽略首帧',
@@ -308,6 +338,8 @@ window.__ModuleLoader__.load({
308
338
  'allProjects': '全部项目',
309
339
  'currentProject': '当前项目',
310
340
  'expandDone': '已铺开为一次多镜头生成:{n} 镜合成为一条视频(时长 = 各镜之和)',
341
+ 'expandGenCreated': '已为 {n} 个缺失资产新建图片生成节点(设定图提示词已按技能规范写好)——先生成资产图,再生成视频',
342
+ 'expandAssetReused': '{n} 个直接复用资产库已有图片',
311
343
  'multiShotHint': '多镜头模式:提示词由上游分镜表组合(全部镜头按顺序+时长合成一条提示词),一次性生成整段视频',
312
344
  'aiNeedScript': '分镜节点还没有剧本来源:连线上游剧本节点,或在节点里粘贴剧本',
313
345
  'upstreamStillRunning': '上游剧本节点还没生成完成(有连线时内容只从连线取);等它完成后再生成分镜',
@@ -385,10 +417,12 @@ window.__ModuleLoader__.load({
385
417
  'modelsLabel': '模型',
386
418
  'modelNamePh': '模型 ID(如 doubao-seedance-2.0)',
387
419
  'starDefault': '设为默认模型(节点未指定时自动路由到这里)',
388
- 'noModelsCfg': '未配置模型——节点运行将报错',
420
+ 'noModelsCfg': '未配置模型——运行会提示先配置(对话侧将返回可读错误)',
389
421
  'errNoImageModel': '未指定图片模型:请在设置里配置图片模型并星标默认模型,或在节点上选择模型',
390
422
  'errNoVideoModel': '未指定视频模型:请在设置里配置视频模型并星标默认模型,或在节点上选择模型',
391
423
  'consistencyTitle': '一致性检查',
424
+ 'refreshCheck': '刷新',
425
+ 'refreshCheckHint': '按当前设定集重新校验(生成后补全的设定集条目不会让旧告警自动消失)',
392
426
  'restartHint': '插件后台尚未加载最新版本:请重启一次 DeepSeek Harness 后重试(仅首次更新需要)',
393
427
  'rpcFail': '请求失败',
394
428
  'skillEnhance': '技能增强(用已安装技能替代内置专家方法论)',
@@ -531,7 +565,24 @@ window.__ModuleLoader__.load({
531
565
  'ungroup': '解散分组',
532
566
  'redraw': '重绘',
533
567
  'lastFrame': '尾帧',
568
+ 'tailPick': '尾帧选帧',
569
+ 'tailPickHint': '续帧接力用的尾帧:从片尾多帧里选「动作进行中、清晰、不抖」的一帧,而不是机械取最后一帧',
534
570
  'chainNext': '接续下一镜',
571
+ 'chainInfo': '续帧:首帧来自上一段尾帧',
572
+ 'chainAuto': '自动取上一段尾帧作首帧',
573
+ 'chainManual': '首帧来自连线',
574
+ 'contToggle': '分镜衔接',
575
+ 'contToggleHint': '开启后,分镜会为每镜写出「承接上镜尾态 / 接点尾态(动作进行中)/ 运镜延续 / 段落类型」——分段生成才能接得上',
576
+ 'shotTail': '接点尾态',
577
+ 'segOpening': '首段·交代',
578
+ 'segContinuation': '续帧',
579
+ 'segNew_character': '新角色入场',
580
+ 'segScene_change': '换场',
581
+ 'segCloseup': '特写插入',
582
+ 'segCut': '特写插入',
583
+ 'seamOk': '接缝通过',
584
+ 'seamFail': '接缝偏跳',
585
+ 'seamHint': '接缝校验:本段首帧与上一段尾帧的画面相似度',
535
586
  'craftCard': 'Craft 后期(ArtCraft)',
536
587
  'craftUnreachable': 'Craft 未连接:确认 dsh-artcraft 插件已安装/启用并重启过 Harness',
537
588
  'craftDataRoot': '数据目录',
@@ -596,6 +647,7 @@ window.__ModuleLoader__.load({
596
647
  'duplicate': 'Duplicate (Ctrl+D)',
597
648
  'confirmDel': 'Confirm delete?',
598
649
  'newProject': 'New project',
650
+ 'emptyProjectHint': 'No projects yet. Click "New project" above to create the first one, or just say "create a project called…" in the chat.',
599
651
  'projectName': 'Project name',
600
652
  'renameHint': 'Double-click to rename',
601
653
  'delProjectHint': 'Delete project and its assets',
@@ -667,6 +719,32 @@ window.__ModuleLoader__.load({
667
719
  'hideKey': 'Hide key (masked)',
668
720
  'supportBtn': 'Help & feedback',
669
721
  'supportTitle': 'Help & feedback',
722
+ // Shortcuts panel (floating, top-left of the canvas)
723
+ 'keysBtn': 'Shortcuts',
724
+ 'keysTitle': 'Shortcuts & gestures',
725
+ 'guideDesc': 'Storyboard, generate and cut right beside the chat',
726
+ 'hostTakenTitle': 'The canvas is open elsewhere',
727
+ 'hostTakenHint': 'The canvas runs in one place at a time (one shared node graph and generation state). Move it here with the button below.',
728
+ 'hostTakeOver': 'Move the canvas here',
729
+ 'keysHint': 'Click the button again to collapse; Esc also closes',
730
+ 'keysDrag': 'Canvas',
731
+ 'keysEdit': 'Editing',
732
+ 'keysView': 'View',
733
+ 'kAltDrag': 'Hold Alt while dragging a node: free placement, no pushing / no snapping (may overlap other nodes)',
734
+ 'kDrag': 'Drag a node: neighbours are pushed aside in real time (nodes never overlap)',
735
+ 'kShiftDrag': 'Shift + drag: temporarily disable alignment snapping',
736
+ 'kShiftClick': 'Shift + click: add / remove a node from the selection',
737
+ 'kMarquee': 'Shift + drag on empty canvas: marquee select',
738
+ 'kMiddle': 'Middle-button drag: pan the canvas',
739
+ 'kWheel': 'Wheel: zoom around the pointer',
740
+ 'kDblClick': 'Double-click empty canvas: add-node menu',
741
+ 'kRightClick': 'Right-click: node / canvas context menu',
742
+ 'kUndo': 'Ctrl / ⌘ + Z: undo',
743
+ 'kRedo': 'Ctrl / ⌘ + Shift + Z (or Ctrl + Y): redo',
744
+ 'kCopy': 'Ctrl / ⌘ + D: duplicate selected nodes',
745
+ 'kDel': 'Delete / Backspace: delete selected nodes or edge',
746
+ 'kEsc': 'Esc: close menu / clear selection',
747
+ 'kZoomKeys': 'Double-click the project name: rename the project',
670
748
  'supportNote': 'Found a bug or have an idea? File an issue or join the group.',
671
749
  'supportGithub': 'GitHub repository',
672
750
  'supportIssues': 'Visit GitHub & file an issue',
@@ -704,7 +782,7 @@ window.__ModuleLoader__.load({
704
782
  'lbHint': 'Click outside or press Esc to close',
705
783
  'resizeList': 'Drag to resize the shot list',
706
784
  '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',
785
+ '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
786
  'frameModeAuto': 'Fuse with refs',
709
787
  'frameModeHard': 'Hard first frame',
710
788
  'frameModeOff': 'Ignore frame',
@@ -716,6 +794,8 @@ window.__ModuleLoader__.load({
716
794
  'allProjects': 'All projects',
717
795
  'currentProject': 'Current project',
718
796
  'expandDone': 'Expanded to one multi-shot generation: {n} shots composed into a single video',
797
+ 'expandGenCreated': 'Created {n} image-generation node(s) for assets that had no image yet, with sheet prompts already written — generate the asset images first, then the video',
798
+ 'expandAssetReused': '{n} reused from the asset library',
719
799
  'multiShotHint': 'Multi-shot mode: the prompt is composed from the upstream storyboard (all shots, in order, with durations) and generated as one clip',
720
800
  'aiNeedScript': 'The storyboard has no script source: connect an upstream script node, or paste a script into the node',
721
801
  'upstreamStillRunning': 'The upstream script node is still generating (with a wire, content comes only from the wire); wait for it before splitting shots',
@@ -755,6 +835,7 @@ window.__ModuleLoader__.load({
755
835
  'scriptAssetPrefix': 'Script-',
756
836
  'storyboardAssetPrefix': 'Shots-',
757
837
  'defaultProject': 'My Canvas',
838
+ 'emptyProjectHint': 'No projects yet. Click "New Project" above to create your first one, or just ask the assistant: "create a project called …".',
758
839
  'shotPrefix': 'Shot ',
759
840
  'shotsUnit': 'shots',
760
841
  'savedOk': 'Saved as asset',
@@ -793,10 +874,12 @@ window.__ModuleLoader__.load({
793
874
  'modelsLabel': 'Models',
794
875
  'modelNamePh': 'Model id (e.g. doubao-seedance-2.0)',
795
876
  'starDefault': 'Set as default model (nodes without a selection route here)',
796
- 'noModelsCfg': 'No models configured — node runs will fail',
877
+ 'noModelsCfg': 'No models configured — runs will ask you to configure first (readable error in chat)',
797
878
  'errNoImageModel': 'No image model selected: configure image models in Settings and star a default, or pick one on the node',
798
879
  'errNoVideoModel': 'No video model selected: configure video models in Settings and star a default, or pick one on the node',
799
880
  'consistencyTitle': 'Consistency check',
881
+ 'refreshCheck': 'Refresh',
882
+ 'refreshCheckHint': 'Re-check against the current series bible (entries added after generation do not clear stale warnings automatically)',
800
883
  'restartHint': 'The plugin backend is not loaded yet: restart DeepSeek Harness once and retry (first update only)',
801
884
  'rpcFail': 'Request failed',
802
885
  'skillEnhance': 'Skill boost (use installed Harness skills instead of built-in experts)',
@@ -939,7 +1022,24 @@ window.__ModuleLoader__.load({
939
1022
  'ungroup': 'Ungroup',
940
1023
  'redraw': 'Redraw from this',
941
1024
  'lastFrame': 'Last frame',
1025
+ 'tailPick': 'Tail pick',
1026
+ 'tailPickHint': 'Tail frame used for chaining: the best "mid-motion, sharp, stable" frame near the end, not blindly the last one',
942
1027
  'chainNext': 'Chain next shot',
1028
+ 'chainInfo': 'Chained: first frame comes from the previous segment tail',
1029
+ 'chainAuto': 'Auto: previous segment tail as first frame',
1030
+ 'chainManual': 'First frame from wire',
1031
+ 'contToggle': 'Continuity',
1032
+ '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',
1033
+ 'shotTail': 'Hand-off tail',
1034
+ 'segOpening': 'Opening',
1035
+ 'segContinuation': 'Continuation',
1036
+ 'segNew_character': 'New character',
1037
+ 'segScene_change': 'Scene change',
1038
+ 'segCloseup': 'Insert',
1039
+ 'segCut': 'Insert',
1040
+ 'seamOk': 'Seam OK',
1041
+ 'seamFail': 'Seam jump',
1042
+ 'seamHint': 'Seam check: similarity between this segment first frame and the previous tail frame',
943
1043
  'craftCard': 'Craft post (ArtCraft)',
944
1044
  'craftUnreachable': 'Craft not connected: make sure dsh-artcraft is installed/enabled and Harness was restarted',
945
1045
  'craftDataRoot': 'Data root',
@@ -1248,6 +1348,44 @@ window.__ModuleLoader__.load({
1248
1348
  .dshic-mask-stage img{max-width:100%;max-height:52vh;display:block;}
1249
1349
  .dshic-mask-paint{position:absolute;inset:0;width:100%;height:100%;cursor:crosshair;touch-action:none;}
1250
1350
  .dshic-mask-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;}
1351
+ /* 快捷键入口(工具栏内)+ 下拉面板(绝对定位在按钮下方) */
1352
+ .dshic-keys-wrap{position:relative;display:inline-flex;flex:none;}
1353
+ .dshic-keys-btn{display:inline-flex;align-items:center;gap:5px;height:28px;padding:0 9px;border-radius:8px;
1354
+ border:none;background:transparent;cursor:pointer;font:inherit;font-size:13px;white-space:nowrap;flex:none;
1355
+ color:var(--dsw-alias-label-primary);}
1356
+ .dshic-keys-btn:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 8%,transparent);}
1357
+ .dshic-keys-btn.is-open{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 18%,transparent);color:var(--dsw-alias-brand-primary);}
1358
+ .dshic-keys-btn-label{line-height:1;}
1359
+ .dshic-keys-caret{font-size:9px;opacity:.75;line-height:1;}
1360
+ /* 面板右对齐:按钮位于工具栏右半侧,向左展开才不会出屏;窄屏换行时也跟随按钮 */
1361
+ .dshic-keys-pop{position:absolute;right:0;top:calc(100% + 6px);z-index:30;
1362
+ width:330px;max-height:min(70vh,520px);overflow:auto;box-sizing:border-box;padding:10px 12px;
1363
+ border-radius:10px;border:1px solid var(--dsw-alias-border-l2);
1364
+ background:color-mix(in srgb,var(--dsw-alias-bg-overlay) 72%,transparent);
1365
+ backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);
1366
+ box-shadow:0 8px 24px color-mix(in srgb,var(--dsw-alias-bg-base) 50%,transparent);}
1367
+ .dshic-keys-title{font-size:12px;font-weight:600;color:var(--dsw-alias-label-primary);margin-bottom:8px;}
1368
+ .dshic-keys-group{margin-bottom:9px;}
1369
+ .dshic-keys-group-title{font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;
1370
+ color:var(--dsw-alias-label-secondary);opacity:.8;margin-bottom:4px;}
1371
+ .dshic-keys-row{display:flex;align-items:baseline;gap:8px;padding:2px 0;font-size:12px;line-height:17px;}
1372
+ .dshic-keys-key{flex:none;min-width:84px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
1373
+ font-size:11px;color:var(--dsw-alias-label-primary);background:color-mix(in srgb,var(--dsw-alias-label-primary) 8%,transparent);
1374
+ border-radius:4px;padding:1px 5px;text-align:center;white-space:nowrap;}
1375
+ .dshic-keys-text{flex:1;min-width:0;color:var(--dsw-alias-label-secondary);word-break:break-word;}
1376
+ .dshic-keys-hint{margin-top:2px;padding-top:7px;border-top:1px solid var(--dsw-alias-border-l2);
1377
+ font-size:11px;color:var(--dsw-alias-label-secondary);opacity:.75;}
1378
+ /* Alt 自由摆放(不推挤)时的节点轮廓:虚线 + 轻微透明,明确"现在可以覆盖穿过" */
1379
+ .dshic-node.is-free-drag{outline:2px dashed var(--dsw-alias-brand-primary);outline-offset:2px;opacity:.92;}
1380
+ /* 会话右栏:画布页签(占位卡) */
1381
+ .dshic-host-taken{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
1382
+ background:var(--dsw-alias-bg-base);z-index:5;}
1383
+ .dshic-host-taken-card{max-width:320px;display:flex;flex-direction:column;align-items:center;gap:10px;
1384
+ padding:20px 22px;border:1px solid var(--dsw-alias-border-l2);border-radius:12px;
1385
+ background:var(--dsw-alias-bg-layer-1);text-align:center;}
1386
+ .dshic-host-taken-title{font-size:13px;font-weight:600;}
1387
+ .dshic-host-taken-hint{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);}
1388
+ .dshic-pane-tab-title{font-size:12px;font-weight:500;}
1251
1389
  /* 小地图 */
1252
1390
  .dshic-minimap{position:absolute;right:12px;bottom:12px;width:160px;height:100px;background:var(--dsw-alias-bg-overlay);
1253
1391
  border:1px solid var(--dsw-alias-border-l2);border-radius:8px;z-index:15;overflow:hidden;cursor:pointer;box-sizing:border-box;
@@ -1607,15 +1745,17 @@ window.__ModuleLoader__.load({
1607
1745
  * light=true(图片生成专用):只保留风格行与 [图N] 名称绑定——参考图本身就是外观事实源,
1608
1746
  * 内联设定全文会稀释参考图约束,且文字渲染型模型(Qwen-Image)会把追加文本直接画进画面(用户实测:
1609
1747
  * 设定集全文被渲染成画面右下角的乱码文字块、剧情词凭空生成道具)。视频路径不用 light(不渲染文字,上下文有益)。 */
1610
- function buildEffectivePrompt(prompt, refs, entities, bible, light) {
1748
+ function buildEffectivePrompt(prompt, refs, entities, bible, light, opts) {
1611
1749
  const zh = effLang() !== 'en';
1750
+ // 续帧降级模式(图1=尾帧)下实体参考图实际是图2..N+1 —— 与请求侧 videoRefNote 统一口径
1751
+ const picOffset = (opts && opts.picOffset) || 0;
1612
1752
  const bind = refs.map((r, i) => {
1613
1753
  if (r.entity) {
1614
1754
  const ent = matchBibleEntity(r.entity, bible);
1615
1755
  const desc = light ? '' : ((ent && ent.desc) || '');
1616
- return `[图${i + 1}] ${entityLabelOf((ent || {}).kind || 'prop')} ${r.entity}${desc ? '(' + desc + ')' : ''}`;
1756
+ return `[图${i + 1 + picOffset}] ${entityLabelOf((ent || {}).kind || 'prop')} ${r.entity}${desc ? '(' + desc + ')' : ''}`;
1617
1757
  }
1618
- return `[图${i + 1}] ${catLabel(r.category)}:${r.name}`;
1758
+ return `[图${i + 1 + picOffset}] ${catLabel(r.category)}:${r.name}`;
1619
1759
  }).join(';');
1620
1760
  const entLines = light ? [] : entities.map((e) => `${entityLabelOf(e.kind)} ${e.name}:${e.desc}`);
1621
1761
  return [
@@ -1665,6 +1805,541 @@ window.__ModuleLoader__.load({
1665
1805
  return [].concat(b.characters || [], b.scenes || [], b.props || [])
1666
1806
  .map((x) => (x && x.name ? String(x.name).trim() : '')).filter(Boolean);
1667
1807
  }
1808
+
1809
+ // ---------- 分镜衔接(续帧接力 / 段落调度 / 接缝校验)----------
1810
+ // 为什么需要它:每段视频是**独立采样**的,模型不知道上一段最后发生了什么。分镜只写"本镜拍什么"、
1811
+ // 不写"接在哪、怎么接",模型就自己脑补起点 → 每段各演各的。行业解法不是提示词玄学,而是三层工程:
1812
+ // ① 分镜层写「承接上镜尾态 / 本镜起始态 / 接点尾态(动作进行中)/ 运镜延续」;
1813
+ // ② 生成层把上一段尾帧作为本段首帧(首尾帧模式优先,其次尾帧降级为第 1 张参考图并软锚定);
1814
+ // ③ 剪辑/校验层比对接缝(尾帧 vs 下段首帧),超阈值打回。
1815
+ /** 归一到 0-1 的相似度(0.8 以上视为同一画面)。数字或 null。 */
1816
+ function normSim(x) {
1817
+ const v = Number(x);
1818
+ if (!Number.isFinite(v)) return null;
1819
+ return Math.max(0, Math.min(1, v > 1.0001 ? v / 100 : v));
1820
+ }
1821
+ /** 镜头画面文本(visual + motion),用于衔接描述与相似度兜底。 */
1822
+ function shotVisualText(sh) {
1823
+ if (!sh) return '';
1824
+ return [String(sh.visual || '').trim(), String(sh.motion || '').trim()].filter(Boolean).join(';');
1825
+ }
1826
+ /**
1827
+ * 镜头衔接规划:把「尾部状态 / 起始状态 / 运镜延续 / 段落类型 / 接缝档位」统一解出来。
1828
+ * 分镜字段可手写(tail/start/cam),缺失时用 visual 兜底——**绝不编造剧情**,只做"能确认的推断"。
1829
+ * seg: 'opening'(首段,交代)| 'continuation'(续帧,默认)| 'new_character'(新角色入场,参考图模式)
1830
+ * | 'scene_change'(换场,不接尾帧)| 'closeup'(特写/细节插入,接缝不被画面盯着看 → 标准放宽)
1831
+ * join: 'strict'(同步长硬接)| 'loose'(跨场/换景,允许软接)| 'cut'(特写插入,跳切合理,不接力)
1832
+ * @returns {{seg,name,join,fromTail,fromShot,tail,start,cam,chain,note}}
1833
+ */
1834
+ function shotContinuityPlan(sh, prev, idx) {
1835
+ const s = sh || {};
1836
+ const i = Number.isFinite(Number(idx)) ? Number(idx) : 0;
1837
+ const prevShot = i > 0 ? (prev || null) : null;
1838
+ const segRaw = String(s.seg || '').trim().toLowerCase();
1839
+ const SEG_ALIAS = {
1840
+ opening: 'opening', open: 'opening', '首段': 'opening', '开场': 'opening',
1841
+ continuation: 'continuation', cont: 'continuation', '续': 'continuation', '续帧': 'continuation', '承接': 'continuation',
1842
+ new_character: 'new_character', newchar: 'new_character', '新角色': 'new_character', '入场': 'new_character',
1843
+ scene_change: 'scene_change', scene: 'scene_change', '换场': 'scene_change', '转场': 'scene_change',
1844
+ closeup: 'closeup', insert: 'closeup', '特写': 'closeup', '插入': 'closeup',
1845
+ };
1846
+ let seg = SEG_ALIAS[segRaw] || '';
1847
+ const boxed = (k) => String(s[k] || '').trim();
1848
+ if (!seg) {
1849
+ if (!prevShot) seg = 'opening';
1850
+ else if (/换场|场景切换|转场|另一处|另一场景|切至|切到|次日|翌日|与此同时|数日后|[Cc]ut to/.test(shotVisualText(s))) seg = 'scene_change';
1851
+ else if (/新角色|首次出场|走进来|入镜|入场|冒出|推门进|[Ee]nters?/.test(shotVisualText(s))) seg = 'new_character';
1852
+ else if (Number(s.duration) > 0 && Number(s.duration) < 1.5 && !(s.dialogue || []).length) seg = 'closeup';
1853
+ else seg = 'continuation';
1854
+ }
1855
+ if (seg === 'opening' && prevShot) seg = 'continuation'; // 首段只能在第 1 镜;后面标"开场"视为续帧
1856
+ // 接缝档位:换场/新角色入场都放宽(新角色段本就不承接上段画面),特写插入允许跳切
1857
+ const join = (seg === 'scene_change' || seg === 'new_character') ? 'loose' : seg === 'closeup' ? 'cut' : 'strict';
1858
+ // 只有真正需要画面承接的段落才接力尾帧;新角色入场首帧若来自上一段,新角色反而无从进入
1859
+ const chain = seg === 'continuation' && !!prevShot;
1860
+ // ownTail = 本镜的接点尾态(留给**下一镜**);cam = 本镜运镜(同样留给下一镜延续)
1861
+ const tail = boxed('tail');
1862
+ const cam = boxed('cam');
1863
+ // prevTail = **上一镜**的接点尾态 = 本镜的起始态(衔接块要引用的是它,不是本镜自己的 tail)。
1864
+ // 旧分镜没写 tail 时退到上一镜的「最后动作」——诚实标注来源,而不是拿本镜画面冒充。
1865
+ let prevTail = '';
1866
+ let prevTailSource = '';
1867
+ if (prevShot) {
1868
+ const pt = String(prevShot.tail || '').trim();
1869
+ const pm = String(prevShot.motion || '').trim();
1870
+ if (pt) { prevTail = pt; prevTailSource = 'tail'; }
1871
+ else if (pm) { prevTail = pm; prevTailSource = 'motion'; }
1872
+ }
1873
+ const start = boxed('start');
1874
+ return {
1875
+ idx: i, seg, join, fromTail: i > 0, fromShot: i > 0, chain,
1876
+ name: String(s.title || '').trim(),
1877
+ tail, cam, start, prevTail, prevTailSource,
1878
+ note: boxed('note'),
1879
+ };
1880
+ }
1881
+ /**
1882
+ * 由上一段分镜的尾镜推出本分镜的「承接约束」(跨分镜表接戏)。
1883
+ * 与 aiStoryboardShots 的 prevTail 契约一致:{ label, text, shot },但这里显式带出衔接字段。
1884
+ */
1885
+ function storyboardContinuityNote(prevShots, label, zh) {
1886
+ const arr = (prevShots || []).filter(Boolean);
1887
+ const tailShot = arr.length ? arr[arr.length - 1] : null;
1888
+ if (!tailShot) return null;
1889
+ // 尾镜的 tail/cam 是直接字段(第 0 镜没有"上一镜",规划器不会替它推断,所以直接读字段)
1890
+ const plan = shotContinuityPlan(tailShot, null, 0);
1891
+ const tailState = String(tailShot.tail || '').trim();
1892
+ const camState = String(tailShot.cam || '').trim();
1893
+ const text = [
1894
+ zh ? '尾镜画面:' : 'Tail frame: ', String(tailShot.visual || '').trim(),
1895
+ tailState ? (zh ? '\n尾镜接点尾态(本段第 1 镜必须从这里继续,动作处于进行中):' + tailState
1896
+ : '\nTail hand-off state (shot 1 continues from here, mid-motion): ' + tailState) : '',
1897
+ camState ? (zh ? '\n尾镜运镜(第 1 镜须同向延续):' + camState : '\nTail camera (shot 1 continues the same direction): ' + camState) : '',
1898
+ String(tailShot.motion || '').trim() ? '\n' + (zh ? '尾镜最后动作:' : 'Tail last action: ') + String(tailShot.motion).trim() : '',
1899
+ ].filter(Boolean).join('');
1900
+ return { label: String(label || ''), text: text + '\n', shot: tailShot, plan };
1901
+ }
1902
+ /**
1903
+ * 镜头衔接块(写进视频提示词):让模型知道"接在哪、从哪起、留什么给下一段"。
1904
+ * 只在分镜明确给出衔接信息或确属跨分镜承接时生成;普通首镜(无上游)返回 '',绝不无中生有。
1905
+ */
1906
+ function shotContinuityBlock(plan, opts) {
1907
+ const p = plan || {};
1908
+ const o = opts || {};
1909
+ const zh = o.zh !== false;
1910
+ if (!p.fromShot) return '';
1911
+ const isFirst = p.idx === 0;
1912
+ // 段类型:本分镜表内续帧 / 承接上一个分镜表 / 不接(换场·特写插入)
1913
+ let kindLine = '';
1914
+ 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)';
1915
+ else if (p.seg === 'closeup') kindLine = zh ? '段落类型:特写/细节插入(可与上一镜跳切,不要求画面连续)' : 'Segment type: insert close-up (jump cut allowed, no frame continuity required)';
1916
+ else if (p.seg === 'new_character') kindLine = zh ? '段落类型:新角色入场(新角色须在画面中自然进入,不得凭空出现)' : 'Segment type: new character enters (must enter naturally, never pop in)';
1917
+ else if (p.seg === 'continuation') kindLine = zh
1918
+ ? (isFirst ? '段落类型:承接上一个分镜表(本分镜第 1 镜从上一镜结尾继续,不重演、不重新建立场景)' : '段落类型:续帧(从上一镜结尾继续,不重演)')
1919
+ : (isFirst ? 'Segment type: continues the previous storyboard (no re-establishing, no replay)' : 'Segment type: continuation (continue from the previous shot, no replay)');
1920
+ const parts = [];
1921
+ // 承接上镜结尾:引用的是**上一镜**的接点尾态(本镜的起始态),不是本镜自己的 tail。
1922
+ // 旧分镜没写 tail 时用上一镜的 motion 兜底,并诚实标注来源(不拿本镜画面冒充上一镜结尾)。
1923
+ if (p.prevTail && (p.seg === 'continuation' || p.seg === 'new_character')) {
1924
+ const src = p.prevTailSource === 'motion'
1925
+ ? (zh ? '(上一镜未写接点尾态,按其最后动作推断)' : ' (previous shot has no explicit hand-off state; inferred from its last action)')
1926
+ : '';
1927
+ parts.push((zh ? '承接上镜结尾(本镜起始态,不得重置姿态/位置/手持物):' : 'Continue from the previous shot end state (start exactly here; do not reset posture/position/handheld props): ') + p.prevTail + src);
1928
+ } else if (p.start && p.seg === 'continuation') {
1929
+ parts.push((zh ? '本镜起始态:' : 'Start state: ') + p.start);
1930
+ }
1931
+ // 运镜延续:优先用上一镜的运镜(本镜要接着走);没有则用本镜自己写的 cam
1932
+ if (p.cam) parts.push((zh ? '运镜延续:' : 'Camera continues: ') + p.cam + (zh ? '(方向与速度保持,不反向、不突变)' : ' (same direction and speed; no reversal, no sudden change)'));
1933
+ // 本镜的接点尾态单独提示,明确它是留给下一段的(同时提醒不要在片尾收成定格)
1934
+ if (p.tail && p.seg === 'continuation') {
1935
+ parts.push((zh ? '本镜接点(片尾停在此状态,保持动作进行中,不要演完站定)' : 'End this shot on this hand-off state, still mid-motion; do not settle to a static pose') + ':' + p.tail);
1936
+ }
1937
+ if (p.note) parts.push((zh ? '接戏要求:' : 'Continuity requirement: ') + p.note);
1938
+ if (!kindLine && !parts.length) return '';
1939
+ const head = zh ? '【衔接】' : '[Continuity] ';
1940
+ const end = zh
1941
+ ? '禁止重启姿态、禁止重新建立场景、禁止改变人物朝向与站位(除非剧情明确要求)'
1942
+ : 'Never reset posture, never re-establish the scene, never change facing or blocking unless the story demands it';
1943
+ const body = [kindLine].concat(parts, [end]).filter(Boolean);
1944
+ // 统一标点:拼接前去掉各自尾部标点,避免出现「。;」这种叠标点
1945
+ const clean = body.map((x) => String(x).replace(/[。;;.、\s]+$/, '')).filter(Boolean);
1946
+ return head + clean.join(zh ? ';' : '; ') + (zh ? '。' : '.');
1947
+ }
1948
+ /**
1949
+ * 首帧(上一段尾帧)的软锚定说明:把首帧图钉在"第 1 帧",模型只允许往后运动。
1950
+ * 接口不支持硬首帧时这是**提高权重**,不是锁像素——调用方要给用户可见的告警。
1951
+ */
1952
+ function frameAnchorText(opts) {
1953
+ const o = opts || {};
1954
+ const zh = o.zh !== false;
1955
+ if (!o.hasFrame) return '';
1956
+ const asFirst = o.role === 'first_frame';
1957
+ return zh
1958
+ ? (asFirst
1959
+ ? '【首帧锚定】本次输入的首帧图就是上一段视频的结尾画面:第 1 帧必须与该图完全一致(构图、人物位置、朝向、手持道具、光线),只允许从第 1 帧之后继续运动。'
1960
+ : '【首帧锚定】[图1] 是上一段视频的结尾画面(衔接参考,非风格参考):第 1 帧必须与 [图1] 的画面一致——构图、人物位置、朝向、手持道具、主光方向都不得改动,只允许往后延续运动。')
1961
+ : (asFirst
1962
+ ? '[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.'
1963
+ : '[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.');
1964
+ }
1965
+ /**
1966
+ * 尾帧选帧评分:不是"取最后一帧"——最后一帧常落在动作顶点/运动模糊/遮挡/收敛抖动上。
1967
+ * 返回 0-1 分,越高越适合当下一段首帧(动作进行中、清晰、无遮挡、不抖)。
1968
+ * @param m { motion(相邻帧差 0-1), sharp(清晰度 0-1), coverage(前景占比 0-1), jitter(模型收敛抖动 0-1), complete(动作完成度 0-1) }
1969
+ */
1970
+ function tailFrameScore(m) {
1971
+ const c = (x, d) => (Number.isFinite(Number(x)) ? Math.max(0, Math.min(1, Number(x))) : d);
1972
+ const motion = c(m && m.motion, 0.5);
1973
+ const sharp = c(m && m.sharp, 0.5);
1974
+ const coverage = c(m && m.coverage, 1);
1975
+ const jitter = c(m && m.jitter, 0);
1976
+ const complete = c(m && m.complete, 0.5);
1977
+ // 动作"进行中"最好:太快(模糊/顶点)或完全静止(定格)都扣分
1978
+ const motionFit = 1 - Math.abs(motion - 0.45) / 0.55;
1979
+ // 动作完成度:留 30% 未完成最利于续接(完成度 0.7 附近满分,1=完成态扣分)
1980
+ const incomplete = 1 - Math.abs(complete - 0.7) / 0.7;
1981
+ const score = 0.34 * Math.max(0, motionFit) + 0.30 * sharp + 0.16 * coverage + 0.14 * Math.max(0, incomplete) - 0.22 * jitter;
1982
+ return Math.max(0, Math.min(1, score));
1983
+ }
1984
+ /**
1985
+ * 帧间相似度(接缝校验):只比较缩略灰度图,抗压缩噪声。
1986
+ * 指标:MAD(平均绝对差)、粗糙结构相关(16 宫格均值相关)——两者都要过才算接得上。
1987
+ * @param a/b { data: Uint8ClampedArray|number[](RGBA), w, h }(同尺寸,调用方先缩到 32×32 灰度或同等)
1988
+ */
1989
+ function frameSeamMetrics(a, b) {
1990
+ const ga = a && a.data, gb = b && b.data;
1991
+ if (!ga || !gb || !ga.length || ga.length !== gb.length) return null;
1992
+ const n = ga.length;
1993
+ let sad = 0;
1994
+ let ma = 0, mb = 0;
1995
+ for (let i = 0; i < n; i++) { sad += Math.abs(ga[i] - gb[i]); ma += ga[i]; mb += gb[i]; }
1996
+ ma /= n; mb /= n;
1997
+ let cov = 0, va = 0, vb = 0;
1998
+ 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; }
1999
+ const corr = (va > 1e-6 && vb > 1e-6) ? cov / Math.sqrt(va * vb) : (Math.abs(ma - mb) < 6 ? 1 : 0);
2000
+ // 宫格结构相似:把图切成 G×G 求每格均值,再做相关(对整体位移/缩放不敏感,抓"画面是否同一空间")
2001
+ const G = 16;
2002
+ const side = Math.round(Math.sqrt(n)) || 1;
2003
+ const cellA = new Array(G * G).fill(0), cellB = new Array(G * G).fill(0), cnt = new Array(G * G).fill(0);
2004
+ for (let i = 0; i < n; i++) {
2005
+ const x = i % side, y = Math.floor(i / side);
2006
+ const gx = Math.min(G - 1, Math.floor((x / side) * G));
2007
+ const gy = Math.min(G - 1, Math.floor((y / side) * G));
2008
+ const k = gy * G + gx;
2009
+ cellA[k] += ga[i]; cellB[k] += gb[i]; cnt[k]++;
2010
+ }
2011
+ let cm = 0;
2012
+ const ca = cellA.map((v, k) => (cnt[k] ? v / cnt[k] : 0)), cb = cellB.map((v, k) => (cnt[k] ? v / cnt[k] : 0));
2013
+ for (let k = 0; k < ca.length; k++) cm += ca[k] / ca.length;
2014
+ let cv = 0, cv2 = 0, cva = 0, cvb = 0;
2015
+ 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; }
2016
+ const gridCorr = (cva > 1e-6 && cvb > 1e-6) ? cv / Math.sqrt(cva * cvb) : 0;
2017
+ return {
2018
+ mad: sad / n, // 0-255
2019
+ corr, // -1..1
2020
+ gridCorr, // -1..1
2021
+ sim: Math.max(0, Math.min(1, 0.35 * corr + 0.65 * gridCorr)), // 0-1 综合相似度
2022
+ };
2023
+ }
2024
+ /** 接缝判定:超过档位阈值才算"接得上",否则给出明确原因(供 UI 打回/告警)。 */
2025
+ function seamVerdict(metrics, join) {
2026
+ if (!metrics) return { ok: true, sim: null, note: '' };
2027
+ const thr = join === 'loose' ? 0.35 : join === 'cut' ? 0.15 : 0.55;
2028
+ const sim = metrics.sim;
2029
+ const ok = sim >= thr;
2030
+ return {
2031
+ ok, sim, thr,
2032
+ note: ok ? '' : ('接缝相似度 ' + sim.toFixed(2) + ' < 阈值 ' + thr.toFixed(2) + '(' + (join === 'loose' ? '跨场/换景' : join === 'cut' ? '特写插入' : '同场续帧') + '):画面可能跳变'),
2033
+ };
2034
+ }
2035
+
2036
+ // ---------- 视频「参考生成」结构化提示词(对齐 MiniMax H3 / 优云智算的优化后格式)----------
2037
+ // 为什么需要它:机械拼装(风格 + [图N] 绑定 + 镜头列表 + 设定集全文)会让模型把台词/场景判错;
2038
+ // 这个格式把「素材 → 主体 → 分镜 → 台词 → 声音」显式绑定,模型据此做音画同步与口型对齐。
2039
+ /** 参考图 → 主体信息:实体名(@引用或资产名解析)、品类、设定集描述。 */
2040
+ function subjectInfoFromRef(ref, bible) {
2041
+ const raw = String((ref && ref.name) || '').trim();
2042
+ let name = String((ref && ref.entity) || '').trim();
2043
+ let kind = ref && ref.category === 'character' ? 'character' : ref && ref.category === 'scene' ? 'scene' : ref && ref.category === 'prop' ? 'prop' : '';
2044
+ if (!name) {
2045
+ const m = /^(角色|场景|道具)[-_//]/.exec(raw);
2046
+ if (m) kind = m[1] === '角色' ? 'character' : m[1] === '场景' ? 'scene' : 'prop';
2047
+ name = raw
2048
+ .replace(/^(角色|场景|道具)[-_//]\s*/, '')
2049
+ .replace(/\s*\d{1,2}-\d{1,2}\s+\d{1,2}:\d{2}[\s\S]*$/, '')
2050
+ .replace(/\s*(设定图|参考图|多视图|三视图)[\s\S]*$/, '')
2051
+ .trim();
2052
+ }
2053
+ const ent = bible && name ? matchBibleEntity(name, bible) : null;
2054
+ return { name: ent ? ent.name : name, kind: (ent && ent.kind) || kind, desc: ent ? String(ent.desc || '') : '' };
2055
+ }
2056
+ /** 描述压缩成一句:优先「外观:…」片段,其次首句,最长 80 字。 */
2057
+ function condenseDesc(desc) {
2058
+ const s = String(desc || '').replace(/\s+/g, ' ').trim();
2059
+ if (!s) return '';
2060
+ const ap = /外观[::]\s*([^。;]+)/.exec(s);
2061
+ const seg = ap ? ap[1] : s.split(/[。;]/)[0];
2062
+ const one = String(seg || '').replace(/^[,,、\s]+/, '').trim();
2063
+ return one.length > 80 ? one.slice(0, 80) + '…' : one;
2064
+ }
2065
+ /** 设定集描述里的「声音:…」→ 说话人声音标注(台词的音色依据)。 */
2066
+ function voiceFromDesc(desc) {
2067
+ const m = /声音[::]\s*([^。;]+)/.exec(String(desc || ''));
2068
+ return m ? m[1].trim() : '';
2069
+ }
2070
+ /**
2071
+ * 由分镜表 + 参考图生成「参考生成」结构化提示词。
2072
+ * 段名保持英文(与厂商优化后的协议一致),正文用中文;没有参考图时返回 ''(调用方回退到普通提示词)。
2073
+ * @param shots 分镜镜头(visual/motion/duration/dialogue/sfx/title/characters/scene/props)
2074
+ * @param refs collectRefs 结果(顺序即 <Picture N>)
2075
+ * @param bible 设定集(desc 提供外观/声音信息)
2076
+ * @param opts { zh, prompt(节点上手写的补充说明), music(非叙事音乐,来自扩展参数) }
2077
+ */
2078
+ function buildStructuredVideoPrompt(shots, refs, bible, opts) {
2079
+ const o = opts || {};
2080
+ const zh = o.zh !== false;
2081
+ const list = (shots || []).filter((s) => s && (String(s.visual || '').trim() || String(s.motion || '').trim() || (s.dialogue || []).length));
2082
+ if (!list.length) return '';
2083
+ // 主体分配:一个实体一个 Subject,可对应多张 Picture;顺序 = 参考图中首次出现
2084
+ const subjects = [];
2085
+ const byKey = new Map();
2086
+ // 续帧降级模式(图1=上一段尾帧):请求侧 videoRefNote/frameAnchorText 都把尾帧编号为 [图1]、
2087
+ // 实体参考图为 [图2..N+1] —— <Picture N> 绑定必须整体偏移一位,否则同一提示词里
2088
+ // <Picture 1> 说实体、[图2] 才是实体,主体-素材绑定自相矛盾(参考生成格式赖以工作的锚点)。
2089
+ const picOffset = (o.hasFrame && o.frameRole === 'reference_image') ? 1 : 0;
2090
+ (refs || []).forEach((r, i) => {
2091
+ const info = subjectInfoFromRef(r, bible);
2092
+ if (!info || !info.name) return;
2093
+ const key = info.name.toLowerCase();
2094
+ let sub = byKey.get(key);
2095
+ if (!sub) {
2096
+ sub = { name: info.name, kind: info.kind, desc: info.desc, voice: voiceFromDesc(info.desc), pics: [], voiced: false };
2097
+ subjects.push(sub);
2098
+ byKey.set(key, sub);
2099
+ }
2100
+ if (!sub.pics.includes(i + 1 + picOffset)) sub.pics.push(i + 1 + picOffset);
2101
+ });
2102
+ if (!subjects.length) return '';
2103
+ const sid = (sub) => subjects.indexOf(sub) + 1;
2104
+ const pic = (n2) => '<Picture ' + n2 + '>';
2105
+ // 名称 → <Subject N>(长名优先,避免"瓦力"吃掉"瓦力的铃铛");顺带清掉 @引用@ 的包裹符号,
2106
+ // 否则会留下「@<Subject 2>@」这种残迹(@ 是画布内的引用语法,不该发给视频模型)
2107
+ const ordered = subjects.slice().sort((a, b) => b.name.length - a.name.length);
2108
+ const mentionRe = /@([^@\s,。;、,;:.!?!?「」()()]{1,24})@/g;
2109
+ const rewrite = (text) => {
2110
+ let out = String(text || '');
2111
+ if (out.includes('@')) {
2112
+ out = out.replace(mentionRe, (all, inner) => {
2113
+ const hit = subjects.find((sub) => sub.name.toLowerCase() === String(inner).trim().toLowerCase());
2114
+ if (hit) return '<Subject ' + sid(hit) + '>';
2115
+ return inner; // 未登记的 @名称@ 去掉包裹符号(不要再把画布语法发给模型)
2116
+ });
2117
+ }
2118
+ for (const sub of ordered) {
2119
+ if (!sub.name) continue;
2120
+ out = out.split(sub.name).join('<Subject ' + sid(sub) + '>');
2121
+ }
2122
+ return out;
2123
+ };
2124
+ // 说话人编号:跨镜台词按首次出现顺序 (S1)、(S2)…
2125
+ const speakers = [];
2126
+ const speakerId = (roleName) => {
2127
+ const nm = String(roleName || '').trim();
2128
+ if (!nm) return '';
2129
+ let i = speakers.findIndex((x) => x.name === nm);
2130
+ if (i < 0) { speakers.push({ name: nm, voiced: false }); i = speakers.length - 1; }
2131
+ return 'S' + (i + 1);
2132
+ };
2133
+ // 绝对时间轴
2134
+ let acc = 0;
2135
+ const starts = list.map((s) => { const st = acc; acc += Math.max(0.1, Number(s.duration) || 3); return st; });
2136
+ const total = acc;
2137
+ const fmtT = (sec) => {
2138
+ const ms = Math.max(0, Math.round(sec * 1000));
2139
+ const m = Math.floor(ms / 60000);
2140
+ const s2 = Math.floor((ms % 60000) / 1000);
2141
+ return String(m).padStart(2, '0') + ':' + String(s2).padStart(2, '0') + '.' + String(ms % 1000).padStart(3, '0');
2142
+ };
2143
+ const appearsIn = (sub) => list.map((_, i) => i + 1).filter((idx) => {
2144
+ const s = list[idx - 1];
2145
+ 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(' ');
2146
+ return hay.includes(sub.name);
2147
+ });
2148
+ const outs = [];
2149
+ // ① 主体定义(素材绑定)
2150
+ outs.push('subject_definitions:');
2151
+ subjects.forEach((sub, i) => {
2152
+ const brief = condenseDesc(sub.desc);
2153
+ outs.push('<Subject ' + (i + 1) + '> 是 ' + sub.pics.map(pic).join('、') + ' 中的 ' + sub.name
2154
+ + (brief ? ':' + brief : (sub.kind ? '(' + entityLabelOf(sub.kind) + ')' : '')));
2155
+ });
2156
+ // ② 总述(只写事实,不臆造剧情)
2157
+ const first = String(list[0].title || '').trim();
2158
+ const last = String(list[list.length - 1].title || '').trim();
2159
+ outs.push('summary:');
2160
+ outs.push('[参考生成] 目标视频共 ' + list.length + ' 镜、总时长 ' + (Math.round(total * 10) / 10) + ' 秒'
2161
+ + (first || last ? ':从「' + (first || '起始镜') + '」开始,到「' + (last || '收束镜') + '」结束' : '')
2162
+ + '。主体:' + subjects.map((s) => '<Subject ' + sid(s) + '>').join('、') + '。'
2163
+ + (o.prompt ? ' ' + String(o.prompt).trim() : ''));
2164
+ // ③ 保真核对(每个主体出现在哪些镜)
2165
+ outs.push('retention_analysis:');
2166
+ subjects.forEach((sub) => {
2167
+ const where = appearsIn(sub);
2168
+ const brief = condenseDesc(sub.desc) || (sub.kind ? entityLabelOf(sub.kind) : sub.name);
2169
+ outs.push('<Subject ' + sid(sub) + '>(出现于 ' + (where.length ? where.map((i) => '[Shot ' + i + ']').join('、') : '未明确出现') + '):fully_preserved —— 保持' + brief + '。');
2170
+ });
2171
+ // ④ 逐镜描述(画面 + 运镜 + 台词 + 音效)
2172
+ outs.push('detailed_description:');
2173
+ const style = String((bible && bible.style) || '').trim();
2174
+ if (style) outs.push((zh ? '风格:' : 'Style: ') + style);
2175
+ const hasDialogue = list.some((s) => (s.dialogue || []).some((d) => String((d && d.line) || '').trim()));
2176
+ if (hasDialogue) {
2177
+ outs.push(zh
2178
+ ? '台词必须由画面内对应的主体当场开口说出(口型与台词同步、语气贴合情绪);严禁画外音/旁白。'
2179
+ : 'Dialogue must be spoken on screen by the matching subject with lip-sync; never voice-over.');
2180
+ }
2181
+ // 跨段续帧:首帧锚定(上一段尾帧作为本段第 1 帧)——放在逐镜之前,权重最高
2182
+ const anchor = frameAnchorText({ hasFrame: !!o.hasFrame, role: o.frameRole, zh });
2183
+ if (anchor) outs.push(anchor);
2184
+ list.forEach((s, i) => {
2185
+ const parts = [];
2186
+ // 镜头衔接:段类型 + 承接上镜尾态 + 运镜延续(只对**本分镜表内**的镜头生成;
2187
+ // 第 1 镜跨段承接由 frameAnchorText/调用方负责,避免同一句话说两遍)
2188
+ const plan = shotContinuityPlan(s, list[i - 1], i);
2189
+ if (i > 0) {
2190
+ const cb = shotContinuityBlock(plan, { zh });
2191
+ // 衔接块自带句末标点,作为段落级前置说明独立成句(去掉句末标点后由上层分隔符统一)
2192
+ if (cb) parts.push(rewrite(cb).replace(/[。.]+$/, ''));
2193
+ }
2194
+ const visual = rewrite(String(s.visual || '').trim());
2195
+ if (visual) parts.push(visual);
2196
+ const motion = rewrite(String(s.motion || '').trim());
2197
+ if (motion) parts.push((zh ? '运镜/动作:' : 'Camera/Action: ') + motion);
2198
+ const dls = (s.dialogue || [])
2199
+ .map((d) => ({ role: String((d && d.role) || '').trim(), line: String((d && d.line) || '').trim() }))
2200
+ .filter((d) => d.line);
2201
+ if (dls.length) {
2202
+ const txt = dls.map((d) => {
2203
+ const sp = speakerId(d.role);
2204
+ const sub = subjects.find((x) => x.name === d.role);
2205
+ const spk = speakers.find((x) => x.name === d.role);
2206
+ const useVoice = !!(sub && sub.voice && spk && !spk.voiced);
2207
+ if (useVoice) spk.voiced = true;
2208
+ const who = sub ? ('<Subject ' + sid(sub) + '>(' + sp + ')') : (d.role ? d.role + '(' + sp + ')' : sp);
2209
+ const lang = /[\u4e00-\u9fa5]/.test(d.line) ? 'Chinese' : 'English';
2210
+ const said = useVoice
2211
+ ? (zh ? ' 用' + sub.voice + '说' : ' says (voice: ' + sub.voice + ')')
2212
+ : (zh ? ' 说' : ' says');
2213
+ return who + said + ':<d>[' + lang + '] ' + d.line + '</d>';
2214
+ }).join(';');
2215
+ parts.push((zh ? '台词:' : 'Dialogue: ') + txt);
2216
+ }
2217
+ const sfx = rewrite(String(s.sfx || '').trim());
2218
+ if (sfx) parts.push((zh ? '音效:' : 'SFX: ') + sfx);
2219
+ const title = String(s.title || '').trim();
2220
+ outs.push('[Shot ' + (i + 1) + '] ' + fmtT(starts[i]) + (zh ? ' 起' : '')
2221
+ + (title ? '(' + title + ')' : '') + ':' + parts.join(zh ? ';' : '; '));
2222
+ });
2223
+ // ⑤ 声音景观(各镜音效合并)
2224
+ const sfxAll = [...new Set(list.map((s) => String(s.sfx || '').trim()).filter(Boolean))];
2225
+ outs.push('overall_soundscape:');
2226
+ outs.push(sfxAll.length ? sfxAll.join(';') : (zh ? '仅保留场景环境声,不添加额外音效' : 'Ambient scene sound only'));
2227
+ // ⑥ 非叙事音乐(仅在用户显式给出时输出,绝不臆造)
2228
+ const music = String(o.music || '').trim();
2229
+ if (music) {
2230
+ outs.push('non_diegetic_music:');
2231
+ outs.push(music);
2232
+ }
2233
+ // ⑦ 跨段接戏(仅当本段承接上一段:有首帧尾帧接力或分镜标了承接)
2234
+ const contLine = String(o.continuity || '').trim();
2235
+ if (contLine) {
2236
+ outs.push('continuity:');
2237
+ outs.push(contLine);
2238
+ }
2239
+ return outs.join('\n');
2240
+ }
2241
+ /**
2242
+ * 资产设定图提示词生成器(纯函数):按 canvas-craft「资产设定图版式」的规范,从设定集条目
2243
+ * 组装专业设定集排版提示词——角色走「六段式」,场景走「主图 + 环绕补充小图」,道具走
2244
+ * 「标题 + 斜 45° 主图 + 细节特写 + 侧视图 + 圆形色卡」。
2245
+ * 共同铁律(技能实测结论):版面里绝不渲染成段文字(文字型模型画长文本必乱码),
2246
+ * 只允许 ≤4 字短标签;同一资产在所有分格中外观完全一致。
2247
+ * @param kind 'character' | 'scene' | 'prop'
2248
+ * @param name 资产名(设定集条目名)
2249
+ * @param desc 设定集描述(含「外观:…」时只取该片段作为外观事实)
2250
+ * @param bible 设定集(style 提供风格基调)
2251
+ * @param zh 中文提示词(false → 英文)
2252
+ */
2253
+ function buildAssetSheetPrompt(kind, name, desc, bible, zh) {
2254
+ const nm = String(name || '').trim() || (zh ? '未命名资产' : 'Unnamed asset');
2255
+ const raw = String(desc || '').trim();
2256
+ // 外观要点:优先「外观:…」片段(技能的写法约定),否则整段描述;去掉「声音:…」(画图用不到)
2257
+ const look = (() => {
2258
+ const ap = /外观[::]\s*([^。;\n]+)/.exec(raw);
2259
+ const seg = ap ? ap[1] : raw.replace(/声音[::][^。;\n]*/g, '').trim();
2260
+ return seg.replace(/\s+/g, ' ').trim();
2261
+ })();
2262
+ const style = String((bible && bible.style) || '').trim() || (zh ? '写实电影感' : 'realistic cinematic');
2263
+ const lookLine = look ? look : (zh ? '(设定集未提供外观描述:按名称与常识绘制,保持朴素克制)' : '(no appearance notes in the bible: keep it plain and coherent)');
2264
+ if (kind === 'scene') {
2265
+ return zh
2266
+ ? [
2267
+ '【总起】' + style + ' 的场景设定图:一张主场景大图占画面主体,周围环绕补充小图;所有格子的光线方向、天气与色调统一,等大长条格一律不要。',
2268
+ '【主体·主场景】全景建立镜头,交代空间结构、纵深与光线方向(主光来自哪一侧要能看出来):' + lookLine + '。',
2269
+ '【环绕补充小图(3–6 张,位置与数量不拘)】右侧一列:多角度图(俯瞰/贴地/第一人称视线等其他机位)+ 其他时间图(夜景/雨雾/逆光等同一场景的不同时段天气);底部一排:细节格(标志物/入口/材质纹理/关键道具在场景中的位置)。',
2270
+ '【标注】每张小图下方一行短标注,不超过 6 字(如「入口·夜」「雨中·逆光」)。',
2271
+ '【文字规则】图内只允许上述短标注:黑色细体、笔画清楚;不写大标题、不写说明段或长句。',
2272
+ '【画面白名单】画面内容只来自该场景与它的固有陈设;不出现剧情人物的面部特写、不出现与该场景无关的器物、没有文字水印。',
2273
+ ].join('\n')
2274
+ : [
2275
+ '[Overall] A ' + style + ' location sheet: one large establishing image dominates the frame, ringed by supporting detail images; every panel shares the same light direction, weather and colour grade. No equal-width strip panels.',
2276
+ '[Main image] A wide establishing shot that explains the space, its depth and where the key light comes from: ' + lookLine + '.',
2277
+ '[Supporting images (3-6, placement free)] Right column: alternative angles (overhead / ground level / first-person sightline) plus other times of day (night / rain / backlit); bottom row: detail panels (landmark, entrance, material texture, where the key prop sits).',
2278
+ '[Captions] One short caption under each small image, 6 characters max (e.g. "entrance·night").',
2279
+ '[Text rules] Only those short captions: thin dark strokes, clearly drawn; no headline, no paragraph, no long sentence.',
2280
+ '[Whitelist] Everything in frame comes from this location and its fixed dressing; no character close-ups, no unrelated objects, no watermark.',
2281
+ ].join('\n');
2282
+ }
2283
+ if (kind === 'prop') {
2284
+ return zh
2285
+ ? [
2286
+ '【总起】' + style + ' 的道具设定图:深色简洁背景,同一道具在所有分格中的材质、比例与配色完全一致。',
2287
+ '【标题】顶部道具名大标题(不超过 6 字):' + nm + '。',
2288
+ '【中央主图】道具斜 45° 悬浮展示,带氛围光与细微粒子,占画面主体:' + lookLine + '。',
2289
+ '【细节特写 2–3 格】材质纹理、关键部件,格内不写字。',
2290
+ '【侧栏侧视图】同一道具的正侧面完整形态(交代厚度与结构)。',
2291
+ '【底部颜色参考】3–5 个圆形色卡,每个下方标注颜色名(不超过 3 字,如「赤红」「熔金」)。',
2292
+ '【文字规则】图内文字只有标题、颜色名与短标签:黑色细体、笔画清楚、字要写大;禁止铭文、短诗、说明文字(必然乱码)。',
2293
+ '【画面白名单】画面中只有该道具本体与上述分格内容;不出现人物、动物、场景陈设、其他道具、文字水印。',
2294
+ ].join('\n')
2295
+ : [
2296
+ '[Overall] A ' + style + ' prop sheet: dark plain background, identical material, scale and palette in every panel.',
2297
+ '[Title] One large prop-name heading at the top (6 characters max): ' + nm + '.',
2298
+ '[Main image] The prop floating at a 45 degree angle with ambient light and fine particles, filling the frame: ' + lookLine + '.',
2299
+ '[Detail close-ups, 2-3 panels] Material texture and key components, no text inside them.',
2300
+ '[Side view] A clean front/side view of the same prop (thickness and construction visible).',
2301
+ '[Colour reference] 3-5 round swatches along the bottom, each labelled with a colour name (3 characters max).',
2302
+ '[Text rules] Only the heading, colour names and short labels: thin dark strokes, clearly drawn, large enough; no inscriptions, no verse, no paragraphs.',
2303
+ '[Whitelist] Only this prop and the panels above; no people, no animals, no set dressing, no other props, no watermark.',
2304
+ ].join('\n');
2305
+ }
2306
+ // 角色(默认):六段式
2307
+ return zh
2308
+ ? [
2309
+ '【总起】16:9 横版专业角色设定集排版:深色简洁背景,' + style + ',同一角色在所有分格中外观完全一致。',
2310
+ '【身份锚】本图没有参考照片:以下外观描述是唯一形象事实,所有分格严格按它绘制,不得自行换装、改体型或改变配色。',
2311
+ '【主体】中央大幅全身立绘(正面自然站姿):' + lookLine + '。姿态为自然空手站立——四肢自然着地或下垂,口部闭合或自然吐舌,不抱、不拿、不叼、不踩、不坐任何外物。',
2312
+ '【主图下方·三视图(必须项)】正面/侧面/背面三格全身站姿——同一角色只换角度,交代侧背形态(后续视频一致性靠它)。',
2313
+ '【右侧窄栏·自上而下分块,每块内容全图只出现一次】① 脸部特写一格 + 一行不超过 12 字的五官短标注;② 表情格:6 个头部小头像分两行(每行 3 个),每个下方只标两字表情名。',
2314
+ '【文字规则】图内文字极少:只保留两三个字的短标签,黑色细体、笔画清楚、字要写大;不写大标题、说明段或长句。',
2315
+ '【画面白名单】画面中只有该角色本体与它的固定配饰(如项圈/挂牌),以及上述分格内容;没有任何被抱、被拿、被叼、被踩或被坐着的物品——纸箱、包裹、篮子、玩具、食物、乐器、器材、道具、家具一律不出现;没有其他人物、没有其他动物、没有场景陈设、没有文字水印。',
2316
+ ].join('\n')
2317
+ : [
2318
+ '[Overall] A 16:9 landscape professional character sheet: dark plain background, ' + style + ', the same character looking identical in every panel.',
2319
+ '[Identity anchor] There is no reference photo: the appearance description below is the only source of truth - every panel follows it exactly; no wardrobe change, no body change, no palette drift.',
2320
+ '[Main figure] A large full-body standing figure in the centre (front, natural stance): ' + lookLine + '. Empty-handed stance - limbs resting naturally, mouth closed or tongue relaxed; nothing held, carried, bitten, stood on or sat on.',
2321
+ '[Turnaround, required] Front / side / back full-body panels below the main figure - same character, only the angle changes (this carries video consistency later).',
2322
+ '[Right column, stacked, each block drawn only once in the whole sheet] (1) one face close-up with a short feature note (12 characters max); (2) six small head icons for expressions in two rows of three, each labelled with a two-character expression name.',
2323
+ '[Text rules] Very little text: two or three character labels only, thin dark strokes, clearly drawn and large; no headline, no paragraph, no long sentence.',
2324
+ '[Whitelist] Only this character and its fixed accessories (collar, tag), plus the panels above; nothing is being held, carried, bitten, stood on or sat on - no boxes, parcels, baskets, toys, food, instruments, equipment, props or furniture; no other people, no other animals, no set dressing, no watermark.',
2325
+ ].join('\n');
2326
+ }
2327
+ /** 资产设定图节点的规格:命名走技能约定(角色-<名> 设定图)、画幅/清晰度按版式(设定集排版需要细节)。
2328
+ * 标签前缀恒用中文「角色-/场景-/道具-」——这是**数据契约**而非界面文案:categoryFromName 与
2329
+ * assetNameFor 都靠它把生成出来的资产自动归到正确品类(英文界面同样如此)。 */
2330
+ function assetSheetNodeSpec(kind, name, bible, zh) {
2331
+ const k = String(kind || '');
2332
+ const label = entityLabelOf(k) + '-' + String(name || '').trim() + ' 设定图';
2333
+ const ent = matchBibleEntity(name, bible);
2334
+ const desc = (ent && ent.desc) || '';
2335
+ return {
2336
+ kind: k,
2337
+ label,
2338
+ prompt: buildAssetSheetPrompt(k, name, desc, bible, zh),
2339
+ size: '2K',
2340
+ ratio: '16:9', // 设定集排版(角色三视图 + 侧栏)横版信息量最大
2341
+ };
2342
+ }
1668
2343
  // <<<MENTION_PURE_END>>>
1669
2344
 
1670
2345
  /** 解析提示词里的 @引用:返回命中实体 + 其名下的参考图资产与**声音参考资产**(按实体出现顺序、各实体内按生成时间)。
@@ -1858,6 +2533,9 @@ window.__ModuleLoader__.load({
1858
2533
  const assetUrlCache = new Map();
1859
2534
  const assetNameCache = new Map(); // assetId -> name(连线绑定的同步名称查询)
1860
2535
  function fileUrlFor(rel) { return '/' + NS + '/api/file?rel=' + encodeURIComponent(rel); }
2536
+ /** 磁盘落盘扩展名(IC-B3 统一后唯一保留的 mime→ext 映射):mime 子类型直取,
2537
+ * 非法子类型回退 .bin。与 extForDownload 的区别:落盘时 mime 已由生成链路给出
2538
+ * (mp4-first 录制 / png 编码),不存在"从文件名猜"的场景——这里没有任何名字兜底。 */
1861
2539
  function extFromMime(mime) {
1862
2540
  const sub = String(mime || '').split(';')[0].split('/')[1] || 'bin';
1863
2541
  return /^[a-z0-9]{1,8}$/i.test(sub) ? sub : 'bin';
@@ -1905,7 +2583,8 @@ window.__ModuleLoader__.load({
1905
2583
  }
1906
2584
  async function saveAssetRecord(projectId, kind, blob, meta) {
1907
2585
  const id = uid('a');
1908
- const mime = blob.type || (kind === 'image' ? 'image/png' : 'video/webm');
2586
+ const mime = blob.type && blob.type !== 'application/octet-stream' ? blob.type
2587
+ : (kind === 'image' ? 'image/png' : 'video/mp4');
1909
2588
  const rec = {
1910
2589
  id, projectId, kind,
1911
2590
  mime,
@@ -1920,36 +2599,29 @@ window.__ModuleLoader__.load({
1920
2599
  if (meta && Array.isArray(meta.entities) && meta.entities.length) rec.entities = meta.entities;
1921
2600
  if (meta && meta.nodeLabel) rec.nodeLabel = meta.nodeLabel;
1922
2601
  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';
2602
+ // 【硬规则】资产文件一律落数据目录的项目文件夹(projects/<项目>/<类型>/<文件>),元数据进
2603
+ // canvas.db;IndexedDB 已废弃——未配置数据目录时明确报错,绝不静默塞进浏览器。
2604
+ if (!S.dataRoot) {
2605
+ throw new Error('未设置数据根目录:资产无法持久化(浏览器 IndexedDB 存储已废弃)。请在 画布 → 设置 → 数据根目录 里配置后重新生成。');
2606
+ }
2607
+ const rel = `projects/${projectId}/${kind}/${assetFileBase(rec)}.${extFromMime(mime)}`;
2608
+ const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
2609
+ if (!r.ok) throw new Error('资产落盘失败: ' + (await r.text()));
2610
+ rec.rel = rel;
2611
+ rec.size = blob.size;
2612
+ rec.stored = 'disk';
2613
+ const proj = S.projects.find((p) => p.id === projectId);
2614
+ if (S.store === 'sqlite') {
2615
+ // 主路径:文件落项目文件夹 + 元数据进 canvas.db(任意浏览器/远程立即可见)
2616
+ const saved = await hostRpc('assetCreate', { asset: assetIndexRecord(rec), projectName: (proj && proj.name) || '' });
2617
+ const out = (saved && saved.asset) || rec;
2618
+ assetMem.set(out.id, out);
2619
+ assetNameCache.set(out.id, out.name);
2620
+ return out;
1949
2621
  }
1950
- await idbPut(rec);
2622
+ await idbPut(rec); // JSON 降级模式(宿主缺 node:sqlite):本机元数据缓存
1951
2623
  assetNameCache.set(rec.id, rec.name);
1952
- if (S.dataRoot) mirrorAssetIndex(rec); // JSON 降级模式:元数据镜像进 assets.json
2624
+ mirrorAssetIndex(rec); // 元数据镜像进 assets.json
1953
2625
  return rec;
1954
2626
  }
1955
2627
  /** 资产元数据索引记录(去 blob;文本资产保留 text 以便导入端免读文件即可预览)。 */
@@ -1975,8 +2647,8 @@ window.__ModuleLoader__.load({
1975
2647
  projectId: rec.projectId, projectName: (proj && proj.name) || rec.projectId, asset: assetIndexRecord(rec),
1976
2648
  }).catch(() => { /* 索引失败不影响资产本身 */ });
1977
2649
  }
1978
- /** 数据根目录就绪后:把浏览器 IndexedDB 里的存量 blob 资产搬进服务端(CAS + 资产行),搬完即从本地退役。
1979
- * 0.17 旧版本的"落盘到项目文件夹 + assets.json 索引"由 Host 侧 v1→v2 迁移负责,这里只处理从未落盘的遗留。 */
2650
+ /** 数据根目录就绪后:把浏览器 IndexedDB 里的存量 blob 资产搬到数据目录的项目文件夹
2651
+ * (projects/<项目>/<类型>/<文件>),搬完从 IndexedDB 移除——**IndexedDB 只减不增,最终清零**。 */
1980
2652
  async function autoMigrateAssetsToRoot() {
1981
2653
  if (!S.dataRoot) return;
1982
2654
  try {
@@ -1986,16 +2658,15 @@ window.__ModuleLoader__.load({
1986
2658
  let done = 0;
1987
2659
  for (const rec of pending) {
1988
2660
  try {
1989
- const r = await fetch('/' + NS + '/api/object', { method: 'POST', body: rec.blob });
2661
+ const mime = rec.blob.type || rec.mime || (rec.kind === 'image' ? 'image/png' : 'video/mp4');
2662
+ const rel = `projects/${rec.projectId}/${rec.kind}/${assetFileBase(rec)}.${extFromMime(mime)}`;
2663
+ const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: rec.blob });
1990
2664
  if (!r.ok) continue;
1991
- const j = await r.json();
1992
- const meta = { ...rec, sha: j.sha, size: rec.size || rec.blob.size };
2665
+ const meta = { ...rec, rel, size: rec.size || rec.blob.size };
1993
2666
  delete meta.blob;
1994
- meta.stored = 'cas';
2667
+ meta.stored = 'disk';
1995
2668
  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);
2669
+ await hostRpc('assetCreate', { asset: assetIndexRecord(meta), projectName: (proj && proj.name) || '' });
1999
2670
  try { await idbDelete(rec.id); } catch { /* 本地清理失败不阻塞迁移 */ }
2000
2671
  done++;
2001
2672
  } catch { /* 单个失败继续下一个 */ }
@@ -2084,7 +2755,8 @@ window.__ModuleLoader__.load({
2084
2755
  const g = cv.getContext('2d');
2085
2756
  const rnd = mulberry32(hashStr(prompt || 'demo'));
2086
2757
  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'];
2758
+ // mp4 优先(现代 Chrome/Edge 原生支持 H.264 录制):webm 只是兜底,保证产物统一为 mp4
2759
+ const mimes = ['video/mp4;codecs=avc1.42E01E', 'video/mp4', 'video/webm;codecs=vp9', 'video/webm;codecs=vp8', 'video/webm'];
2088
2760
  const mime = (typeof MediaRecorder !== 'undefined') && mimes.find((m) => MediaRecorder.isTypeSupported(m));
2089
2761
  if (!mime) throw new Error('当前环境不支持 MediaRecorder,无法使用演示视频生成');
2090
2762
  const stream = cv.captureStream(30);
@@ -2272,21 +2944,49 @@ window.__ModuleLoader__.load({
2272
2944
  } catch { /* 解析失败返回 null */ }
2273
2945
  return null;
2274
2946
  }
2947
+ /** 魔数嗅探真实编码(IC-04 抽出的纯函数):只认 PNG/JPEG/WebP 三类平台可收的字节签名。
2948
+ * 返回 { mime, ext, kind };不匹配(改后缀的 AVIF/HEIC 等)返回 null,调用方给出可执行报错。 */
2949
+ function sniffImageEncoding(u8) {
2950
+ const isPng = u8 && u8[0] === 0x89 && u8[1] === 0x50 && u8[2] === 0x4e && u8[3] === 0x47;
2951
+ const isJpg = u8 && u8[0] === 0xff && u8[1] === 0xd8 && u8[2] === 0xff;
2952
+ const isWebp = u8 && u8[0] === 0x52 && u8[1] === 0x49 && u8[2] === 0x46 && u8[3] === 0x46
2953
+ && u8[8] === 0x57 && u8[9] === 0x45 && u8[10] === 0x42 && u8[11] === 0x50;
2954
+ if (isPng) return { mime: 'image/png', ext: 'png', kind: 'png' };
2955
+ if (isJpg) return { mime: 'image/jpeg', ext: 'jpg', kind: 'jpeg' };
2956
+ if (isWebp) return { mime: 'image/webp', ext: 'webp', kind: 'webp' };
2957
+ return null;
2958
+ }
2959
+ /** 参考图登记(IC-04 抽出的纯流程):先用解码尺寸登记,被平台以 width/height
2960
+ * 口径拒绝(EXIF 旋转图:浏览器解码会交换宽高,平台读原始文件)时用文件原始尺寸重试一次。
2961
+ * api = async (method, path, body) => 响应;两次都失败时抛出带两次原因的错误。 */
2962
+ async function registerReferenceAsset(api, regPayload, meta) {
2963
+ try {
2964
+ return await api('POST', '/image/v1/assets', regPayload(meta.width, meta.height));
2965
+ } catch (e) {
2966
+ const msg = String((e && e.message) || e);
2967
+ const alt = meta.rawWidth && meta.rawHeight && (meta.rawWidth !== meta.width || meta.rawHeight !== meta.height);
2968
+ if (!/width\/?height/i.test(msg) || !alt) throw e;
2969
+ try {
2970
+ const reg = await api('POST', '/image/v1/assets', regPayload(meta.rawWidth, meta.rawHeight));
2971
+ console.info('[infinite-canvas] 参考图登记改用文件原始尺寸成功:' + meta.rawWidth + '×' + meta.rawHeight);
2972
+ return reg;
2973
+ } catch (e2) {
2974
+ throw new Error(msg + ';改用文件原始尺寸 ' + meta.rawWidth + '×' + meta.rawHeight + ' 重试仍失败:' + ((e2 && e2.message) || e2));
2975
+ }
2976
+ }
2977
+ }
2275
2978
  /** 参考图上传前置:魔数嗅探真实编码(平台仅收 JPG/PNG/WebP,且校验声明 MIME 与实际字节一致——
2276
2979
  * 改后缀的 AVIF/HEIC 会在登记时报 "Params [Width/Height] not available")+ 测像素尺寸
2277
2980
  * (同时给出解码尺寸与文件原始尺寸,登记不一致时用原始尺寸重试)。 */
2278
2981
  async function imageBlobMeta(input) {
2279
2982
  const blob = input.blob || input;
2280
2983
  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) {
2984
+ const sniffed = sniffImageEncoding(head);
2985
+ if (!sniffed) {
2286
2986
  throw new Error('参考图编码不受支持:平台仅接受 JPG/PNG/WebP 原生编码(当前文件的实际字节不是这三类,常见于改后缀的 AVIF/HEIC)——请转成 JPG/PNG 后重新挂参考图');
2287
2987
  }
2288
- const mime = isPng ? 'image/png' : isJpg ? 'image/jpeg' : 'image/webp';
2289
- const ext = isPng ? 'png' : isJpg ? 'jpg' : 'webp';
2988
+ const mime = sniffed.mime;
2989
+ const ext = sniffed.ext;
2290
2990
  let raw = null;
2291
2991
  try { raw = rawImageDims(new Uint8Array(await blob.slice(0, 262144).arrayBuffer())); } catch { /* 忽略 */ }
2292
2992
  let width = 0;
@@ -2298,7 +2998,7 @@ window.__ModuleLoader__.load({
2298
2998
  try { bmp.close && bmp.close(); } catch { /* 忽略 */ }
2299
2999
  } catch { /* 解码失败时退回文件头尺寸 */ }
2300
3000
  if (!width || !height) { if (raw) { width = raw.w; height = raw.h; } }
2301
- if (!width || !height) throw new Error('参考图尺寸测量失败');
3001
+ if (!width || !height) throw new Error('参考图尺寸测量失败(' + mime + ',文件头与浏览器解码都没读出宽高——文件可能已损坏)');
2302
3002
  return { blob, mime, ext, width, height, rawWidth: raw ? raw.w : width, rawHeight: raw ? raw.h : height };
2303
3003
  }
2304
3004
  async function compshareImageTask(prompt, refs, s, opts) {
@@ -2308,6 +3008,7 @@ window.__ModuleLoader__.load({
2308
3008
  if (!base) throw new Error('未配置图片 API 地址(优云智算:https://cp.compshare.cn)');
2309
3009
  const headers = { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: 'Bearer ' + String(s.apiKey || '').trim() };
2310
3010
  const extra = parseExtraParams(s.extra); // 透传:optimize_prompt / use_context_ir 等任务级字段
3011
+ if (o.warn && Array.isArray(refs) && refs.length > 10) o.warn('参考图 ' + refs.length + ' 张超过优云上限 10 张,本次只使用前 10 张');
2311
3012
  // 报错附带实际发出的参数:登记/提交被拒时一眼看到缺什么、值是多少(不再靠猜)
2312
3013
  const bodySummary = (b) => {
2313
3014
  if (!b || typeof b !== 'object') return '';
@@ -2352,22 +3053,15 @@ window.__ModuleLoader__.load({
2352
3053
  });
2353
3054
  if (!put.ok) throw new Error('参考图上传失败 HTTP ' + put.status + ':' + put.text.slice(0, 200));
2354
3055
  // 登记:宽高必须与平台解析出的文件尺寸一致。浏览器解码尺寸与文件原始尺寸在
2355
- // EXIF 旋转图上不同 → 先用解码尺寸,被拒(Params [Width/Height] not available)时用原始尺寸重试一次
3056
+ // EXIF 旋转图上不同 → 先用解码尺寸,被拒(Params [Width/Height] not available)时用原始尺寸重试一次。
3057
+ // 重试发生过 = 这张图带 EXIF 旋转,节点上给一条可见提示(用户下次换图心里有数)
2356
3058
  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
- }
3059
+ let retried = false;
3060
+ const reg = await registerReferenceAsset(api, (w, h) => {
3061
+ if (w === meta.rawWidth && h === meta.rawHeight && (meta.rawWidth !== meta.width || meta.rawHeight !== meta.height)) retried = true;
3062
+ return regPayload(w, h);
3063
+ }, meta);
3064
+ if (retried && o.warn) o.warn('参考图带 EXIF 旋转:平台按文件原始尺寸 ' + meta.rawWidth + '×' + meta.rawHeight + ' 登记成功(浏览器解码是 ' + meta.width + '×' + meta.height + ')');
2371
3065
  const aid = reg && reg.reference && reg.reference.asset_id;
2372
3066
  if (!aid) throw new Error('参考图登记失败:响应中没有 reference.asset_id');
2373
3067
  if (ref.assetId) compAssetIdCache.set(ref.assetId, aid);
@@ -2551,6 +3245,8 @@ window.__ModuleLoader__.load({
2551
3245
  const body = {
2552
3246
  model: s.model,
2553
3247
  content,
3248
+ // 声音参考与首次提交保持同一口径:降级换格式不能静默丢掉音色对齐(否则无任何告警)
3249
+ ...((o.audioRefs || []).length ? { audios: await Promise.all((o.audioRefs || []).slice(0, 3).map((b) => blobToDataUrl(b))) } : {}),
2554
3250
  metadata: {
2555
3251
  duration: Math.round(Number(o.duration)) || 5,
2556
3252
  ratio: o.ratio || '16:9',
@@ -3149,6 +3845,18 @@ window.__ModuleLoader__.load({
3149
3845
  return await downloadVideo(url, {});
3150
3846
  },
3151
3847
  };
3848
+ /** 各视频厂商的参考素材上限(与各 runner 内部的截断口径一致):
3849
+ * 超出上限会被截断——必须显式告警(o.warn → 节点 genWarn),绝不静默丢素材。 */
3850
+ const VIDEO_REF_CAPS = {
3851
+ wan: { img: 10 },
3852
+ minimaxv2: { img: 9, audio: 3 },
3853
+ openai: { img: 8 },
3854
+ seedance: { img: 8 },
3855
+ // img:0 = 该厂商的协议**没有任何实体参考图入口**(已核对各自 runner:只收首帧/提示词)——
3856
+ // 曾误标 img:1:恰 1 张时静默弃用不告警,≥2 张时告警"用前 1 张"实际 0 张,全部误导
3857
+ kling: { img: 0 }, vidu: { img: 0 }, runway: { img: 0 }, veo: { img: 0 },
3858
+ luma: { img: 0 }, pixverse: { img: 0 }, minimax: { img: 0 }, stability: { img: 0 },
3859
+ };
3152
3860
  async function apiVideo(prompt, frameBlob, s, opts) {
3153
3861
  const o = opts || {};
3154
3862
  // 节点级扩展参数(手动参数框)与供应商扩展参数合并(节点优先),各 runner 的 mergeExtra 自动生效
@@ -3165,10 +3873,30 @@ window.__ModuleLoader__.load({
3165
3873
  if (o.frameMode === 'hard' && frameBlob) o.hardFrame = true;
3166
3874
  // 厂商预设层:显式选择了厂商格式(或旧配置自动识别)→ 走该厂商的官方协议适配器
3167
3875
  const vendor = detectVideoVendor(s);
3876
+ let vendorDowngraded = false; // IC-03:识别到厂商但无适配器 → 降级走 OpenAI 兼容通用链(节点上可见)
3877
+ // 参考素材上限检查:超出即告警(截断发生在各 runner 内,这里先把事实说清楚)。
3878
+ // 未知厂商兜底 8 = IC-03 降级后的 OpenAI 兼容链实际发送上限。
3879
+ if (o.warn) {
3880
+ const caps = VIDEO_REF_CAPS[vendor] || { img: 8 };
3881
+ const refCount = o.hardFrame ? 0 : (Array.isArray(o.refs) ? o.refs.length : 0);
3882
+ if (refCount > (caps.img || 0)) {
3883
+ o.warn(caps.img
3884
+ ? ('参考图 ' + refCount + ' 张超过该厂商上限 ' + caps.img + ' 张,本次只使用前 ' + caps.img + ' 张')
3885
+ : ('该厂商不支持实体参考图,本次将忽略 ' + refCount + ' 张参考图(仅保留首帧与提示词;角色/场景一致性请改用支持参考图的厂商)'));
3886
+ }
3887
+ const audCount = Array.isArray(o.audioRefs) ? o.audioRefs.length : 0;
3888
+ if (audCount > (caps.audio || 0)) o.warn('参考音频 ' + audCount + ' 条超过该厂商上限 ' + (caps.audio || 0) + ' 条,本次只使用前 ' + (caps.audio || 0) + ' 条');
3889
+ }
3168
3890
  if (vendor !== 'openai' && vendor !== 'seedance') {
3169
3891
  const run = VIDEO_VENDOR_RUNNERS[vendor];
3170
- if (!run) throw new Error('暂未适配的视频厂商格式:' + vendor);
3171
- return await run(prompt, frameBlob, s, o);
3892
+ if (!run) {
3893
+ // IC-03:识别到的厂商没有官方适配器 → 不再硬失败(历史:`暂未适配的视频厂商格式:X` 直接炸),
3894
+ // 降级走下方 OpenAI 兼容通用链,并把降级事实放节点上(绝不静默换协议)。
3895
+ if (o.warn) o.warn('未识别到「' + vendor + '」的官方适配器,本次按 OpenAI 兼容格式发送(/v1/video/generations,失败自动降级 Seedance content[] / Sora /videos)——若该厂商协议不同,请在供应商设置里显式选择厂商');
3896
+ vendorDowngraded = true; // 源码级契约锚点(test-vendor-detect 断言):降级必须留痕
3897
+ } else {
3898
+ return await run(prompt, frameBlob, s, o);
3899
+ }
3172
3900
  }
3173
3901
  // OpenAI 风格视频接口(JUAI 等:/v1/video/generations)优先识别——
3174
3902
  // 即使误把协议设成 seedance 也不走火山官方协议(两者状态/结果字段不同)
@@ -3396,36 +4124,250 @@ window.__ModuleLoader__.load({
3396
4124
 
3397
4125
  /** 从视频 Blob 提取最后一帧为 PNG(通用:任何 API 生成的视频都能取尾帧)。 */
3398
4126
  async function extractLastFrame(videoBlob) {
4127
+ const r = await extractTailFrames(videoBlob, { count: 2, span: 0.2 });
4128
+ if (!r || !r.last) throw new Error('尾帧提取失败');
4129
+ return r.last;
4130
+ }
4131
+ /**
4132
+ * 尾帧候选抽取(续帧接力的关键):**不取最后一帧**。
4133
+ * 最后一帧常落在动作顶点、运动模糊、遮挡、模型收敛抖动上——用它当下一段首帧,接缝必然跳。
4134
+ * 做法:取末尾若干帧做候选,给每帧打「动作进行中 / 清晰 / 无遮挡 / 不抖」分,取最高分那帧。
4135
+ * @returns { blobs: Blob[], picks: Array<{ t, blob, score, metrics }>, best: { blob, t, score } | null }
4136
+ */
4137
+ async function extractTailFrames(videoBlob, opts) {
4138
+ const o = opts || {};
4139
+ const count = Math.max(2, Math.min(24, Number(o.count) || 9)); // 候选帧数(末段采样)
4140
+ const span = Math.max(0.1, Math.min(2, Number(o.span) || 0.8)); // 采样窗口:结尾前 0.8s
3399
4141
  const url = URL.createObjectURL(videoBlob);
4142
+ let v = null;
3400
4143
  try {
3401
- const v = document.createElement('video');
4144
+ v = document.createElement('video');
3402
4145
  v.src = url; v.muted = true; v.preload = 'auto';
3403
4146
  await new Promise((res, rej) => {
3404
4147
  v.onloadedmetadata = () => res();
3405
4148
  v.onerror = () => rej(new Error('视频解码失败'));
3406
- setTimeout(res, 10000);
4149
+ setTimeout(res, 12000);
4150
+ });
4151
+ const dur = Number(v.duration);
4152
+ if (!isFinite(dur) || dur <= 0) throw new Error('视频时长不可用');
4153
+ const canvas = document.createElement('canvas');
4154
+ canvas.width = v.videoWidth || 1280;
4155
+ canvas.height = v.videoHeight || 720;
4156
+ const ctx = canvas.getContext('2d', { willReadFrequently: true });
4157
+ // 采样时刻:结尾前 span 秒内均匀取 count 帧,最后一帧贴近片尾
4158
+ const t0 = Math.max(0, dur - span);
4159
+ const times = [];
4160
+ for (let i = 0; i < count; i++) times.push(Math.min(dur - 0.02, t0 + (span * i) / Math.max(1, count - 1)));
4161
+ times[times.length - 1] = Math.max(0, dur - 0.04);
4162
+ const grabs = [];
4163
+ for (const t of times) {
4164
+ await new Promise((res) => {
4165
+ let done = false;
4166
+ const fin = () => { if (!done) { done = true; res(); } };
4167
+ v.onseeked = fin;
4168
+ try { v.currentTime = t; } catch { fin(); }
4169
+ setTimeout(fin, 4000);
4170
+ });
4171
+ await new Promise((res) => {
4172
+ if (v.readyState >= 2) return res();
4173
+ v.oncanplay = res;
4174
+ setTimeout(res, 2000);
4175
+ });
4176
+ try {
4177
+ ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
4178
+ } catch { continue; }
4179
+ // 缩略灰度(64×36):算清晰度/运动/结构用,够用且极快
4180
+ const tw = 64, th = Math.max(1, Math.round((64 * canvas.height) / Math.max(1, canvas.width)));
4181
+ const small = document.createElement('canvas');
4182
+ small.width = tw; small.height = th;
4183
+ const sctx = small.getContext('2d', { willReadFrequently: true });
4184
+ sctx.drawImage(canvas, 0, 0, tw, th);
4185
+ const img = sctx.getImageData(0, 0, tw, th);
4186
+ const gray = new Uint8ClampedArray(tw * th);
4187
+ let sum = 0;
4188
+ for (let i = 0; i < gray.length; i++) {
4189
+ const r = img.data[i * 4], g = img.data[i * 4 + 1], b = img.data[i * 4 + 2];
4190
+ const y = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
4191
+ gray[i] = y; sum += y;
4192
+ }
4193
+ // 清晰度:拉普拉斯响应均值(越高越锐利)
4194
+ let lap = 0, lapN = 0;
4195
+ for (let y2 = 1; y2 < th - 1; y2++) {
4196
+ for (let x2 = 1; x2 < tw - 1; x2++) {
4197
+ const i = y2 * tw + x2;
4198
+ const l = 4 * gray[i] - gray[i - 1] - gray[i + 1] - gray[i - tw] - gray[i + tw];
4199
+ lap += Math.abs(l); lapN++;
4200
+ }
4201
+ }
4202
+ const sharpRaw = lapN ? lap / lapN : 0;
4203
+ // 前景占比:与画面均值的偏离度(粗估有人/物主体的程度)
4204
+ let dev = 0;
4205
+ const mean = sum / gray.length;
4206
+ for (let i = 0; i < gray.length; i++) dev += Math.abs(gray[i] - mean);
4207
+ const coverageRaw = gray.length ? dev / gray.length : 0;
4208
+ grabs.push({ t, gray, sharpRaw, coverageRaw, full: null });
4209
+ // 全分辨率 PNG 只对最终入选帧生成(省内存)
4210
+ }
4211
+ if (!grabs.length) throw new Error('尾帧采样失败');
4212
+ // 帧间差(运动幅度)+ 收敛抖动(相邻帧差突然变小 = 定格;突然变大 = 抖)
4213
+ const diffs = [];
4214
+ for (let i = 1; i < grabs.length; i++) {
4215
+ const a = grabs[i - 1].gray, b = grabs[i].gray;
4216
+ let s2 = 0;
4217
+ for (let k = 0; k < a.length; k++) s2 += Math.abs(a[k] - b[k]);
4218
+ diffs.push(a.length ? s2 / a.length / 255 : 0); // 0-1
4219
+ }
4220
+ const maxSharp = Math.max(...grabs.map((g) => g.sharpRaw), 1e-6);
4221
+ const picks = grabs.map((g, i) => {
4222
+ const prevDiff = i > 0 ? diffs[i - 1] : (diffs[0] || 0);
4223
+ const nextDiff = i < diffs.length ? diffs[i] : prevDiff;
4224
+ const motion = Math.max(0, Math.min(1, (prevDiff * 6))); // 归一:0.17/帧 ≈ 满运动
4225
+ const sharp = Math.max(0, Math.min(1, g.sharpRaw / maxSharp));
4226
+ const coverage = Math.max(0, Math.min(1, g.coverageRaw / 60));
4227
+ // 收敛抖动:自身运动相对邻域突变(模型末端常见突变)
4228
+ const jitter = Math.min(1, Math.abs(prevDiff - nextDiff) * 8);
4229
+ // 动作完成度:越接近片尾越可能"演完了"(末 3 帧按时间梯度推断)
4230
+ const complete = Math.max(0, Math.min(1, 0.35 + (i / Math.max(1, grabs.length - 1)) * 0.65));
4231
+ const score = tailFrameScore({ motion, sharp, coverage, jitter, complete });
4232
+ return { t: g.t, score, metrics: { motion, sharp, coverage, jitter, complete }, gray: g.gray, idx: i };
3407
4233
  });
3408
- if (!isFinite(v.duration) || v.duration <= 0) throw new Error('视频时长不可用');
4234
+ const best = picks.slice().sort((a, b) => b.score - a.score)[0];
4235
+ // 为入选帧 + 末帧各生成一张全分辨率 PNG(末帧始终产出,保证 last 端口行为不变)
4236
+ const wanted = new Set([best.idx, grabs.length - 1]);
4237
+ const blobs = new Map();
4238
+ for (const gi of [...wanted].sort((a, b) => a - b)) {
4239
+ const g = grabs[gi];
4240
+ await new Promise((res) => {
4241
+ let done = false;
4242
+ const fin = () => { if (!done) { done = true; res(); } };
4243
+ v.onseeked = fin;
4244
+ try { v.currentTime = g.t; } catch { fin(); }
4245
+ setTimeout(fin, 4000);
4246
+ });
4247
+ await new Promise((res) => { if (v.readyState >= 2) return res(); v.oncanplay = res; setTimeout(res, 2000); });
4248
+ try { ctx.drawImage(v, 0, 0, canvas.width, canvas.height); } catch { continue; }
4249
+ const blob = await new Promise((res) => canvas.toBlob(res, 'image/png'));
4250
+ if (blob) blobs.set(gi, blob);
4251
+ }
4252
+ const lastBlob = blobs.get(grabs.length - 1) || blobs.get(best.idx) || null;
4253
+ const bestBlob = blobs.get(best.idx) || lastBlob;
4254
+ return {
4255
+ last: lastBlob,
4256
+ best: bestBlob,
4257
+ bestT: best.t,
4258
+ bestScore: best.score,
4259
+ picks: picks.map((p) => ({ t: p.t, score: p.score, metrics: p.metrics })),
4260
+ };
4261
+ } finally {
4262
+ URL.revokeObjectURL(url);
4263
+ }
4264
+ }
4265
+ /** 缩略灰度(接缝校验用):任意图 Blob → 32×32 RGBA。 */
4266
+ async function grayThumb(blob, size) {
4267
+ const n = Math.max(8, Math.min(128, Number(size) || 32));
4268
+ const url = URL.createObjectURL(blob);
4269
+ try {
4270
+ const img = new Image();
4271
+ img.src = url;
4272
+ await new Promise((res, rej) => {
4273
+ img.onload = () => res();
4274
+ img.onerror = () => rej(new Error('图片解码失败'));
4275
+ setTimeout(res, 8000);
4276
+ });
4277
+ const cv = document.createElement('canvas');
4278
+ cv.width = n; cv.height = n;
4279
+ const ctx = cv.getContext('2d', { willReadFrequently: true });
4280
+ // 保持比例居中裁切(letterbox 会稀释位移信号,直接拉伸更敏感)
4281
+ ctx.drawImage(img, 0, 0, n, n);
4282
+ const im = ctx.getImageData(0, 0, n, n);
4283
+ const gray = new Uint8ClampedArray(n * n);
4284
+ for (let i = 0; i < gray.length; i++) {
4285
+ gray[i] = Math.round(0.299 * im.data[i * 4] + 0.587 * im.data[i * 4 + 1] + 0.114 * im.data[i * 4 + 2]);
4286
+ }
4287
+ return { data: gray, w: n, h: n };
4288
+ } finally {
4289
+ URL.revokeObjectURL(url);
4290
+ }
4291
+ }
4292
+ /** 首帧抽取(末段续帧时校验用):视频开头第一帧 PNG。 */
4293
+ async function extractFirstFrame(videoBlob) {
4294
+ const url = URL.createObjectURL(videoBlob);
4295
+ try {
4296
+ const v = document.createElement('video');
4297
+ v.src = url; v.muted = true; v.preload = 'auto';
3409
4298
  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);
4299
+ let done = false;
4300
+ const fin = () => { if (!done) { done = true; res(); } };
4301
+ v.onloadedmetadata = fin;
4302
+ v.onerror = fin;
4303
+ setTimeout(fin, 10000);
3413
4304
  });
3414
4305
  await new Promise((res) => {
3415
- if (v.readyState >= 2) return res();
3416
- v.oncanplay = res;
3417
- setTimeout(res, 3000);
4306
+ let done = false;
4307
+ const fin = () => { if (!done) { done = true; res(); } };
4308
+ v.onseeked = fin;
4309
+ try { v.currentTime = 0.02; } catch { fin(); }
4310
+ setTimeout(fin, 4000);
3418
4311
  });
4312
+ await new Promise((res) => { if (v.readyState >= 2) return res(); v.oncanplay = res; setTimeout(res, 2000); });
3419
4313
  const cv = document.createElement('canvas');
3420
- cv.width = v.videoWidth; cv.height = v.videoHeight;
4314
+ cv.width = v.videoWidth || 1280; cv.height = v.videoHeight || 720;
3421
4315
  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;
4316
+ return await new Promise((res) => cv.toBlob(res, 'image/png'));
3425
4317
  } finally {
3426
4318
  URL.revokeObjectURL(url);
3427
4319
  }
3428
4320
  }
4321
+ /** 续帧链:找到「上一个视频生成节点」。按可靠性依次尝试:
4322
+ * ① frame 口连线的来源视频(用户显式指定;也支持从视频资产节点回溯);
4323
+ * ② 同一上游分镜表连出的其它视频节点里、已生成且位置更靠上的那一个(一个分镜表铺多个视频段);
4324
+ * ③ **沿分镜表的 prev 承接链向上游找**:上游分镜表所连的视频节点。
4325
+ * 标准短剧布局是「一场一个分镜表 + 一场一个视频」,此时每个视频节点的上游分镜表都不同,
4326
+ * ①②永远找不到——这是主用法,必须支持。逐级向上并防环。 */
4327
+ function autoPrevVideoNode(n) {
4328
+ const frameEdge = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'frame');
4329
+ if (frameEdge) {
4330
+ const src = S.graph.nodes[frameEdge.fromNode];
4331
+ if (src && src.type === 'video-gen' && src.data.assetId) return src;
4332
+ if (src && src.type === 'video-asset') {
4333
+ const up = S.graph.edges.find((e) => e.toNode === src.id && e.toPort === 'in');
4334
+ const pv = up && S.graph.nodes[up.fromNode];
4335
+ if (pv && pv.type === 'video-gen' && pv.data.assetId) return pv;
4336
+ }
4337
+ }
4338
+ const promptEdge = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'prompt');
4339
+ if (!promptEdge) return null;
4340
+ const sbId = promptEdge.fromNode;
4341
+ const sb = S.graph.nodes[sbId];
4342
+ // ② 同一分镜表内的兄弟视频段
4343
+ const sibs = S.graph.edges
4344
+ .filter((x) => x.fromNode === sbId && x.toPort === 'prompt')
4345
+ .map((x) => S.graph.nodes[x.toNode])
4346
+ .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)));
4347
+ if (sibs.length) {
4348
+ sibs.sort((a, b) => (b.y - a.y) || (b.x - a.x)); // 最近的一段(同列/同排里最靠近的上一段)
4349
+ return sibs[0];
4350
+ }
4351
+ // ③ 沿 prev 链向上游分镜表找它连出的视频段(跨分镜表续帧 = 标准用法)
4352
+ let curSb = sb;
4353
+ const seen = new Set([sbId]);
4354
+ for (let hop = 0; hop < 32 && curSb; hop++) {
4355
+ const prevEdge = S.graph.edges.find((x) => x.toNode === curSb.id && x.toPort === 'prev');
4356
+ const upSb = prevEdge && S.graph.nodes[prevEdge.fromNode];
4357
+ if (!upSb || upSb.type !== 'storyboard' || seen.has(upSb.id)) break;
4358
+ seen.add(upSb.id);
4359
+ const vids = S.graph.edges
4360
+ .filter((x) => x.fromNode === upSb.id && x.toPort === 'prompt')
4361
+ .map((x) => S.graph.nodes[x.toNode])
4362
+ .filter((v) => v && v.type === 'video-gen' && v.data.assetId);
4363
+ if (vids.length) {
4364
+ vids.sort((a, b) => (b.y - a.y) || (b.x - a.x));
4365
+ return vids[0];
4366
+ }
4367
+ curSb = upSb; // 上一场还没生成则继续向上找更早的已成片段
4368
+ }
4369
+ return null;
4370
+ }
3429
4371
  /** 链式下一镜:以本镜尾帧为首帧,克隆一个视频生成节点并自动连线。 */
3430
4372
  function chainNext(n) {
3431
4373
  const c = { x: n.x + 320, y: n.y + 60 };
@@ -3438,8 +4380,12 @@ window.__ModuleLoader__.load({
3438
4380
  nd2.data.resolution = n.data.resolution || '720P';
3439
4381
  nd2.data.ratio = n.data.ratio || '16:9';
3440
4382
  nd2.data.videoModel = nodeVideoModelRef(n);
4383
+ // 与自动续帧保持同一默认(auto = 尾帧降级为首张参考图,保留实体参考图,衔接为软约束)。
4384
+ // 硬首帧会放弃角色/场景参考图,不该作为默认——用户需要绝对承接时自己在下拉里选。
4385
+ nd2.data.frameMode = 'auto';
3441
4386
  }
3442
- s.graph.edges.push({ id: uid('e'), fromNode: n.id, fromPort: 'last', toNode: nn.id, toPort: 'frame' });
4387
+ // 尾帧接力:优先「最佳选帧」(动作进行中/清晰),退到真·尾帧
4388
+ s.graph.edges.push({ id: uid('e'), fromNode: n.id, fromPort: (n.data.lastPickAssetId ? 'tailpick' : 'last'), toNode: nn.id, toPort: 'frame' });
3443
4389
  });
3444
4390
  pushHistory();
3445
4391
  toast(T('lastFrame') + ' → ' + T('portFrame') + ' ✓');
@@ -3493,18 +4439,35 @@ window.__ModuleLoader__.load({
3493
4439
  for (const seg of segments) {
3494
4440
  const v = document.createElement('video');
3495
4441
  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);
4442
+ // 加载失败必须留下来而不是静默丢弃:静默丢弃 = 用户以为全片都合进去了
4443
+ let loadErr = null;
4444
+ await new Promise((res) => {
4445
+ let settled = false;
4446
+ const fin = () => { if (!settled) { settled = true; res(); } };
4447
+ v.onloadedmetadata = fin;
4448
+ v.onerror = () => { loadErr = '视频解码失败(格式不支持或文件损坏)'; fin(); };
4449
+ setTimeout(fin, 15000); // 超时也继续:时长拿不到时按 8s 兜底
4450
+ });
4451
+ if (loadErr && !(isFinite(v.duration) && v.duration > 0)) {
4452
+ throw new Error((seg.name || '片段') + ':' + loadErr + '——请确认该段视频能正常播放(可在节点上预览)');
4453
+ }
4454
+ const clipDur = isFinite(v.duration) && v.duration > 0 ? v.duration : 0;
4455
+ // 编辑时长(分镜时长)用于裁剪:不能超过真实素材时长
4456
+ const trimSec = seg.dur && seg.dur > 0 ? Math.min(seg.dur, clipDur || seg.dur) : 0;
4457
+ totalMs += (trimSec || clipDur || 8) * 1000;
4458
+ vids.push({
4459
+ v, name: seg.name, sub: seg.sub || '', withAudio: seg.withAudio !== false,
4460
+ ttsUrl: seg.ttsUrl || null, ttsEl: null, ttsSrc: null, started: false, srcNode: null,
4461
+ trim: trimSec || null, // 裁剪到该秒数(0/null = 用完整片段)
4462
+ clipDur, // 素材真实时长(可能为 0:元数据没拿到)
4463
+ startAt: 0,
3500
4464
  });
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
4465
  }
4466
+ if (!vids.length) throw new Error('没有可合成的片段');
3505
4467
  const cv = document.createElement('canvas'); cv.width = W; cv.height = H;
3506
4468
  const g = cv.getContext('2d');
3507
- const mimes = ['video/webm;codecs=vp9,opus', 'video/webm;codecs=vp8,opus', 'video/webm', 'video/mp4'];
4469
+ // mp4 优先(含音轨:H.264 + AAC):webm 只是兜底,保证成片统一为 mp4
4470
+ 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
4471
  const mime = (typeof MediaRecorder !== 'undefined') && mimes.find((m) => MediaRecorder.isTypeSupported(m));
3509
4472
  if (!mime) throw new Error('当前环境不支持视频录制');
3510
4473
  const stream = cv.captureStream(30);
@@ -3566,67 +4529,102 @@ window.__ModuleLoader__.load({
3566
4529
  }
3567
4530
  let idx = 0;
3568
4531
  let done = false;
4532
+ let failed = null;
3569
4533
  let blackUntil = 0;
3570
4534
  let lastProgressIdx = -1;
4535
+ let lastTickAt = 0;
4536
+ // 看门狗:即使某段 video 元素永远不触发 ended/timeupdate(解码卡住、seek 失效),
4537
+ // 也按"本段应有时长"强制推进——否则用户只看到"运行中"永远不结束。
4538
+ const startedAt = performance.now();
3571
4539
  startPlayback(vids[0]);
3572
4540
  await new Promise((resolve) => {
4541
+ function finish(err) {
4542
+ if (done) return;
4543
+ done = true;
4544
+ failed = err || null;
4545
+ resolve();
4546
+ }
3573
4547
  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) {
4548
+ if (done) return;
4549
+ try {
4550
+ const now = performance.now();
4551
+ const item = vids[idx];
4552
+ if (!item) return finish();
4553
+ const v = item.v;
4554
+ // 每段超时保护:单段最多等到 应有+10s,超过即视为该段已结束(不卡死整片)
4555
+ if (item.startAt && item.hardDeadline && now > item.hardDeadline) {
4556
+ stopItem(item);
4557
+ idx++;
4558
+ if (idx >= vids.length) return finish();
4559
+ if (transition === 'black') blackUntil = now + 350;
4560
+ }
4561
+ // 黑场过渡窗口
4562
+ if (blackUntil && now < blackUntil) {
4563
+ g.fillStyle = '#000'; g.fillRect(0, 0, W, H);
4564
+ requestAnimationFrame(frame);
4565
+ return;
4566
+ }
4567
+ blackUntil = 0;
4568
+ startPlayback(item);
4569
+ if (lastProgressIdx !== idx) { lastProgressIdx = idx; if (onProgress) onProgress(idx / Math.max(1, vids.length), item.name); }
4570
+ const clipDur = isFinite(v.duration) && v.duration > 0 ? v.duration : item.clipDur;
4571
+ // 有效时长 = 裁剪时长(若有)与素材真实时长的较小值
4572
+ const effDur = item.trim && item.trim > 0 ? Math.min(item.trim, clipDur || item.trim) : clipDur;
4573
+ if (!item.hardDeadline && item.startAt) item.hardDeadline = item.startAt + (effDur > 0 ? effDur * 1000 : 8000) + 10000;
4574
+ const elapsed = item.startAt ? (now - item.startAt) / 1000 : 0;
4575
+ // 叠化窗口:距本段结束还剩多少毫秒(effDur 为 0 时视为无限,不会误触发)
4576
+ const remainMs = effDur > 0 ? (effDur - (v.currentTime || 0)) * 1000 : Infinity;
4577
+ // 叠化:临近结束时提前启动下一片段
4578
+ let next = null;
4579
+ if (transition === 'fade' && idx < vids.length - 1 && remainMs <= td && Number.isFinite(remainMs)) {
4580
+ next = vids[idx + 1];
4581
+ startPlayback(next);
4582
+ }
4583
+ // 绘制当前片段
3580
4584
  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();
4585
+ letterbox(v);
4586
+ let fadeT = 0;
4587
+ if (next && next.started) {
4588
+ fadeT = clamp(1 - remainMs / td, 0, 1);
4589
+ if (next.v.videoWidth) {
4590
+ g.save(); g.globalAlpha = fadeT; letterbox(next.v); g.restore();
4591
+ }
3605
4592
  }
4593
+ // 字幕随叠化淡出/淡入
4594
+ if (next && fadeT > 0.5) {
4595
+ g.save(); g.globalAlpha = fadeT; drawSubtitle(g, W, H, next.sub); g.restore();
4596
+ } else {
4597
+ g.save(); g.globalAlpha = 1 - fadeT; drawSubtitle(g, W, H, item.sub); g.restore();
4598
+ }
4599
+ // 推进(三判据:播放器自报结束 / 到达有效时长 / 墙钟超时)
4600
+ const ended = v.ended || (effDur > 0 && elapsed >= effDur - 0.03);
4601
+ if (ended) {
4602
+ stopItem(item);
4603
+ idx++;
4604
+ if (idx >= vids.length) return finish();
4605
+ if (transition === 'black') blackUntil = now + 350;
4606
+ }
4607
+ lastTickAt = now;
4608
+ requestAnimationFrame(frame);
4609
+ } catch (e) {
4610
+ // 帧循环内异常必须转成 reject:静默卡住比报错更糟
4611
+ try { stopItem(vids[idx] || {}); } catch { /* 忽略 */ }
4612
+ if (lastTickAt) void lastTickAt;
4613
+ finish(e);
3606
4614
  }
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
4615
  }
3623
4616
  requestAnimationFrame(frame);
3624
- setTimeout(() => { done = true; resolve(); }, totalMs + 30000);
4617
+ // 全局兜底:总时长 + 30s(含看门狗余量)后强制收尾,并明确报告超时而不是假装成功
4618
+ setTimeout(() => {
4619
+ if (!done) finish(new Error('成片合成超时(已等待 ' + Math.round((performance.now() - startedAt) / 1000) + 's):请检查所选片段是否都能正常播放'));
4620
+ }, totalMs + 30000);
3625
4621
  });
3626
4622
  vids.forEach(stopItem);
3627
4623
  rec.stop();
3628
4624
  await stopped;
3629
4625
  if (audio) { try { audio.ac.close(); } catch { /* 忽略 */ } }
4626
+ if (failed) throw failed;
4627
+ if (!chunks.length) throw new Error('成片录制未产出数据(浏览器录制失败,请重试或降低分辨率)');
3630
4628
  return new Blob(chunks, { type: mime.split(';')[0] });
3631
4629
  }
3632
4630
 
@@ -3663,10 +4661,12 @@ window.__ModuleLoader__.load({
3663
4661
  const craft = {
3664
4662
  status: async () => { const r = await craftFetch('/status', {}, 20000, true); return await r.json(); },
3665
4663
  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(); },
4664
+ // BLK-02:所有文件类调用一律 base(项目绝对目录) + path(相对),绝不发「无 base 的相对 rel」——
4665
+ // 无 base 时 ArtCraft 按插件进程 cwd 解析,会 200 静默写到 DSH profile 目录(现场证据见 _qa/BLK-02)。
4666
+ 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(); },
4667
+ 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(); },
4668
+ readText: async (base, rel) => { const r = await craftFetch('/fs?op=readtext&base=' + encodeURIComponent(base) + '&path=' + encodeURIComponent(rel)); return (await r.json()).text; },
4669
+ file: async (base, rel) => { const r = await craftFetch('/file?base=' + encodeURIComponent(base) + '&path=' + encodeURIComponent(rel), {}, 180000); return await r.blob(); },
3670
4670
  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
4671
  jobGet: async (id, tail) => { const r = await craftFetch('/job?id=' + encodeURIComponent(id) + '&tail=' + (tail || 12)); return await r.json(); },
3672
4672
  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 +4681,41 @@ window.__ModuleLoader__.load({
3681
4681
  update((s) => { s.craftStatus = { at: Date.now(), data }; });
3682
4682
  return data;
3683
4683
  }
4684
+ // ---- BLK-02:ArtCraft 调用的目录基准 ----
4685
+ // ArtCraft 是纯桥接、**没有自己的数据根**(0.3.x 起 /api/status 也不再返回 dataRoot)。
4686
+ // 因此画布必须自己给出绝对基准:每个项目的目录 = <画布数据根>/projects/<物理文件夹>,
4687
+ // 由 Host 的 projectDirGet 解析(meta 无条目时回退 id),再以 base + 相对 path 调 ArtCraft。
4688
+ // 绝不发「无 base 的相对 rel」——那样会被按插件进程 cwd 解析、200 却写错目录(见 _qa/BLK-02)。
4689
+ const craftBaseCache = new Map(); // projectId -> { at, abs }
4690
+ async function craftBaseDir(pid) {
4691
+ const id = String(pid || S.activeId || '');
4692
+ if (!id) return null;
4693
+ const hit = craftBaseCache.get(id);
4694
+ if (hit && Date.now() - hit.at < 60000) return hit.abs;
4695
+ let r = null;
4696
+ try { r = await hostRpc('projectDirGet', { projectId: id }); } catch { r = null; }
4697
+ if (r && r.ok && r.abs) { craftBaseCache.set(id, { at: Date.now(), abs: r.abs }); return r.abs; }
4698
+ return null;
4699
+ }
4700
+ /** 取不到项目目录时明确失败(未配数据根/宿主过旧),不做任何相对路径兜底。 */
4701
+ async function craftBaseDirRequired(pid) {
4702
+ const abs = await craftBaseDir(pid);
4703
+ if (!abs) throw new Error('未设置画布数据根目录:ArtCraft 调用需要绝对的项目目录作为基准(设置 → 数据根目录)');
4704
+ return abs;
4705
+ }
4706
+ /** IC-B2:项目删除时清掉其 craft/ 后期目录(Host RPC projectCraftRemove,幂等、失败可见)。 */
4707
+ async function craftProjectCleanup(pid) {
4708
+ try {
4709
+ const r = await hostRpc('projectCraftRemove', { projectId: pid });
4710
+ if (r && r.ok === false && r.error) toast('Craft 目录清理未完成:' + r.error);
4711
+ } catch { /* 旧 Host 无该 RPC → 静默(目录残留不影响功能) */ }
4712
+ }
4713
+ /** 项目目录下的相对路径 → 本机绝对路径(ArtCraft 的 args/FCP7 XML 都用它)。 */
4714
+ function joinLocal(absDir, rel) {
4715
+ const s = String(absDir || '').replace(/\//g, '\\').replace(/\\+$/, '');
4716
+ const r = String(rel || '').replace(/\\/g, '/').replace(/^\/+/, '');
4717
+ return s + '\\' + r.replace(/\//g, '\\');
4718
+ }
3684
4719
  async function craftStatusSafe() {
3685
4720
  try { return await craft.status(); } catch { toast(T('craftUnreachable')); return null; }
3686
4721
  }
@@ -3689,25 +4724,14 @@ window.__ModuleLoader__.load({
3689
4724
  if (!cs || !cs.at || Date.now() - cs.at > 120000 || !cs.data || !cs.data.bins) return false;
3690
4725
  return Object.keys(cs.data.bins).some((k) => cs.data.bins[k] && cs.data.bins[k].found);
3691
4726
  }
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)
4727
+ // 资产 assetId → 项目内相对路径 映射(项目级元数据,localStorage)
3708
4728
  function craftMapLoad(pid) { return lsGet('dsh-ic:craftmap:' + pid, {}) || {}; }
3709
4729
  function craftMapSave(pid, map) { lsSet('dsh-ic:craftmap:' + pid, map); }
3710
4730
  function craftMapSet(pid, assetId, rel) { const m = craftMapLoad(pid); m[assetId] = rel; craftMapSave(pid, m); }
4731
+ /** Craft 落盘/产物路径的扩展名(IC-B3 统一):**文件名优先**——Craft 侧复用资产的
4732
+ * 既有名字与扩展名(已登记的 media/graded 文件靠名字对回 ArtCraft 工程),mime 只兜底。
4733
+ * 与 extForDownload(mime 优先)方向相反是有意的:下载侧扩展名必须反映真实容器,
4734
+ * 落盘侧名字才是资产在 ArtCraft 工程里的稳定标识。 */
3711
4735
  function extForMime(mime, name) {
3712
4736
  const fromName = String(name || '').match(/(\.[a-z0-9]{2,5})$/i);
3713
4737
  if (fromName) return fromName[1].toLowerCase();
@@ -3721,6 +4745,36 @@ window.__ModuleLoader__.load({
3721
4745
  if (m.includes('wav')) return '.wav';
3722
4746
  return '.bin';
3723
4747
  }
4748
+ /** IC-01/IC-B3:下载侧扩展名判定(**mime 优先** → 文件名白名单兜底 → kind 兜底)。
4749
+ * 三套映射的分工(IC-B3 统一结论,各自单一职责):
4750
+ * - extFromMime:磁盘落盘(mime 子类型直取,无名字兜底——落盘时 mime 由生成链路给出)
4751
+ * - extForMime:Craft 落盘/产物路径(文件名优先——名字是 ArtCraft 工程里的稳定标识)
4752
+ * - extForDownload:用户下载(mime 优先——扩展名必须反映真实容器,`final.2026`
4753
+ * 这类带点号的资产名绝不能得到 `.2026`,所以名字兜底只认白名单) */
4754
+ const DOWNLOAD_EXT_BY_MIME = [
4755
+ [/^image\/png$/, '.png'], [/^image\/jpe?g$/, '.jpg'], [/^image\/webp$/, '.webp'], [/^image\/gif$/, '.gif'],
4756
+ [/^video\/mp4$/, '.mp4'], [/^video\/quicktime$/, '.mov'], [/^video\/webm$/, '.webm'], [/^video\/x-matroska$/, '.mkv'],
4757
+ [/^audio\/mpeg$/, '.mp3'], [/^audio\/mp4$/, '.m4a'], [/^audio\/(x-)?wav$/, '.wav'],
4758
+ [/^text\/markdown$/, '.md'], [/^text\/plain$/, '.txt'],
4759
+ ];
4760
+ const DOWNLOAD_EXT_BY_NAME = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.mp4', '.mov', '.webm', '.mkv', '.mp3', '.m4a', '.wav', '.md', '.txt']);
4761
+ function extForDownload(mime, name, kind) {
4762
+ const m = String(mime || '').split(';')[0].trim().toLowerCase();
4763
+ if (m) { for (const [re, ext] of DOWNLOAD_EXT_BY_MIME) if (re.test(m)) return ext; }
4764
+ const nm = String(name || '').match(/\.([a-z0-9]{2,5})$/i);
4765
+ if (nm) { const ext = '.' + nm[1].toLowerCase(); if (DOWNLOAD_EXT_BY_NAME.has(ext)) return ext; }
4766
+ if (kind === 'image') return '.png';
4767
+ if (kind === 'video') return '.mp4';
4768
+ if (kind === 'audio') return '.mp3';
4769
+ if (kind === 'text') return '.md';
4770
+ return '.bin';
4771
+ }
4772
+ /** 下载名 = 可读前缀 + 真实扩展名。assetId 只为取 mime/kind;记录取不到时按 kind 兜底。 */
4773
+ async function downloadNameForAsset(assetId, base, kind) {
4774
+ let rec = null;
4775
+ try { rec = assetId ? await idbGet(assetId) : null; } catch { rec = null; }
4776
+ return base + extForDownload(rec && rec.mime, rec && rec.name, (rec && rec.kind) || kind);
4777
+ }
3724
4778
  /** 资产落盘到 <项目>/media/(force=false 时已有映射则复用;force=true 覆盖写)。 */
3725
4779
  /** ArtCraft 工作区里的文件名:资产名(人工可读,如「角色-张山 多视图」)+ id 尾号(唯一,避免同名覆盖)。 */
3726
4780
  function assetFileBase(rec) {
@@ -3731,16 +4785,29 @@ window.__ModuleLoader__.load({
3731
4785
  }
3732
4786
  async function syncAssetToDisk(rec, opts = {}) {
3733
4787
  const pid = S.activeId;
4788
+ const base = await craftBaseDirRequired(pid);
3734
4789
  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);
4790
+ // 陈旧映射防护(旧版本升级边界):映射存的是写入时刻的逻辑路径
4791
+ // 'projects/<当时的物理文件夹>/craft/…'。BLK-02 后物理文件夹名来自数据根 meta,
4792
+ // 旧版 slug-era 的映射前缀几乎必然 ≠ 现文件夹——直接复用会让 FilmCraft 导出/
4793
+ // PhotoCraft 调色引用到不存在的媒体("文件不存在")。前缀不符 = 过期,强制重写。
4794
+ const curPrefix = 'projects/' + craftFolderOf(base) + '/';
4795
+ if (!opts.force && map[rec.id] && String(map[rec.id]).startsWith(curPrefix)) return map[rec.id];
4796
+ // BLK-02:写入基准 = 项目绝对目录(base),路径只用 craft/ 下的相对段。
4797
+ // ArtCraft 侧解析后落点 = <数据根>/projects/<物理文件夹>/craft/media/…;不再依赖 slug 与它的 cwd。
4798
+ const rel = 'craft/media/' + assetFileBase(rec) + extForMime(rec.mime, rec.name);
3739
4799
  const blob = rec.blob || await assetBlob(rec.id);
3740
4800
  if (!blob) throw new Error('资产内容不可用: ' + rec.id);
3741
- await craft.write(rel, blob);
3742
- craftMapSet(pid, rec.id, rel);
3743
- return rel;
4801
+ await craft.write(base, rel, blob);
4802
+ const stored = 'projects/' + craftFolderOf(base) + '/' + rel; // 返回给 Host 侧取用(逻辑路径)
4803
+ craftMapSet(pid, rec.id, stored);
4804
+ return stored;
4805
+ }
4806
+ /** 从项目绝对目录里取物理文件夹名(base 的末段),用于拼 Host 可识别的逻辑路径。 */
4807
+ function craftFolderOf(abs) {
4808
+ const s = String(abs || '').replace(/[\\/]+$/, '');
4809
+ const i = Math.max(s.lastIndexOf('\\'), s.lastIndexOf('/'));
4810
+ return i >= 0 ? s.slice(i + 1) : s;
3744
4811
  }
3745
4812
  async function ensureAssetOnDisk(rec) { return syncAssetToDisk(rec, { force: false }); }
3746
4813
  async function videoDurationOf(blob) {
@@ -3835,8 +4902,9 @@ window.__ModuleLoader__.load({
3835
4902
  });
3836
4903
  return out.join('\n');
3837
4904
  }
3838
- /** FilmCraft 任务后台轮询:状态写回节点,done 后回收产物入资产库。 */
3839
- async function pollFilmcraftJob(nodeId, jobId, outRel) {
4905
+ /** FilmCraft 任务后台轮询:状态写回节点,done 后回收产物入资产库。
4906
+ * base = 项目绝对目录(BLK-02:取产物也必须带 base,否则读到的是插件 cwd 下的同名文件)。 */
4907
+ async function pollFilmcraftJob(nodeId, jobId, outRel, base) {
3840
4908
  const deadline = Date.now() + 30 * 60 * 1000; // 与 jobStart 的 timeoutMs 对齐:绝不无限轮询
3841
4909
  for (;;) {
3842
4910
  if (Date.now() > deadline) {
@@ -3856,7 +4924,7 @@ window.__ModuleLoader__.load({
3856
4924
  });
3857
4925
  if (snap.status === 'done') {
3858
4926
  try {
3859
- const blob = await craft.file(outRel);
4927
+ const blob = await craft.file(base, outRel);
3860
4928
  const rec = await saveAssetRecord(S.activeId, 'video', blob, {
3861
4929
  name: T('finalCutDone') + '-FC-' + new Date().toISOString().slice(5, 16).replace('T', ' '),
3862
4930
  prompt: 'filmcraft export', source: 'filmcraft', category: 'finalcut',
@@ -4013,9 +5081,8 @@ window.__ModuleLoader__.load({
4013
5081
  s.projects = s.projects.filter((p) => !gone.has(p.id));
4014
5082
  if (gone.has(s.activeId)) {
4015
5083
  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;
5084
+ // OBS-UI-1:删光后进入空态,不再自动建「我的画布」占位(同 initStore 口径)
5085
+ s.activeId = null;
4019
5086
  s.graph = emptyGraph();
4020
5087
  } else {
4021
5088
  s.activeId = s.projects[0].id;
@@ -4157,6 +5224,8 @@ window.__ModuleLoader__.load({
4157
5224
  /** 启动时把本地全部项目推给 Host:让对话能看到并操作画布上已有的项目(客户端为内容权威)。 */
4158
5225
  async function pushAllProjectsToHost() {
4159
5226
  if (hostSyncStopped) return;
5227
+ // OBS-UI-1:空态(用户还没建任何项目)不推送——否则空态下的占位也会写进 canvas.db
5228
+ if (!S.projects.length) return;
4160
5229
  for (const p of [...S.projects]) {
4161
5230
  try {
4162
5231
  const doc = loadGraphDoc(p.id) || emptyGraph();
@@ -4444,14 +5513,25 @@ window.__ModuleLoader__.load({
4444
5513
  }
4445
5514
  return out;
4446
5515
  }
4447
- async function aiStoryboardShots(scriptText, requirement, count, model, onDelta, prevTail) {
5516
+ async function aiStoryboardShots(scriptText, requirement, count, model, onDelta, prevTail, opts) {
4448
5517
  const settings = loadSettings();
4449
5518
  const bible = bibleBlock(loadBible(S.activeId));
4450
5519
  const zh = effLang() !== 'en';
5520
+ const contOn = !(opts && opts.cont === false); // 分镜衔接开关(默认开)
5521
+ const contFields = (() => {
5522
+ if (zh) {
5523
+ return contOn
5524
+ ? '{"title":"…","visual":"…","motion":"…","tail":"…","cam":"…","start":"…","seg":"continuation","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"陈拾","line":"收件时间,1999……"}],"sfx":"邮包搭扣声、撕纸声"}'
5525
+ : '{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"陈拾","line":"收件时间,1999……"}],"sfx":"邮包搭扣声、撕纸声"}';
5526
+ }
5527
+ return contOn
5528
+ ? '{"title":"…","visual":"…","motion":"…","tail":"…","cam":"…","start":"…","seg":"continuation","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"Chen","line":"…"}],"sfx":"zipper snap, paper tear"}'
5529
+ : '{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"Chen","line":"…"}],"sfx":"zipper snap, paper tear"}';
5530
+ })();
4451
5531
  const jsonContract = zh
4452
5532
  ? [
4453
5533
  '把剧本拆解为「多镜头一次性视频生成」的分镜脚本:铺开后由**一个视频生成节点**把全部镜头按顺序与时长写进同一条提示词,由视频模型一次性生成整段视频(镜头间由模型直接硬切,不是一镜一视频再拼接)。',
4454
- '只输出一个 JSON 对象:{"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"陈拾","line":"收件时间,1999……"}],"sfx":"邮包搭扣声、撕纸声"}]}。',
5534
+ '只输出一个 JSON 对象:{"shots":[' + contFields + ']}。',
4455
5535
  '规则:',
4456
5536
  '- visual 是本镜画面描述(景别、人物外观与站位、场景、光线、构图,60 字内);',
4457
5537
  '- **visual 与 motion 里的角色/场景/道具必须写成 @资产名@ 引用形式**(前后各一个 @ 精确包裹,如「@陈拾@ 在 @城市街头@ 的围挡旁拿起 @奇怪快递件@」);名称必须与设定集/资产**逐字一致**;剧本原文里已出现的 @资产名@ 锚点要在镜头中原样保留(名称逐字一致);名称内不得含 @、空格或标点;@ 后绝不允许直接跟其他文字(必须以 @ 闭合)——系统会隐藏两个 @ 并把名称显示为蓝色加粗,未登记的 @名称@ 会被一致性检查提示补充;',
@@ -4467,10 +5547,21 @@ window.__ModuleLoader__.load({
4467
5547
  '- **按情绪选景别**:孤独/绝望/宏大危机=远景;对峙/动作/关系建立=全景;对话/试探/信息交换=中景;震惊/怀疑/内心动摇=近景;崩溃/嘶吼/情绪爆发=特写;关键细节/道具揭示=大特写——冲突用全景、揭秘转近景、爆发给特写、反转可拉远,景别变化本身在讲情绪;',
4468
5548
  '- **快切镜头(<1.5s)的 visual/motion 要极短**(一个动作、一句画内信息),信息量大的镜头才配长时长——时长与内容量必须匹配;',
4469
5549
  '- 只输出 JSON,不要输出任何其他内容。',
4470
- ].join('\n')
5550
+ ].concat(contOn ? [
5551
+ '',
5552
+ '**【分镜衔接·硬性】成片是分段生成的,每段模型看不到上一段画面——衔接只能靠写在分镜里的"接点"。除末镜外,每镜都必须写 tail 与 cam:**',
5553
+ '- **tail(接点尾态)**:本镜**最后一刻**的状态,一行写清——人物姿势停在哪个动作的中途、朝向、视线落点、手持物位置、位置关系(如「右手抬到胸口一半,视线仍锁在 @陈拾@ 脸上,@糯糯@ 停在他左后方半步」「已侧身转到 3/4 面,还没完全转身面对对方」)。**必须是"动作进行中"的状态,不能写成"已经完成/站定/看向"**——切点停在静止定格,下一镜就没有动作可接,接缝必然暴露;',
5554
+ '- **cam(运镜延续)**:本镜运镜的方向与速度,写成下一镜可以直接"接着走"的形式(如「右移横移,匀速,未停」「缓推向面部,接近停住」);末镜写「收束静止」;**全片避免相邻镜反向运镜**(上一镜右移、下一镜突然左移 = 观众立刻感觉跳),需要反向时把切点设计成遮挡/过肩/特写插入;',
5555
+ '- **start(仅第 1 镜或 seg 非续帧的镜头需要)**:本镜起始状态;续帧镜头不写 start(它会自动承接上一镜 tail);',
5556
+ '- **seg(段落类型)**:`continuation` 续帧(默认,绝大多数镜头)、`scene_change` 换场/切换地点时间、`new_character` 新角色首次出场、`closeup` 特写/细节插入(允许跳切)、`opening` 仅第 1 镜且无上游承接时;',
5557
+ '- **一镜只做一个主动作**:转身、走路、推镜、换表情不要塞进同一镜——模型只会执行前半段,下一镜就对不上;',
5558
+ '- **一段一运镜**:不要又推又横移又摇;',
5559
+ '- **大位移拆镜**:人物横移超过画面 1/4、或场景大换位,就再拆一镜(用不同景别接,而不是一镜硬走);',
5560
+ '- **轴线一致**:两人对话,同一轴线关系(如男左女右)在相邻镜必须保持一致,不得越轴。',
5561
+ ] : []).join('\n')
4471
5562
  : [
4472
5563
  '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"}]}.',
5564
+ 'Output only one JSON object: {"shots":[' + contFields + ']}.',
4474
5565
  'Rules:',
4475
5566
  '- visual = the shot\'s frame description (shot size, wardrobe, staging, light, framing, <=60 words);',
4476
5567
  '- **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 +5577,18 @@ window.__ModuleLoader__.load({
4486
5577
  '- **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
5578
  '- **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
5579
  '- Output JSON only.',
4489
- ].join('\n');
5580
+ ].concat(contOn ? [
5581
+ '',
5582
+ '**[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:**',
5583
+ '- **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;',
5584
+ '- **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;',
5585
+ '- **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);',
5586
+ '- **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);',
5587
+ '- **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;',
5588
+ '- **one camera move per shot**;',
5589
+ '- **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;',
5590
+ '- **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.',
5591
+ ] : []).join('\n');
4490
5592
  const methodology = await loadSkillRules(settings.skillStoryboard, zh)
4491
5593
  || await loadSkillRules(CRAFT_SKILL, zh); // 未选技能时默认注入内置创作方法论
4492
5594
  const system = [
@@ -4514,27 +5616,35 @@ window.__ModuleLoader__.load({
4514
5616
  const sh = prevTail.shot;
4515
5617
  if (!sh) return null;
4516
5618
  const dl = (sh.dialogue || []).map((d) => d.role + (zh ? ':' : ': ') + d.line).join(zh ? ';' : '; ');
5619
+ const tailState = String(sh.tail || '').trim();
5620
+ const camState = String(sh.cam || '').trim();
4517
5621
  return zh
4518
5622
  ? `【承接约束·硬性】本分镜承接「${label}」的最后一镜——**只承接它的结尾瞬间,不要重演它**:\n`
4519
5623
  + ` 尾镜画面:${String(sh.visual || '').trim()}\n`
4520
5624
  + ` 尾镜最后动作:${String(sh.motion || '').trim()}\n`
5625
+ + (tailState ? ` 尾镜接点尾态(本分镜第 1 镜必须从这里继续,动作处于进行中):${tailState}\n` : '')
5626
+ + (camState ? ` 尾镜运镜(第 1 镜须同向延续,不得反向、不得突变):${camState}\n` : '')
4521
5627
  + (dl ? ` 尾镜台词:${dl}\n` : '')
4522
5628
  + (String(sh.sfx || '').trim() ? ` 尾镜音效/环境声:${String(sh.sfx).trim()}\n` : '')
4523
5629
  + `要求:\n`
4524
5630
  + ` 1) 第 1 镜从上一镜的**最后一个动作**继续(人物位置、朝向、情绪、光线保持一致),不得重新建立场景、不得跳回远景交代、不得切换地点;\n`
4525
5631
  + ` 2) 与上一镜的画面重叠**不超过 0.2 秒**(不要重演上一镜的内容);\n`
4526
5632
  + ` 3) **背景声连续**:环境音/音效自然延续(风声、电流声、街道底噪等),不得突然静音或更换环境声;\n`
4527
- + ` 4) 第 1 镜的画面描述里明确写出承接关系(如「承接上一镜,<角色>保持<姿态>,继续…」)。`
5633
+ + ` 4) 第 1 镜的画面描述里明确写出承接关系(如「承接上一镜,<角色>保持<姿态>,继续…」);\n`
5634
+ + ` 5) 第 1 镜的 seg 标 continuation,并**不要写 start**(自动承接上一镜 tail)。`
4528
5635
  : `[CONTINUITY CONSTRAINT - HARD] This storyboard continues the LAST SHOT of "${label}" - continue only its final instant, do NOT replay it:\n`
4529
5636
  + ` Tail frame: ${String(sh.visual || '').trim()}\n`
4530
5637
  + ` Tail last action: ${String(sh.motion || '').trim()}\n`
5638
+ + (tailState ? ` Tail hand-off state (shot 1 must continue from here, mid-motion): ${tailState}\n` : '')
5639
+ + (camState ? ` Tail camera (shot 1 continues the same direction, no reversal, no sudden change): ${camState}\n` : '')
4531
5640
  + (dl ? ` Tail dialogue: ${dl}\n` : '')
4532
5641
  + (String(sh.sfx || '').trim() ? ` Tail SFX/ambience: ${String(sh.sfx).trim()}\n` : '')
4533
5642
  + `Requirements:\n`
4534
5643
  + ` 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
5644
  + ` 2) overlap with the previous shot must be <= 0.2s (do not replay the previous shot);\n`
4536
5645
  + ` 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.`;
5646
+ + ` 4) state the continuity explicitly in shot 1's frame description;\n`
5647
+ + ` 5) shot 1 uses seg=continuation and omits start (it inherits the previous tail).`;
4538
5648
  })();
4539
5649
  const prevLine = cont || '';
4540
5650
  const user = [
@@ -4569,6 +5679,11 @@ window.__ModuleLoader__.load({
4569
5679
  title: String(s.title || (T('shotPrefix') + (i + 1))).slice(0, 24),
4570
5680
  visual: String(s.visual || ''),
4571
5681
  motion: String(s.motion || ''),
5682
+ // 分镜衔接(分段生成的接戏依据):接点尾态 / 运镜延续 / 起始态 / 段落类型
5683
+ tail: String(s.tail || '').trim().slice(0, 120),
5684
+ cam: String(s.cam || '').trim().slice(0, 80),
5685
+ start: String(s.start || '').trim().slice(0, 120),
5686
+ seg: String(s.seg || '').trim().toLowerCase().slice(0, 20),
4572
5687
  characters: Array.isArray(s.characters) ? s.characters.map(String).slice(0, 6) : [],
4573
5688
  scene: String(s.scene || ''),
4574
5689
  props: Array.isArray(s.props) ? s.props.map(String).slice(0, 6) : [],
@@ -4660,35 +5775,27 @@ window.__ModuleLoader__.load({
4660
5775
  source: (meta && meta.source) || 'ai',
4661
5776
  category: (meta && meta.category) || 'script',
4662
5777
  };
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
- }
5778
+ // 文本资产:文件(.md)落数据目录的项目文件夹,正文同时进 canvas.db(预览/下载不必回读文件)。
5779
+ // 未设数据目录时明确报错(IndexedDB 已废弃),绝不静默。
5780
+ if (!S.dataRoot) throw new Error('未设置数据根目录:文本资产无法持久化(IndexedDB 已废弃)。请在设置里配置数据根目录。');
5781
+ const rel = `projects/${projectId}/text/${assetFileBase(rec)}.md`;
5782
+ const blob = new Blob([rec.text], { type: 'text/markdown' });
5783
+ const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
5784
+ if (!r.ok) throw new Error('文本资产落盘失败: ' + (await r.text()));
5785
+ rec.rel = rel;
5786
+ rec.size = blob.size;
5787
+ rec.stored = 'disk';
5788
+ const proj = S.projects.find((p) => p.id === projectId);
5789
+ if (S.store === 'sqlite') {
5790
+ const saved = await hostRpc('assetCreate', { asset: assetIndexRecord(rec), projectName: (proj && proj.name) || '' });
5791
+ const out = (saved && saved.asset) || rec;
5792
+ assetMem.set(out.id, out);
5793
+ assetNameCache.set(out.id, out.name);
5794
+ return out;
4688
5795
  }
4689
5796
  await idbPut(rec);
4690
5797
  assetNameCache.set(rec.id, rec.name);
4691
- if (S.dataRoot) mirrorAssetIndex(rec); // JSON 降级模式:镜像进 assets.json
5798
+ mirrorAssetIndex(rec); // JSON 降级模式:镜像进 assets.json
4692
5799
  return rec;
4693
5800
  }
4694
5801
 
@@ -4718,6 +5825,8 @@ window.__ModuleLoader__.load({
4718
5825
  updateBusy: false,
4719
5826
  updateMsg: null,
4720
5827
  updateDismissed: false,
5828
+ keysOpen: false, // 左上角快捷键面板是否展开(纯 UI 状态,不持久化)
5829
+ canvasHost: null, // 画布当前宿主:'main'(全屏面板)| 'pane'(会话右栏页签)| null——同一时刻只允许一处实例
4721
5830
  };
4722
5831
  const listeners = new Set();
4723
5832
  let saveTimer = null;
@@ -4774,9 +5883,16 @@ window.__ModuleLoader__.load({
4774
5883
  }
4775
5884
  function initStore() {
4776
5885
  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);
5886
+ if (!Array.isArray(S.projects)) S.projects = [];
5887
+ // OBS-UI-1:不再自动创建「我的画布」空项目——干净客户端每次打开面板都会
5888
+ // 经 pushAllProjectsToHost 把它写进 canvas.db(QA 实测 13→14 持续累积)。
5889
+ // 空列表 = 空态:画布显示"新建项目"引导,用户显式点击才真正建项目。
5890
+ if (S.projects.length === 0) {
5891
+ S.activeId = null;
5892
+ S.graph = emptyGraph();
5893
+ V.x = 60; V.y = 40; V.scale = 1;
5894
+ S.ready = true;
5895
+ return;
4780
5896
  }
4781
5897
  const active = lsGet(LS_ACTIVE, null);
4782
5898
  S.activeId = S.projects.some((p) => p.id === active) ? active : S.projects[0].id;
@@ -5170,6 +6286,8 @@ window.__ModuleLoader__.load({
5170
6286
  function servedAssetId(src, fromPort) {
5171
6287
  if (!src || !src.data) return null;
5172
6288
  if (fromPort === 'last') return src.data.lastAssetId || null;
6289
+ // tailpick = 尾帧「最佳选帧」(续帧接力首选:动作进行中/清晰/不抖)
6290
+ if (fromPort === 'tailpick') return src.data.lastPickAssetId || src.data.lastAssetId || null;
5173
6291
  return src.data.assetId || null;
5174
6292
  }
5175
6293
  async function resolveAssetBlob(nodeId, portId) {
@@ -5324,7 +6442,7 @@ window.__ModuleLoader__.load({
5324
6442
  flushSave();
5325
6443
  try {
5326
6444
  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);
6445
+ 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
6446
  // 未闭合引用自愈:句尾丢闭合 @ 的引用按设定集名补全(否则参考图静默丢失)
5329
6447
  const _known = bibleNames(loadBible(S.activeId));
5330
6448
  for (const sh of shots) {
@@ -5377,21 +6495,36 @@ window.__ModuleLoader__.load({
5377
6495
  return v != null ? v : defSub;
5378
6496
  };
5379
6497
  const metas = [];
6498
+ const skipped = []; // 被跳过的片段必须让用户看见:静默少段 = 用户以为全片都合进去了
5380
6499
  for (const x of segs) {
5381
- if (!x.nd.data.assetId) continue;
6500
+ const segName = String((x.nd.data && x.nd.data.label) || TYPE_LABEL()[x.nd.type] || '片段');
6501
+ if (!x.nd.data.assetId) { skipped.push(segName + '(还没有生成结果)'); continue; }
5382
6502
  try {
5383
6503
  const rec = await idbGet(x.nd.data.assetId);
5384
6504
  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 { /* 跳过加载失败的片段 */ }
6505
+ if (!blob) { skipped.push(segName + '(资产读不到,可能是文件已被删除)'); continue; }
6506
+ if (!blob.size) { skipped.push(segName + '(资产为空文件)'); continue; }
6507
+ const ttsId = n.data.tts && n.data.tts[x.e.id] && n.data.tts[x.e.id].assetId;
6508
+ const ttsUrl = ttsId ? await assetUrl(ttsId) : null;
6509
+ // 分镜的编辑时长(1-3s 级)作为成片裁剪依据:API 生成的片段偏长,成片时裁到分镜时长
6510
+ const editDur = Number(x.nd.data.editDuration) || null;
6511
+ metas.push({ url: URL.createObjectURL(blob), name: (rec && rec.name) || 'seg', sub: getSub(x), ttsUrl, dur: editDur || undefined });
6512
+ } catch (e) {
6513
+ skipped.push(segName + '(读取失败:' + String((e && e.message) || e) + ')');
6514
+ }
6515
+ }
6516
+ if (!metas.length) {
6517
+ throw new Error(skipped.length
6518
+ ? ('没有可合成的片段——' + skipped.join(';'))
6519
+ : T('noSegments'));
6520
+ }
6521
+ if (skipped.length) {
6522
+ // 不阻断合成,但必须在节点上留痕(否则"少了一段"会被当成 bug 却查不到原因)
6523
+ update((s) => {
6524
+ const nd = s.graph.nodes[n.id];
6525
+ if (nd) nd.data.genWarn = '已跳过 ' + skipped.length + ' 个片段:' + skipped.join(';');
6526
+ });
5393
6527
  }
5394
- if (!metas.length) throw new Error(T('noSegments'));
5395
6528
  update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.status = 'running'; nd.data.error = null; nd.data.progress = '0%'; } });
5396
6529
  flushSave();
5397
6530
  try {
@@ -5431,9 +6564,20 @@ window.__ModuleLoader__.load({
5431
6564
  const ctl = new AbortController();
5432
6565
  genAbort.ctl = ctl;
5433
6566
  genActive++;
6567
+ // 生成告警收集器:参考素材截断、厂商参数落空等"需要用户知道"的事实,一律挂到节点上(绝不静默)
6568
+ const addWarn = (msg) => {
6569
+ if (!msg) return;
6570
+ update((s) => {
6571
+ const nd = s.graph.nodes[n.id];
6572
+ if (!nd) return;
6573
+ const prev = String(nd.data.genWarn || '').trim();
6574
+ if (prev.includes(msg)) return;
6575
+ nd.data.genWarn = prev ? (prev + ';' + msg) : String(msg);
6576
+ });
6577
+ };
5434
6578
  update((s) => {
5435
6579
  const nd = s.graph.nodes[n.id];
5436
- if (nd) { nd.data.status = 'running'; nd.data.error = null; nd.data.stopNote = null; }
6580
+ if (nd) { nd.data.status = 'running'; nd.data.error = null; nd.data.stopNote = null; nd.data.genWarn = null; }
5437
6581
  s.running = true; // 单节点触发(对话/节点按钮)也显示停止按钮
5438
6582
  });
5439
6583
  flushSave(); // 立刻落盘 + 推送 Host:切走画面/对话查询都能看到"运行中"
@@ -5455,7 +6599,7 @@ window.__ModuleLoader__.load({
5455
6599
  // 模型解析:节点选择优先,未指定路由到星标默认模型;都没有 = 明确报错(不再静默走演示模式)
5456
6600
  const mcfg = resolveImageModelCfg(settings, nodeImageModelRef(n));
5457
6601
  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 });
6602
+ const blob = await apiImage(p, metas, mcfg, { size: n.data.size, ratio: n.data.ratio, nodeExtra: n.data.imgExtra, warn: addWarn });
5459
6603
  const rec = await saveAssetRecord(S.activeId, 'image', blob, {
5460
6604
  name: assetNameFor(n, entities, 'image'),
5461
6605
  prompt: stripMentionMarks(prompt), effectivePrompt: p,
@@ -5468,17 +6612,115 @@ window.__ModuleLoader__.load({
5468
6612
  });
5469
6613
  commitGenResult(n.id, rec.id, false, 'image');
5470
6614
  } else {
5471
- const frameBlob = await resolveAssetBlob(n.id, 'frame');
5472
6615
  const { refs, entities, audioBlobs } = await collectRefs(n, prompt, bible);
5473
- const p = buildEffectivePrompt(prompt, refs, entities, bible);
6616
+ // 上游是分镜表 → 走厂商「参考生成」结构化提示词(主体/分镜/台词/声音显式绑定,音画同步;
6617
+ // 机械拼装会让模型把台词与场景判错);节点上已手写结构化提示词则原样尊重。
6618
+ const sbUp = (() => {
6619
+ const e2 = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'prompt');
6620
+ const src = e2 && S.graph.nodes[e2.fromNode];
6621
+ return src && src.type === 'storyboard' ? src : null;
6622
+ })();
6623
+ const shotList = sbUp && Array.isArray(sbUp.data.shots) ? sbUp.data.shots : [];
6624
+ const handWritten = String(n.data.prompt || '').trim();
6625
+ // 分镜衔接规划:段落类型(续帧/换场/新角色/特写插入)+ 首镜承接口 + 是否接力尾帧
6626
+ const zh = effLang() !== 'en';
6627
+ const plan = (() => {
6628
+ if (!shotList.length) return { first: null, chain: false, join: 'loose', seg: 'opening', fromShot: false };
6629
+ const first = shotContinuityPlan(shotList[0], null, 0);
6630
+ // 分镜衔接总开关(分镜表 cont):关掉 = 不做跨段承接、不自动取尾帧、不写衔接块
6631
+ const contOn = sbUp ? sbUp.data.cont !== false : true;
6632
+ // 跨分镜表承接:上游分镜表「衔接上一镜」输入口 ← 上一个分镜表「尾镜」输出口
6633
+ const prevSb = (() => {
6634
+ const e2 = S.graph.edges.find((x) => x.toNode === (sbUp && sbUp.id) && x.toPort === 'prev');
6635
+ const src = e2 && S.graph.nodes[e2.fromNode];
6636
+ return src && src.type === 'storyboard' ? src : null;
6637
+ })();
6638
+ const prevNote = prevSb ? storyboardContinuityNote(prevSb.data.shots || [], String(prevSb.data.label || ''), zh) : null;
6639
+ let seg = first.seg;
6640
+ // 第 1 镜没有表内上一镜时,可承接上一个分镜表 → 实质是续帧
6641
+ if (seg === 'opening' && prevNote) seg = 'continuation';
6642
+ const join = (seg === 'scene_change' || seg === 'new_character') ? 'loose' : seg === 'closeup' ? 'cut' : 'strict';
6643
+ // 只有续帧段才自动取上一段尾帧;其余段落没有可承接的画面
6644
+ const chain = contOn && seg === 'continuation';
6645
+ return { first, chain, seg, join, fromShot: contOn && !!prevNote, contOn };
6646
+ })();
6647
+ // 首帧来源:① frame 口连线(用户显式)② 续帧链自动取「上一个视频」的尾帧
6648
+ let frameBlob = await resolveAssetBlob(n.id, 'frame');
6649
+ let frameAuto = false;
6650
+ if (!frameBlob && plan.chain) {
6651
+ const prevVid = autoPrevVideoNode(n);
6652
+ if (prevVid && prevVid.data.assetId) {
6653
+ try {
6654
+ const vb = await assetBlob(prevVid.data.assetId);
6655
+ if (vb) {
6656
+ // 优先复用它已选好的最佳尾帧(避免重复解码整段视频);没有再现场选帧
6657
+ if (prevVid.data.lastPickAssetId && prevVid.data.lastAssetVideo === prevVid.data.assetId) {
6658
+ frameBlob = await assetBlob(prevVid.data.lastPickAssetId).catch(() => null);
6659
+ }
6660
+ if (!frameBlob) {
6661
+ const tail = await extractTailFrames(vb, { count: 9, span: 0.8 }).catch(() => null);
6662
+ frameBlob = (tail && (tail.best || tail.last)) || null;
6663
+ }
6664
+ if (frameBlob) {
6665
+ frameAuto = true;
6666
+ addWarn('已自动取「' + String(prevVid.data.label || '上一段视频') + '」的尾帧作为本段首帧(续帧接力)');
6667
+ }
6668
+ }
6669
+ } catch { /* 取不到尾帧则降级为纯参考图生成 */ }
6670
+ }
6671
+ }
6672
+ const frameMode = n.data.frameMode || 'auto';
6673
+ const useFrame = frameMode !== 'off' && !!frameBlob;
6674
+ // 硬首帧只在"不挂实体参考图"时可用(多数厂商 first_frame 与 reference_image 互斥);
6675
+ // 续帧段若还要实体参考图 → 尾帧降级为首张参考图 + 软锚定(frameAnchorText 会写明)
6676
+ const hardFrame = useFrame && frameMode === 'hard';
6677
+ const anchorRole = (useFrame && !hardFrame && refs.length) ? 'reference_image' : 'first_frame';
6678
+ let music = '';
6679
+ try { const ex2 = parseExtraParams(n.data.vidExtra); if (ex2 && typeof ex2.music === 'string') music = ex2.music; } catch { /* 扩展参数非法时忽略音乐提示 */ }
6680
+ // 跨段承接说明:本分镜第 1 镜承接上一个分镜表尾镜(续帧时首帧图已带画面,这里补文字约束)
6681
+ const continuity = (() => {
6682
+ if (!plan.fromShot) return '';
6683
+ const st = shotList[0] || {};
6684
+ const tail = String(st.tail || '').trim();
6685
+ const bits = [];
6686
+ if (plan.seg === 'continuation') {
6687
+ bits.push(zh ? '本段是上一段的直接续帧:第 1 帧从上一段结尾接着演,不重演、不重新建立场景、不切换机位' : 'This segment continues the previous one: start from its last frame, no replay, no re-establishing, no camera reset');
6688
+ if (tail) bits.push((zh ? '起始状态:' : 'Start state: ') + tail);
6689
+ } else if (plan.seg === 'scene_change') {
6690
+ bits.push(zh ? '本段换场:不承接上一段画面,但环境声/情绪可延续' : 'Scene change: do not continue the previous frame; ambience and mood may carry over');
6691
+ } else if (plan.seg === 'new_character') {
6692
+ bits.push(zh ? '本段有新角色入场:新角色必须从画面内自然进入(走入/被带入),不得凭空出现' : 'A new character enters: they must enter naturally from within the frame, never pop in');
6693
+ }
6694
+ return bits.join(zh ? ';' : '; ');
6695
+ })();
6696
+ let p;
6697
+ // 图1=尾帧(降级模式)时实体参考图为图2..N+1:两条提示词组装路径都要同步偏移
6698
+ const effOpts = { picOffset: (useFrame && anchorRole === 'reference_image') ? 1 : 0 };
6699
+ if (handWritten.includes('subject_definitions')) {
6700
+ p = handWritten; // 高级用法:手写结构化提示词,原样发送
6701
+ } else if (shotList.length) {
6702
+ p = buildStructuredVideoPrompt(shotList, refs, bible, {
6703
+ zh, prompt: handWritten, music, hasFrame: useFrame, frameRole: anchorRole, continuity,
6704
+ });
6705
+ if (!p) p = buildEffectivePrompt(prompt, refs, entities, bible, false, effOpts); // 无参考图时回退
6706
+ } else {
6707
+ p = buildEffectivePrompt(prompt, refs, entities, bible, false, effOpts);
6708
+ }
6709
+ // 非结构化路径(无参考图/手写提示词)也要有首帧锚定与承接说明
6710
+ if (!shotList.length || !p.includes('subject_definitions')) {
6711
+ const pre = [frameAnchorText({ hasFrame: useFrame, role: anchorRole, zh }), continuity].filter(Boolean).join(zh ? ';' : '; ');
6712
+ if (pre) p = pre + '\n' + p;
6713
+ }
5474
6714
  const mcfg = resolveVideoModelCfg(settings, nodeVideoModelRef(n));
5475
6715
  if (!mcfg) throw new Error(T('errNoVideoModel'));
5476
- const blob = await apiVideo(p, frameBlob, mcfg, {
6716
+ const blob = await apiVideo(p, useFrame ? frameBlob : null, mcfg, {
5477
6717
  duration: n.data.duration, resolution: n.data.resolution, ratio: n.data.ratio,
5478
- frameMode: n.data.frameMode || 'auto', // auto=尾帧降级为参考图 / hard=硬首帧(放弃参考图) / off=忽略首帧
6718
+ frameMode, // auto=尾帧降级为参考图(保留实体参考图)/ hard=硬首帧(放弃参考图)/ off=忽略首帧
6719
+ hardFrame,
5479
6720
  refs: refs.map((r) => r.blob),
5480
6721
  refNames: refs.map((r) => r.entity || r.name || ''), // 跨镜衔接标注用(图2..N 的实体名)
5481
6722
  nodeExtra: n.data.vidExtra, // 节点级扩展参数(手动参数框,如 {"use_context_ir":true})
6723
+ warn: addWarn, // 参考素材超限等告警回传(节点上可见)
5482
6724
  // 任务提交成功即持久化 task id:轮询中断/页面关闭后仍可凭 id 回查结果(钱花了就必须能找回)
5483
6725
  onTask: (tid, qurl) => {
5484
6726
  update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.taskId = String(tid || ''); nd.data.taskQueryUrl = String(qurl || ''); } });
@@ -5495,6 +6737,13 @@ window.__ModuleLoader__.load({
5495
6737
  nodeLabel: (n.data && n.data.label) || '',
5496
6738
  category: 'finalcut', // 节点生成的视频 = 成片(实体归属由 entities 字段承担,不占用分类)
5497
6739
  });
6740
+ // 记录续帧来源(节点上可追溯:本段是从哪一段接过来的)
6741
+ if (useFrame) {
6742
+ update((s) => {
6743
+ const nd = s.graph.nodes[n.id];
6744
+ if (nd) { nd.data.chainFrom = frameAuto ? (autoPrevVideoNode(n) || {}).id || null : 'wired'; }
6745
+ });
6746
+ }
5498
6747
  commitGenResult(n.id, rec.id, false, 'video');
5499
6748
  // 实测结果与设置不一致时留可见提示(厂商忽略 size/duration 是常见情况,必须让人看见而不是静默)
5500
6749
  try {
@@ -5507,22 +6756,55 @@ window.__ModuleLoader__.load({
5507
6756
  else if (wantRatio === '9:16' && gotLandscape) notes.push('厂商返回 ' + meta.w + '×' + meta.h + '(横屏),与设置的 9:16 不符');
5508
6757
  const wantDur = Number(n.data && n.data.duration) || 0;
5509
6758
  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(';'); });
6759
+ if (notes.length) addWarn(notes.join(';')); // 追加而非覆盖:本轮更早的告警(自动续帧/参考图超限/厂商降级)不能被抹掉
5511
6760
  }
5512
6761
  } catch { /* 探测失败不影响生成 */ }
5513
- // 尾帧提取(通用):供 last 端口输出与链式下一镜使用
6762
+ // 尾帧抽取(续帧接力的关键):末段多帧候选里选「动作进行中/清晰/不抖」的一帧,
6763
+ // 而不是机械取最后一帧(最后一帧常在动作顶点/模糊/抖动上)。
5514
6764
  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
- });
6765
+ const tail = await extractTailFrames(blob, { count: 9, span: 0.8 });
6766
+ if (tail && tail.last) {
6767
+ const fRec = await saveAssetRecord(S.activeId, 'image', tail.last, {
6768
+ name: T('lastFrame') + '-' + (n.data.label || n.id.slice(-4)),
6769
+ prompt: 'last frame of ' + prompt.slice(0, 60), source: 'api', category: 'image',
6770
+ });
6771
+ // 最佳选帧单独存一份(last 口保持"真·最后一帧"语义,接力用"最佳帧")
6772
+ let pickRec = null;
6773
+ if (tail.best && tail.best !== tail.last) {
6774
+ pickRec = await saveAssetRecord(S.activeId, 'image', tail.best, {
6775
+ name: T('tailPick') + '-' + (n.data.label || n.id.slice(-4)),
6776
+ prompt: 'continuity tail pick t=' + Number(tail.bestT || 0).toFixed(2) + 's; ' + prompt.slice(0, 40),
6777
+ source: 'api', category: 'image',
6778
+ });
6779
+ }
6780
+ update((s) => {
6781
+ const nd = s.graph.nodes[n.id];
6782
+ if (nd) {
6783
+ nd.data.lastAssetId = fRec.id; nd.data.lastAssetVideo = rec.id;
6784
+ nd.data.lastPickAssetId = pickRec ? pickRec.id : fRec.id;
6785
+ nd.data.tailPickMeta = { t: Number(tail.bestT || 0), score: Math.round((tail.bestScore || 0) * 100) / 100 };
6786
+ }
6787
+ s.assetsTick++;
6788
+ });
6789
+ }
5525
6790
  } catch { /* 尾帧提取失败不影响主流程 */ }
6791
+ // 接缝校验:本段首帧 vs 上一段尾帧(续帧段才有意义)。不达标 → 节点上可见告警(不静默)
6792
+ if (useFrame && plan.chain) {
6793
+ try {
6794
+ const first = await extractFirstFrame(blob);
6795
+ if (first) {
6796
+ const ga = await grayThumb(frameBlob, 32);
6797
+ const gb = await grayThumb(first, 32);
6798
+ const met = frameSeamMetrics(ga, gb);
6799
+ const v = seamVerdict(met, plan.join);
6800
+ update((s) => {
6801
+ const nd = s.graph.nodes[n.id];
6802
+ 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() };
6803
+ });
6804
+ if (!v.ok) addWarn('接缝校验未通过:' + v.note + '(建议:调低运动幅度重生成,或改用"硬首帧"模式)');
6805
+ }
6806
+ } catch { /* 校验失败不影响产物 */ }
6807
+ }
5526
6808
  }
5527
6809
  return true;
5528
6810
  } catch (err) {
@@ -5618,7 +6900,9 @@ window.__ModuleLoader__.load({
5618
6900
  // ============================================================
5619
6901
  // 画布交互(平移 / 缩放 / 拖动 / 连线 / 框选)
5620
6902
  // ============================================================
5621
- const drag = { mode: null, sx: 0, sy: 0, moved: false, nodeStarts: null, marquee: null, linkFrom: null, shift: false };
6903
+ // altNoPush:本次拖动是否处于「按住 Alt 自由摆放」模式(不推挤邻居、也不吸附加固)。
6904
+ // 注意每次 pointermove 都重新读 e.altKey,所以拖动中途按下/松开 Alt 会实时切换。
6905
+ const drag = { mode: null, sx: 0, sy: 0, moved: false, nodeStarts: null, marquee: null, linkFrom: null, shift: false, altNoPush: false };
5622
6906
 
5623
6907
  function onCanvasPointerDown(e) {
5624
6908
  const target = e.target;
@@ -5673,6 +6957,7 @@ window.__ModuleLoader__.load({
5673
6957
  drag.mode = 'node';
5674
6958
  drag.sx = e.clientX; drag.sy = e.clientY;
5675
6959
  drag.moved = false;
6960
+ drag.altNoPush = !!e.altKey; // 起手就按住 Alt → 整段拖动自由摆放
5676
6961
  const ids = S.sel.has(n.id) ? [...S.sel] : [n.id];
5677
6962
  // 分组拖动时携带其成员
5678
6963
  const allIds = [];
@@ -5726,10 +7011,15 @@ window.__ModuleLoader__.load({
5726
7011
  } else if (drag.mode === 'node') {
5727
7012
  if (Math.abs(dxs) + Math.abs(dys) > 2) drag.moved = true;
5728
7013
  let dxw = dxs / V.scale, dyw = dys / V.scale;
7014
+ // 按住 Alt = 自由摆放:跳过对齐吸附 + 跳过碰撞推挤(允许穿过/覆盖其他节点)。
7015
+ // 典型场景:矩阵布局里挪中间的节点,普通拖动会把整片邻居推开打乱布局,Alt 拖动只挪它自己。
7016
+ // 实时读 e.altKey(而不是只看起手那一刻):拖动途中按下/松开立即生效。
7017
+ const noPush = !!e.altKey;
7018
+ drag.altNoPush = noPush;
5729
7019
  // 对齐吸附:以首个拖动节点为基准,与其他节点左/中/右、上/中/下对齐(容差 6px)
5730
7020
  const prim = drag.nodeStarts[0];
5731
7021
  const ndP = prim && S.graph.nodes[prim.id];
5732
- if (ndP && !e.shiftKey && drag.nodeStarts.length) {
7022
+ if (ndP && !e.shiftKey && !noPush && drag.nodeStarts.length) {
5733
7023
  const snap = 6 / V.scale;
5734
7024
  const ph = ndP.h || 96;
5735
7025
  const px = prim.x + dxw, py = prim.y + dyw;
@@ -5751,6 +7041,15 @@ window.__ModuleLoader__.load({
5751
7041
  if (bestV) dxw += bestV.delta;
5752
7042
  if (bestH) dyw += bestH.delta;
5753
7043
  showGuides(bestV ? bestV.line : null, bestH ? bestH.line : null);
7044
+ } else if (noPush) {
7045
+ hideGuides(); // Alt 自由摆放:吸附线一并隐藏,避免"看着被吸住其实没吸"
7046
+ }
7047
+ // Alt 自由摆放的可见反馈:被拖节点加虚线轮廓,用户能立刻分清"现在没在推挤"
7048
+ for (const st of drag.nodeStarts) {
7049
+ const el = nodeEls.get(st.id);
7050
+ if (!el) continue;
7051
+ if (noPush) el.classList.add('is-free-drag');
7052
+ else el.classList.remove('is-free-drag');
5754
7053
  }
5755
7054
  const movedIds = new Set();
5756
7055
  for (const st of drag.nodeStarts) {
@@ -5765,7 +7064,9 @@ window.__ModuleLoader__.load({
5765
7064
  }
5766
7065
  // —— 实时碰撞推挤(物理感):拖动节点碰到谁,谁沿远离方向实时让位;被推者再推别人(级联)。
5767
7066
  // 上下左右同理:下节点上移碰到上节点 → 上节点实时上移;全程无弹跳,松手即定格。
5768
- resolvePushNow(movedIds);
7067
+ // 按住 Alt 时**整段禁用**(不推挤 = 允许覆盖穿过),松手后位置照旧保留,不会弹回。
7068
+ if (!noPush) resolvePushNow(movedIds);
7069
+
5769
7070
  if (movedIds.size) {
5770
7071
  for (const e2 of S.graph.edges) {
5771
7072
  if (!movedIds.has(e2.fromNode) && !movedIds.has(e2.toNode)) continue;
@@ -5794,6 +7095,14 @@ window.__ModuleLoader__.load({
5794
7095
  function onCanvasPointerUp(e) {
5795
7096
  if (!drag.mode) return;
5796
7097
  const mode = drag.mode;
7098
+ // 收尾:清掉 Alt 自由摆放的视觉标记(无论是否发生过移动,避免虚线轮廓残留在节点上)
7099
+ if (mode === 'node') {
7100
+ for (const st of (drag.nodeStarts || [])) {
7101
+ const el = nodeEls.get(st.id);
7102
+ if (el) el.classList.remove('is-free-drag');
7103
+ }
7104
+ drag.altNoPush = false;
7105
+ }
5797
7106
  if (mode === 'node' && drag.moved) {
5798
7107
  // 拖动期间直接写了模型坐标,这里只需通知 + 保存
5799
7108
  notify();
@@ -6294,7 +7603,7 @@ window.__ModuleLoader__.load({
6294
7603
  function portLabel(pid) {
6295
7604
  const map = {
6296
7605
  prompt: T('portPrompt'), ref: T('portRef'), frame: T('portFrame'),
6297
- in: T('portIn'), out: T('portOut'), last: T('lastFrame'),
7606
+ in: T('portIn'), out: T('portOut'), last: T('lastFrame'), tailpick: T('tailPick'),
6298
7607
  char: T('portChar'), scene: T('portScene'), prop: T('portProp'),
6299
7608
  visual: T('portVisual'), motion: T('portMotion'), script: T('portScript'),
6300
7609
  prev: T('portPrev'), tail: T('portTail'),
@@ -6744,7 +8053,9 @@ window.__ModuleLoader__.load({
6744
8053
  ? h('div', { key: 'ref', className: 'dshic-hint' },
6745
8054
  T('portFrame') + ' × ' + refEdges.length + ' · ' + T('connected'))
6746
8055
  : null,
6747
- kind === 'video' && S.graph.edges.some((x) => x.toNode === n.id && x.toPort === 'frame')
8056
+ // 「衔接」下拉:视频节点**始终**显示——自动续帧(无 frame 线)也需要它来关闭接力或改硬首帧。
8057
+ // 若有 frame 连线则额外说明连线数量。
8058
+ kind === 'video'
6748
8059
  ? h('div', { key: 'fmode', className: 'dshic-node-row' },
6749
8060
  h('span', { className: 'dshic-shot-label', style: { flex: 'none' } }, T('frameModeLabel')),
6750
8061
  h('select', {
@@ -6764,6 +8075,23 @@ window.__ModuleLoader__.load({
6764
8075
  n.data.genWarn ? h('div', { key: 'gw', className: 'dshic-hint', style: { color: 'var(--dsw-alias-state-warning-primary, #d08700)' } }, '⚠ ' + n.data.genWarn) : null,
6765
8076
  n.data.stopNote ? h('div', { key: 'sn', className: 'dshic-hint' }, '⏹ ' + n.data.stopNote) : null,
6766
8077
  n.data.error ? h('div', { key: 'err', className: 'dshic-err' }, n.data.error) : null,
8078
+ // 续帧状态:本段首帧来自哪里(上一段尾帧 / 连线)+ 接缝校验结果(不达标必须让人看见)
8079
+ kind === 'video' && (n.data.chainFrom || n.data.seam)
8080
+ ? h('div', { key: 'chain', className: 'dshic-hint', style: { display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' } },
8081
+ n.data.chainFrom
8082
+ ? h('span', { title: T('chainInfo') }, '🔗 ' + (n.data.chainFrom === 'wired' ? T('chainManual') : T('chainAuto')))
8083
+ : null,
8084
+ n.data.seam
8085
+ ? h('span', {
8086
+ title: T('seamHint'),
8087
+ style: { color: n.data.seam.ok ? 'var(--dsw-alias-state-success-primary, #2f9e44)' : 'var(--dsw-alias-state-warning-primary, #d08700)' },
8088
+ }, (n.data.seam.ok ? '✓ ' : '⚠ ') + (n.data.seam.ok ? T('seamOk') : T('seamFail')) + (n.data.seam.sim != null ? ' ' + n.data.seam.sim : ''))
8089
+ : null,
8090
+ n.data.tailPickMeta
8091
+ ? h('span', { title: T('tailPickHint') }, '✂ ' + T('tailPick') + ' ' + Number(n.data.tailPickMeta.t || 0).toFixed(1) + 's')
8092
+ : null,
8093
+ )
8094
+ : null,
6767
8095
  assetUrlV && kind === 'image'
6768
8096
  ? 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
8097
  : null,
@@ -6775,7 +8103,7 @@ window.__ModuleLoader__.load({
6775
8103
  h('button', {
6776
8104
  type: 'button', className: 'dshic-mini is-ghost',
6777
8105
  onPointerDown: (e) => e.stopPropagation(),
6778
- onClick: () => downloadBlobFromUrl(assetUrlV, (kind === 'image' ? 'image' : 'video') + '-' + n.id.slice(-5) + (kind === 'image' ? '.png' : '.webm')),
8106
+ onClick: async () => downloadBlobFromUrl(assetUrlV, await downloadNameForAsset(n.data.assetId, (kind === 'image' ? 'image' : 'video') + '-' + n.id.slice(-5), kind)),
6779
8107
  }, Ic('download', 11), T('download')),
6780
8108
  h('button', {
6781
8109
  type: 'button', className: 'dshic-mini is-ghost', disabled: S.running || n.data.status === 'running',
@@ -7251,23 +8579,6 @@ window.__ModuleLoader__.load({
7251
8579
  ? h('div', { key: 'stream', className: 'dshic-stream', title: aiStreamLine(n.id) },
7252
8580
  h('span', { className: 'dshic-stream-dot' }), aiStreamLine(n.id))
7253
8581
  : null,
7254
- h('div', { key: 'btns', className: 'dshic-node-row' },
7255
- h('div', { className: 'dshic-flex' }),
7256
- n.data.output ? h('button', {
7257
- type: 'button', className: 'dshic-mini is-ghost', title: T('expandScenes') + '(每场 → 文本+分镜表 组合)',
7258
- onPointerDown: (e) => e.stopPropagation(),
7259
- onClick: () => expandScriptToScenes(n),
7260
- }, Ic('expandAll', 11), T('expandScenes')) : null,
7261
- n.data.output ? h('button', {
7262
- type: 'button', className: 'dshic-mini is-ghost', title: T('saveTextAsset'),
7263
- onPointerDown: (e) => e.stopPropagation(),
7264
- onClick: async () => {
7265
- await saveTextAssetRecord(S.activeId, { name: (String(n.data.label || '').trim() || T('scriptAssetPrefix')) + ' ' + nowStamp(), text: n.data.output, category: 'script', prompt: n.data.idea });
7266
- update((s) => { s.assetsTick++; });
7267
- toast(T('savedOk'));
7268
- },
7269
- }, Ic('download', 11), T('saveTextAsset')) : null,
7270
- ),
7271
8582
  n.data.aiError ? h('div', { key: 'err', className: 'dshic-err' }, n.data.aiError) : null,
7272
8583
  h('span', { key: 'l2', className: 'dshic-shot-label' }, T('scriptOutputLabel')),
7273
8584
  h(PromptArea, {
@@ -7276,6 +8587,26 @@ window.__ModuleLoader__.load({
7276
8587
  placeholder: running ? T('aiRunning') : '',
7277
8588
  onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.output = v; }),
7278
8589
  }),
8590
+ // 结果相关操作放节点末尾左下角(与分镜节点同一规范:内容之后 + alignSelf:flex-start)——
8591
+ // 这些按钮作用于「已经生成出来的剧本」,不该夹在生成按钮与输出正文之间。
8592
+ n.data.output
8593
+ ? h('div', { key: 'acts', className: 'dshic-node-row', style: { alignSelf: 'flex-start' } },
8594
+ h('button', {
8595
+ type: 'button', className: 'dshic-mini is-ghost', title: T('saveTextAsset'),
8596
+ onPointerDown: (e) => e.stopPropagation(),
8597
+ onClick: async () => {
8598
+ await saveTextAssetRecord(S.activeId, { name: (String(n.data.label || '').trim() || T('scriptAssetPrefix')) + ' ' + nowStamp(), text: n.data.output, category: 'script', prompt: n.data.idea });
8599
+ update((s) => { s.assetsTick++; });
8600
+ toast(T('savedOk'));
8601
+ },
8602
+ }, Ic('download', 11), T('saveTextAsset')),
8603
+ h('button', {
8604
+ type: 'button', className: 'dshic-mini', title: T('expandScenes') + '(每场 → 文本+分镜表 组合)',
8605
+ onPointerDown: (e) => e.stopPropagation(),
8606
+ onClick: () => expandScriptToScenes(n),
8607
+ }, Ic('expandAll', 11), T('expandScenes')),
8608
+ )
8609
+ : null,
7279
8610
  ];
7280
8611
  }
7281
8612
 
@@ -7335,6 +8666,18 @@ window.__ModuleLoader__.load({
7335
8666
  try { await genStoryboardNode(n); } catch (e) { toast(String((e && e.message) || e)); }
7336
8667
  };
7337
8668
  const shots = n.data.shots || [];
8669
+ // 一致性告警的手动重校验:告警是**生成时刻**算的,之后补全设定集不会让它们自动消失——
8670
+ // 点刷新按当前设定集重算(checkShotConsistency 读的是实时 bible/资产名索引)
8671
+ const [wvChecking, setWvChecking] = useState(false);
8672
+ const revalidate = async () => {
8673
+ if (wvChecking || !shots.length) return;
8674
+ setWvChecking(true);
8675
+ try {
8676
+ const warnings = await checkShotConsistency(shots);
8677
+ update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.warnings = warnings; });
8678
+ } catch { /* 校验失败保留旧告警 */ }
8679
+ setWvChecking(false);
8680
+ };
7338
8681
  return [
7339
8682
  h('div', { key: 'mrow', className: 'dshic-node-row' },
7340
8683
  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 +8702,14 @@ window.__ModuleLoader__.load({
7359
8702
  options: [{ value: '0', label: T('autoShots') }].concat([3, 4, 6, 8, 10, 12, 16].map((x) => ({ value: String(x), label: x + ' ' + T('shotsUnit') }))),
7360
8703
  onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.count = Number(v); }),
7361
8704
  }),
8705
+ // 分镜衔接:写作时强制每镜带「承接上镜尾态 / 接点尾态 / 运镜延续」,并可标记段落类型
8706
+ h('button', {
8707
+ key: 'cont', type: 'button',
8708
+ className: 'dshic-mini' + (n.data.cont !== false ? '' : ' is-ghost'),
8709
+ title: T('contToggleHint'),
8710
+ onPointerDown: (e) => e.stopPropagation(),
8711
+ onClick: () => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.cont = nd.data.cont === false; }),
8712
+ }, (n.data.cont !== false ? '✓ ' : '') + T('contToggle')),
7362
8713
  h('div', { className: 'dshic-flex' }),
7363
8714
  ),
7364
8715
  h('button', { key: 'gen', type: 'button', className: 'dshic-gen', disabled: running, onClick: generate, onPointerDown: (e) => e.stopPropagation() },
@@ -7373,22 +8724,43 @@ window.__ModuleLoader__.load({
7373
8724
  n.data.aiError ? h('div', { key: 'err', className: 'dshic-err' }, n.data.aiError) : null,
7374
8725
  (n.data.warnings || []).length
7375
8726
  ? h('div', { key: 'warn', className: 'dshic-card', style: { padding: 6, gap: 3 } },
7376
- h('div', { className: 'dshic-shot-label' }, T('consistencyTitle')),
8727
+ h('div', { key: 'thead', className: 'dshic-node-row', style: { gap: 6 } },
8728
+ h('div', { className: 'dshic-shot-label', style: { flex: 1, minWidth: 0 } }, T('consistencyTitle')),
8729
+ // 手动重校验:生成时刻的告警在设定集补全后不会自动变绿——点一下按**当前**设定集重算
8730
+ h('button', {
8731
+ type: 'button', className: 'dshic-mini is-ghost', title: T('refreshCheckHint'),
8732
+ disabled: wvChecking,
8733
+ onPointerDown: (e) => e.stopPropagation(),
8734
+ onClick: revalidate,
8735
+ }, wvChecking ? h('span', { className: 'dshic-spin' }) : '↻', T('refreshCheck')),
8736
+ ),
7377
8737
  n.data.warnings.map((w, i) => h('div', { key: i, className: 'dshic-err', style: { color: 'var(--dsw-alias-state-warn-primary)' } }, '· ' + w)),
7378
8738
  )
7379
8739
  : null,
7380
8740
  shots.length
7381
8741
  ? h('div', { key: 'listwrap', style: { position: 'relative', alignSelf: 'stretch' } },
7382
8742
  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() },
8743
+ shots.map((sh, i) => {
8744
+ const pl = shotContinuityPlan(sh, shots[i - 1], i);
8745
+ return h('div', { key: i, className: 'dshic-shot-item', onPointerDown: (e) => e.stopPropagation() },
7384
8746
  h('strong', {}, `${sh.title} · ${sh.duration}s `), ...mentionNodes(sh.visual),
8747
+ pl.seg !== 'continuation'
8748
+ ? h('div', { className: 'dshic-shot-label', style: { marginTop: 2, opacity: 0.9 } }, '〔' + T('seg' + pl.seg.charAt(0).toUpperCase() + pl.seg.slice(1)) + '〕')
8749
+ : null,
8750
+ String(sh.tail || '').trim()
8751
+ ? h('div', { className: 'dshic-shot-label', style: { marginTop: 2, opacity: 0.9 } }, '↳ ' + T('shotTail') + ':' + String(sh.tail).trim())
8752
+ : null,
8753
+ String(sh.cam || '').trim()
8754
+ ? h('div', { className: 'dshic-shot-label', style: { marginTop: 2, opacity: 0.85 } }, '🎥 ' + String(sh.cam).trim())
8755
+ : null,
7385
8756
  (sh.dialogue || []).length
7386
8757
  ? h('div', { className: 'dshic-shot-label', style: { marginTop: 3 } }, '台词 ' + sh.dialogue.map((d) => `${d.role}:${d.line}`).join(';'))
7387
8758
  : null,
7388
8759
  String(sh.sfx || '').trim()
7389
8760
  ? h('div', { className: 'dshic-shot-label', style: { marginTop: 2, opacity: 0.85 } }, '音效 ' + String(sh.sfx).trim())
7390
8761
  : null,
7391
- )),
8762
+ );
8763
+ }),
7392
8764
  ),
7393
8765
  h('div', { key: 'grip', className: 'dshic-shot-grip', title: T('resizeList'), onPointerDown: startResize }, '⌟'),
7394
8766
  )
@@ -7436,26 +8808,46 @@ window.__ModuleLoader__.load({
7436
8808
  // 项目资产名称索引(用于把角色/场景/道具连接到已有资产节点)
7437
8809
  const recs = await idbByProject(S.activeId).catch(() => []);
7438
8810
  const norm = (s) => String(s || '').trim().toLowerCase();
7439
- const nameIndex = recs.filter((r) => r.kind === 'image').map((r) => ({ id: r.id, name: norm(r.name), category: r.category }));
7440
- // 跨镜去重的实体清单:所有镜头共用的角色/场景/道具各一个参考图节点
7441
- const entityNames = [];
7442
- const pushName = (nm) => {
8811
+ const nameIndex = recs.filter((r) => r.kind === 'image').map((r) => ({ id: r.id, name: norm(r.name), raw: String(r.name || ''), category: r.category }));
8812
+ // 跨镜去重的实体清单:所有镜头共用的角色/场景/道具各一个参考节点(带品类——决定用哪种设定图版式)
8813
+ const entities = [];
8814
+ const pushEntity = (nm, kind) => {
7443
8815
  const q = String(nm || '').trim();
7444
- if (q && !entityNames.some((x) => x.toLowerCase() === q.toLowerCase())) entityNames.push(q);
8816
+ if (!q) return;
8817
+ if (entities.some((x) => x.name.toLowerCase() === q.toLowerCase())) return;
8818
+ entities.push({ name: q, kind: kind || 'character' });
7445
8819
  };
7446
8820
  for (const sh of shots) {
7447
- (sh.characters || []).forEach(pushName);
7448
- if (sh.scene) pushName(sh.scene);
7449
- (sh.props || []).forEach(pushName);
8821
+ (sh.characters || []).forEach((nm) => pushEntity(nm, 'character'));
8822
+ pushEntity(sh.scene, 'scene');
8823
+ (sh.props || []).forEach((nm) => pushEntity(nm, 'prop'));
7450
8824
  }
8825
+ const zh = effLang() !== 'en';
8826
+ const bible = loadBible(S.activeId);
8827
+ let reuseAssets = 0; // 资产库已有 → 只挂节点
8828
+ let newGens = 0; // 库中没有 → 新建带提示词的图片生成节点
7451
8829
  update((s) => {
7452
- const findAssetNode = (name) => {
8830
+ // 资产库命中(名称相等优先,其次包含匹配)——有图就直接挂资产节点,绝不重复生成
8831
+ const findLibraryAsset = (name) => {
7453
8832
  const q = norm(name);
7454
8833
  if (!q) return null;
7455
- const hit = nameIndex.find((x) => x.name === q) || nameIndex.find((x) => x.name.includes(q) || q.includes(x.name));
7456
- if (!hit) return null;
8834
+ return nameIndex.find((x) => x.name === q)
8835
+ || nameIndex.find((x) => x.name.includes(q) || q.includes(x.name))
8836
+ || null;
8837
+ };
8838
+ const findAssetNodeById = (assetId) => {
8839
+ if (!assetId) return null;
7457
8840
  for (const nd of Object.values(s.graph.nodes)) {
7458
- if (nd.type === 'image-asset' && nd.data.assetId === hit.id) return nd;
8841
+ if (nd.type === 'image-asset' && nd.data.assetId === assetId) return nd;
8842
+ }
8843
+ return null;
8844
+ };
8845
+ // 同标签的图片生成节点(上次铺开已建过)→ 复用,避免每点一次铺开又叠一层
8846
+ const findGenNodeByLabel = (label) => {
8847
+ const q = norm(label);
8848
+ if (!q) return null;
8849
+ for (const nd of Object.values(s.graph.nodes)) {
8850
+ if (nd.type === 'image-gen' && norm(nd.data && nd.data.label) === q) return nd;
7459
8851
  }
7460
8852
  return null;
7461
8853
  };
@@ -7479,18 +8871,46 @@ window.__ModuleLoader__.load({
7479
8871
  const total = shots.reduce((a, sh) => a + Math.max(1, Number(sh.duration) || 3), 0);
7480
8872
  const videoGen = mk('video-gen', baseX + 360, baseY + 20, { prompt: '', duration: Math.max(1, Math.round(total)) });
7481
8873
  const videoAsset = mk('video-asset', baseX + 720, baseY + 20);
7482
- entityNames.forEach((nm, i) => {
7483
- let an = findAssetNode(nm);
7484
- if (!an) an = mk('image-asset', baseX, baseY + i * 130, { placeholderName: nm });
7485
- linkRaw(an, 'out', videoGen, 'ref');
8874
+ entities.forEach((ent, i) => {
8875
+ const y = baseY + i * 150;
8876
+ const hit = findLibraryAsset(ent.name);
8877
+ if (hit) {
8878
+ let an = findAssetNodeById(hit.id);
8879
+ if (!an) an = mk('image-asset', baseX, y, { assetId: hit.id }, hit.raw || hit.name);
8880
+ linkRaw(an, 'out', videoGen, 'ref');
8881
+ reuseAssets++;
8882
+ return;
8883
+ }
8884
+ // 资产库没有该实体的图:建「图片生成」节点,并按 canvas-craft 资产设定图版式写好提示词
8885
+ //(角色六段式 / 场景主图+环绕小图 / 道具标题+45°主图+细节+侧视+色卡)——生成后自动成为本段参考图
8886
+ const spec = assetSheetNodeSpec(ent.kind, ent.name, bible, zh);
8887
+ let gn = findGenNodeByLabel(spec.label);
8888
+ if (!gn) {
8889
+ gn = mk('image-gen', baseX, y, { prompt: spec.prompt, size: spec.size, ratio: spec.ratio }, spec.label);
8890
+ newGens++;
8891
+ }
8892
+ linkRaw(gn, 'out', videoGen, 'ref');
7486
8893
  });
7487
8894
  linkRaw(n, 'out', videoGen, 'prompt'); // 多镜头提示词来自上游分镜表(不复制文本)
7488
8895
  linkRaw(videoGen, 'out', videoAsset, 'in');
8896
+ // 把「上一段视频」的尾帧接到本段的 frame:铺开时就建好,用户按顺序生成即可自动接力。
8897
+ // 找上一段的规则与 autoPrevVideoNode 一致(同分镜表兄弟段 / 沿 prev 链上溯的上游分镜表所连视频)。
8898
+ (() => {
8899
+ const prevVid = autoPrevVideoNode(videoGen);
8900
+ if (!prevVid || !prevVid.id) return;
8901
+ const fromPort = prevVid.data && prevVid.data.lastPickAssetId ? 'tailpick' : 'last';
8902
+ linkRaw(prevVid, fromPort, videoGen, 'frame');
8903
+ })();
7489
8904
  s.sel = new Set([videoGen.id]);
7490
8905
  });
7491
8906
  pushHistory();
7492
8907
  flushSave(); // 批量铺开立即推送,Host 侧立即可见
7493
8908
  toast(T('expandDone').replace('{n}', String(shots.length)));
8909
+ // 铺开结果如实告知:哪些实体资产库已有、哪些新建了待生成的资产节点(提示词已写好)
8910
+ if (newGens) {
8911
+ setTimeout(() => toast(T('expandGenCreated').replace('{n}', String(newGens))
8912
+ + (reuseAssets ? ' · ' + T('expandAssetReused').replace('{n}', String(reuseAssets)) : '')), 400);
8913
+ }
7494
8914
  setTimeout(() => fitView(), 60);
7495
8915
  }
7496
8916
 
@@ -7542,6 +8962,9 @@ window.__ModuleLoader__.load({
7542
8962
  const st = await craftStatusSafe();
7543
8963
  if (!st) return;
7544
8964
  if (!(st.bins && st.bins.filmcraft && st.bins.filmcraft.found)) { toast(T('craftCliMissing').replace('{name}', 'filmcraft')); return; }
8965
+ // BLK-02:先取项目绝对目录(ArtCraft 无数据根,必须由画布给 base)
8966
+ let base;
8967
+ try { base = await craftBaseDirRequired(S.activeId); } catch (e) { toast('Craft: ' + ((e && e.message) || e)); return; }
7545
8968
  const items = [];
7546
8969
  for (const x of segs) {
7547
8970
  if (!x.nd.data.assetId) continue;
@@ -7552,31 +8975,34 @@ window.__ModuleLoader__.load({
7552
8975
  const rel = await ensureAssetOnDisk({ ...(rec || {}), id: x.nd.data.assetId, blob });
7553
8976
  const vd = await videoDurationOf(blob);
7554
8977
  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 });
8978
+ // items.rel 用「项目目录下的相对路径」,FCP7 XML 里再拼成绝对路径
8979
+ 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
8980
  } catch (e) { toast('Craft: ' + (e.message || e)); return; }
7557
8981
  }
7558
8982
  if (!items.length) { toast(T('noSegments')); return; }
7559
- const slug = craftProjectSlug();
7560
8983
  const dims = videoDimsFor({ resolution: n.data.resolution, ratio: n.data.ratio });
7561
8984
  const xml = buildFcp7Xml(items, {
7562
8985
  name: (S.projects.find((p) => p.id === S.activeId) || {}).name || 'project',
7563
- dataRoot: (st.dataRoot && st.dataRoot.path) || '',
8986
+ dataRoot: base, // BLK-02:媒体绝对路径 = 项目目录 + items.rel(原读 ArtCraft 的 dataRoot 字段恒为空)
7564
8987
  width: dims.w, height: dims.h,
7565
8988
  });
8989
+ const outRel = 'craft/out/final.mp4'; // 相对 base
7566
8990
  try {
7567
- await craft.writeText('projects/' + slug + '/craft/timeline.xml', xml);
7568
- await craft.writeText('projects/' + slug + '/craft/subs.srt', buildSrt(items));
8991
+ await craft.writeText(base, 'craft/timeline.xml', xml);
8992
+ await craft.writeText(base, 'craft/subs.srt', buildSrt(items));
7569
8993
  } catch (e) { return; /* craftFetch 已提示 */ }
7570
8994
  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',
8995
+ // args 里一律绝对路径(job 的 cwd 语义不依赖)
8996
+ args: JSON.stringify(['--project', joinLocal(base, 'craft/drama.fcproj'), '--import', joinLocal(base, 'craft/timeline.xml'), '--export', joinLocal(base, outRel)], null, 1),
8997
+ outRel,
8998
+ base,
7573
8999
  });
7574
9000
  };
7575
- const submitFc = (args, outRel) => {
7576
- craft.jobStart({ bin: 'filmcraft', args, outputs: [outRel], timeoutMs: 30 * 60 * 1000 }).then((job) => {
9001
+ const submitFc = (args, outRel, base) => {
9002
+ craft.jobStart({ bin: 'filmcraft', cwd: base, args, outputs: [outRel], timeoutMs: 30 * 60 * 1000 }).then((job) => {
7577
9003
  update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.fcJobId = job.id; nd.data.fcStatus = 'running'; } });
7578
9004
  setFcDialog(null);
7579
- pollFilmcraftJob(n.id, job.id, outRel);
9005
+ pollFilmcraftJob(n.id, job.id, outRel, base);
7580
9006
  }).catch(() => { /* craftFetch 已提示 */ });
7581
9007
  };
7582
9008
  return [
@@ -7686,7 +9112,7 @@ window.__ModuleLoader__.load({
7686
9112
  h('button', {
7687
9113
  type: 'button', className: 'dshic-mini is-ghost',
7688
9114
  onPointerDown: (e) => e.stopPropagation(),
7689
- onClick: () => downloadBlobFromUrl(fcUrl, 'finalcut-fc-' + n.id.slice(-5) + '.mp4'),
9115
+ onClick: async () => downloadBlobFromUrl(fcUrl, await downloadNameForAsset(n.data.fcAssetId, 'finalcut-fc-' + n.id.slice(-5), 'video')),
7690
9116
  }, Ic('download', 11), T('download')),
7691
9117
  h('button', {
7692
9118
  type: 'button', className: 'dshic-mini is-ghost', title: T('voiceExtractBtn'),
@@ -7705,7 +9131,7 @@ window.__ModuleLoader__.load({
7705
9131
  h('button', {
7706
9132
  type: 'button', className: 'dshic-mini is-ghost',
7707
9133
  onPointerDown: (e) => e.stopPropagation(),
7708
- onClick: () => downloadBlobFromUrl(assetUrlV, 'finalcut-' + n.id.slice(-5) + '.webm'),
9134
+ onClick: async () => downloadBlobFromUrl(assetUrlV, await downloadNameForAsset(n.data.assetId, 'finalcut-' + n.id.slice(-5), 'video')),
7709
9135
  }, Ic('download', 11), T('download')),
7710
9136
  h('div', { className: 'dshic-flex' }),
7711
9137
  ),
@@ -7843,6 +9269,8 @@ window.__ModuleLoader__.load({
7843
9269
  h(Btn, { onClick: () => zoomStep(1.18), title: T('zoomIn') }, Ic('zoomIn', 12)),
7844
9270
  ),
7845
9271
  h('div', { className: 'dshic-toolbar-sep' }),
9272
+ h(ShortcutsPanel), // 快捷键入口(工具栏右侧,缩放控件与项目名之间)
9273
+ h('div', { className: 'dshic-toolbar-sep' }),
7846
9274
  naming
7847
9275
  ? h('input', {
7848
9276
  className: 'dshic-input', style: { width: 160 }, autoFocus: true, defaultValue: proj ? proj.name : '',
@@ -7921,9 +9349,8 @@ window.__ModuleLoader__.load({
7921
9349
  try { localStorage.removeItem(LS_BIBLE + pid); } catch { /* 忽略 */ }
7922
9350
  if (s.activeId === pid) {
7923
9351
  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;
9352
+ // OBS-UI-1:删光后进入空态,不再自动建「我的画布」占位(同 initStore 口径)
9353
+ s.activeId = null;
7927
9354
  s.graph = emptyGraph();
7928
9355
  } else {
7929
9356
  s.activeId = s.projects[0].id;
@@ -7938,7 +9365,17 @@ window.__ModuleLoader__.load({
7938
9365
  });
7939
9366
  lsSet(LS_PROJECTS, S.projects);
7940
9367
  lsSet(LS_ACTIVE, S.activeId);
7941
- try { hostRpc('projectsDelete', { id: pid }).catch(() => { /* 静默 */ }); } catch { /* 忽略 */ }
9368
+ // IC-B2:先清 ArtCraft 后期目录**再**删项目——projectCraftRemove 靠 meta 条目解析物理文件夹,
9369
+ // 若先删项目(meta 条目随之被清),清理会解析不到目录而"伪成功",craft 残留无人知晓。
9370
+ // 失败只 toast 不阻断删除(目录残留可手动清)。
9371
+ await craftProjectCleanup(pid);
9372
+ try {
9373
+ const del = await hostRpc('projectsDelete', { id: pid });
9374
+ // BLK-01 的清理结果必须可见:rmrf 部分失败(Windows 文件锁)时残留在盘上,
9375
+ // 同名重建的项目会继承旧 assets.json 条目——用户至少要知道发生过什么。
9376
+ const diskErrs = del && del.disk && Array.isArray(del.disk.errors) ? del.disk.errors : [];
9377
+ if (diskErrs.length) toast('项目已删除,但磁盘清理有 ' + diskErrs.length + ' 项失败:' + diskErrs.join(';'));
9378
+ } catch (e) { toast('删除请求失败(本地已移除,刷新后可能复现):' + String((e && e.message) || e)); }
7942
9379
  if (!S.store) {
7943
9380
  // IndexedDB 模式(未设数据目录):本地逐个清理
7944
9381
  try {
@@ -7969,6 +9406,10 @@ window.__ModuleLoader__.load({
7969
9406
  h('button', { type: 'button', className: 'dshic-mini is-ghost', title: T('cancel'), onClick: () => setCreating(false) }, '✕'),
7970
9407
  )
7971
9408
  : h('button', { key: 'newbtn', type: 'button', className: 'dshic-mini', style: { alignSelf: 'flex-start' }, onClick: () => { setCreating(true); setName(''); } }, Ic('plus', 11), T('newProject')),
9409
+ !S.projects.length && !creating
9410
+ ? h('div', { key: 'emptystate', className: 'dshic-hint', style: { alignSelf: 'flex-start', padding: '6px 2px', maxWidth: 260 } },
9411
+ T('emptyProjectHint'))
9412
+ : null,
7972
9413
  ...S.projects.map((p) => {
7973
9414
  const editing = editId === p.id;
7974
9415
  return h('div', {
@@ -8328,7 +9769,7 @@ window.__ModuleLoader__.load({
8328
9769
  onPointerDown: (e) => e.stopPropagation(),
8329
9770
  onClick: startRename,
8330
9771
  }, Ic('pencil', 11));
8331
- const downloadName = (a.name || 'asset') + (a.kind === 'image' ? '.png' : a.kind === 'video' ? '.webm' : a.kind === 'audio' ? '.mp3' : '.md');
9772
+ const downloadName = (a.name || 'asset') + extForDownload(a.mime, a.name, a.kind);
8332
9773
  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
9774
  a.kind === 'text'
8334
9775
  ? h('div', { className: 'dshic-textcard is-title' },
@@ -8798,12 +10239,16 @@ window.__ModuleLoader__.load({
8798
10239
  useEffect(() => { probeCraft().then(() => force((x) => x + 1)); }, []);
8799
10240
  const cs = S.craftStatus;
8800
10241
  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(/[\\/]+$/, ''));
10242
+ const [craftBase, setCraftBase] = useState('');
10243
+ // BLK-02:后期目录由画布自己算 —— <数据根>/projects/<物理文件夹>/craft。
10244
+ // ArtCraft 0.3.x 已无「自有数据根」概念(/status 无 dataRoot),故不再做两边对比,
10245
+ // 改为把实际基准显示出来,供 QA/用户核对落点。
10246
+ useEffect(() => {
10247
+ let ok = true;
10248
+ craftBaseDir(S.activeId).then((abs) => { if (ok) setCraftBase(abs || ''); }).catch(() => { /* 未配置数据根 */ });
10249
+ return () => { ok = false; };
10250
+ }, [S.activeId, S.dataRoot]);
10251
+ const craftDir = craftBase ? (craftBase.replace(/[\\/]+$/, '') + '\\craft') : '';
8807
10252
  // Craft 家族全量动态渲染:ArtCraft /status 返回几款 bins 就渲染几款(当前 12 款),不再写死
8808
10253
  const binKeys = (data && data.bins ? Object.keys(data.bins) : []).sort();
8809
10254
  const readyCount = binKeys.filter((k) => data.bins[k] && data.bins[k].found).length;
@@ -8831,7 +10276,6 @@ window.__ModuleLoader__.load({
8831
10276
  ),
8832
10277
  open ? [
8833
10278
  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
10279
  ...(data ? binKeys.map((k) => binRow(k)) : []),
8836
10280
  ] : null,
8837
10281
  );
@@ -8861,11 +10305,15 @@ window.__ModuleLoader__.load({
8861
10305
  if (!rec || (!rec.blob && !rec.rel)) { toast(T('noResult')); return; }
8862
10306
  setRunning(true); setErr(null); setProgress('…');
8863
10307
  try {
10308
+ // BLK-02:PhotoCraft 的输入/输出也一律 base(项目绝对目录) + 相对路径
10309
+ const base = await craftBaseDirRequired(S.activeId);
8864
10310
  const mediaRel = await ensureAssetOnDisk(rec);
8865
- const outRel = 'projects/' + craftProjectSlug() + '/craft/out/graded/' + rec.id + '-graded' + extForMime(rec.mime, rec.name);
10311
+ const mediaUnderBase = mediaRel.slice(mediaRel.indexOf('/craft/') + 1); // craft/media/…
10312
+ const outRel = 'craft/out/graded/' + rec.id + '-graded' + extForMime(rec.mime, rec.name);
8866
10313
  const job = await craft.jobStart({
8867
10314
  bin: 'photocraft',
8868
- args: ['run', mediaRel, '--cmd', cmd, '--params', JSON.stringify(paramsObj), '--out', outRel],
10315
+ cwd: base,
10316
+ args: ['run', joinLocal(base, mediaUnderBase), '--cmd', cmd, '--params', JSON.stringify(paramsObj), '--out', joinLocal(base, outRel)],
8869
10317
  outputs: [outRel], timeoutMs: 600000,
8870
10318
  });
8871
10319
  setJobId(job.id);
@@ -8881,7 +10329,7 @@ window.__ModuleLoader__.load({
8881
10329
  throw new Error('PhotoCraft ' + status + ':' + ((snap.stderr || '').split('\n').filter(Boolean).slice(-3).join('\n') || ''));
8882
10330
  }
8883
10331
  if (!(snap.outputs && snap.outputs[0] && snap.outputs[0].exists)) throw new Error('任务完成但未找到输出文件');
8884
- const blob = await craft.file(outRel);
10332
+ const blob = await craft.file(base, outRel);
8885
10333
  const rec2 = await saveAssetRecord(S.activeId, 'image', blob, {
8886
10334
  name: (rec.name || 'image') + '-graded',
8887
10335
  prompt: 'photocraft ' + cmd, source: 'craft', category: rec.category || 'general',
@@ -8946,7 +10394,7 @@ window.__ModuleLoader__.load({
8946
10394
  let args;
8947
10395
  try { args = JSON.parse(argsText); } catch { setErr(T('craftInvalidJson')); return; }
8948
10396
  if (!Array.isArray(args)) { setErr(T('craftInvalidJson')); return; }
8949
- onSubmit(args, String(outRel || '').trim());
10397
+ onSubmit(args, String(outRel || '').trim(), info.base);
8950
10398
  };
8951
10399
  return h('div', { className: 'dshic-mask-overlay', onPointerDown: (e) => { if (e.target === e.currentTarget) onClose(); } },
8952
10400
  h('div', { className: 'dshic-mask-card' },
@@ -8970,7 +10418,10 @@ window.__ModuleLoader__.load({
8970
10418
  const [val, setVal] = useState('');
8971
10419
  const [busy, setBusy] = useState(false);
8972
10420
  const [picking, setPicking] = useState(false);
10421
+ const [stats, setStats] = useState(null); // 存储实况:是否真在用 SQLite、库在哪、多少数据
10422
+ const loadStats = () => { hostRpc('dbStats').then((r) => setStats(r || null)).catch(() => setStats({ error: true })); };
8973
10423
  useEffect(() => { if (S.dataRoot) setVal(S.dataRoot); }, [S.dataRoot]);
10424
+ useEffect(() => { loadStats(); }, [S.dataRoot, S.assetsTick]);
8974
10425
  const save = async () => {
8975
10426
  if (busy) return;
8976
10427
  setBusy(true);
@@ -9057,6 +10508,21 @@ window.__ModuleLoader__.load({
9057
10508
  h('button', { type: 'button', className: 'dshic-mini', disabled: busy || picking, onPointerDown: (e) => e.stopPropagation(), onClick: save }, T('dataRootSave')),
9058
10509
  ),
9059
10510
  picking ? h('div', { className: 'dshic-hint', key: 'picking' }, T('dataRootPicking')) : null,
10511
+ h('div', { className: 'dshic-row', key: 'stats', style: { alignItems: 'center', gap: 6 } },
10512
+ h('span', { className: 'dshic-hint', style: { flex: 1, minWidth: 0 } },
10513
+ !stats ? '存储实况:读取中…'
10514
+ : stats.error ? '存储实况:无法读取(后端未提供该接口)'
10515
+ : (stats.store === 'sqlite'
10516
+ ? '存储实况:SQLite 库' + (stats.dbExists ? '(' + (stats.dbBytes / 1048576).toFixed(2) + ' MB' + (stats.walBytes ? ' + WAL ' + (stats.walBytes / 1048576).toFixed(2) + ' MB' : '') + ')' : '(未创建)') + ' · ' + stats.projects + ' 项目 · ' + stats.assets + ' 资产 · 检索 ' + (stats.fts || '-')
10517
+ : stats.store === 'json'
10518
+ ? '存储实况:JSON 降级模式(宿主运行时缺 node:sqlite,数据仍在数据目录的文件里)'
10519
+ : '存储实况:未设置数据目录(资产存浏览器本地 IndexedDB)')),
10520
+ h('button', {
10521
+ type: 'button', className: 'dshic-mini is-ghost', title: '重新检测存储实况',
10522
+ onPointerDown: (e) => e.stopPropagation(), onClick: loadStats,
10523
+ }, '↻'),
10524
+ ),
10525
+ stats && stats.dbPath ? h('div', { key: 'dbpath', className: 'dshic-hint' }, '库文件:' + stats.dbPath) : null,
9060
10526
  h('div', { className: 'dshic-row', key: 'row2' },
9061
10527
  S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini', onPointerDown: (e) => e.stopPropagation(), onClick: openDir }, T('dataRootOpen')) : null,
9062
10528
  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 +11129,81 @@ window.__ModuleLoader__.load({
9663
11129
  );
9664
11130
  }
9665
11131
 
11132
+ // ============================================================
11133
+ // 快捷键面板(画布左上角浮层):按钮点开 → 下方展开半透明列表
11134
+ // 纯展示、不持久化;点面板外或再点按钮或 Esc 收起。
11135
+ // 内容与代码里**真实存在**的快捷键一一对应(改快捷键时同步这里)。
11136
+ // ============================================================
11137
+ function ShortcutsPanel() {
11138
+ const open = !!S.keysOpen;
11139
+ const wrapRef = useRef(null);
11140
+ // 打开时:Esc 关闭 + 点击面板外关闭(都只在打开期间挂监听,避免常年占用全局事件)
11141
+ useEffect(() => {
11142
+ if (!open) return undefined;
11143
+ const onKey = (e) => { if (e.key === 'Escape') { update((s) => { s.keysOpen = false; }); } };
11144
+ // 用捕获阶段:画布/工具栏上的 handler 可能 stopPropagation,冒泡阶段会收不到
11145
+ const onDown = (e) => {
11146
+ const el = wrapRef.current;
11147
+ if (el && e.target && el.contains && el.contains(e.target)) return; // 点面板内或按钮本身:交给按钮自己的 onClick 处理
11148
+ update((s) => { s.keysOpen = false; });
11149
+ };
11150
+ window.addEventListener('keydown', onKey);
11151
+ window.addEventListener('pointerdown', onDown, true);
11152
+ return () => {
11153
+ window.removeEventListener('keydown', onKey);
11154
+ window.removeEventListener('pointerdown', onDown, true);
11155
+ };
11156
+ }, [open]);
11157
+ // row(id, keyLabel, text):id 仅作 React key;此前误收两参导致第三参(说明文案)被丢弃,
11158
+ // 键位栏渲染成 "Alt + 拖动|0"、说明栏重复键名——整张面板的描述全部失效。
11159
+ const row = (id, keyLabel, text) => h('div', { className: 'dshic-keys-row', key: id },
11160
+ h('span', { className: 'dshic-keys-key' }, keyLabel),
11161
+ h('span', { className: 'dshic-keys-text' }, text));
11162
+ const group = (title, items) => h('div', { className: 'dshic-keys-group', key: title },
11163
+ h('div', { className: 'dshic-keys-group-title' }, title),
11164
+ ...items.map(([k, v], i) => row(k + '|' + i, k, v)));
11165
+ return h('div', { className: 'dshic-keys-wrap', ref: wrapRef },
11166
+ h('button', {
11167
+ type: 'button',
11168
+ className: cls('dshic-keys-btn', open && 'is-open'),
11169
+ title: T('keysBtn'),
11170
+ onPointerDown: (e) => e.stopPropagation(), // 不触发放大/拖拽等工具栏行为
11171
+ onClick: (e) => { e.stopPropagation(); update((s) => { s.keysOpen = !s.keysOpen; }); },
11172
+ }, h('span', { className: 'dshic-keys-btn-label' }, T('keysBtn')), h('span', { className: 'dshic-keys-caret' }, open ? '▴' : '▾')),
11173
+ open
11174
+ ? h('div', {
11175
+ className: 'dshic-keys-pop',
11176
+ onPointerDown: (e) => e.stopPropagation(), // 面板内点击不进画布
11177
+ onWheel: (e) => e.stopPropagation(),
11178
+ },
11179
+ h('div', { className: 'dshic-keys-title' }, T('keysTitle')),
11180
+ group(T('keysDrag'), [
11181
+ ['Alt + 拖动', T('kAltDrag')],
11182
+ ['拖动', T('kDrag')],
11183
+ ['Shift + 拖动', T('kShiftDrag')],
11184
+ ['Shift + 点击', T('kShiftClick')],
11185
+ ['Shift + 框选', T('kMarquee')],
11186
+ ['中键拖动', T('kMiddle')],
11187
+ ['滚轮', T('kWheel')],
11188
+ ['双击空白', T('kDblClick')],
11189
+ ['右键', T('kRightClick')],
11190
+ ]),
11191
+ group(T('keysEdit'), [
11192
+ ['Ctrl/⌘ Z', T('kUndo')],
11193
+ ['Ctrl/⌘ ⇧ Z', T('kRedo')],
11194
+ ['Ctrl/⌘ D', T('kCopy')],
11195
+ ['Delete', T('kDel')],
11196
+ ['Esc', T('kEsc')],
11197
+ ]),
11198
+ group(T('keysView'), [
11199
+ ['双击项目名', T('kZoomKeys')],
11200
+ ]),
11201
+ h('div', { className: 'dshic-keys-hint' }, T('keysHint')),
11202
+ )
11203
+ : null,
11204
+ );
11205
+ }
11206
+
9666
11207
  // ============================================================
9667
11208
  // 帮助与反馈:GitHub 仓库/Issue + 微信交流群二维码(插件静态资源)
9668
11209
  // ============================================================
@@ -9729,7 +11270,7 @@ window.__ModuleLoader__.load({
9729
11270
  // 插件入口
9730
11271
  // ============================================================
9731
11272
  return {
9732
- inject: ['slots', 'locale', 'layout'],
11273
+ inject: ['slots', 'locale', 'layout', 'sidebarRightTabs'],
9733
11274
  apply(ctx) {
9734
11275
  CTX = ctx;
9735
11276
  ctx.effect(() => ctx.locale.register(NS, { zh: DICT_ZH, en: DICT_EN }), 'infinite-canvas: dictionaries');
@@ -9772,23 +11313,118 @@ window.__ModuleLoader__.load({
9772
11313
  // 面板根:包一层错误边界——任何渲染期崩溃都不再白屏,而是显示可复制的错误卡片
9773
11314
  PanelErrorBoundary.__dshicClass = true; // 冒烟 walker 标记
9774
11315
  function PanelRoot() {
9775
- return h(PanelErrorBoundary, null, h(CanvasPage));
11316
+ return h(CanvasHostedApp, { host: 'main' });
9776
11317
  }
9777
11318
  ctx.slots.inject('main', () => ctx.slots.register({
9778
11319
  name: 'main',
9779
11320
  key: PANEL_ID,
9780
11321
  locale: NS,
9781
11322
  }, PanelRoot));
11323
+ // —— 会话右侧面板:画布作为右栏页签(对话与画布同屏工作)——
11324
+ // 契约照抄 dsh-client-ui-sidebar-terminal:**注册 id、三个槽位的 key、guide 条目 id 全部同一口径**。
11325
+ // 关键在壳的 TabSlot:`renderSlot(seat, {}, { entryKey: definition?.id ?? tab.kind })` ——
11326
+ // 插槽调度用的是注册表里的 definition.id,只有 kind 没进注册表时才回退到 kind。
11327
+ // 上一次 body 槽位 key 写的是 kind('infinite-canvas')而注册 id 是包名,
11328
+ // 于是永远命中 fallback:「这类内容还没有可用的查看方式」。
11329
+ const CANVAS_TAB_KIND = 'infinite-canvas';
11330
+ const CANVAS_TAB_ID = '@ninkch/dsh-infinite-canvas'; // = 注册 id = 槽位 key
11331
+ function CanvasHostedApp({ host }) {
11332
+ useApp();
11333
+ useEffect(() => {
11334
+ // 挂载即声明宿主(后开者接管):在窄栏宿主里顺手收起画布自带的右侧栏(宽度不够)
11335
+ const takeover = S.canvasHost !== host;
11336
+ S.canvasHost = host;
11337
+ if (takeover && host === 'pane' && S.rightOpen) S.rightOpen = false;
11338
+ notify();
11339
+ return () => { if (S.canvasHost === host) S.canvasHost = null; };
11340
+ }, [host]);
11341
+ if (S.canvasHost && S.canvasHost !== host) {
11342
+ return h('div', { className: 'dshic-host-taken' },
11343
+ h('div', { className: 'dshic-host-taken-card' },
11344
+ h('div', { className: 'dshic-host-taken-title' }, T('hostTakenTitle')),
11345
+ h('div', { className: 'dshic-host-taken-hint' }, T('hostTakenHint')),
11346
+ h('button', {
11347
+ type: 'button', className: 'dshic-mini',
11348
+ onPointerDown: (e) => e.stopPropagation(),
11349
+ onClick: () => update((s) => { s.canvasHost = host; if (host === 'pane' && s.rightOpen) s.rightOpen = false; }),
11350
+ }, T('hostTakeOver')),
11351
+ ));
11352
+ }
11353
+ return h(PanelErrorBoundary, null, h(CanvasPage, { host }));
11354
+ }
11355
+ // 页签 kind 注册(**开始页卡片的真正来源**):壳的 GuideBody 遍历注册表里每个 kind 的
11356
+ // guide 数组渲染标准卡——只注册槽位不进注册表时,页签体存在但开始页永远不显示入口
11357
+ //(首次上线时漏了这一步,卡片不出现)。DEFAULT_BAND='extension':插件默认档,与
11358
+ // builtin 一次性配对共存,无需显式 priority。
11359
+ // 守卫:旧 runner 未提供 sidebarRightTabs 服务时跳过(保持全屏面板可用,不让整个插件挂掉)。
11360
+ if (ctx.sidebarRightTabs && typeof ctx.sidebarRightTabs.register === 'function') {
11361
+ ctx.effect(() => ctx.sidebarRightTabs.register({
11362
+ id: CANVAS_TAB_ID, // ← 壳用这个 id 作为 slot entryKey 调度 body/title
11363
+ kind: CANVAS_TAB_KIND,
11364
+ multiple: false, // 一个页签足够(同 kind 再开 = 聚焦已有页签,壳的 page uniqueness 语义)
11365
+ title: () => T('panel'), // chip 文本(未注册 title 槽位时的兜底口径)
11366
+ guide: [{
11367
+ id: 'open',
11368
+ order: 30,
11369
+ title: () => T('panel'),
11370
+ description: () => T('guideDesc'),
11371
+ icon: PanelIcon,
11372
+ }],
11373
+ }), 'infinite-canvas: tab type');
11374
+ }
11375
+ // 槽位 key = 注册 id(不是 kind):与 TabSlot 的 entryKey 口径一致,否则永远落到 fallback
11376
+ ctx.slots.inject('sidebar.right.pane.tab', () => ctx.slots.register({
11377
+ name: 'sidebar.right.pane.tab',
11378
+ key: CANVAS_TAB_ID,
11379
+ locale: NS,
11380
+ }, () => h(CanvasHostedApp, { host: 'pane' })));
11381
+ // 标题槽位同样用注册 id;缺了它 chip 显示注册表 title(address) 抓到的开页标题(也够用)
11382
+ ctx.slots.inject('sidebar.right.pane.tab.title', () => ctx.slots.register({
11383
+ name: 'sidebar.right.pane.tab.title',
11384
+ key: CANVAS_TAB_ID,
11385
+ locale: NS,
11386
+ }, function CanvasPaneTitle() {
11387
+ return h('span', { className: 'dshic-pane-tab-title' }, T('panel'));
11388
+ }));
9782
11389
  // 冒烟测试专用缝隙(仅在 smoke-render.mjs 设置了该标记时存在):
9783
11390
  // 直接暴露各页签组件与同步入口,让测试能逐个渲染/驱动,抓白屏与同步断链问题
9784
11391
  if (typeof window !== 'undefined' && window.__DSH_IC_SMOKE__) {
9785
- window.__DSH_IC_TABS__ = { ProjectsTab, AssetsTab, BibleTab, SettingsTab, CanvasPage };
11392
+ window.__DSH_IC_TABS__ = { ProjectsTab, AssetsTab, BibleTab, SettingsTab, CanvasPage, ShortcutsPanel, CanvasHostedApp };
11393
+ // 节点体组件出口:供 scripts/test-node-actions.mjs 断言「结果操作按钮必须在节点末尾左下角」的排版规范
11394
+ window.__DSH_IC_NODES__ = { ScriptNodeBody, StoryboardNodeBody, GenNodeBody, FinalCutNodeBody, AssetNodeBody, TextNodeBody, ShotNodeBody };
11395
+ // 成片合成(composeFinalCut)纯函数级出口:浏览器录制链路无法在 Node 端跑,
11396
+ // 但合成主循环(分段推进/裁剪/转场/字幕)可以用假的 video/MediaRecorder 真实执行——
11397
+ // 这正是历史上一处 "dur is not defined" 在真机上才炸的漏洞所在。
11398
+ window.__DSH_IC_COMPOSE__ = {
11399
+ composeFinalCut,
11400
+ videoDimsFor,
11401
+ drawSubtitle,
11402
+ extractTailFrames,
11403
+ grayThumb,
11404
+ frameSeamMetrics,
11405
+ seamVerdict,
11406
+ tailFrameScore,
11407
+ };
9786
11408
  window.__DSH_IC_SYNC__ = {
9787
11409
  hostSyncTick, mergeHostDoc, loadBible, loadGraphDoc,
9788
11410
  removeNodes, ungroupNode, undo, redo, pushHistory,
9789
11411
  graph: () => JSON.parse(JSON.stringify(S.graph)),
11412
+ // 冒烟测试用:切换「快捷键面板」展开态,让展开后的列表也走一次真实渲染
11413
+ setKeysOpen: (v) => { S.keysOpen = !!v; },
9790
11414
  state: () => ({ stopped: hostSyncStopped, busy: hostSyncBusy, projects: S.projects.map((p) => p.id), activeId: S.activeId, ready: S.ready }),
11415
+ // 铺开到画布(缺资产 → 建带设定图提示词的图片生成节点)供 test-expand-assets 驱动
11416
+ expandStoryboard,
11417
+ // 纯函数出口:资产设定图提示词生成器(不依赖 DOM,可直接断言)
11418
+ buildAssetSheetPrompt, assetSheetNodeSpec,
9791
11419
  };
11420
+ // IC-01:下载侧扩展名判定的纯函数出口(供 scripts/test-download-ext.mjs 直接断言)
11421
+ window.__DSH_IC_MEDIA__ = { extForDownload };
11422
+ // IC-04:参考图上传三件套的纯函数出口(供 scripts/test-image-upload.mjs 直接断言)
11423
+ window.__DSH_IC_IMGREF__ = { rawImageDims, sniffImageEncoding, registerReferenceAsset };
11424
+ // IC-03:视频厂商识别/前缀归一化的纯函数出口(供 scripts/test-vendor-detect.mjs 直接断言)
11425
+ window.__DSH_IC_VENDOR__ = { detectVideoVendor, gatewayPrefixFor, wanEndpoints, vendorBase, VIDEO_REF_CAPS };
11426
+ // BLK-02:ArtCraft 目录基准相关的纯函数出口(供 scripts/test-craft-base.mjs 断言)
11427
+ window.__DSH_IC_CRAFT__ = { craftBaseDir, craftBaseDirRequired, joinLocal, craftFolderOf, craft };
9792
11428
  }
9793
11429
  },
9794
11430
  };