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