@ninkch/dsh-infinite-canvas 0.17.4 → 0.17.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/canvas-craft/SKILL.md +106 -0
- package/assets/canvas-studio/SKILL.md +169 -0
- package/client.js +2045 -302
- package/docs/release.md +42 -46
- package/index.js +1623 -49
- package/package.json +2 -1
package/client.js
CHANGED
|
@@ -15,8 +15,10 @@ window.__ModuleLoader__.load({
|
|
|
15
15
|
const LS_GRAPH = 'dsh-ic:graph:';
|
|
16
16
|
const LS_SETTINGS = 'dsh-ic:settings';
|
|
17
17
|
const DB_NAME = 'dsh-infinite-canvas';
|
|
18
|
+
const GENERAL_PID = 'general'; // 通用项目:多项目共用的资产挂在这里
|
|
18
19
|
const DB_STORE = 'assets';
|
|
19
20
|
const NODE_W = 240;
|
|
21
|
+
const CRAFT_SKILL = 'canvas-craft'; // 内置创作方法论技能(未选技能时默认注入)
|
|
20
22
|
|
|
21
23
|
const NODE_TYPES = ['text', 'shot', 'image-gen', 'video-gen', 'image-asset', 'video-asset'];
|
|
22
24
|
|
|
@@ -32,7 +34,7 @@ window.__ModuleLoader__.load({
|
|
|
32
34
|
outputs: [{ id: 'visual', kind: 'text' }, { id: 'motion', kind: 'text' }],
|
|
33
35
|
},
|
|
34
36
|
'script': { inputs: [], outputs: [{ id: 'out', kind: 'text' }] },
|
|
35
|
-
'storyboard': { inputs: [{ id: 'script', kind: 'text' }], outputs: [] },
|
|
37
|
+
'storyboard': { inputs: [{ id: 'script', kind: 'text' }], outputs: [{ id: 'out', kind: 'text' }] },
|
|
36
38
|
'image-gen': {
|
|
37
39
|
inputs: [{ id: 'prompt', kind: 'text' }, { id: 'ref', kind: 'image' }],
|
|
38
40
|
outputs: [{ id: 'out', kind: 'image' }],
|
|
@@ -53,7 +55,7 @@ window.__ModuleLoader__.load({
|
|
|
53
55
|
const CATEGORIES = ['character', 'scene', 'prop', 'script', 'storyboard', 'audio', 'finalcut', 'general'];
|
|
54
56
|
const RATIOS = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];
|
|
55
57
|
const IMAGE_SIZES = ['1K', '2K', '4K'];
|
|
56
|
-
const VIDEO_DURATIONS = [4, 5, 6, 8, 10];
|
|
58
|
+
const VIDEO_DURATIONS = [4, 5, 6, 8, 10, 12, 15];
|
|
57
59
|
const VIDEO_RESOLUTIONS = ['480P', '720P', '1080P'];
|
|
58
60
|
|
|
59
61
|
// ============================================================
|
|
@@ -89,6 +91,13 @@ window.__ModuleLoader__.load({
|
|
|
89
91
|
for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i);
|
|
90
92
|
return new Blob([arr], { type: mime || 'application/octet-stream' });
|
|
91
93
|
}
|
|
94
|
+
function arrayBufferToBase64(buf) {
|
|
95
|
+
const bytes = new Uint8Array(buf);
|
|
96
|
+
let bin = '';
|
|
97
|
+
const CH = 0x8000;
|
|
98
|
+
for (let i = 0; i < bytes.length; i += CH) bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CH));
|
|
99
|
+
return btoa(bin);
|
|
100
|
+
}
|
|
92
101
|
function blobToDataUrl(blob) {
|
|
93
102
|
return new Promise((res, rej) => {
|
|
94
103
|
const r = new FileReader();
|
|
@@ -246,8 +255,10 @@ window.__ModuleLoader__.load({
|
|
|
246
255
|
'projectFilter': '项目',
|
|
247
256
|
'allProjects': '全部项目',
|
|
248
257
|
'currentProject': '当前项目',
|
|
249
|
-
'expandDone': '
|
|
250
|
-
'
|
|
258
|
+
'expandDone': '已铺开为一次多镜头生成:{n} 镜合成为一条视频(时长 = 各镜之和)',
|
|
259
|
+
'multiShotHint': '多镜头模式:提示词由上游分镜表组合(全部镜头按顺序+时长合成一条提示词),一次性生成整段视频',
|
|
260
|
+
'aiNeedScript': '分镜节点还没有剧本来源:连线上游剧本节点,或在节点里粘贴剧本',
|
|
261
|
+
'upstreamStillRunning': '上游剧本节点还没生成完成(有连线时内容只从连线取);等它完成后再生成分镜',
|
|
251
262
|
'expandNoShots': '还没有分镜,请先 AI 生成',
|
|
252
263
|
'aiRunning': 'AI 生成中…',
|
|
253
264
|
'langLabel': '界面语言',
|
|
@@ -345,10 +356,63 @@ window.__ModuleLoader__.load({
|
|
|
345
356
|
'dataRootOpen': '打开目录',
|
|
346
357
|
'dataRootMigrate': '迁移历史资产',
|
|
347
358
|
'dataRootMigrated': '已迁移 {n} 个资产到数据根目录',
|
|
359
|
+
'dataRootImportBtn': '从数据目录导入',
|
|
360
|
+
'dataRootImported': '导入完成:合并 {n} 个项目、补入 {a} 条资产元数据',
|
|
361
|
+
'dataRootImportHint': '换电脑迁移:把整个数据目录拷到新机器 → 设置为数据根目录 → 点"从数据目录导入"即可恢复项目/节点/设定集/资产',
|
|
348
362
|
'dataRootMigratedDone': '已迁移',
|
|
349
363
|
'dataRootMigrating': '迁移中',
|
|
350
364
|
'dataRootUnsupported': '当前插件后台版本较旧(不支持数据根目录):重启 Harness 后可用',
|
|
351
365
|
'dataRootSaved': '数据根目录已设置',
|
|
366
|
+
'resizeHint': '拖动左右上下边可调宽高(右键头调宽、底边调高、右下角同时)',
|
|
367
|
+
'resizeHeightHint': '拖动调高;双击恢复「随内容自适应」',
|
|
368
|
+
'aiEmptyResult': '模型返回了空内容——请检查画布设置里的文本模型,或换一个模型重试',
|
|
369
|
+
'aiInterrupted': '上次生成被页面刷新或关闭中断,请重新生成',
|
|
370
|
+
'skillHint': '默认已含内置专家 + 创作方法论(canvas-craft);也可选择其它已安装技能覆盖',
|
|
371
|
+
'nodeNotRunnable': '该节点类型不支持运行(请用画布上的对应按钮)',
|
|
372
|
+
'delAssetTitle': '删除资产',
|
|
373
|
+
'delAssetWarn': '将删除该资产及其文件,且无法恢复;引用它的节点会清空引用。请输入资产名以确认:',
|
|
374
|
+
'delAssetInput': '资产名',
|
|
375
|
+
'renameAsset': '重命名',
|
|
376
|
+
'renamed': '已重命名',
|
|
377
|
+
'llmMaxTokens': '输出上限 maxTokens',
|
|
378
|
+
'llmTemperature': '温度 temperature',
|
|
379
|
+
'llmParamAuto': '留空 = 用 Harness/模型默认',
|
|
380
|
+
'llmParamHint': '留空则不下发该参数,直接使用你在 Harness 里为该模型配置的默认值(插件不会写死任何生成参数)',
|
|
381
|
+
'imgSizeOverride': '尺寸覆盖 size',
|
|
382
|
+
'imgSizePlaceholder': '如 1024x1024(留空=按清晰度×比例)',
|
|
383
|
+
'imgResponseFormat': '响应格式 response_format',
|
|
384
|
+
'seedanceAudio': '生成音频(Seedance)',
|
|
385
|
+
'seedanceWatermark': '添加水印(Seedance)',
|
|
386
|
+
'seedanceHint': '仅 Seedance 协议生效;模型名必填(协议要求显式指定)',
|
|
387
|
+
'bibleWorldview': '世界观 / 核心设定',
|
|
388
|
+
'bibleWorldviewPh': '题材、年代、核心规则、主线与暗线(会注入所有 AI 生成,保证跨集一致)',
|
|
389
|
+
'bibleDraft': 'AI 起草设定集',
|
|
390
|
+
'bibleDrafted': '已根据创作意图起草设定集,请检查后再生成本',
|
|
391
|
+
'bibleDraftEmpty': '没能起草出设定集:请先在剧本/文本节点写点创作意图,或检查文本模型配置',
|
|
392
|
+
'bibleOrderHint': '设定集先行:世界观/风格/角色/场景/道具先定好 → 生成剧本 → 为角色/场景/道具生成参考图 → 再拆解分镜',
|
|
393
|
+
'bibleLinkAssets': '链接资产',
|
|
394
|
+
'bibleLinkTitle': '链接资产到设定条目',
|
|
395
|
+
'bibleLinkHint': '勾选要绑定的图片/音频/视频(可多选);@引用该条目时会优先把这些资产作为参考图',
|
|
396
|
+
'bibleLinkApply': '完成',
|
|
397
|
+
'generalProject': '通用项目',
|
|
398
|
+
'moved': '已移动到指定项目',
|
|
399
|
+
'pagePrev': '上一页',
|
|
400
|
+
'pageNext': '下一页',
|
|
401
|
+
'bibleAutoAdd': '已把 {n} 个新元素补进设定集(描述待补充)',
|
|
402
|
+
'kindAudio': '音频',
|
|
403
|
+
'mentionRefs': '@引用命中',
|
|
404
|
+
'mentionAssets': '可用参考图 {n} 张',
|
|
405
|
+
'mentionNoAsset': '暂无参考图',
|
|
406
|
+
'mentionNoAssetHint': '该实体还没有参考图:先建一个图片节点生成(建议命名「角色-名称 多视图」),之后所有 @名称 的提示词都会自动带上它',
|
|
407
|
+
'craftTimeout': 'Craft 任务超时(可在任务面板重试或取消)',
|
|
408
|
+
'errNoImageApi': '未配置图片 API:请到画布设置里添加(未配置时可用演示模式)',
|
|
409
|
+
'photoCmd': 'Craft 命令',
|
|
410
|
+
'bitrate': '码率',
|
|
411
|
+
'bitrateAuto': '自动(浏览器默认)',
|
|
412
|
+
'runRequestFailed': '对话触发的生成未能执行',
|
|
413
|
+
'apiTimeout': '请求超时(毫秒)',
|
|
414
|
+
'uploadFailed': '导入失败',
|
|
415
|
+
'syncFailed': '与 Host 同步失败',
|
|
352
416
|
'dataRootBrowse': '浏览选择目录',
|
|
353
417
|
'dataRootPicking': '请在弹出的窗口中选择目录(可直接取消)…',
|
|
354
418
|
'dataRootPickUnsupported': '当前平台不支持目录选择器,请手动填写路径',
|
|
@@ -522,8 +586,10 @@ window.__ModuleLoader__.load({
|
|
|
522
586
|
'projectFilter': 'Project',
|
|
523
587
|
'allProjects': 'All projects',
|
|
524
588
|
'currentProject': 'Current project',
|
|
525
|
-
'expandDone': 'Expanded {n} shots
|
|
526
|
-
'
|
|
589
|
+
'expandDone': 'Expanded to one multi-shot generation: {n} shots composed into a single video',
|
|
590
|
+
'multiShotHint': 'Multi-shot mode: the prompt is composed from the upstream storyboard (all shots, in order, with durations) and generated as one clip',
|
|
591
|
+
'aiNeedScript': 'The storyboard has no script source: connect an upstream script node, or paste a script into the node',
|
|
592
|
+
'upstreamStillRunning': 'The upstream script node is still generating (with a wire, content comes only from the wire); wait for it before splitting shots',
|
|
527
593
|
'expandNoShots': 'No shots yet — run AI generation first',
|
|
528
594
|
'aiRunning': 'AI generating…',
|
|
529
595
|
'langLabel': 'UI language',
|
|
@@ -621,10 +687,63 @@ window.__ModuleLoader__.load({
|
|
|
621
687
|
'dataRootOpen': 'Open folder',
|
|
622
688
|
'dataRootMigrate': 'Migrate existing assets',
|
|
623
689
|
'dataRootMigrated': 'Migrated {n} assets to the data root',
|
|
690
|
+
'dataRootImportBtn': 'Import from data root',
|
|
691
|
+
'dataRootImported': 'Imported: {n} projects merged, {a} asset records added',
|
|
692
|
+
'dataRootImportHint': 'Migration: copy the whole data folder to the new machine, set it as data root, then click "Import from data root" to restore projects/nodes/bible/assets',
|
|
624
693
|
'dataRootMigratedDone': 'Migrated',
|
|
625
694
|
'dataRootMigrating': 'Migrating',
|
|
626
695
|
'dataRootUnsupported': 'The plugin backend is older and lacks data-root support — restart Harness to enable',
|
|
627
696
|
'dataRootSaved': 'Data root set',
|
|
697
|
+
'resizeHint': 'Drag any edge to resize (top/bottom = height, left/right = width, corner = both)',
|
|
698
|
+
'resizeHeightHint': 'Drag to set height; double-click to fit content again',
|
|
699
|
+
'aiEmptyResult': 'The model returned empty content — check the text model in canvas settings or try another model',
|
|
700
|
+
'aiInterrupted': 'The previous generation was interrupted by a page reload; please run it again',
|
|
701
|
+
'skillHint': 'Defaults to built-in experts plus the canvas-craft methodology; pick another installed skill to override',
|
|
702
|
+
'nodeNotRunnable': 'This node type cannot be run (use its button on the canvas)',
|
|
703
|
+
'delAssetTitle': 'Delete asset',
|
|
704
|
+
'delAssetWarn': 'The asset and its file will be deleted permanently; nodes referencing it will lose the reference. Type the asset name to confirm:',
|
|
705
|
+
'delAssetInput': 'Asset name',
|
|
706
|
+
'renameAsset': 'Rename',
|
|
707
|
+
'renamed': 'Renamed',
|
|
708
|
+
'llmMaxTokens': 'Output cap (maxTokens)',
|
|
709
|
+
'llmTemperature': 'Temperature',
|
|
710
|
+
'llmParamAuto': 'empty = Harness/model default',
|
|
711
|
+
'llmParamHint': 'Left empty, the parameter is not sent at all — the model uses the default you configured in Harness. The plugin never hardcodes generation parameters.',
|
|
712
|
+
'imgSizeOverride': 'Size override',
|
|
713
|
+
'imgSizePlaceholder': 'e.g. 1024x1024 (empty = clarity × ratio)',
|
|
714
|
+
'imgResponseFormat': 'response_format',
|
|
715
|
+
'seedanceAudio': 'Generate audio (Seedance)',
|
|
716
|
+
'seedanceWatermark': 'Watermark (Seedance)',
|
|
717
|
+
'seedanceHint': 'Seedance protocol only; the model name is required by the protocol',
|
|
718
|
+
'bibleWorldview': 'Worldview / core settings',
|
|
719
|
+
'bibleWorldviewPh': 'Genre, era, core rules, main and hidden threads (injected into all generation for consistency)',
|
|
720
|
+
'bibleDraft': 'Draft bible with AI',
|
|
721
|
+
'bibleDrafted': 'Bible drafted from your intent — review it before generating the script',
|
|
722
|
+
'bibleDraftEmpty': 'Nothing drafted: write some intent in a script/text node first, or check the text model',
|
|
723
|
+
'bibleOrderHint': 'Bible first: set worldview/style/characters/scenes/props → generate the script → generate reference images → then break down the storyboard',
|
|
724
|
+
'bibleLinkAssets': 'Link assets',
|
|
725
|
+
'bibleLinkTitle': 'Link assets to this bible entry',
|
|
726
|
+
'bibleLinkHint': 'Pick images/audio/video to bind (multi-select); @mentions of this entry will use them as references first',
|
|
727
|
+
'bibleLinkApply': 'Done',
|
|
728
|
+
'generalProject': 'General',
|
|
729
|
+
'moved': 'Moved to the target project',
|
|
730
|
+
'pagePrev': 'Prev page',
|
|
731
|
+
'pageNext': 'Next page',
|
|
732
|
+
'bibleAutoAdd': 'Added {n} new entries to the bible (descriptions pending)',
|
|
733
|
+
'kindAudio': 'Audio',
|
|
734
|
+
'mentionRefs': '@mention hits',
|
|
735
|
+
'mentionAssets': '{n} reference image(s) available',
|
|
736
|
+
'mentionNoAsset': 'no reference image yet',
|
|
737
|
+
'mentionNoAssetHint': 'No reference image for this entity yet: create an image node (name it like "角色-张山 多视图") and every later @mention will pick it up automatically',
|
|
738
|
+
'craftTimeout': 'Craft job timed out (retry or cancel it in the job panel)',
|
|
739
|
+
'errNoImageApi': 'No image API configured — add one in canvas settings (demo mode is available without one)',
|
|
740
|
+
'photoCmd': 'Craft command',
|
|
741
|
+
'bitrate': 'Bitrate',
|
|
742
|
+
'bitrateAuto': 'Auto (browser default)',
|
|
743
|
+
'runRequestFailed': 'Chat-triggered generation failed',
|
|
744
|
+
'apiTimeout': 'Request timeout (ms)',
|
|
745
|
+
'uploadFailed': 'Import failed',
|
|
746
|
+
'syncFailed': 'Failed to sync with Host',
|
|
628
747
|
'dataRootBrowse': 'Browse for folder',
|
|
629
748
|
'dataRootPicking': 'Pick a folder in the dialog (cancel anytime)…',
|
|
630
749
|
'dataRootPickUnsupported': 'No folder picker on this platform — type the path instead',
|
|
@@ -715,7 +834,7 @@ window.__ModuleLoader__.load({
|
|
|
715
834
|
.dshic-edge-temp{fill:none;stroke:var(--dsw-alias-label-secondary);stroke-width:2;stroke-dasharray:6 5;pointer-events:none;}
|
|
716
835
|
.dshic-node{position:absolute;box-sizing:border-box;width:${NODE_W}px;background:var(--dsw-alias-bg-layer-1);
|
|
717
836
|
border:1px solid var(--dsw-alias-border-l2);border-radius:12px;box-shadow:0 4px 14px color-mix(in srgb,var(--dsw-alias-bg-base) 40%,transparent);
|
|
718
|
-
cursor:grab;min-height:96px;}
|
|
837
|
+
cursor:grab;min-height:96px;display:flex;flex-direction:column;}
|
|
719
838
|
.dshic-node:active{cursor:grabbing;}
|
|
720
839
|
.dshic-node.is-selected{border-color:var(--dsw-alias-brand-primary);box-shadow:0 0 0 2px color-mix(in srgb,var(--dsw-alias-brand-primary) 35%,transparent);}
|
|
721
840
|
.dshic-node.st-running{border-color:var(--dsw-alias-brand-primary);}
|
|
@@ -732,10 +851,24 @@ window.__ModuleLoader__.load({
|
|
|
732
851
|
.dshic-node-x{flex:none;width:18px;height:18px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);
|
|
733
852
|
cursor:pointer;font-size:13px;line-height:18px;padding:0;display:inline-flex;align-items:center;justify-content:center;}
|
|
734
853
|
.dshic-node-x:hover{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 16%,transparent);color:var(--dsw-alias-state-error-primary);}
|
|
735
|
-
|
|
854
|
+
/* 节点尺寸把手:上下左右四条边 + 右下角;双击底边恢复随内容自适应 */
|
|
855
|
+
.dshic-rs{position:absolute;z-index:6;}
|
|
856
|
+
.dshic-rs:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 35%,transparent);border-radius:4px;}
|
|
857
|
+
.dshic-rs-e{right:-3px;top:14px;bottom:14px;width:7px;cursor:ew-resize;}
|
|
858
|
+
.dshic-rs-w{left:-3px;top:14px;bottom:14px;width:7px;cursor:ew-resize;}
|
|
859
|
+
.dshic-rs-s{bottom:-3px;left:14px;right:14px;height:7px;cursor:ns-resize;}
|
|
860
|
+
.dshic-rs-n{top:-3px;left:14px;right:14px;height:7px;cursor:ns-resize;}
|
|
861
|
+
.dshic-rs-se{right:-4px;bottom:-4px;width:16px;height:16px;cursor:nwse-resize;}
|
|
862
|
+
.dshic-node-body{padding:8px;display:flex;flex-direction:column;gap:6px;font-size:12px;flex:1;min-height:0;overflow:auto;}
|
|
863
|
+
/* 手动拉过高高度的节点:主体内的文本框随节点一起拉伸(拖高=对话框变高,而不是只撑外框) */
|
|
864
|
+
.dshic-node.h-manual .dshic-node-body{overflow:hidden;}
|
|
865
|
+
.dshic-node.h-manual .dshic-node-body > .dshic-textarea{flex:1 1 auto;min-height:0;}
|
|
866
|
+
.dshic-node.h-manual .dshic-node-body > .dshic-prompt{flex:1 1 auto;display:flex;flex-direction:column;}
|
|
867
|
+
.dshic-node.h-manual .dshic-node-body > .dshic-prompt > .dshic-prompt-input{flex:1 1 auto;min-height:0;}
|
|
736
868
|
.dshic-textarea{width:100%;box-sizing:border-box;min-height:56px;resize:vertical;border:1px solid var(--dsw-alias-border-l2);
|
|
737
|
-
border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;
|
|
738
|
-
|
|
869
|
+
border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;
|
|
870
|
+
/* 节点内文本统一字号/行高/内距:与 .dshic-prompt-back/.dshic-prompt-input 必须逐项一致(透明叠层对齐) */
|
|
871
|
+
font-size:12px;line-height:18px;padding:7px 9px;outline:none;user-select:text;-webkit-user-select:text;cursor:text;}
|
|
739
872
|
.dshic-textarea:focus{border-color:var(--dsw-alias-brand-primary);}
|
|
740
873
|
.dshic-textarea:disabled{opacity:.6;}
|
|
741
874
|
.dshic-input{width:100%;box-sizing:border-box;height:28px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;
|
|
@@ -762,11 +895,12 @@ window.__ModuleLoader__.load({
|
|
|
762
895
|
.dshic-err{color:var(--dsw-alias-state-error-primary);font-size:11px;line-height:15px;word-break:break-all;user-select:text;-webkit-user-select:text;}
|
|
763
896
|
.dshic-hint{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;}
|
|
764
897
|
.dshic-port{position:absolute;width:11px;height:11px;border-radius:50%;background:var(--dsw-alias-bg-layer-1);
|
|
765
|
-
border:2px solid var(--dsw-alias-label-secondary);cursor:crosshair;z-index:
|
|
898
|
+
border:2px solid var(--dsw-alias-label-secondary);cursor:crosshair;z-index:9;box-sizing:border-box;padding:0;}
|
|
766
899
|
.dshic-port.k-image{border-color:var(--dsw-alias-brand-primary);}
|
|
767
900
|
.dshic-port.k-video{border-color:var(--dsw-alias-state-success-primary);}
|
|
768
901
|
.dshic-port:hover,.dshic-port.is-active{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);
|
|
769
902
|
box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,transparent);}
|
|
903
|
+
.dshic-port-wrap{position:absolute;width:0;height:0;}
|
|
770
904
|
.dshic-port-label{position:absolute;font-size:9px;color:var(--dsw-alias-label-secondary);white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .12s;}
|
|
771
905
|
.dshic-node:hover .dshic-port-label{opacity:1;}
|
|
772
906
|
.dshic-marquee{position:absolute;border:1px solid var(--dsw-alias-brand-primary);
|
|
@@ -800,6 +934,15 @@ window.__ModuleLoader__.load({
|
|
|
800
934
|
.dshic-asset-meta{display:flex;align-items:center;gap:4px;padding:3px 6px;font-size:10px;color:var(--dsw-alias-label-secondary);flex-wrap:wrap;}
|
|
801
935
|
.dshic-asset-actions{position:absolute;top:4px;right:4px;display:flex;gap:3px;opacity:0;transition:opacity .12s;}
|
|
802
936
|
.dshic-asset:hover .dshic-asset-actions{opacity:1;}
|
|
937
|
+
/* 资产名称行:所有媒体类资产统一显示名称,旁边 ✎ 重命名(与节点改名同一支笔图标) */
|
|
938
|
+
.dshic-asset-name{display:flex;align-items:center;gap:4px;padding:0 7px 6px;}
|
|
939
|
+
.dshic-asset-name-text{flex:1;min-width:0;font-size:11px;line-height:15px;color:var(--dsw-alias-label-primary);
|
|
940
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;user-select:text;-webkit-user-select:text;}
|
|
941
|
+
.dshic-asset-name .dshic-input{height:24px;font-size:11px;padding:0 6px;}
|
|
942
|
+
/* 资产所属项目下拉:改到通用/其它项目(多项目共用) */
|
|
943
|
+
.dshic-asset-project{width:100%;box-sizing:border-box;height:22px;margin:0 0 6px;border:1px solid var(--dsw-alias-border-l2);
|
|
944
|
+
border-radius:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font:inherit;font-size:10px;
|
|
945
|
+
padding:0 5px;outline:none;cursor:pointer;text-align:left;}
|
|
803
946
|
.dshic-asset-actions .dshic-iconbtn{width:20px;height:20px;background:color-mix(in srgb,var(--dsw-alias-bg-base) 70%,transparent);
|
|
804
947
|
color:var(--dsw-alias-label-primary);backdrop-filter:blur(4px);}
|
|
805
948
|
.dshic-field{display:flex;flex-direction:column;gap:3px;}
|
|
@@ -835,7 +978,7 @@ window.__ModuleLoader__.load({
|
|
|
835
978
|
border-radius:14px;box-shadow:0 18px 50px color-mix(in srgb,var(--dsw-alias-bg-base) 60%,transparent);padding:16px;display:flex;flex-direction:column;gap:10px;}
|
|
836
979
|
.dshic-modal-title{font-size:14px;font-weight:600;}
|
|
837
980
|
.dshic-modal-warn{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);}
|
|
838
|
-
.dshic-modal-name{font-weight:600;color:var(--dsw-alias-label-primary);}
|
|
981
|
+
.dshic-modal-name{font-weight:600;color:var(--dsw-alias-label-primary);user-select:text;-webkit-user-select:text;cursor:text;}
|
|
839
982
|
.dshic-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:2px;}
|
|
840
983
|
.dshic-btn-outline{border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);}
|
|
841
984
|
.dshic-btn-outline:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 8%,transparent);}
|
|
@@ -848,7 +991,28 @@ window.__ModuleLoader__.load({
|
|
|
848
991
|
color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);}
|
|
849
992
|
.dshic-shot-item strong{color:var(--dsw-alias-label-primary);font-size:11px;}
|
|
850
993
|
.dshic-textcard{width:100%;height:86px;box-sizing:border-box;border:none;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);
|
|
851
|
-
font:inherit;font-size:
|
|
994
|
+
font:inherit;font-size:12px;line-height:18px;padding:7px 9px;overflow:hidden;text-align:left;display:block;white-space:pre-wrap;}
|
|
995
|
+
/* 文本(剧本等)资产卡:只展示标题与项目标签,不展开正文 */
|
|
996
|
+
.dshic-textcard.is-title{display:flex;flex-direction:column;gap:6px;justify-content:center;padding:10px 9px;color:inherit;}
|
|
997
|
+
.dshic-textcard-title{font-size:12px;font-weight:600;line-height:17px;color:var(--dsw-alias-label-primary);word-break:break-word;
|
|
998
|
+
display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;}
|
|
999
|
+
.dshic-textcard-sub{font-size:10px;line-height:14px;color:var(--dsw-alias-label-secondary);}
|
|
1000
|
+
/* @引用高亮输入框:字号/行高/内距与 .dshic-textarea 完全一致(背景层与透明输入框必须逐像素对齐) */
|
|
1001
|
+
.dshic-prompt{position:relative;display:block;width:100%;}
|
|
1002
|
+
.dshic-prompt-back,.dshic-prompt-input{font:inherit;font-size:12px;line-height:18px;padding:7px 9px;box-sizing:border-box;
|
|
1003
|
+
white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;}
|
|
1004
|
+
.dshic-prompt-back{position:absolute;inset:0;overflow:hidden;pointer-events:none;color:var(--dsw-alias-label-primary);
|
|
1005
|
+
border:1px solid transparent;border-radius:6px;}
|
|
1006
|
+
.dshic-prompt-ph{color:var(--dsw-alias-label-tertiary);}
|
|
1007
|
+
.dshic-prompt-input{position:relative;background:transparent;color:transparent;caret-color:var(--dsw-alias-label-primary);
|
|
1008
|
+
resize:vertical;}
|
|
1009
|
+
.dshic-prompt-input::placeholder{color:transparent;}
|
|
1010
|
+
.dshic-mention{color:#2f7cf6;font-weight:700;}
|
|
1011
|
+
/* 生成中的一行思考内容:单行、省略号,让用户看到"在动"而不是卡住 */
|
|
1012
|
+
.dshic-stream{display:flex;align-items:center;gap:5px;font-size:10px;line-height:14px;color:var(--dsw-alias-label-secondary);
|
|
1013
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding:0 2px;font-variant-numeric:tabular-nums;}
|
|
1014
|
+
.dshic-stream-dot{flex:none;width:5px;height:5px;border-radius:50%;background:var(--dsw-alias-brand-primary);animation:dshic-pulse 1.1s ease-in-out infinite;}
|
|
1015
|
+
@keyframes dshic-pulse{0%,100%{opacity:.25;}50%{opacity:1;}}
|
|
852
1016
|
.dshic-projchip{flex:none;height:15px;padding:0 5px;border-radius:4px;font-size:9px;line-height:15px;
|
|
853
1017
|
background:color-mix(in srgb,var(--dsw-alias-state-warn-primary) 16%,transparent);color:var(--dsw-alias-state-warn-primary);max-width:72px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
854
1018
|
/* 节点类型色条:左缘 accent */
|
|
@@ -928,9 +1092,10 @@ window.__ModuleLoader__.load({
|
|
|
928
1092
|
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 忽略配额错误 */ }
|
|
929
1093
|
}
|
|
930
1094
|
const DEFAULT_SETTINGS = {
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1095
|
+
// 生成参数一律"留空即不发送",由 Harness 适配器/模型用已配置的默认值(不在代码里写死)
|
|
1096
|
+
textLlm: { provider: '', model: '', maxTokens: '', temperature: '' },
|
|
1097
|
+
imageApis: [], // [{id, name, baseUrl, apiKey, model, mode, size?, responseFormat?}]
|
|
1098
|
+
videoApis: [], // [{id, name, url, apiKey, model, protocol?, watermark?, generateAudio?}]
|
|
934
1099
|
ttsApis: [], // [{id, name, baseUrl, apiKey, model, voice}]
|
|
935
1100
|
imageApiId: '',
|
|
936
1101
|
videoApiId: '',
|
|
@@ -954,20 +1119,22 @@ window.__ModuleLoader__.load({
|
|
|
954
1119
|
skillStoryboard: s.skillStoryboard || '',
|
|
955
1120
|
};
|
|
956
1121
|
// v0.3 及以前的单配置自动迁移为列表
|
|
1122
|
+
// 注意:id 必须**确定性**——loadSettings 是高频调用(每次渲染/生成都会走),
|
|
1123
|
+
// 若这里用 uid() 每次生成新 id,节点上保存的 apiId 会永远匹配不上、默认项也会漂移
|
|
957
1124
|
if (!out.imageApis.length && s.imageApi && s.imageApi.baseUrl) {
|
|
958
1125
|
out.imageApis = [{
|
|
959
|
-
id:
|
|
1126
|
+
id: 'legacy-image', name: T('defaultImageCfg'),
|
|
960
1127
|
baseUrl: s.imageApi.baseUrl || '', apiKey: s.imageApi.apiKey || '',
|
|
961
1128
|
model: s.imageApi.model || '', mode: s.imageApi.mode || 'generations',
|
|
962
1129
|
}];
|
|
963
|
-
out.imageApiId =
|
|
1130
|
+
out.imageApiId = s.imageApiId || 'legacy-image';
|
|
964
1131
|
}
|
|
965
1132
|
if (!out.videoApis.length && s.videoApi && s.videoApi.url) {
|
|
966
1133
|
out.videoApis = [{
|
|
967
|
-
id:
|
|
1134
|
+
id: 'legacy-video', name: T('defaultVideoCfg'),
|
|
968
1135
|
url: s.videoApi.url || '', apiKey: s.videoApi.apiKey || '', model: s.videoApi.model || '',
|
|
969
1136
|
}];
|
|
970
|
-
out.videoApiId =
|
|
1137
|
+
out.videoApiId = s.videoApiId || 'legacy-video';
|
|
971
1138
|
}
|
|
972
1139
|
return out;
|
|
973
1140
|
}
|
|
@@ -981,28 +1148,236 @@ window.__ModuleLoader__.load({
|
|
|
981
1148
|
// 项目设定集(Series Bible):跨集/跨镜一致性的唯一事实源
|
|
982
1149
|
// ============================================================
|
|
983
1150
|
const LS_BIBLE = 'dsh-ic:bible:';
|
|
984
|
-
|
|
1151
|
+
const LS_BIBLE_SYNC = 'dsh-ic:biblesync:';
|
|
1152
|
+
function emptyBible() { return { worldview: '', style: '', characters: [], scenes: [], props: [] }; }
|
|
985
1153
|
function loadBible(pid) {
|
|
986
1154
|
if (!pid) return emptyBible();
|
|
987
1155
|
const b = lsGet(LS_BIBLE + pid, null);
|
|
988
1156
|
if (!b) return emptyBible();
|
|
989
1157
|
return {
|
|
1158
|
+
worldview: b.worldview || '',
|
|
990
1159
|
style: b.style || '',
|
|
991
1160
|
characters: Array.isArray(b.characters) ? b.characters : [],
|
|
992
1161
|
scenes: Array.isArray(b.scenes) ? b.scenes : [],
|
|
993
1162
|
props: Array.isArray(b.props) ? b.props : [],
|
|
994
1163
|
};
|
|
995
1164
|
}
|
|
996
|
-
|
|
1165
|
+
/** 只写本地(用于接收 Host 侧改写,避免回推形成环)。 */
|
|
1166
|
+
function saveBibleLocal(pid, b) { if (pid) lsSet(LS_BIBLE + pid, b); }
|
|
1167
|
+
// 上一次已同步给 Host 的设定集快照:避免每次画布保存都把(可能已被对话改过的)本地版本推回去覆盖
|
|
1168
|
+
const biblePushSnap = new Map();
|
|
1169
|
+
function bibleSnapshot(pid) { try { return JSON.stringify(loadBible(pid)); } catch { return ''; } }
|
|
1170
|
+
/** 需要时才推送设定集:本地内容与上次同步的一致 → 不带 bible 字段,Host 保留对话侧的新版本。 */
|
|
1171
|
+
function bibleFieldForPush(pid) {
|
|
1172
|
+
const snap = bibleSnapshot(pid);
|
|
1173
|
+
if (biblePushSnap.get(pid) === snap) return {};
|
|
1174
|
+
return { bible: loadBible(pid), __bibleChanged: true };
|
|
1175
|
+
}
|
|
1176
|
+
/** 把设定集同步到 Host:对话侧才能读写设定集(设定集先行)。内容未变则不推送(避免覆盖对话侧的新版本)。 */
|
|
1177
|
+
function pushBible(pid, b) {
|
|
1178
|
+
if (!pid) return;
|
|
1179
|
+
const bible = b || loadBible(pid);
|
|
1180
|
+
if (biblePushSnap.get(pid) === JSON.stringify(bible)) return;
|
|
1181
|
+
try {
|
|
1182
|
+
const proj = S.projects.find((p) => p.id === pid);
|
|
1183
|
+
const doc = loadGraphDoc(pid) || emptyGraph();
|
|
1184
|
+
hostRpc('projectsPut', { id: pid, name: (proj && proj.name) || pid, doc: { ...doc, bible } })
|
|
1185
|
+
.then((r) => {
|
|
1186
|
+
if (r && r.bibleUpdatedAt) {
|
|
1187
|
+
lsSet(LS_BIBLE_SYNC + pid, r.bibleUpdatedAt);
|
|
1188
|
+
biblePushSnap.set(pid, JSON.stringify(bible));
|
|
1189
|
+
}
|
|
1190
|
+
})
|
|
1191
|
+
.catch(() => { /* 静默 */ });
|
|
1192
|
+
} catch { /* 忽略 */ }
|
|
1193
|
+
}
|
|
1194
|
+
const biblePushTimers = new Map();
|
|
1195
|
+
/** 设定集保存:本地立即写,推送防抖(输入过程中不逐字打网络)。 */
|
|
1196
|
+
function saveBible(pid, b) {
|
|
1197
|
+
if (!pid) return;
|
|
1198
|
+
saveBibleLocal(pid, b);
|
|
1199
|
+
if (biblePushTimers.has(pid)) clearTimeout(biblePushTimers.get(pid));
|
|
1200
|
+
biblePushTimers.set(pid, setTimeout(() => { biblePushTimers.delete(pid); pushBible(pid, loadBible(pid)); }, 800));
|
|
1201
|
+
}
|
|
997
1202
|
function bibleBlock(b) {
|
|
998
1203
|
if (!b) return '';
|
|
999
1204
|
const lines = [];
|
|
1205
|
+
if ((b.worldview || '').trim()) lines.push('世界观/核心设定:' + b.worldview.trim());
|
|
1000
1206
|
if ((b.style || '').trim()) lines.push('风格基调:' + b.style.trim());
|
|
1001
1207
|
for (const c of b.characters || []) if ((c.name || '').trim()) lines.push(`角色 ${c.name.trim()}:${(c.desc || '').trim()}`);
|
|
1002
1208
|
for (const c of b.scenes || []) if ((c.name || '').trim()) lines.push(`场景 ${c.name.trim()}:${(c.desc || '').trim()}`);
|
|
1003
1209
|
for (const c of b.props || []) if ((c.name || '').trim()) lines.push(`道具 ${c.name.trim()}:${(c.desc || '').trim()}`);
|
|
1004
1210
|
return lines.join('\n');
|
|
1005
1211
|
}
|
|
1212
|
+
|
|
1213
|
+
// ============================================================
|
|
1214
|
+
// @ 引用(mention):提示词里写 @张山 → 自动带上该实体的设定集描述与其参考图资产
|
|
1215
|
+
// 以下为纯函数(无副作用),供 scripts/test-mentions.mjs 抽取单测
|
|
1216
|
+
// <<<MENTION_PURE_BEGIN>>>
|
|
1217
|
+
/** @名称 的词法:到空白或常见标点为止(支持中英文名与 - _ ·) */
|
|
1218
|
+
const MENTION_RE = /@([^\s@,。;:、,.;:!?!?()()\[\]【】"'“”<>《》/\\|]{1,24})/g;
|
|
1219
|
+
/** 解析提示词里的 @名称(含位置,按出现顺序)。 */
|
|
1220
|
+
function parseMentions(text) {
|
|
1221
|
+
const out = [];
|
|
1222
|
+
const src = String(text || '');
|
|
1223
|
+
const re = new RegExp(MENTION_RE.source, 'g');
|
|
1224
|
+
let m;
|
|
1225
|
+
while ((m = re.exec(src)) !== null) {
|
|
1226
|
+
const name = String(m[1] || '').trim();
|
|
1227
|
+
if (name) out.push({ name, start: m.index, end: m.index + m[0].length });
|
|
1228
|
+
}
|
|
1229
|
+
return out;
|
|
1230
|
+
}
|
|
1231
|
+
function escapeHtml(s) {
|
|
1232
|
+
return String(s || '')
|
|
1233
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
1234
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
1235
|
+
}
|
|
1236
|
+
/** 把提示词转成高亮 HTML:@名称 用 .dshic-mention(蓝色加粗)标记。 */
|
|
1237
|
+
function highlightMentions(text) {
|
|
1238
|
+
const src = String(text || '');
|
|
1239
|
+
let out = '';
|
|
1240
|
+
let last = 0;
|
|
1241
|
+
const re = new RegExp(MENTION_RE.source, 'g');
|
|
1242
|
+
let m;
|
|
1243
|
+
while ((m = re.exec(src)) !== null) {
|
|
1244
|
+
out += escapeHtml(src.slice(last, m.index));
|
|
1245
|
+
out += '<span class="dshic-mention">' + escapeHtml(m[0]) + '</span>';
|
|
1246
|
+
last = m.index + m[0].length;
|
|
1247
|
+
}
|
|
1248
|
+
out += escapeHtml(src.slice(last));
|
|
1249
|
+
return out === '' ? '' : out + '\n'; // 末尾补换行,保证背景层与 textarea 行高对齐
|
|
1250
|
+
}
|
|
1251
|
+
/** 送给模型前去掉 @ 标记(保留名称本体)。 */
|
|
1252
|
+
function stripMentionMarks(text) {
|
|
1253
|
+
return String(text || '').replace(new RegExp(MENTION_RE.source, 'g'), '$1');
|
|
1254
|
+
}
|
|
1255
|
+
function entityLabelOf(kind) {
|
|
1256
|
+
return kind === 'character' ? '角色' : kind === 'scene' ? '场景' : kind === 'prop' ? '道具' : '元素';
|
|
1257
|
+
}
|
|
1258
|
+
function entityCategoryOf(kind) {
|
|
1259
|
+
return kind === 'character' ? 'character' : kind === 'scene' ? 'scene' : kind === 'prop' ? 'prop' : 'general';
|
|
1260
|
+
}
|
|
1261
|
+
/** 在设定集里按名称找实体(角色/场景/道具),携带其链接的资产 id。 */
|
|
1262
|
+
function matchBibleEntity(name, bible) {
|
|
1263
|
+
const want = String(name || '').trim().toLowerCase();
|
|
1264
|
+
if (!want) return null;
|
|
1265
|
+
for (const [kind, key] of [['character', 'characters'], ['scene', 'scenes'], ['prop', 'props']]) {
|
|
1266
|
+
for (const it of (bible && bible[key]) || []) {
|
|
1267
|
+
if (String((it && it.name) || '').trim().toLowerCase() === want) {
|
|
1268
|
+
return {
|
|
1269
|
+
kind, name: String(it.name).trim(), desc: String((it.desc || '')).trim(),
|
|
1270
|
+
assetIds: Array.isArray(it.assetIds) ? it.assetIds.map(String).filter(Boolean) : [],
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
return null;
|
|
1276
|
+
}
|
|
1277
|
+
/** 资产是否属于某实体:优先显式 entity 字段,其次标准命名「角色-名称 …」(名称后须为边界,避免张山/张山峰混淆)。 */
|
|
1278
|
+
function assetBelongsToEntity(rec, ent) {
|
|
1279
|
+
if (!rec || !ent) return false;
|
|
1280
|
+
if (rec.entity && String(rec.entity).trim().toLowerCase() === ent.name.toLowerCase()) return true;
|
|
1281
|
+
const nm = String(rec.name || '').trim();
|
|
1282
|
+
if (!nm) return false;
|
|
1283
|
+
if (nm.toLowerCase() === ent.name.toLowerCase()) return true;
|
|
1284
|
+
const pref = entityLabelOf(ent.kind) + '-' + ent.name;
|
|
1285
|
+
if (!nm.startsWith(pref)) return false;
|
|
1286
|
+
const rest = nm.slice(pref.length);
|
|
1287
|
+
return rest === '' || !/[\p{L}\p{N}]/u.test(rest[0]);
|
|
1288
|
+
}
|
|
1289
|
+
/** 生成用提示词:@标记去掉 + [图N] 绑定 + 命中实体的设定描述。 */
|
|
1290
|
+
function buildEffectivePrompt(prompt, refs, entities, bible) {
|
|
1291
|
+
const zh = effLang() !== 'en';
|
|
1292
|
+
const bind = refs.map((r, i) => {
|
|
1293
|
+
if (r.entity) {
|
|
1294
|
+
const ent = matchBibleEntity(r.entity, bible);
|
|
1295
|
+
const desc = ent ? ent.desc : '';
|
|
1296
|
+
return `[图${i + 1}] ${entityLabelOf(ent ? ent.kind : 'prop')} ${r.entity}${desc ? '(' + desc + ')' : ''}`;
|
|
1297
|
+
}
|
|
1298
|
+
return `[图${i + 1}] ${catLabel(r.category)}:${r.name}`;
|
|
1299
|
+
}).join(';');
|
|
1300
|
+
const entLines = entities.map((e) => `${entityLabelOf(e.kind)} ${e.name}:${e.desc}`);
|
|
1301
|
+
return [
|
|
1302
|
+
(bible.style || '').trim() ? (zh ? '风格:' : 'Style: ') + bible.style.trim() : '',
|
|
1303
|
+
bind ? (zh ? '参考图 → ' : 'Refs -> ') + bind : '',
|
|
1304
|
+
stripMentionMarks(prompt),
|
|
1305
|
+
entLines.length ? (zh ? '设定集:' : 'Series bible: ') + entLines.join(';') : '',
|
|
1306
|
+
].filter(Boolean).join('\n');
|
|
1307
|
+
}
|
|
1308
|
+
/** 资产命名时间戳(MM-DD HH:MM):同节点多次生成靠它区分。 */
|
|
1309
|
+
function nowStamp() {
|
|
1310
|
+
return new Date().toISOString().slice(5, 16).replace('T', ' ');
|
|
1311
|
+
}
|
|
1312
|
+
/** 资产命名:**节点名 + 时间**(用户规则);无节点名退回实体名/类型前缀 + 时间。 */
|
|
1313
|
+
function assetNameFor(node, entities, fallbackKind) {
|
|
1314
|
+
const label = String((node && node.data && node.data.label) || '').trim();
|
|
1315
|
+
if (label) return label + ' ' + nowStamp();
|
|
1316
|
+
if (entities && entities.length) return entityLabelOf(entities[0].kind) + '-' + entities[0].name + ' ' + nowStamp();
|
|
1317
|
+
return (fallbackKind === 'video' ? '视频' : '图片') + '-' + nowStamp();
|
|
1318
|
+
}
|
|
1319
|
+
// <<<MENTION_PURE_END>>>
|
|
1320
|
+
|
|
1321
|
+
/** 解析提示词里的 @引用:返回命中实体 + 其名下的参考图资产(按实体出现顺序、各实体内按生成时间)。
|
|
1322
|
+
* 实体的参考图来源:① 设定条目里**链接的资产**(assetIds,绑定关系优先)
|
|
1323
|
+
* ② 其次按命名/实体字段匹配的资产(角色-张山 多视图 等)。 */
|
|
1324
|
+
async function resolveMentionBundle(prompt, bible, projectId) {
|
|
1325
|
+
const entities = [];
|
|
1326
|
+
for (const m of parseMentions(prompt)) {
|
|
1327
|
+
const hit = matchBibleEntity(m.name, bible);
|
|
1328
|
+
if (hit && !entities.some((e) => e.name === hit.name)) entities.push(hit);
|
|
1329
|
+
}
|
|
1330
|
+
if (!entities.length) return { entities: [], refs: [] };
|
|
1331
|
+
let all = [];
|
|
1332
|
+
try { all = (await idbByProject(projectId)).concat(await idbByProject(GENERAL_PID)); } catch { all = []; }
|
|
1333
|
+
const byId = new Map(all.map((r) => [r.id, r]));
|
|
1334
|
+
const refs = [];
|
|
1335
|
+
for (const ent of entities) {
|
|
1336
|
+
const owned = [];
|
|
1337
|
+
// ① 条目链接的资产(绑定优先,任何类型都认;生成时只有图片能当参考图)
|
|
1338
|
+
for (const aid of ent.assetIds || []) {
|
|
1339
|
+
const rec = byId.get(aid);
|
|
1340
|
+
if (rec && rec.kind === 'image') owned.push(rec);
|
|
1341
|
+
}
|
|
1342
|
+
// ② 命名/实体匹配的资产
|
|
1343
|
+
for (const r of all) {
|
|
1344
|
+
if (r.kind === 'image' && assetBelongsToEntity(r, ent) && !owned.some((x) => x.id === r.id)) owned.push(r);
|
|
1345
|
+
}
|
|
1346
|
+
owned.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
|
|
1347
|
+
for (const r of owned) if (!refs.some((x) => x.assetId === r.id)) refs.push({ assetId: r.id, entity: ent.name, kind: ent.kind, name: r.name });
|
|
1348
|
+
}
|
|
1349
|
+
return { entities, refs };
|
|
1350
|
+
}
|
|
1351
|
+
/** 合成参考图列表:连线(用户显式顺序)优先,其后追加 @引用命中的设定集资产(去重)。 */
|
|
1352
|
+
async function collectRefs(n, prompt, bible) {
|
|
1353
|
+
const out = [];
|
|
1354
|
+
const edges = S.graph.edges.filter((x) => x.toNode === n.id && x.toPort === 'ref');
|
|
1355
|
+
const order = (n.data.refOrder || []).filter((id) => edges.some((e) => e.id === id));
|
|
1356
|
+
for (const e of edges) if (!order.includes(e.id)) order.push(e.id);
|
|
1357
|
+
for (const id of order) {
|
|
1358
|
+
const e = edges.find((x) => x.id === id);
|
|
1359
|
+
const src = e && S.graph.nodes[e.fromNode];
|
|
1360
|
+
if (!src) continue;
|
|
1361
|
+
const aid = servedAssetId(src, e.fromPort);
|
|
1362
|
+
if (!aid) continue;
|
|
1363
|
+
try {
|
|
1364
|
+
const rec = await idbGet(aid);
|
|
1365
|
+
const blob = await assetBlob(aid);
|
|
1366
|
+
if (blob) out.push({ assetId: aid, blob, name: (rec && rec.name) || '', category: (rec && rec.category) || 'general', source: 'wired' });
|
|
1367
|
+
} catch { /* 单个失败不影响其余 */ }
|
|
1368
|
+
}
|
|
1369
|
+
const bundle = await resolveMentionBundle(prompt, bible, S.activeId);
|
|
1370
|
+
for (const r of bundle.refs) {
|
|
1371
|
+
if (out.some((x) => x.assetId === r.assetId)) continue;
|
|
1372
|
+
try {
|
|
1373
|
+
const rec = await idbGet(r.assetId);
|
|
1374
|
+
const blob = await assetBlob(r.assetId);
|
|
1375
|
+
if (blob) out.push({ assetId: r.assetId, blob, name: (rec && rec.name) || r.name, category: (rec && rec.category) || 'general', source: 'mention', entity: r.entity });
|
|
1376
|
+
} catch { /* 忽略 */ }
|
|
1377
|
+
}
|
|
1378
|
+
return { refs: out, entities: bundle.entities };
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1006
1381
|
function outLangLine() {
|
|
1007
1382
|
return effLang() === 'en'
|
|
1008
1383
|
? 'Write all narrative content in English.'
|
|
@@ -1048,6 +1423,12 @@ window.__ModuleLoader__.load({
|
|
|
1048
1423
|
}
|
|
1049
1424
|
async function idbDelete(id) {
|
|
1050
1425
|
assetNameCache.delete(id);
|
|
1426
|
+
// 释放该资产的 objectURL:否则 Blob(视频可达几十 MB)会被 URL 钉在内存里直到刷新
|
|
1427
|
+
if (assetUrlCache.has(id)) {
|
|
1428
|
+
const u = assetUrlCache.get(id);
|
|
1429
|
+
assetUrlCache.delete(id);
|
|
1430
|
+
if (typeof u === 'string' && u.startsWith('blob:')) { try { URL.revokeObjectURL(u); } catch { /* 忽略 */ } }
|
|
1431
|
+
}
|
|
1051
1432
|
const db = await openDb();
|
|
1052
1433
|
return new Promise((resolve, reject) => {
|
|
1053
1434
|
const tx = db.transaction(DB_STORE, 'readwrite');
|
|
@@ -1087,7 +1468,18 @@ window.__ModuleLoader__.load({
|
|
|
1087
1468
|
if (!rec) return null;
|
|
1088
1469
|
let url = null;
|
|
1089
1470
|
if (rec.rel) url = fileUrlFor(rec.rel); // 磁盘资产:Host 文件服务(支持 Range)
|
|
1090
|
-
else if (rec.blob)
|
|
1471
|
+
else if (rec.blob) {
|
|
1472
|
+
// IndexedDB 资产:objectURL 有内存成本 → 缓存总量设上限,超出时释放最早的一批
|
|
1473
|
+
if (assetUrlCache.size >= 60) {
|
|
1474
|
+
const first = assetUrlCache.keys().next();
|
|
1475
|
+
if (!first.done) {
|
|
1476
|
+
const oldUrl = assetUrlCache.get(first.value);
|
|
1477
|
+
assetUrlCache.delete(first.value);
|
|
1478
|
+
if (typeof oldUrl === 'string' && oldUrl.startsWith('blob:')) { try { URL.revokeObjectURL(oldUrl); } catch { /* 忽略 */ } }
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
url = URL.createObjectURL(rec.blob);
|
|
1482
|
+
}
|
|
1091
1483
|
if (url) assetUrlCache.set(id, url);
|
|
1092
1484
|
return url;
|
|
1093
1485
|
}
|
|
@@ -1115,9 +1507,14 @@ window.__ModuleLoader__.load({
|
|
|
1115
1507
|
source: (meta && meta.source) || 'demo',
|
|
1116
1508
|
category: (meta && meta.category) || 'general',
|
|
1117
1509
|
};
|
|
1510
|
+
// 设定集绑定(@引用解析依据):entity=主实体名,entities=全部命中实体,nodeLabel=来源节点名
|
|
1511
|
+
if (meta && meta.entity) rec.entity = meta.entity;
|
|
1512
|
+
if (meta && Array.isArray(meta.entities) && meta.entities.length) rec.entities = meta.entities;
|
|
1513
|
+
if (meta && meta.nodeLabel) rec.nodeLabel = meta.nodeLabel;
|
|
1514
|
+
if (meta && meta.effectivePrompt) rec.effectivePrompt = meta.effectivePrompt;
|
|
1118
1515
|
if (S.dataRoot) {
|
|
1119
|
-
// 数据根目录已配置:落盘到 <root>/projects
|
|
1120
|
-
const rel = `projects/${projectId}/${kind}/${
|
|
1516
|
+
// 数据根目录已配置:落盘到 <root>/projects/<项目>/<类型>/<可读名>-<id尾号>.<ext>,IndexedDB 只存元数据
|
|
1517
|
+
const rel = `projects/${projectId}/${kind}/${assetFileBase(rec)}.${extFromMime(mime)}`;
|
|
1121
1518
|
const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
|
|
1122
1519
|
if (!r.ok) throw new Error('资产落盘失败: ' + (await r.text()));
|
|
1123
1520
|
rec.rel = rel;
|
|
@@ -1129,21 +1526,72 @@ window.__ModuleLoader__.load({
|
|
|
1129
1526
|
}
|
|
1130
1527
|
await idbPut(rec);
|
|
1131
1528
|
assetNameCache.set(rec.id, rec.name);
|
|
1529
|
+
mirrorAssetIndex(rec); // 资产元数据镜像进数据目录 assets.json(迁移便利,失败静默)
|
|
1132
1530
|
return rec;
|
|
1133
1531
|
}
|
|
1532
|
+
/** 资产元数据索引记录(去 blob;文本资产保留 text 以便导入端免读文件即可预览)。 */
|
|
1533
|
+
function assetIndexRecord(rec) {
|
|
1534
|
+
const out = {
|
|
1535
|
+
id: rec.id, projectId: rec.projectId, kind: rec.kind, mime: rec.mime,
|
|
1536
|
+
name: rec.name, createdAt: rec.createdAt, prompt: rec.prompt || '',
|
|
1537
|
+
source: rec.source || '', category: rec.category || 'general',
|
|
1538
|
+
};
|
|
1539
|
+
if (rec.rel) { out.rel = rec.rel; out.size = rec.size || 0; }
|
|
1540
|
+
if (rec.entity) out.entity = rec.entity;
|
|
1541
|
+
if (Array.isArray(rec.entities) && rec.entities.length) out.entities = rec.entities;
|
|
1542
|
+
if (rec.nodeLabel) out.nodeLabel = rec.nodeLabel;
|
|
1543
|
+
if (rec.kind === 'text' && typeof rec.text === 'string') out.text = rec.text;
|
|
1544
|
+
return out;
|
|
1545
|
+
}
|
|
1546
|
+
/** 把资产元数据镜像进数据目录的 assets.json(失败静默:索引是迁移便利,不是主存储)。 */
|
|
1547
|
+
function mirrorAssetIndex(rec) {
|
|
1548
|
+
if (!S.dataRoot || !rec || !rec.id) return;
|
|
1549
|
+
const proj = S.projects.find((p) => p.id === rec.projectId);
|
|
1550
|
+
hostRpc('assetIndexPut', {
|
|
1551
|
+
projectId: rec.projectId, projectName: (proj && proj.name) || rec.projectId, asset: assetIndexRecord(rec),
|
|
1552
|
+
}).catch(() => { /* 索引失败不影响资产本身 */ });
|
|
1553
|
+
}
|
|
1554
|
+
/** 启动/设置数据目录后:一次性把 IndexedDB 里的全部资产元数据同步进 assets.json(补齐历史资产索引)。
|
|
1555
|
+
* 全量**替换**语义(含空项目占位):IndexedDB 是资产库唯一事实源,借此自愈删除后残留的幽灵条目。 */
|
|
1556
|
+
let assetIndexSynced = false;
|
|
1557
|
+
async function syncAssetIndexToHost() {
|
|
1558
|
+
if (assetIndexSynced || !S.dataRoot) return;
|
|
1559
|
+
assetIndexSynced = true;
|
|
1560
|
+
try {
|
|
1561
|
+
const all = await idbAll();
|
|
1562
|
+
const items = [];
|
|
1563
|
+
const seen = new Set();
|
|
1564
|
+
const projName = new Map(S.projects.map((p) => [p.id, p.name]));
|
|
1565
|
+
for (const rec of all) {
|
|
1566
|
+
if (!rec || !rec.id || !rec.projectId) continue;
|
|
1567
|
+
seen.add(rec.projectId);
|
|
1568
|
+
items.push({ projectId: rec.projectId, projectName: projName.get(rec.projectId) || rec.projectId, asset: assetIndexRecord(rec) });
|
|
1569
|
+
}
|
|
1570
|
+
// 本机已知但没有任何资产的项目也要发占位:assets.json 清空对齐(否则删除后会残留幽灵条目)
|
|
1571
|
+
for (const p of S.projects) {
|
|
1572
|
+
if (!seen.has(p.id)) items.push({ projectId: p.id, projectName: p.name, asset: null });
|
|
1573
|
+
}
|
|
1574
|
+
if (items.length) await hostRpc('assetIndexBulkPut', { items });
|
|
1575
|
+
} catch { /* 索引同步失败不影响 */ }
|
|
1576
|
+
}
|
|
1134
1577
|
/** 一次性把 IndexedDB 里的历史资产迁移到数据根目录(保留元数据,移除 blob)。 */
|
|
1135
1578
|
async function migrateAssetsToDisk(onProgress) {
|
|
1136
1579
|
if (!S.dataRoot) throw new Error('请先设置数据根目录');
|
|
1137
1580
|
const all = await idbAll();
|
|
1138
|
-
|
|
1581
|
+
// 二进制资产(有 blob)与文本资产(text,此前从不落盘)都要迁移
|
|
1582
|
+
const targets = all.filter((r) => (r.blob && !r.rel) || (r.kind === 'text' && !r.rel && typeof r.text === 'string' && r.text.length));
|
|
1139
1583
|
let done = 0;
|
|
1140
1584
|
for (const rec of targets) {
|
|
1141
|
-
const
|
|
1142
|
-
const
|
|
1585
|
+
const isText = rec.kind === 'text' && !rec.blob;
|
|
1586
|
+
const rel = `projects/${rec.projectId}/${rec.kind}/${assetFileBase(rec)}.${isText ? 'md' : extFromMime(rec.mime)}`;
|
|
1587
|
+
const blob = isText ? new Blob([rec.text || ''], { type: 'text/markdown' }) : rec.blob;
|
|
1588
|
+
const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
|
|
1143
1589
|
if (!r.ok) throw new Error('迁移失败: ' + rel + ' — ' + (await r.text()));
|
|
1144
|
-
const next = { ...rec, rel, size:
|
|
1145
|
-
|
|
1590
|
+
const next = { ...rec, rel, size: blob.size, stored: 'disk' };
|
|
1591
|
+
if (isText) next.text = rec.text; // 文本保留在元数据里:预览/下载不必回读文件
|
|
1592
|
+
else delete next.blob;
|
|
1146
1593
|
await idbPut(next);
|
|
1594
|
+
mirrorAssetIndex(next); // 迁移后刷新数据目录 assets.json(补 rel/size)
|
|
1147
1595
|
assetUrlCache.delete(rec.id); // 旧 objectURL 失效,改走文件 URL
|
|
1148
1596
|
done++;
|
|
1149
1597
|
if (onProgress) onProgress(done, targets.length);
|
|
@@ -1268,11 +1716,30 @@ window.__ModuleLoader__.load({
|
|
|
1268
1716
|
return new Blob(chunks, { type: mime.split(';')[0] });
|
|
1269
1717
|
}
|
|
1270
1718
|
|
|
1719
|
+
/** 外部 API 请求统一经 Host 代理(图片/视频/音频网关都走这里):浏览器直连第三方接口会被
|
|
1720
|
+
* CORS 拦截(带 Authorization 的 POST 触发预检,网关不返回 CORS 头 → TypeError: Failed to fetch)。
|
|
1721
|
+
* 返回 { ok, status, contentType, text, blob }。 */
|
|
1722
|
+
async function fetchViaProxy(url, { method = 'POST', headers = {}, body = null, bodyRaw = null, bodyContentType = '', timeoutMs = 120000, label = '请求' } = {}) {
|
|
1723
|
+
const payload = { url, method, headers, timeoutMs };
|
|
1724
|
+
if (bodyRaw != null) { payload.bodyRaw = bodyRaw; payload.bodyContentType = bodyContentType; }
|
|
1725
|
+
else if (body != null) payload.body = body;
|
|
1726
|
+
// 客户端→Host 的预算比上游超时多 15s:让上游超时(504/带原因)先到,而不是客户端先断
|
|
1727
|
+
const res = await fetchT('/' + NS + '/api/fetch-proxy', {
|
|
1728
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload),
|
|
1729
|
+
}, Number(timeoutMs) + 15000, label + '(代理)');
|
|
1730
|
+
const ct = res.headers.get('content-type') || '';
|
|
1731
|
+
const buf = await res.arrayBuffer();
|
|
1732
|
+
const blob = new Blob([buf], { type: ct || 'application/octet-stream' });
|
|
1733
|
+
let text = '';
|
|
1734
|
+
if (!ct || /json|text|plain/.test(ct)) { try { text = new TextDecoder().decode(buf); } catch { text = ''; } }
|
|
1735
|
+
return { ok: res.ok, status: res.status, contentType: ct, text, blob };
|
|
1736
|
+
}
|
|
1271
1737
|
async function apiImage(prompt, refBlobs, s, opts) {
|
|
1272
1738
|
if (!s.baseUrl) throw new Error('未配置图片 API');
|
|
1273
1739
|
const base = s.baseUrl.replace(/\/+$/, '');
|
|
1274
1740
|
const refs = refBlobs || [];
|
|
1275
1741
|
const auth = s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {};
|
|
1742
|
+
const upstreamTimeout = Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000; // 商汤等同步接口 20s+,默认 120s
|
|
1276
1743
|
// 参考图编辑模式:multipart 上传到 /images/edits(OpenAI gpt-image-1 兼容)
|
|
1277
1744
|
if (refs.length && s.mode === 'edits') {
|
|
1278
1745
|
const fd = new FormData();
|
|
@@ -1281,45 +1748,62 @@ window.__ModuleLoader__.load({
|
|
|
1281
1748
|
fd.append('n', '1');
|
|
1282
1749
|
if (s.size) fd.append('size', s.size);
|
|
1283
1750
|
refs.forEach((b, i) => fd.append('image[]', b, 'ref-' + i + '.png'));
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
const
|
|
1751
|
+
// FormData 经 Response 序列化为 multipart 字节(含 boundary)→ base64 经代理原样转发
|
|
1752
|
+
const serialized = new Response(fd);
|
|
1753
|
+
const bodyRaw = arrayBufferToBase64(await serialized.arrayBuffer());
|
|
1754
|
+
const bodyContentType = serialized.headers.get('content-type') || 'multipart/form-data';
|
|
1755
|
+
const res = await fetchViaProxy(base + '/images/edits', { headers: auth, bodyRaw, bodyContentType, timeoutMs: upstreamTimeout, label: '图片编辑 API' });
|
|
1756
|
+
if (!res.ok) throw new Error('图片编辑 API HTTP ' + res.status + ':' + res.text.slice(0, 300));
|
|
1757
|
+
let j = null;
|
|
1758
|
+
try { j = JSON.parse(res.text); } catch { j = null; }
|
|
1287
1759
|
const d = j && j.data && j.data[0];
|
|
1288
1760
|
if (d && d.b64_json) return base64ToBlob(d.b64_json, 'image/png');
|
|
1289
1761
|
if (d && d.url) {
|
|
1290
|
-
const r2 = await
|
|
1762
|
+
const r2 = await fetchViaProxy(d.url, { method: 'GET', timeoutMs: 120000, label: '下载图片' });
|
|
1291
1763
|
if (!r2.ok) throw new Error('下载生成图片失败 HTTP ' + r2.status);
|
|
1292
|
-
return
|
|
1764
|
+
return r2.blob;
|
|
1293
1765
|
}
|
|
1294
1766
|
throw new Error('图片编辑 API 响应中没有 data[0].b64_json / url');
|
|
1295
1767
|
}
|
|
1296
1768
|
const body = {
|
|
1297
1769
|
...(s.model ? { model: s.model } : {}),
|
|
1298
1770
|
prompt, n: 1,
|
|
1299
|
-
response_format: 'b64_json',
|
|
1300
1771
|
};
|
|
1301
|
-
//
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
if (
|
|
1306
|
-
const
|
|
1307
|
-
|
|
1772
|
+
// 响应格式:留空 = 用 API 默认(响应解析同时支持 b64_json 与 url)
|
|
1773
|
+
if (String(s.responseFormat || '').trim()) body.response_format = String(s.responseFormat).trim();
|
|
1774
|
+
// 尺寸:留空 = 按节点上的「清晰度 × 比例」计算;填了就用你配置的值
|
|
1775
|
+
const sizeOverride = String(s.size || '').trim();
|
|
1776
|
+
if (sizeOverride) body.size = sizeOverride;
|
|
1777
|
+
else { const dims = imageDimsFor(opts); body.size = `${dims.w}x${dims.h}`; }
|
|
1778
|
+
const res = await fetchViaProxy(base + '/images/generations', {
|
|
1308
1779
|
headers: { 'Content-Type': 'application/json', ...auth },
|
|
1309
|
-
body:
|
|
1780
|
+
body, timeoutMs: upstreamTimeout, label: '图片 API',
|
|
1310
1781
|
});
|
|
1311
|
-
if (!res.ok) throw new Error('图片 API HTTP ' + res.status + ':' +
|
|
1312
|
-
|
|
1782
|
+
if (!res.ok) throw new Error('图片 API HTTP ' + res.status + ':' + res.text.slice(0, 300));
|
|
1783
|
+
let j = null;
|
|
1784
|
+
try { j = JSON.parse(res.text); } catch { j = null; }
|
|
1313
1785
|
const d = j && j.data && j.data[0];
|
|
1314
1786
|
if (d && d.b64_json) return base64ToBlob(d.b64_json, 'image/png');
|
|
1315
1787
|
if (d && d.url) {
|
|
1316
|
-
const r2 = await
|
|
1788
|
+
const r2 = await fetchViaProxy(d.url, { method: 'GET', timeoutMs: 120000, label: '下载图片' });
|
|
1317
1789
|
if (!r2.ok) throw new Error('下载生成图片失败 HTTP ' + r2.status);
|
|
1318
|
-
return
|
|
1790
|
+
return r2.blob;
|
|
1319
1791
|
}
|
|
1320
1792
|
throw new Error('图片 API 响应中没有 data[0].b64_json / url');
|
|
1321
1793
|
}
|
|
1322
1794
|
|
|
1795
|
+
/** 带超时的 fetch:网关挂住时不会让节点永久处于 running(停止/刷新仍能恢复)。 */
|
|
1796
|
+
async function fetchT(url, init, timeoutMs, label) {
|
|
1797
|
+
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : 120000;
|
|
1798
|
+
const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
1799
|
+
const timer = setTimeout(() => { try { if (ctrl) ctrl.abort(); } catch { /* 忽略 */ } }, ms);
|
|
1800
|
+
try {
|
|
1801
|
+
return await fetch(url, { ...(init || {}), ...(ctrl ? { signal: ctrl.signal } : {}) });
|
|
1802
|
+
} catch (e) {
|
|
1803
|
+
if (ctrl && ctrl.signal && ctrl.signal.aborted) throw new Error((label || '请求') + '超时(' + Math.round(ms / 1000) + ' 秒)');
|
|
1804
|
+
throw e;
|
|
1805
|
+
} finally { clearTimeout(timer); }
|
|
1806
|
+
}
|
|
1323
1807
|
function extractVideoUrl(j) {
|
|
1324
1808
|
if (typeof j === 'string' && /^https?:/.test(j)) return j;
|
|
1325
1809
|
if (!j || typeof j !== 'object') return null;
|
|
@@ -1339,13 +1823,15 @@ window.__ModuleLoader__.load({
|
|
|
1339
1823
|
?? (j.output && (j.output.task_id ?? j.output.id));
|
|
1340
1824
|
}
|
|
1341
1825
|
async function downloadVideo(url, headers) {
|
|
1342
|
-
|
|
1826
|
+
// 经 Host 代理:签名下载直连同样会被 CORS 拦
|
|
1827
|
+
const r = await fetchViaProxy(url, { method: 'GET', headers: headers || {}, timeoutMs: 300000, label: '下载视频' });
|
|
1343
1828
|
if (!r.ok) throw new Error('下载视频失败 HTTP ' + r.status);
|
|
1344
|
-
return
|
|
1829
|
+
return r.blob;
|
|
1345
1830
|
}
|
|
1346
1831
|
/** 火山方舟 Seedance 官方协议:创建任务 → 轮询 → 下载(支持首帧 + 最多 4 张参考图)。 */
|
|
1347
1832
|
async function seedanceVideo(prompt, frameBlob, refs, s, opts) {
|
|
1348
1833
|
if (!s.url) throw new Error('未配置视频 API');
|
|
1834
|
+
if (!s.model) throw new Error('请在设置里为该视频 API 填写模型名(Seedance 协议要求显式指定模型)');
|
|
1349
1835
|
const base = s.url.replace(/\/+$/, '');
|
|
1350
1836
|
const headers = { 'Content-Type': 'application/json', ...(s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {}) };
|
|
1351
1837
|
const o = opts || {};
|
|
@@ -1358,31 +1844,31 @@ window.__ModuleLoader__.load({
|
|
|
1358
1844
|
if (frameBlob) content.push({ type: 'image_url', image_url: { url: await blobToDataUrl(frameBlob) }, role: 'first_frame' });
|
|
1359
1845
|
for (const b of refBlobs) content.push({ type: 'image_url', image_url: { url: await blobToDataUrl(b) }, role: 'reference_image' });
|
|
1360
1846
|
const body = {
|
|
1361
|
-
model: s.model
|
|
1847
|
+
model: s.model,
|
|
1362
1848
|
content,
|
|
1363
1849
|
duration: Number(o.duration) || 5,
|
|
1364
1850
|
resolution: (o.resolution || '720P').toLowerCase(),
|
|
1365
|
-
watermark:
|
|
1366
|
-
generate_audio:
|
|
1851
|
+
watermark: s.watermark === true,
|
|
1852
|
+
generate_audio: s.generateAudio !== false,
|
|
1367
1853
|
};
|
|
1368
1854
|
body.ratio = frameBlob ? 'adaptive' : (o.ratio || '16:9');
|
|
1369
|
-
const res = await
|
|
1370
|
-
if (!res.ok) throw new Error('Seedance HTTP ' + res.status + ':' +
|
|
1371
|
-
const j =
|
|
1855
|
+
const res = await fetchViaProxy(base, { headers, body, timeoutMs: Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000, label: 'Seedance 视频 API' });
|
|
1856
|
+
if (!res.ok) throw new Error('Seedance HTTP ' + res.status + ':' + res.text.slice(0, 300));
|
|
1857
|
+
const j = JSON.parse(res.text || '{}');
|
|
1372
1858
|
const taskId = j && j.id;
|
|
1373
1859
|
if (!taskId) throw new Error('Seedance 响应中没有任务 id');
|
|
1374
1860
|
const deadline = Date.now() + 15 * 60 * 1000;
|
|
1375
1861
|
while (Date.now() < deadline) {
|
|
1376
1862
|
await sleep(10000);
|
|
1377
|
-
const r2 = await
|
|
1863
|
+
const r2 = await fetchViaProxy(base + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询 Seedance 任务' });
|
|
1378
1864
|
if (!r2.ok) throw new Error('查询 Seedance 任务失败 HTTP ' + r2.status);
|
|
1379
|
-
const st =
|
|
1865
|
+
const st = JSON.parse(r2.text || '{}');
|
|
1380
1866
|
if (st.status === 'succeeded') {
|
|
1381
1867
|
const u = st.content && st.content.video_url;
|
|
1382
1868
|
if (!u) throw new Error('Seedance 任务成功但未返回视频地址');
|
|
1383
1869
|
return await downloadVideo(u, {});
|
|
1384
1870
|
}
|
|
1385
|
-
if (
|
|
1871
|
+
if (['failed', 'expired', 'canceled', 'cancelled', 'error'].includes(st.status)) {
|
|
1386
1872
|
throw new Error('Seedance 任务失败:' + ((st.error && st.error.message) || st.status));
|
|
1387
1873
|
}
|
|
1388
1874
|
}
|
|
@@ -1409,10 +1895,10 @@ window.__ModuleLoader__.load({
|
|
|
1409
1895
|
if (frameBlob) {
|
|
1410
1896
|
try { body.image = await blobToDataUrl(frameBlob); } catch { /* 忽略首帧编码失败 */ }
|
|
1411
1897
|
}
|
|
1412
|
-
const res = await
|
|
1413
|
-
if (!res.ok) throw new Error('视频 API HTTP ' + res.status + ':' +
|
|
1898
|
+
const res = await fetchViaProxy(base, { headers, body, timeoutMs: Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000, label: '视频 API' });
|
|
1899
|
+
if (!res.ok) throw new Error('视频 API HTTP ' + res.status + ':' + res.text.slice(0, 300));
|
|
1414
1900
|
let j = null;
|
|
1415
|
-
try { j =
|
|
1901
|
+
try { j = JSON.parse(res.text); } catch { /* 非 JSON 响应 */ }
|
|
1416
1902
|
const direct = extractVideoUrl(j);
|
|
1417
1903
|
if (direct) return await downloadVideo(direct, {});
|
|
1418
1904
|
const taskId = extractTaskId(j);
|
|
@@ -1420,17 +1906,44 @@ window.__ModuleLoader__.load({
|
|
|
1420
1906
|
const deadline = Date.now() + 15 * 60 * 1000;
|
|
1421
1907
|
while (Date.now() < deadline) {
|
|
1422
1908
|
await sleep(3000);
|
|
1423
|
-
const r2 = await
|
|
1909
|
+
const r2 = await fetchViaProxy(base + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询视频任务' });
|
|
1424
1910
|
if (!r2.ok) throw new Error('查询视频任务失败 HTTP ' + r2.status);
|
|
1425
1911
|
let st = null;
|
|
1426
|
-
try { st =
|
|
1912
|
+
try { st = JSON.parse(r2.text); } catch { /* 忽略 */ }
|
|
1427
1913
|
const u = extractVideoUrl(st);
|
|
1428
1914
|
if (u) return await downloadVideo(u, {});
|
|
1429
|
-
|
|
1430
|
-
|
|
1915
|
+
// 失败判定必须看状态字段,不能拿整串 JSON 做子串匹配:
|
|
1916
|
+
// 很多网关在 processing 阶段返回 {"status":"processing","error":null},子串匹配会误判失败
|
|
1917
|
+
const failed = taskFailureOf(st);
|
|
1918
|
+
if (failed) throw new Error('视频任务失败:' + failed);
|
|
1431
1919
|
}
|
|
1432
1920
|
throw new Error('视频生成超时(15 分钟)');
|
|
1433
1921
|
}
|
|
1922
|
+
/** 从任务轮询响应里判断是否真的失败(白名单状态 + 显式错误信息),返回失败原因或 null。 */
|
|
1923
|
+
function taskFailureOf(st) {
|
|
1924
|
+
if (!st || typeof st !== 'object') return null;
|
|
1925
|
+
const pick = (v) => {
|
|
1926
|
+
if (typeof v === 'string') return v.toLowerCase();
|
|
1927
|
+
if (v && typeof v === 'object' && typeof v.status === 'string') return v.status.toLowerCase();
|
|
1928
|
+
return '';
|
|
1929
|
+
};
|
|
1930
|
+
const status = pick(st.status) || pick(st.state) || pick(st.task_status)
|
|
1931
|
+
|| pick(st.data && st.data.status) || pick(st.output && st.output.status) || pick(st.result && st.result.status);
|
|
1932
|
+
const FAIL = ['failed', 'fail', 'error', 'canceled', 'cancelled', 'expired', 'rejected'];
|
|
1933
|
+
const DONE = ['succeeded', 'success', 'completed', 'complete', 'done', 'finished'];
|
|
1934
|
+
if (status && FAIL.includes(status)) {
|
|
1935
|
+
const msg = (st.error && (st.error.message || st.error.msg)) || st.message || st.msg || st.reason
|
|
1936
|
+
|| (st.data && st.data.error && (st.data.error.message || st.data.error)) || status;
|
|
1937
|
+
return String(msg).slice(0, 300);
|
|
1938
|
+
}
|
|
1939
|
+
if (status && DONE.includes(status)) return null; // 成功但没解析出地址:交给调用方超时/后续处理
|
|
1940
|
+
// 状态未知(如 processing/queued/running/空):仅在明确带有错误对象时才算失败
|
|
1941
|
+
const errObj = st.error || (st.data && st.data.error) || (st.output && st.output.error);
|
|
1942
|
+
if (errObj && (typeof errObj === 'string' ? errObj.trim() : Object.keys(errObj).length)) {
|
|
1943
|
+
return typeof errObj === 'string' ? errObj.slice(0, 300) : JSON.stringify(errObj).slice(0, 300);
|
|
1944
|
+
}
|
|
1945
|
+
return null;
|
|
1946
|
+
}
|
|
1434
1947
|
|
|
1435
1948
|
function resolveApiCfg(list, defId, wantId) {
|
|
1436
1949
|
if (!Array.isArray(list) || !list.length) return null;
|
|
@@ -1446,7 +1959,7 @@ window.__ModuleLoader__.load({
|
|
|
1446
1959
|
async function generateVideo(prompt, frameBlob, opts) {
|
|
1447
1960
|
const settings = loadSettings();
|
|
1448
1961
|
const cfg = resolveApiCfg(settings.videoApis, settings.videoApiId, opts && opts.apiId);
|
|
1449
|
-
const blob = cfg ? await apiVideo(prompt, frameBlob, cfg, opts || {}) : await demoVideoBlob(prompt, videoDimsFor(opts), ((opts && opts.duration) || 5) * 1000);
|
|
1962
|
+
const blob = cfg ? await apiVideo(prompt, frameBlob, cfg, opts || {}) : await demoVideoBlob(prompt, videoDimsFor(opts, { capLong: 1280 }), ((opts && opts.duration) || 5) * 1000);
|
|
1450
1963
|
return { blob, demo: !cfg };
|
|
1451
1964
|
}
|
|
1452
1965
|
|
|
@@ -1554,8 +2067,9 @@ window.__ModuleLoader__.load({
|
|
|
1554
2067
|
v.onerror = () => rej(new Error('视频加载失败: ' + seg.name));
|
|
1555
2068
|
setTimeout(res, 15000);
|
|
1556
2069
|
});
|
|
1557
|
-
|
|
1558
|
-
|
|
2070
|
+
const trimSec = seg.dur && seg.dur > 0 ? Math.min(seg.dur, (isFinite(v.duration) && v.duration > 0 ? v.duration : seg.dur)) : 0;
|
|
2071
|
+
totalMs += trimSec * 1000 || (isFinite(v.duration) && v.duration > 0 ? v.duration * 1000 : 8000);
|
|
2072
|
+
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 });
|
|
1559
2073
|
}
|
|
1560
2074
|
const cv = document.createElement('canvas'); cv.width = W; cv.height = H;
|
|
1561
2075
|
const g = cv.getContext('2d');
|
|
@@ -1571,7 +2085,11 @@ window.__ModuleLoader__.load({
|
|
|
1571
2085
|
stream.addTrack(dest.stream.getAudioTracks()[0]);
|
|
1572
2086
|
audio = { ac, dest };
|
|
1573
2087
|
} catch { audio = null; }
|
|
1574
|
-
const rec = new MediaRecorder(stream, {
|
|
2088
|
+
const rec = new MediaRecorder(stream, {
|
|
2089
|
+
mimeType: mime,
|
|
2090
|
+
// 码率:仅在用户显式选择时指定,否则交给浏览器/编码器默认(不在代码里写死)
|
|
2091
|
+
...(opts.bitrate ? { videoBitsPerSecond: Number(opts.bitrate) } : {}),
|
|
2092
|
+
});
|
|
1575
2093
|
const chunks = [];
|
|
1576
2094
|
rec.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
|
|
1577
2095
|
const stopped = new Promise((res) => { rec.onstop = res; });
|
|
@@ -1579,6 +2097,7 @@ window.__ModuleLoader__.load({
|
|
|
1579
2097
|
function startPlayback(item) {
|
|
1580
2098
|
if (item.started) return;
|
|
1581
2099
|
item.started = true;
|
|
2100
|
+
item.startAt = performance.now();
|
|
1582
2101
|
const v = item.v;
|
|
1583
2102
|
const wantAudio = !!audio && opts.keepAudio !== false && item.withAudio;
|
|
1584
2103
|
if (wantAudio) {
|
|
@@ -1634,7 +2153,9 @@ window.__ModuleLoader__.load({
|
|
|
1634
2153
|
blackUntil = 0;
|
|
1635
2154
|
startPlayback(item);
|
|
1636
2155
|
if (lastProgressIdx !== idx) { lastProgressIdx = idx; if (onProgress) onProgress(idx / Math.max(1, vids.length), item.name); }
|
|
1637
|
-
const
|
|
2156
|
+
const clipDur = isFinite(v.duration) && v.duration > 0 ? v.duration : 0;
|
|
2157
|
+
const effDur = item.dur && item.dur > 0 ? Math.min(item.dur, clipDur || item.dur) : clipDur;
|
|
2158
|
+
const elapsed = item.startAt ? (now - item.startAt) / 1000 : 0;
|
|
1638
2159
|
const remainMs = dur ? (dur - v.currentTime) * 1000 : Infinity;
|
|
1639
2160
|
// 叠化:临近结束时提前启动下一片段
|
|
1640
2161
|
let next = null;
|
|
@@ -1659,7 +2180,7 @@ window.__ModuleLoader__.load({
|
|
|
1659
2180
|
g.save(); g.globalAlpha = 1 - fadeT; drawSubtitle(g, W, H, item.sub); g.restore();
|
|
1660
2181
|
}
|
|
1661
2182
|
// 推进
|
|
1662
|
-
const ended = v.ended || (
|
|
2183
|
+
const ended = v.ended || (effDur > 0 && elapsed >= effDur - 0.03);
|
|
1663
2184
|
if (ended) {
|
|
1664
2185
|
stopItem(item);
|
|
1665
2186
|
idx++;
|
|
@@ -1770,11 +2291,18 @@ window.__ModuleLoader__.load({
|
|
|
1770
2291
|
return '.bin';
|
|
1771
2292
|
}
|
|
1772
2293
|
/** 资产落盘到 <项目>/media/(force=false 时已有映射则复用;force=true 覆盖写)。 */
|
|
2294
|
+
/** ArtCraft 工作区里的文件名:资产名(人工可读,如「角色-张山 多视图」)+ id 尾号(唯一,避免同名覆盖)。 */
|
|
2295
|
+
function assetFileBase(rec) {
|
|
2296
|
+
const raw = String((rec && rec.name) || '').trim() || String((rec && rec.id) || 'asset');
|
|
2297
|
+
const safe = raw.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
2298
|
+
const tail = String((rec && rec.id) || '').slice(-4);
|
|
2299
|
+
return (safe || 'asset') + '-' + tail;
|
|
2300
|
+
}
|
|
1773
2301
|
async function syncAssetToDisk(rec, opts = {}) {
|
|
1774
2302
|
const pid = S.activeId;
|
|
1775
2303
|
const map = craftMapLoad(pid);
|
|
1776
2304
|
if (!opts.force && map[rec.id]) return map[rec.id];
|
|
1777
|
-
const rel = craftProjectSlug() + '/media/' + rec
|
|
2305
|
+
const rel = craftProjectSlug() + '/media/' + assetFileBase(rec) + extForMime(rec.mime, rec.name);
|
|
1778
2306
|
const blob = rec.blob || await assetBlob(rec.id);
|
|
1779
2307
|
if (!blob) throw new Error('资产内容不可用: ' + rec.id);
|
|
1780
2308
|
await craft.write(rel, blob);
|
|
@@ -1876,7 +2404,12 @@ window.__ModuleLoader__.load({
|
|
|
1876
2404
|
}
|
|
1877
2405
|
/** FilmCraft 任务后台轮询:状态写回节点,done 后回收产物入资产库。 */
|
|
1878
2406
|
async function pollFilmcraftJob(nodeId, jobId, outRel) {
|
|
2407
|
+
const deadline = Date.now() + 30 * 60 * 1000; // 与 jobStart 的 timeoutMs 对齐:绝不无限轮询
|
|
1879
2408
|
for (;;) {
|
|
2409
|
+
if (Date.now() > deadline) {
|
|
2410
|
+
update((s) => { const nd = s.graph.nodes[nodeId]; if (nd) { nd.data.fcStatus = T('craftTimeout'); nd.data.fcJobId = null; } });
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
1880
2413
|
await sleep(2000);
|
|
1881
2414
|
let snap;
|
|
1882
2415
|
try { snap = await craft.jobGet(jobId, 10); } catch (e) {
|
|
@@ -1906,7 +2439,7 @@ window.__ModuleLoader__.load({
|
|
|
1906
2439
|
}
|
|
1907
2440
|
return;
|
|
1908
2441
|
}
|
|
1909
|
-
if (
|
|
2442
|
+
if (['failed', 'timeout', 'canceled', 'cancelled', 'error'].includes(snap.status)) {
|
|
1910
2443
|
const detail = (snap.stderr || '').split('\n').filter(Boolean).slice(-3).join(' | ').slice(0, 300);
|
|
1911
2444
|
update((s) => { const nd = s.graph.nodes[nodeId]; if (nd) nd.data.fcStatus = snap.status + ':' + detail; nd.data.fcJobId = null; });
|
|
1912
2445
|
return;
|
|
@@ -1970,6 +2503,223 @@ window.__ModuleLoader__.load({
|
|
|
1970
2503
|
update((s) => { s.dataRoot = null; s.dataRootSupported = !/未知操作/.test(String((e && e.message) || e)); });
|
|
1971
2504
|
}
|
|
1972
2505
|
}
|
|
2506
|
+
// ---------- 对话 ↔ 画布同步:轮询 Host 项目索引 + 跳转请求 ----------
|
|
2507
|
+
let hostSyncStopped = false;
|
|
2508
|
+
let hostSyncBusy = false; // hostSyncTick 互斥:单次 tick 可能超过 3 秒
|
|
2509
|
+
let syncWarned = false; // 同步失败只提示一次,避免刷屏
|
|
2510
|
+
async function importHostProject(hp) {
|
|
2511
|
+
const full = await hostRpc('projectsGet', { id: hp.id });
|
|
2512
|
+
update((s) => {
|
|
2513
|
+
if (!s.projects.some((p) => p.id === hp.id)) {
|
|
2514
|
+
s.projects.push({ id: hp.id, name: hp.name || hp.id, createdAt: full.createdAt || Date.now(), updatedAt: full.updatedAt || Date.now() });
|
|
2515
|
+
}
|
|
2516
|
+
});
|
|
2517
|
+
lsSet(LS_PROJECTS, S.projects);
|
|
2518
|
+
saveGraphDoc(hp.id, { nodes: full.nodes || {}, edges: full.edges || [], view: full.view || { x: 60, y: 40, scale: 1 } });
|
|
2519
|
+
if (full.bible) {
|
|
2520
|
+
saveBibleLocal(hp.id, full.bible);
|
|
2521
|
+
if (full.bibleUpdatedAt) lsSet(LS_BIBLE_SYNC + hp.id, full.bibleUpdatedAt);
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
const LS_DOCSYNC = 'dsh-ic:docsync:';
|
|
2525
|
+
/** Host 侧改动了项目文档(Agent 建节点/连线/写内容/补设定集)→ 拉回并合并到本地画布。 */
|
|
2526
|
+
function mergeHostDoc(pid, full) {
|
|
2527
|
+
if (!full) return;
|
|
2528
|
+
const local = loadGraphDoc(pid) || emptyGraph();
|
|
2529
|
+
// 节点墓碑:本地已删除的节点,绝不允许从 Host 拉回复活(删除后 3 秒内的同步拉取曾把节点又带回来)。
|
|
2530
|
+
// 墓碑以**内存中的当前画布**为准(可能还没落盘),否则 450ms 防抖窗口内的拉取会复活节点。
|
|
2531
|
+
const live = (pid === S.activeId && S.graph) ? S.graph : null;
|
|
2532
|
+
const deadNodes = new Set([
|
|
2533
|
+
...((local.tombstones) || []),
|
|
2534
|
+
...((full.tombstones) || []),
|
|
2535
|
+
...((live && live.tombstones) || []),
|
|
2536
|
+
]);
|
|
2537
|
+
// 节点以内存画布为种子(与下方 edges 的 live 优先同理):450ms 防抖窗口内新建的节点、
|
|
2538
|
+
// 刚被 Ctrl+Z 恢复的节点都只存在于内存,若从落盘文档起合并会被本次合并结果抹掉
|
|
2539
|
+
const nodes = { ...((live && live.nodes) ? live.nodes : local.nodes) };
|
|
2540
|
+
for (const [nid, n] of Object.entries(full.nodes || {})) {
|
|
2541
|
+
if (deadNodes.has(nid)) continue;
|
|
2542
|
+
nodes[nid] = n; // Host 为准(Agent 写入是有意的)
|
|
2543
|
+
}
|
|
2544
|
+
const deadEdges = new Set([...(local.edgeTombstones || []), ...(full.edgeTombstones || []), ...((live && live.edgeTombstones) || [])]);
|
|
2545
|
+
const edges = (live && live.edges) ? [...live.edges] : [...(local.edges || [])];
|
|
2546
|
+
for (const e of (full.edges || [])) {
|
|
2547
|
+
if (deadEdges.has(e.id)) continue;
|
|
2548
|
+
const i = edges.findIndex((x) => x.id === e.id);
|
|
2549
|
+
if (i >= 0) edges[i] = e;
|
|
2550
|
+
else edges.push(e);
|
|
2551
|
+
}
|
|
2552
|
+
const keep = edges.filter((e) => !deadEdges.has(e.id) && nodes[e.fromNode] && nodes[e.toNode]);
|
|
2553
|
+
const tombOut = [...new Set([...(local.tombstones || []), ...((live && live.tombstones) || [])])].slice(-200);
|
|
2554
|
+
saveGraphDoc(pid, { nodes, edges: keep, view: local.view, tombstones: tombOut, edgeTombstones: local.edgeTombstones || [] });
|
|
2555
|
+
// 设定集随全量文档一起合并:Agent 用 canvas_update_bible 补的设定集在这里落地
|
|
2556
|
+
if (full.bible) {
|
|
2557
|
+
saveBibleLocal(pid, full.bible);
|
|
2558
|
+
if (full.bibleUpdatedAt) lsSet(LS_BIBLE_SYNC + pid, full.bibleUpdatedAt);
|
|
2559
|
+
}
|
|
2560
|
+
if (pid === S.activeId) update((s) => { s.graph = { nodes, edges: keep, view: s.graph.view, tombstones: tombOut, edgeTombstones: local.edgeTombstones || [] }; s.assetsTick++; });
|
|
2561
|
+
}
|
|
2562
|
+
async function hostSyncTick() {
|
|
2563
|
+
if (hostSyncStopped) return;
|
|
2564
|
+
if (hostSyncBusy) return; // 单次 tick 可能超过 3 秒:加互斥,避免两个 tick 交错写回旧文档副本
|
|
2565
|
+
hostSyncBusy = true;
|
|
2566
|
+
try {
|
|
2567
|
+
const r = await hostRpc('projectsSync');
|
|
2568
|
+
// 对话删除的项目:从本地一并移除(否则本地下次推送会把它复活)
|
|
2569
|
+
if (Array.isArray(r.deletedProjects) && r.deletedProjects.length) {
|
|
2570
|
+
const gone = new Set(r.deletedProjects);
|
|
2571
|
+
update((s) => {
|
|
2572
|
+
s.projects = s.projects.filter((p) => !gone.has(p.id));
|
|
2573
|
+
if (gone.has(s.activeId)) {
|
|
2574
|
+
if (s.projects.length === 0) {
|
|
2575
|
+
const p = { id: uid('p'), name: T('defaultProject'), createdAt: Date.now(), updatedAt: Date.now() };
|
|
2576
|
+
s.projects.push(p);
|
|
2577
|
+
s.activeId = p.id;
|
|
2578
|
+
s.graph = emptyGraph();
|
|
2579
|
+
} else {
|
|
2580
|
+
s.activeId = s.projects[0].id;
|
|
2581
|
+
const d0 = loadGraphDoc(s.activeId) || emptyGraph();
|
|
2582
|
+
s.graph = { nodes: d0.nodes || {}, edges: d0.edges || [], view: d0.view || { x: 60, y: 40, scale: 1 } };
|
|
2583
|
+
}
|
|
2584
|
+
V.x = s.graph.view.x; V.y = s.graph.view.y; V.scale = s.graph.view.scale;
|
|
2585
|
+
s.sel = new Set(); s.selEdge = null;
|
|
2586
|
+
}
|
|
2587
|
+
});
|
|
2588
|
+
for (const pid of r.deletedProjects) {
|
|
2589
|
+
try { localStorage.removeItem(LS_GRAPH + pid); } catch { /* 忽略 */ }
|
|
2590
|
+
try { localStorage.removeItem(LS_BIBLE + pid); } catch { /* 忽略 */ }
|
|
2591
|
+
}
|
|
2592
|
+
lsSet(LS_PROJECTS, S.projects);
|
|
2593
|
+
lsSet(LS_ACTIVE, S.activeId);
|
|
2594
|
+
applyViewToDomSoon();
|
|
2595
|
+
}
|
|
2596
|
+
// Agent 改动了项目文档(建节点/连线/写内容)→ 拉回合并,画布几秒内可见
|
|
2597
|
+
if (r.docChanged && r.docChanged.projectId) {
|
|
2598
|
+
const pid = r.docChanged.projectId;
|
|
2599
|
+
const stamp = Number(lsGet(LS_DOCSYNC + pid, 0)) || 0;
|
|
2600
|
+
if ((r.docChanged.at || 0) > stamp) {
|
|
2601
|
+
lsSet(LS_DOCSYNC + pid, String(r.docChanged.at || Date.now()));
|
|
2602
|
+
try {
|
|
2603
|
+
if (!S.projects.some((p) => p.id === pid)) {
|
|
2604
|
+
const hp = (r.projects || []).find((x) => x.id === pid);
|
|
2605
|
+
if (hp) await importHostProject(hp);
|
|
2606
|
+
} else {
|
|
2607
|
+
const full = await hostRpc('projectsGet', { id: pid });
|
|
2608
|
+
mergeHostDoc(pid, full);
|
|
2609
|
+
}
|
|
2610
|
+
} catch { /* 下一轮 sync 会重试不到(信号已清)——由 missing-node 路径兜底 */ }
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
for (const hp of r.projects || []) {
|
|
2614
|
+
// 逐项目容错:任何一个项目合并失败(网络抖动/文件问题)都不得中断整轮,
|
|
2615
|
+
// 否则排在它后面的项目的设定集/节点拉取会被连累跳过
|
|
2616
|
+
try {
|
|
2617
|
+
const known = S.projects.some((p) => p.id === hp.id);
|
|
2618
|
+
const doc = known ? loadGraphDoc(hp.id) : null;
|
|
2619
|
+
// 设定集:Host 侧(对话改写)更新过 → 拉回本地(设定集先行,故优先于节点合并)
|
|
2620
|
+
if (known && hp.bibleUpdatedAt && hp.bibleUpdatedAt > (Number(lsGet(LS_BIBLE_SYNC + hp.id, 0)) || 0)) {
|
|
2621
|
+
try {
|
|
2622
|
+
const full = await hostRpc('projectsGet', { id: hp.id });
|
|
2623
|
+
if (full && full.bible) {
|
|
2624
|
+
saveBibleLocal(hp.id, full.bible);
|
|
2625
|
+
lsSet(LS_BIBLE_SYNC + hp.id, hp.bibleUpdatedAt);
|
|
2626
|
+
biblePushSnap.set(hp.id, JSON.stringify(full.bible)); // 已是最新:后续保存不必回推
|
|
2627
|
+
if (hp.id === S.activeId) update((s) => { s.assetsTick++; });
|
|
2628
|
+
}
|
|
2629
|
+
} catch { /* 忽略 */ }
|
|
2630
|
+
}
|
|
2631
|
+
// 对话删过的节点(墓碑):本地同步删除,避免被推送复活
|
|
2632
|
+
const dead = (hp.tombstones || []).filter((nid) => doc && doc.nodes[nid]);
|
|
2633
|
+
// 内存画布为准:当前画布上还存在的节点不算死(撤销恢复后 Host 可能仍留旧墓碑)
|
|
2634
|
+
const liveNodes = (hp.id === S.activeId && S.graph) ? S.graph.nodes : null;
|
|
2635
|
+
const deadEff = liveNodes ? dead.filter((nid) => !liveNodes[nid]) : dead;
|
|
2636
|
+
if (known && deadEff.length) {
|
|
2637
|
+
for (const nid of deadEff) delete doc.nodes[nid];
|
|
2638
|
+
doc.edges = (doc.edges || []).filter((e) => !deadEff.includes(e.fromNode) && !deadEff.includes(e.toNode));
|
|
2639
|
+
saveGraphDoc(hp.id, doc);
|
|
2640
|
+
if (hp.id === S.activeId) {
|
|
2641
|
+
update((s) => {
|
|
2642
|
+
for (const nid of deadEff) delete s.graph.nodes[nid];
|
|
2643
|
+
s.graph.edges = s.graph.edges.filter((e) => !deadEff.includes(e.fromNode) && !deadEff.includes(e.toNode));
|
|
2644
|
+
s.sel = new Set([...s.sel].filter((x) => !deadEff.includes(x)));
|
|
2645
|
+
s.assetsTick++;
|
|
2646
|
+
});
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
// 缺失节点拉回:跳过本地已标墓碑的节点(本地删除后 Host 尚未落地时的拉取不能让节点复活)。
|
|
2650
|
+
// 墓碑以内存中的当前画布为准(防抖窗口内的删除也要生效)。
|
|
2651
|
+
const liveT = (hp.id === S.activeId && S.graph) ? (S.graph.tombstones || []) : [];
|
|
2652
|
+
const localDead = new Set([...((doc && doc.tombstones) || []), ...liveT]);
|
|
2653
|
+
// 内存画布为准:撤销恢复的节点虽然墓碑可能还没清干净,但画布上还在 → 不能当死节点拦掉拉回
|
|
2654
|
+
if (hp.id === S.activeId && S.graph) {
|
|
2655
|
+
for (const nid of [...localDead]) { if (S.graph.nodes[nid]) localDead.delete(nid); }
|
|
2656
|
+
}
|
|
2657
|
+
const missing = (hp.nodeIds || []).filter((nid) => (!doc || !doc.nodes[nid]) && !localDead.has(nid));
|
|
2658
|
+
if (!known) await importHostProject(hp);
|
|
2659
|
+
else if (missing.length && doc) {
|
|
2660
|
+
const full = await hostRpc('projectsGet', { id: hp.id });
|
|
2661
|
+
for (const nid of missing) { if (full.nodes && full.nodes[nid]) doc.nodes[nid] = full.nodes[nid]; }
|
|
2662
|
+
saveGraphDoc(hp.id, doc);
|
|
2663
|
+
if (hp.id === S.activeId) update((s) => { for (const nid of missing) { if (full.nodes && full.nodes[nid]) s.graph.nodes[nid] = full.nodes[nid]; } s.assetsTick++; });
|
|
2664
|
+
}
|
|
2665
|
+
} catch (e) {
|
|
2666
|
+
if (/未知操作/.test(String((e && e.message) || e))) { hostSyncStopped = true; return; }
|
|
2667
|
+
// 其余失败记入控制台,继续下一个项目
|
|
2668
|
+
console.warn('[infinite-canvas] 项目合并失败(下轮重试):', hp.id, e);
|
|
2669
|
+
}
|
|
2670
|
+
}
|
|
2671
|
+
// 一次性请求(Host 读取即清空,绝不能因后面出错而丢失):
|
|
2672
|
+
// 放在逐项目循环之后、独立 try —— 循环里任何异常都不会让 runRequest 被静默吞掉
|
|
2673
|
+
try {
|
|
2674
|
+
if (r.runRequest) await handleRunRequest(r.runRequest);
|
|
2675
|
+
} catch (e) {
|
|
2676
|
+
toast(T('runRequestFailed') + ':' + String((e && e.message) || e));
|
|
2677
|
+
}
|
|
2678
|
+
try {
|
|
2679
|
+
if (r.openRequest) {
|
|
2680
|
+
const pid = r.openRequest.projectId || null;
|
|
2681
|
+
if (pid && !S.projects.some((p) => p.id === pid)) {
|
|
2682
|
+
const hp = (r.projects || []).find((x) => x.id === pid);
|
|
2683
|
+
if (hp) await importHostProject(hp);
|
|
2684
|
+
}
|
|
2685
|
+
if (!pid || S.projects.some((p) => p.id === pid)) {
|
|
2686
|
+
if (pid && pid !== S.activeId) switchProject(pid);
|
|
2687
|
+
try { if (CTX && CTX.layout && CTX.layout.selectPanel) CTX.layout.selectPanel(PANEL_ID); } catch { /* 忽略 */ }
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
} catch { /* 打开失败不阻断后续同步 */ }
|
|
2691
|
+
} catch (e) {
|
|
2692
|
+
if (/未知操作/.test(String((e && e.message) || e))) hostSyncStopped = true; // 旧 Host:停止轮询
|
|
2693
|
+
} finally {
|
|
2694
|
+
hostSyncBusy = false;
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
/** 处理对话触发的节点执行:切到目标项目 → 用画布内配置的模型/接口真实运行 → 结果回写 Host。 */
|
|
2698
|
+
async function handleRunRequest(runRequest) {
|
|
2699
|
+
const pid = runRequest.projectId;
|
|
2700
|
+
const nodeId = runRequest.nodeId;
|
|
2701
|
+
if (pid && pid !== S.activeId && S.projects.some((p) => p.id === pid)) {
|
|
2702
|
+
switchProject(pid);
|
|
2703
|
+
try { if (CTX && CTX.layout && CTX.layout.selectPanel) CTX.layout.selectPanel(PANEL_ID); } catch { /* 忽略 */ }
|
|
2704
|
+
}
|
|
2705
|
+
const node = S.graph.nodes[nodeId];
|
|
2706
|
+
if (!node) throw new Error('节点不存在: ' + nodeId);
|
|
2707
|
+
if (node.data && node.data.status === 'running') return; // 已在运行:不重复触发
|
|
2708
|
+
await runGenNode(node);
|
|
2709
|
+
flushSave();
|
|
2710
|
+
}
|
|
2711
|
+
/** 启动时把本地全部项目推给 Host:让对话能看到并操作画布上已有的项目(客户端为内容权威)。 */
|
|
2712
|
+
async function pushAllProjectsToHost() {
|
|
2713
|
+
if (hostSyncStopped) return;
|
|
2714
|
+
for (const p of [...S.projects]) {
|
|
2715
|
+
try {
|
|
2716
|
+
const doc = loadGraphDoc(p.id) || emptyGraph();
|
|
2717
|
+
await hostRpc('projectsPut', { id: p.id, name: p.name || p.id, doc });
|
|
2718
|
+
} catch (e) {
|
|
2719
|
+
if (/未知操作/.test(String((e && e.message) || e))) { hostSyncStopped = true; return; }
|
|
2720
|
+
}
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
1973
2723
|
|
|
1974
2724
|
// ============================================================
|
|
1975
2725
|
// 尺寸 / 比例(Toonflow 式:清晰度 × 比例 → 像素)
|
|
@@ -1986,13 +2736,15 @@ window.__ModuleLoader__.load({
|
|
|
1986
2736
|
const base = { '1K': 1024, '2K': 2048, '4K': 4096 }[(opts && opts.size) || '2K'] || 2048;
|
|
1987
2737
|
return dimsFromRatio(opts && opts.ratio, base, base);
|
|
1988
2738
|
}
|
|
1989
|
-
function videoDimsFor(opts) {
|
|
2739
|
+
function videoDimsFor(opts, limits) {
|
|
1990
2740
|
const base = { '480P': 480, '720P': 720, '1080P': 1080 }[(opts && opts.resolution) || '720P'] || 720;
|
|
1991
2741
|
const d = dimsFromRatio(opts && opts.ratio, base, base);
|
|
1992
|
-
//
|
|
2742
|
+
// 仅演示/本地合成路径限制长边(浏览器合成性能保护);真实 API 与成片按你选的分辨率
|
|
2743
|
+
const cap = limits && Number(limits.capLong);
|
|
2744
|
+
if (!cap) return d;
|
|
1993
2745
|
const long = Math.max(d.w, d.h);
|
|
1994
|
-
if (long >
|
|
1995
|
-
const k =
|
|
2746
|
+
if (long > cap) {
|
|
2747
|
+
const k = cap / long;
|
|
1996
2748
|
return { w: Math.round(d.w * k / 2) * 2, h: Math.round(d.h * k / 2) * 2 };
|
|
1997
2749
|
}
|
|
1998
2750
|
return d;
|
|
@@ -2051,7 +2803,42 @@ window.__ModuleLoader__.load({
|
|
|
2051
2803
|
}
|
|
2052
2804
|
return null;
|
|
2053
2805
|
}
|
|
2054
|
-
|
|
2806
|
+
// AI 流式进度(不落盘:只用于"生成中"时显示一行思考内容,让用户知道没卡住)
|
|
2807
|
+
const AI_STREAM = new Map(); // nodeId -> { reasoning, text }
|
|
2808
|
+
const AI_STREAM_KEEP = 600; // 只保留尾部若干字符,避免长思考占内存
|
|
2809
|
+
let aiStreamFlushTimer = null;
|
|
2810
|
+
function aiStreamAppend(nodeId, kind, d) {
|
|
2811
|
+
if (!d) return;
|
|
2812
|
+
const cur = AI_STREAM.get(nodeId) || { reasoning: '', text: '' };
|
|
2813
|
+
const next = { reasoning: cur.reasoning, text: cur.text };
|
|
2814
|
+
if (kind === 'reasoning') next.reasoning = (cur.reasoning + d).slice(-AI_STREAM_KEEP);
|
|
2815
|
+
else next.text = (cur.text + d).slice(-AI_STREAM_KEEP);
|
|
2816
|
+
AI_STREAM.set(nodeId, next);
|
|
2817
|
+
// 节流:思考内容每秒可能来几十段,重渲染节流到 300ms 一次(只展示最后一行足够了)
|
|
2818
|
+
if (aiStreamFlushTimer) return;
|
|
2819
|
+
aiStreamFlushTimer = setTimeout(() => {
|
|
2820
|
+
aiStreamFlushTimer = null;
|
|
2821
|
+
update((s) => { s.aiTick = (s.aiTick || 0) + 1; }, { quiet: true });
|
|
2822
|
+
notify();
|
|
2823
|
+
}, 300);
|
|
2824
|
+
}
|
|
2825
|
+
function aiStreamClear(nodeId) {
|
|
2826
|
+
AI_STREAM.delete(nodeId);
|
|
2827
|
+
if (aiStreamFlushTimer) { clearTimeout(aiStreamFlushTimer); aiStreamFlushTimer = null; }
|
|
2828
|
+
}
|
|
2829
|
+
/** 生成中显示的一行动态内容:优先思考,其次正文;压缩空白并只留尾部。 */
|
|
2830
|
+
function aiStreamLine(nodeId) {
|
|
2831
|
+
const s = AI_STREAM.get(nodeId);
|
|
2832
|
+
if (!s) return '';
|
|
2833
|
+
const tail = (v) => String(v || '').replace(/\s+/g, ' ').trim().slice(-180);
|
|
2834
|
+
return tail(s.reasoning) || tail(s.text);
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
/**
|
|
2838
|
+
* 走 Host 的流式接口生成文本:实时把「思考/正文」增量喂给 onDelta。
|
|
2839
|
+
* 旧 Host 没有该路由时自动回退到一次性 RPC(功能不受影响,只是没有实时预览)。
|
|
2840
|
+
*/
|
|
2841
|
+
async function aiCompleteStream({ system, user, model, onReasoning, onText }) {
|
|
2055
2842
|
const settings = loadSettings();
|
|
2056
2843
|
let provider;
|
|
2057
2844
|
let modelId;
|
|
@@ -2061,9 +2848,69 @@ window.__ModuleLoader__.load({
|
|
|
2061
2848
|
provider = settings.textLlm.provider || undefined;
|
|
2062
2849
|
modelId = settings.textLlm.model || undefined;
|
|
2063
2850
|
}
|
|
2064
|
-
const
|
|
2065
|
-
|
|
2066
|
-
|
|
2851
|
+
const cfg = settings.textLlm || {};
|
|
2852
|
+
const args = { provider, model: modelId, system, user };
|
|
2853
|
+
const maxTokens = Number(String(cfg.maxTokens ?? '').trim());
|
|
2854
|
+
if (String(cfg.maxTokens ?? '').trim() !== '' && Number.isFinite(maxTokens) && maxTokens > 0) args.maxTokens = maxTokens;
|
|
2855
|
+
const temperature = Number(String(cfg.temperature ?? '').trim());
|
|
2856
|
+
if (String(cfg.temperature ?? '').trim() !== '' && Number.isFinite(temperature)) args.temperature = temperature;
|
|
2857
|
+
let res;
|
|
2858
|
+
try {
|
|
2859
|
+
res = await fetch('/' + NS + '/api/llm-stream', {
|
|
2860
|
+
method: 'POST',
|
|
2861
|
+
headers: { 'content-type': 'application/json' },
|
|
2862
|
+
body: JSON.stringify(args),
|
|
2863
|
+
});
|
|
2864
|
+
} catch (e) {
|
|
2865
|
+
throw new Error(T('connectFailed') + ':' + String((e && e.message) || e));
|
|
2866
|
+
}
|
|
2867
|
+
if (res.status === 404 || !res.body) return await aiComplete({ system, user, model }); // 旧 Host 回退
|
|
2868
|
+
if (!res.ok) throw new Error((await res.text()).slice(0, 300) || T('rpcFail'));
|
|
2869
|
+
const reader = res.body.getReader();
|
|
2870
|
+
const decoder = new TextDecoder();
|
|
2871
|
+
let buf = '';
|
|
2872
|
+
let final = null;
|
|
2873
|
+
for (;;) {
|
|
2874
|
+
const { done, value } = await reader.read();
|
|
2875
|
+
if (done) break;
|
|
2876
|
+
buf += decoder.decode(value, { stream: true });
|
|
2877
|
+
let idx;
|
|
2878
|
+
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
2879
|
+
const frame = buf.slice(0, idx);
|
|
2880
|
+
buf = buf.slice(idx + 2);
|
|
2881
|
+
const line = frame.split('\n').find((l) => l.startsWith('data: '));
|
|
2882
|
+
if (!line) continue;
|
|
2883
|
+
let msg = null;
|
|
2884
|
+
try { msg = JSON.parse(line.slice(6)); } catch { continue; }
|
|
2885
|
+
if (msg.t === 'r' && onReasoning) onReasoning(String(msg.d || ''));
|
|
2886
|
+
else if (msg.t === 'x' && onText) onText(String(msg.d || ''));
|
|
2887
|
+
else if (msg.t === 'attempt' && onReasoning) onReasoning('');
|
|
2888
|
+
else if (msg.t === 'done') final = msg.d || {};
|
|
2889
|
+
else if (msg.t === 'fail') throw new Error(String(msg.d || T('rpcFail')));
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
if (!final) throw new Error(T('rpcFail'));
|
|
2893
|
+
return final.text || '';
|
|
2894
|
+
}
|
|
2895
|
+
|
|
2896
|
+
async function aiComplete({ system, user, model }) {
|
|
2897
|
+
const settings = loadSettings();
|
|
2898
|
+
let provider;
|
|
2899
|
+
let modelId;
|
|
2900
|
+
if (model && String(model).includes('|')) {
|
|
2901
|
+
[provider, modelId] = String(model).split('|');
|
|
2902
|
+
} else {
|
|
2903
|
+
provider = settings.textLlm.provider || undefined;
|
|
2904
|
+
modelId = settings.textLlm.model || undefined;
|
|
2905
|
+
}
|
|
2906
|
+
// 生成参数一律来自设置;留空则**不发该字段**,由 Harness 适配器/模型用已配置的默认值
|
|
2907
|
+
const cfg = settings.textLlm || {};
|
|
2908
|
+
const args = { provider, model: modelId, system, user };
|
|
2909
|
+
const maxTokens = Number(String(cfg.maxTokens ?? '').trim());
|
|
2910
|
+
if (String(cfg.maxTokens ?? '').trim() !== '' && Number.isFinite(maxTokens) && maxTokens > 0) args.maxTokens = maxTokens;
|
|
2911
|
+
const temperature = Number(String(cfg.temperature ?? '').trim());
|
|
2912
|
+
if (String(cfg.temperature ?? '').trim() !== '' && Number.isFinite(temperature)) args.temperature = temperature;
|
|
2913
|
+
const res = await hostRpc('llmComplete', args);
|
|
2067
2914
|
return res.text;
|
|
2068
2915
|
}
|
|
2069
2916
|
|
|
@@ -2084,11 +2931,13 @@ window.__ModuleLoader__.load({
|
|
|
2084
2931
|
} catch { return null; /* 技能读取失败回退内置专家 */ }
|
|
2085
2932
|
}
|
|
2086
2933
|
|
|
2087
|
-
async function aiScriptNodes(idea, model) {
|
|
2934
|
+
async function aiScriptNodes(idea, model, onDelta) {
|
|
2088
2935
|
const settings = loadSettings();
|
|
2089
2936
|
const bible = bibleBlock(loadBible(S.activeId));
|
|
2090
2937
|
const zh = effLang() !== 'en';
|
|
2938
|
+
// 未选技能时默认注入内置创作方法论 canvas-craft(与内置专家 persona 叠加,互为补充)
|
|
2091
2939
|
const rules = await loadSkillRules(settings.skillScript, zh)
|
|
2940
|
+
|| await loadSkillRules(CRAFT_SKILL, zh)
|
|
2092
2941
|
|| (zh ? '把输入的创意或片段写成可直接拍摄的短剧剧本。只输出剧本正文,不要解释。' : 'Turn the idea or excerpt into a shootable short-drama script. Output the script only.');
|
|
2093
2942
|
const system = [
|
|
2094
2943
|
EXPERT_SCRIPT(zh),
|
|
@@ -2111,34 +2960,41 @@ window.__ModuleLoader__.load({
|
|
|
2111
2960
|
'- Visual actions only, no inner thoughts; dialogue on its own lines; mark sounds in brackets;',
|
|
2112
2961
|
'- End with "Characters:" and "Locations/Props:" lists for asset production.',
|
|
2113
2962
|
].join('\n');
|
|
2114
|
-
return await
|
|
2963
|
+
return await aiCompleteStream({
|
|
2964
|
+
system, user, model,
|
|
2965
|
+
onReasoning: (d) => onDelta && onDelta('reasoning', d),
|
|
2966
|
+
onText: (d) => onDelta && onDelta('text', d),
|
|
2967
|
+
});
|
|
2115
2968
|
}
|
|
2116
|
-
async function aiStoryboardShots(scriptText, requirement, count, model) {
|
|
2969
|
+
async function aiStoryboardShots(scriptText, requirement, count, model, onDelta) {
|
|
2117
2970
|
const settings = loadSettings();
|
|
2118
2971
|
const bible = bibleBlock(loadBible(S.activeId));
|
|
2119
2972
|
const zh = effLang() !== 'en';
|
|
2120
2973
|
const jsonContract = zh
|
|
2121
2974
|
? [
|
|
2122
|
-
'
|
|
2123
|
-
'只输出一个 JSON 对象:{"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":
|
|
2975
|
+
'把剧本拆解为「多镜头一次性视频生成」的分镜脚本:铺开后由**一个视频生成节点**把全部镜头按顺序与时长写进同一条提示词,由视频模型一次性生成整段视频(镜头间由模型直接硬切,不是一镜一视频再拼接)。',
|
|
2976
|
+
'只输出一个 JSON 对象:{"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3}]}。',
|
|
2124
2977
|
'规则:',
|
|
2125
|
-
'- visual
|
|
2126
|
-
'- motion
|
|
2127
|
-
'-
|
|
2128
|
-
'-
|
|
2978
|
+
'- visual 是本镜画面描述(景别、人物外观与站位、场景、光线、构图,60 字内);',
|
|
2979
|
+
'- **visual 与 motion 里的角色/场景/道具必须写成 @名称 引用形式**(如 `@张山`、`@褶皱巷`),插件会自动带上该实体的设定描述与其参考图资产,保证跨镜一致;',
|
|
2980
|
+
'- motion 是本镜动作与运镜(主体动作节拍、机位运动方向与速度、情绪节奏,50 字内);与 visual 不重复;',
|
|
2981
|
+
'- characters/scene/props 使用剧本与设定集中的名称(不带 @),便于匹配资产;无道具给空数组;',
|
|
2982
|
+
'- duration 为该镜时长,2-5 的整数(一个镜头拍一个连续动作段落);全部 duration 之和 = 目标视频总时长(如 15 秒 ≈ 4-7 镜);镜头顺序即叙事顺序,首镜交代场景、末镜收束情绪;',
|
|
2129
2983
|
'- 只输出 JSON,不要输出任何其他内容。',
|
|
2130
2984
|
].join('\n')
|
|
2131
2985
|
: [
|
|
2132
|
-
'Break the script into shots for a
|
|
2133
|
-
'Output only one JSON object: {"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":
|
|
2986
|
+
'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).',
|
|
2987
|
+
'Output only one JSON object: {"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3}]}.',
|
|
2134
2988
|
'Rules:',
|
|
2135
|
-
'- visual =
|
|
2136
|
-
'-
|
|
2137
|
-
'-
|
|
2138
|
-
'-
|
|
2989
|
+
'- visual = the shot\'s frame description (shot size, wardrobe, staging, light, framing, <=60 words);',
|
|
2990
|
+
'- **characters/scenes/props inside visual and motion must be written as @name references** (e.g. `@ZhangSan`); the plugin then attaches that entity\'s bible description and reference images automatically;',
|
|
2991
|
+
'- motion = action & camera for the shot (action beats, camera move, emotional rhythm, <=50 words); do not repeat visual;',
|
|
2992
|
+
'- characters/scene/props arrays must use plain names from the script and series bible (no @) so assets match; empty props array if none;',
|
|
2993
|
+
'- duration is the shot length in seconds, integer 2-5 (one continuous action beat per shot); the sum of durations = the target total video length (e.g. 15s ≈ 4-7 shots); shot order = narrative order; first shot establishes the scene, last shot lands the emotion;',
|
|
2139
2994
|
'- Output JSON only.',
|
|
2140
2995
|
].join('\n');
|
|
2141
|
-
const methodology = await loadSkillRules(settings.skillStoryboard, zh)
|
|
2996
|
+
const methodology = await loadSkillRules(settings.skillStoryboard, zh)
|
|
2997
|
+
|| await loadSkillRules(CRAFT_SKILL, zh); // 未选技能时默认注入内置创作方法论
|
|
2142
2998
|
const system = [
|
|
2143
2999
|
EXPERT_STORYBOARD(zh),
|
|
2144
3000
|
methodology || '',
|
|
@@ -2151,10 +3007,20 @@ window.__ModuleLoader__.load({
|
|
|
2151
3007
|
`${zh ? '额外要求' : 'Extra requirements'}:${String(requirement ?? '').trim() || (zh ? '无' : 'none')}`,
|
|
2152
3008
|
`${zh ? '目标分镜数量' : 'Shot count'}:${count}`,
|
|
2153
3009
|
].join('\n\n');
|
|
2154
|
-
const text = await
|
|
3010
|
+
const text = await aiCompleteStream({
|
|
3011
|
+
system, user, model,
|
|
3012
|
+
onReasoning: (d) => onDelta && onDelta('reasoning', d),
|
|
3013
|
+
onText: (d) => onDelta && onDelta('text', d),
|
|
3014
|
+
});
|
|
2155
3015
|
const parsed = parseJsonBalanced(text);
|
|
2156
3016
|
const shots = parsed && Array.isArray(parsed.shots) ? parsed.shots : null;
|
|
2157
|
-
if (!shots || !shots.length)
|
|
3017
|
+
if (!shots || !shots.length) {
|
|
3018
|
+
// 解析失败必须带上下文(长度 + 首尾原文),才能判断是被截断还是格式跑偏
|
|
3019
|
+
const raw = String(text || '').trim();
|
|
3020
|
+
const head = raw.slice(0, 160);
|
|
3021
|
+
const tail = raw.slice(-160);
|
|
3022
|
+
throw new Error(T('errParseStoryboard') + '(正文 ' + raw.length + ' 字;开头: ' + head + ';结尾: ' + tail + ')');
|
|
3023
|
+
}
|
|
2158
3024
|
return shots.slice(0, 24).map((s, i) => ({
|
|
2159
3025
|
title: String(s.title || (T('shotPrefix') + (i + 1))).slice(0, 24),
|
|
2160
3026
|
visual: String(s.visual || ''),
|
|
@@ -2162,7 +3028,7 @@ window.__ModuleLoader__.load({
|
|
|
2162
3028
|
characters: Array.isArray(s.characters) ? s.characters.map(String).slice(0, 6) : [],
|
|
2163
3029
|
scene: String(s.scene || ''),
|
|
2164
3030
|
props: Array.isArray(s.props) ? s.props.map(String).slice(0, 6) : [],
|
|
2165
|
-
duration: Number.isFinite(Number(s.duration)) ? clamp(Math.round(Number(s.duration)),
|
|
3031
|
+
duration: Number.isFinite(Number(s.duration)) ? clamp(Math.round(Number(s.duration)), 1, 15) : 3,
|
|
2166
3032
|
}));
|
|
2167
3033
|
}
|
|
2168
3034
|
/** 本地一致性检查:分镜里出现的角色/场景/道具名是否都能在设定集或图片资产中找到。 */
|
|
@@ -2174,7 +3040,7 @@ window.__ModuleLoader__.load({
|
|
|
2174
3040
|
(bible.scenes || []).forEach((c) => addName(c.name));
|
|
2175
3041
|
(bible.props || []).forEach((c) => addName(c.name));
|
|
2176
3042
|
try {
|
|
2177
|
-
const recs = await idbByProject(S.activeId);
|
|
3043
|
+
const recs = (await idbByProject(S.activeId)).concat(await idbByProject(GENERAL_PID));
|
|
2178
3044
|
recs.forEach((r) => addName(r.name));
|
|
2179
3045
|
} catch { /* 忽略 */ }
|
|
2180
3046
|
const warnings = [];
|
|
@@ -2194,6 +3060,36 @@ window.__ModuleLoader__.load({
|
|
|
2194
3060
|
}
|
|
2195
3061
|
return warnings.slice(0, 12);
|
|
2196
3062
|
}
|
|
3063
|
+
/** 分镜生成后:把设定集里没有的角色/场景/道具自动补进设定集(描述留空待用户补充)。 */
|
|
3064
|
+
async function autoRegisterBibleEntries(shots) {
|
|
3065
|
+
const bible = loadBible(S.activeId);
|
|
3066
|
+
const known = new Set();
|
|
3067
|
+
const addName = (n) => { const q = String(n || '').trim().toLowerCase(); if (q) known.add(q); };
|
|
3068
|
+
(bible.characters || []).forEach((c) => addName(c.name));
|
|
3069
|
+
(bible.scenes || []).forEach((c) => addName(c.name));
|
|
3070
|
+
(bible.props || []).forEach((c) => addName(c.name));
|
|
3071
|
+
try { ((await idbByProject(S.activeId)).concat(await idbByProject(GENERAL_PID))).forEach((r) => addName(r.name)); } catch { /* 忽略 */ }
|
|
3072
|
+
const next = { characters: [...bible.characters], scenes: [...bible.scenes], props: [...bible.props] };
|
|
3073
|
+
let added = 0;
|
|
3074
|
+
const push = (kind, name) => {
|
|
3075
|
+
const q = String(name || '').trim();
|
|
3076
|
+
if (!q) return;
|
|
3077
|
+
const ql = q.toLowerCase();
|
|
3078
|
+
if ([...known].some((k) => k.includes(ql) || ql.includes(k))) return;
|
|
3079
|
+
known.add(ql);
|
|
3080
|
+
next[kind].push({ name: q, desc: '', assetIds: [] });
|
|
3081
|
+
added++;
|
|
3082
|
+
};
|
|
3083
|
+
for (const sh of shots) {
|
|
3084
|
+
(sh.characters || []).forEach((n) => push('characters', n));
|
|
3085
|
+
push('scenes', sh.scene);
|
|
3086
|
+
(sh.props || []).forEach((n) => push('props', n));
|
|
3087
|
+
}
|
|
3088
|
+
if (added) {
|
|
3089
|
+
saveBible(S.activeId, { ...bible, ...next });
|
|
3090
|
+
toast(T('bibleAutoAdd').replace('{n}', String(added)));
|
|
3091
|
+
}
|
|
3092
|
+
}
|
|
2197
3093
|
async function saveTextAssetRecord(projectId, meta) {
|
|
2198
3094
|
const rec = {
|
|
2199
3095
|
id: uid('a'), projectId, kind: 'text',
|
|
@@ -2206,8 +3102,25 @@ window.__ModuleLoader__.load({
|
|
|
2206
3102
|
source: (meta && meta.source) || 'ai',
|
|
2207
3103
|
category: (meta && meta.category) || 'script',
|
|
2208
3104
|
};
|
|
3105
|
+
// 文本资产同样落盘到数据根目录(<root>/projects/<项目>/text/<名>.md),
|
|
3106
|
+
// IndexedDB 保留元数据与 text 字段(文本很小,预览/下载不必回读文件)。
|
|
3107
|
+
// 落盘失败降级为仅 IndexedDB(绝不阻塞生成主流程),控制台留痕。
|
|
3108
|
+
if (S.dataRoot) {
|
|
3109
|
+
try {
|
|
3110
|
+
const rel = `projects/${projectId}/text/${assetFileBase(rec)}.md`;
|
|
3111
|
+
const blob = new Blob([rec.text], { type: 'text/markdown' });
|
|
3112
|
+
const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
|
|
3113
|
+
if (!r.ok) throw new Error(await r.text());
|
|
3114
|
+
rec.rel = rel;
|
|
3115
|
+
rec.size = blob.size;
|
|
3116
|
+
rec.stored = 'disk';
|
|
3117
|
+
} catch (err) {
|
|
3118
|
+
console.warn('[infinite-canvas] 文本资产落盘失败(降级为浏览器存储):', String((err && err.message) || err));
|
|
3119
|
+
}
|
|
3120
|
+
}
|
|
2209
3121
|
await idbPut(rec);
|
|
2210
3122
|
assetNameCache.set(rec.id, rec.name);
|
|
3123
|
+
mirrorAssetIndex(rec); // 文本资产元数据同样镜像进 assets.json
|
|
2211
3124
|
return rec;
|
|
2212
3125
|
}
|
|
2213
3126
|
|
|
@@ -2244,7 +3157,14 @@ window.__ModuleLoader__.load({
|
|
|
2244
3157
|
function notify() { S.tick++; for (const fn of listeners) fn(); }
|
|
2245
3158
|
function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
|
|
2246
3159
|
function update(fn, opts) {
|
|
3160
|
+
const edgesBefore = S.graph ? S.graph.edges : null; // 引用比较:fn 换了数组说明发生过边增删
|
|
2247
3161
|
fn(S);
|
|
3162
|
+
// 边墓碑:画布上删除的连线自动记录,Host 合并时据此判定"真的被删了"(否则会被并回来)
|
|
3163
|
+
if (S.graph && S.graph.edges && edgesBefore && S.graph.edges !== edgesBefore) {
|
|
3164
|
+
const gone = new Set(edgesBefore.map((e) => e.id));
|
|
3165
|
+
for (const e of S.graph.edges) gone.delete(e.id);
|
|
3166
|
+
if (gone.size) S.graph.edgeTombstones = [...new Set([...(S.graph.edgeTombstones || []), ...gone])].slice(-400);
|
|
3167
|
+
}
|
|
2248
3168
|
if (!opts || !opts.quiet) notify();
|
|
2249
3169
|
scheduleSave();
|
|
2250
3170
|
}
|
|
@@ -2261,6 +3181,27 @@ window.__ModuleLoader__.load({
|
|
|
2261
3181
|
if (proj) proj.updatedAt = Date.now();
|
|
2262
3182
|
lsSet(LS_PROJECTS, S.projects);
|
|
2263
3183
|
lsSet(LS_ACTIVE, S.activeId);
|
|
3184
|
+
try {
|
|
3185
|
+
// 设定集只在本地确实变化时才带上:否则会把对话侧刚改的版本覆盖回去
|
|
3186
|
+
const bibleField = bibleFieldForPush(S.activeId);
|
|
3187
|
+
hostRpc('projectsPut', {
|
|
3188
|
+
id: S.activeId, name: (proj && proj.name) || S.activeId,
|
|
3189
|
+
doc: {
|
|
3190
|
+
ts: Date.now(), // 推送发出时刻:Host 据此区分"撤销恢复"(清墓碑)与"陈旧推送"(墓碑继续生效)
|
|
3191
|
+
nodes: S.graph.nodes, edges: S.graph.edges, view: S.graph.view,
|
|
3192
|
+
tombstones: S.graph.tombstones || [], // 本地删除标记:Host 依据它判定节点真的被删
|
|
3193
|
+
...(bibleField.__bibleChanged ? { bible: bibleField.bible } : {}),
|
|
3194
|
+
},
|
|
3195
|
+
}).then((r) => {
|
|
3196
|
+
if (bibleField.__bibleChanged && r && r.bibleUpdatedAt) {
|
|
3197
|
+
lsSet(LS_BIBLE_SYNC + S.activeId, r.bibleUpdatedAt);
|
|
3198
|
+
biblePushSnap.set(S.activeId, JSON.stringify(bibleField.bible));
|
|
3199
|
+
}
|
|
3200
|
+
}).catch((err) => {
|
|
3201
|
+
// 同步失败必须让用户知道:否则对话侧看到的是旧内容,用户以为"刚写的对话里查不到"
|
|
3202
|
+
if (!syncWarned) { syncWarned = true; toast(T('syncFailed') + ':' + String((err && err.message) || err)); }
|
|
3203
|
+
});
|
|
3204
|
+
} catch { /* 忽略 */ }
|
|
2264
3205
|
}
|
|
2265
3206
|
function initStore() {
|
|
2266
3207
|
S.projects = lsGet(LS_PROJECTS, []);
|
|
@@ -2272,8 +3213,21 @@ window.__ModuleLoader__.load({
|
|
|
2272
3213
|
S.activeId = S.projects.some((p) => p.id === active) ? active : S.projects[0].id;
|
|
2273
3214
|
const doc = loadGraphDoc(S.activeId) || emptyGraph();
|
|
2274
3215
|
S.graph = { nodes: doc.nodes || {}, edges: doc.edges || [], view: doc.view || { x: 60, y: 40, scale: 1 } };
|
|
3216
|
+
// 页面刷新/关闭会中断进行中的生成:清理残留的 running 状态,避免节点永远卡在"运行中"
|
|
3217
|
+
let stale = false;
|
|
3218
|
+
for (const n of Object.values(S.graph.nodes || {})) {
|
|
3219
|
+
if (n && n.data && (n.data.status === 'running' || n.data.aiStatus === 'running')) {
|
|
3220
|
+
n.data.status = 'idle';
|
|
3221
|
+
if (n.data.aiStatus === 'running') { n.data.aiStatus = 'error'; n.data.aiError = T('aiInterrupted'); }
|
|
3222
|
+
n.data.progress = null;
|
|
3223
|
+
stale = true;
|
|
3224
|
+
}
|
|
3225
|
+
}
|
|
3226
|
+
if (stale) { try { saveGraphDoc(S.activeId, S.graph); } catch { /* 忽略 */ } }
|
|
2275
3227
|
V.x = S.graph.view.x; V.y = S.graph.view.y; V.scale = S.graph.view.scale;
|
|
2276
3228
|
S.ready = true;
|
|
3229
|
+
// 预热设定集快照:页面加载时视本地版本为"已同步",避免把旧版本推回覆盖对话侧的新设定
|
|
3230
|
+
for (const p of S.projects) biblePushSnap.set(p.id, bibleSnapshot(p.id));
|
|
2277
3231
|
}
|
|
2278
3232
|
function switchProject(pid) {
|
|
2279
3233
|
if (pid === S.activeId) return;
|
|
@@ -2282,10 +3236,15 @@ window.__ModuleLoader__.load({
|
|
|
2282
3236
|
update((s) => {
|
|
2283
3237
|
s.activeId = pid;
|
|
2284
3238
|
s.graph = { nodes: doc.nodes || {}, edges: doc.edges || [], view: doc.view || { x: 60, y: 40, scale: 1 } };
|
|
2285
|
-
s.sel = new Set(); s.selEdge = null;
|
|
3239
|
+
s.sel = new Set(); s.selEdge = null;
|
|
3240
|
+
// 注意:不复位 s.running —— 否则运行中切项目会让"运行工作流"重新可点,并发跑同一节点
|
|
2286
3241
|
V.x = s.graph.view.x; V.y = s.graph.view.y; V.scale = s.graph.view.scale;
|
|
2287
3242
|
applyViewToDom();
|
|
2288
3243
|
});
|
|
3244
|
+
// 撤销栈必须按项目隔离:否则在新项目里按 Ctrl+Z 会把上一个项目的节点写进来(覆盖数据)
|
|
3245
|
+
history.stack = [];
|
|
3246
|
+
history.idx = -1;
|
|
3247
|
+
pushHistory();
|
|
2289
3248
|
}
|
|
2290
3249
|
|
|
2291
3250
|
// ============================================================
|
|
@@ -2307,9 +3266,15 @@ window.__ModuleLoader__.load({
|
|
|
2307
3266
|
const snap = history.stack[history.idx];
|
|
2308
3267
|
if (!snap) return;
|
|
2309
3268
|
const doc = JSON.parse(snap);
|
|
3269
|
+
const prevIds = S.graph ? Object.keys(S.graph.nodes || {}) : [];
|
|
2310
3270
|
update((s) => {
|
|
2311
3271
|
s.graph.nodes = doc.nodes;
|
|
2312
3272
|
s.graph.edges = doc.edges;
|
|
3273
|
+
// 撤销/重做必须对齐墓碑:快照里消失的节点=这次撤销动作删掉的(补墓碑,防同步复活);
|
|
3274
|
+
// 快照里出现的节点=被恢复的(撤墓碑,否则 Ctrl+Z 恢复的节点会被下一轮同步按旧墓碑再次抹掉)
|
|
3275
|
+
const live = new Set(Object.keys(s.graph.nodes || {}));
|
|
3276
|
+
s.graph.tombstones = [...new Set([...(s.graph.tombstones || []), ...prevIds])]
|
|
3277
|
+
.filter((id) => !live.has(id)).slice(-200);
|
|
2313
3278
|
s.sel = new Set();
|
|
2314
3279
|
s.selEdge = null;
|
|
2315
3280
|
});
|
|
@@ -2412,7 +3377,8 @@ window.__ModuleLoader__.load({
|
|
|
2412
3377
|
return {
|
|
2413
3378
|
id: uid('n'), type,
|
|
2414
3379
|
x: Math.round(pos.x), y: Math.round(pos.y),
|
|
2415
|
-
w:
|
|
3380
|
+
w: type === 'script' ? 340 : type === 'storyboard' ? 360 : type === 'text' ? 280 : NODE_W,
|
|
3381
|
+
h: 96 + portRows * 4,
|
|
2416
3382
|
data,
|
|
2417
3383
|
};
|
|
2418
3384
|
}
|
|
@@ -2422,14 +3388,18 @@ window.__ModuleLoader__.load({
|
|
|
2422
3388
|
pushHistory();
|
|
2423
3389
|
return n;
|
|
2424
3390
|
}
|
|
2425
|
-
function removeNodes(ids) {
|
|
3391
|
+
function removeNodes(ids, edgeId) {
|
|
2426
3392
|
update((s) => {
|
|
2427
3393
|
for (const id of ids) delete s.graph.nodes[id];
|
|
2428
|
-
s.graph.edges = s.graph.edges.filter((e) => !ids.includes(e.fromNode) && !ids.includes(e.toNode));
|
|
3394
|
+
s.graph.edges = s.graph.edges.filter((e) => !ids.includes(e.fromNode) && !ids.includes(e.toNode) && (!edgeId || e.id !== edgeId));
|
|
2429
3395
|
s.sel = new Set([...s.sel].filter((x) => ids.includes(x) === false));
|
|
2430
3396
|
if (s.selEdge && !s.graph.edges.some((e) => e.id === s.selEdge)) s.selEdge = null;
|
|
3397
|
+
// 本地删除也要留墓碑:Host 侧据此判定"真的被删了",
|
|
3398
|
+
// 否则 Host 合并(保留对话新节点)会把用户刚删的节点又留下来
|
|
3399
|
+
if (ids.length) s.graph.tombstones = [...new Set([...(s.graph.tombstones || []), ...ids])].slice(-200);
|
|
2431
3400
|
});
|
|
2432
3401
|
pushHistory();
|
|
3402
|
+
flushSave(); // 删除立即推送(不等 450ms 防抖):同步把 Host 上的节点删掉,压缩"拉回复活"的时间窗
|
|
2433
3403
|
}
|
|
2434
3404
|
function duplicateSelection() {
|
|
2435
3405
|
const clones = [];
|
|
@@ -2548,6 +3518,28 @@ window.__ModuleLoader__.load({
|
|
|
2548
3518
|
}
|
|
2549
3519
|
|
|
2550
3520
|
// 输入解析:生成节点的实际 prompt(文本节点 / 分镜节点的画面、动作输出)
|
|
3521
|
+
/** 分镜表 → 一次多镜头视频生成的提示词:所有镜头按顺序、按时长写进同一条提示词,
|
|
3522
|
+
* 由支持多镜头的视频模型**一次性生成整段视频**(而不是一镜一视频再拼接)。
|
|
3523
|
+
* 各镜内的 @实体 引用原样保留,collectRefs 会自动挂上对应参考图资产。 */
|
|
3524
|
+
function buildStoryboardVideoPrompt(sb) {
|
|
3525
|
+
const shots = ((sb && sb.data && sb.data.shots) || []).filter((s) => s && (String(s.visual || '').trim() || String(s.motion || '').trim()));
|
|
3526
|
+
if (!shots.length) return '';
|
|
3527
|
+
const zh = effLang() !== 'en';
|
|
3528
|
+
const total = shots.reduce((a, s) => a + Math.max(1, Number(s.duration) || 3), 0);
|
|
3529
|
+
const lines = [
|
|
3530
|
+
zh
|
|
3531
|
+
? `【多镜头一次性生成】共 ${shots.length} 镜、总时长 ${total} 秒。严格按下列顺序与时长切镜(每镜时长不可变),镜头之间直接硬切,不要叠加转场:`
|
|
3532
|
+
: `[Multi-shot single generation] ${shots.length} shots, ${total}s total. Cut strictly in this order with exact per-shot durations, hard cuts only, no transitions:`,
|
|
3533
|
+
];
|
|
3534
|
+
shots.forEach((s, i) => {
|
|
3535
|
+
const dur = Math.max(1, Number(s.duration) || 3);
|
|
3536
|
+
const parts = [];
|
|
3537
|
+
if (String(s.visual || '').trim()) parts.push(String(s.visual).trim());
|
|
3538
|
+
if (String(s.motion || '').trim()) parts.push((zh ? '运镜/动作:' : 'Camera/Action: ') + String(s.motion).trim());
|
|
3539
|
+
lines.push((zh ? `镜头${i + 1}(${dur}s):` : `Shot ${i + 1} (${dur}s): `) + parts.join(zh ? ';' : '; '));
|
|
3540
|
+
});
|
|
3541
|
+
return lines.join('\n');
|
|
3542
|
+
}
|
|
2551
3543
|
function resolvePrompt(n) {
|
|
2552
3544
|
const e = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'prompt');
|
|
2553
3545
|
if (e) {
|
|
@@ -2559,6 +3551,11 @@ window.__ModuleLoader__.load({
|
|
|
2559
3551
|
const v = (src.data[field] || '').trim();
|
|
2560
3552
|
if (v) return v;
|
|
2561
3553
|
}
|
|
3554
|
+
// 上游是分镜表 → 多镜头一次性生成:全部镜头组合成一条提示词(时长 = 各镜之和)
|
|
3555
|
+
if (src.type === 'storyboard') {
|
|
3556
|
+
const composed = buildStoryboardVideoPrompt(src);
|
|
3557
|
+
if (composed) return composed;
|
|
3558
|
+
}
|
|
2562
3559
|
}
|
|
2563
3560
|
}
|
|
2564
3561
|
return (n.data.prompt || '').trim();
|
|
@@ -2605,49 +3602,220 @@ window.__ModuleLoader__.load({
|
|
|
2605
3602
|
// ============================================================
|
|
2606
3603
|
const cancelRef = { cancelled: false };
|
|
2607
3604
|
|
|
3605
|
+
/** 分镜节点的剧本来源:**连线上游与手动粘贴二选一**——
|
|
3606
|
+
* 连了线就只用连线(上游没生成完会明确报错,绝不偷用粘贴的文本混着来)。 */
|
|
3607
|
+
function storyboardScriptText(n) {
|
|
3608
|
+
const e = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'script');
|
|
3609
|
+
if (e) {
|
|
3610
|
+
const src = S.graph.nodes[e.fromNode];
|
|
3611
|
+
if (!src) throw new Error(T('aiNeedScript'));
|
|
3612
|
+
if (src.type === 'script') {
|
|
3613
|
+
const out = String(src.data.output || '').trim();
|
|
3614
|
+
if (!out) throw new Error(T('upstreamStillRunning'));
|
|
3615
|
+
return out;
|
|
3616
|
+
}
|
|
3617
|
+
if (src.type === 'text') {
|
|
3618
|
+
const txt = String(src.data.text || '').trim();
|
|
3619
|
+
if (!txt) throw new Error(T('upstreamStillRunning'));
|
|
3620
|
+
return txt;
|
|
3621
|
+
}
|
|
3622
|
+
throw new Error(T('aiNeedScript'));
|
|
3623
|
+
}
|
|
3624
|
+
return String(n.data.script || '').trim();
|
|
3625
|
+
}
|
|
3626
|
+
const setNodeAiError = (nodeId, message) => update((s) => {
|
|
3627
|
+
const nd = s.graph.nodes[nodeId];
|
|
3628
|
+
if (nd) { nd.data.aiStatus = 'error'; nd.data.aiError = String(message || ''); nd.data.status = 'error'; }
|
|
3629
|
+
});
|
|
3630
|
+
|
|
3631
|
+
/** 剧本节点:idea → 成稿。按钮与对话触发的 canvas_run_node 共用同一实现。 */
|
|
3632
|
+
async function genScriptNode(n) {
|
|
3633
|
+
if (!String(n.data.idea || '').trim()) throw new Error(T('needIdea'));
|
|
3634
|
+
update((s) => {
|
|
3635
|
+
const nd = s.graph.nodes[n.id];
|
|
3636
|
+
if (nd) {
|
|
3637
|
+
// 生成开始即清空结果框(旧稿备份,失败自动还原):避免"还在生成中,结果框却挂着上一版"的误解
|
|
3638
|
+
nd.data.prevOutput = nd.data.output || '';
|
|
3639
|
+
nd.data.output = '';
|
|
3640
|
+
nd.data.aiStatus = 'running';
|
|
3641
|
+
nd.data.aiError = null;
|
|
3642
|
+
}
|
|
3643
|
+
});
|
|
3644
|
+
aiStreamClear(n.id);
|
|
3645
|
+
flushSave();
|
|
3646
|
+
try {
|
|
3647
|
+
const text = await aiScriptNodes(n.data.idea, n.data.model, (kind, d) => aiStreamAppend(n.id, kind, d));
|
|
3648
|
+
if (!String(text || '').trim()) throw new Error(T('aiEmptyResult'));
|
|
3649
|
+
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.output = text; nd.data.prevOutput = null; nd.data.aiStatus = 'idle'; nd.data.status = 'done'; nd.data.error = null; } s.assetsTick++; });
|
|
3650
|
+
await saveTextAssetRecord(S.activeId, {
|
|
3651
|
+
name: (String(n.data.label || '').trim() || T('scriptAssetPrefix')) + ' ' + nowStamp(),
|
|
3652
|
+
text, category: 'script', prompt: n.data.idea,
|
|
3653
|
+
});
|
|
3654
|
+
update((s) => { s.assetsTick++; });
|
|
3655
|
+
flushSave();
|
|
3656
|
+
return text;
|
|
3657
|
+
} catch (e) {
|
|
3658
|
+
update((s) => {
|
|
3659
|
+
const nd = s.graph.nodes[n.id];
|
|
3660
|
+
// 失败时还原上一版成稿,不让用户白丢内容
|
|
3661
|
+
if (nd && !String(nd.data.output || '').trim() && nd.data.prevOutput) { nd.data.output = nd.data.prevOutput; nd.data.prevOutput = null; }
|
|
3662
|
+
});
|
|
3663
|
+
setNodeAiError(n.id, (e && e.message) || e);
|
|
3664
|
+
flushSave();
|
|
3665
|
+
throw e;
|
|
3666
|
+
} finally {
|
|
3667
|
+
aiStreamClear(n.id);
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3670
|
+
|
|
3671
|
+
/** 分镜节点的资产文本(自动入库与手动"存为资产"共用,避免两处格式漂移)。 */
|
|
3672
|
+
function storyboardAssetText(shots) {
|
|
3673
|
+
return (shots || []).map((x) => `【${x.title}】${x.duration}s\n画面:${x.visual}\n动作:${x.motion}\n角色:${(x.characters || []).join('、') || '-'}|场景:${x.scene || '-'}|道具:${(x.props || []).join('、') || '-'}`).join('\n\n');
|
|
3674
|
+
}
|
|
3675
|
+
|
|
3676
|
+
/** 分镜节点:剧本 → 镜头列表(含一致性检查)。 */
|
|
3677
|
+
async function genStoryboardNode(n) {
|
|
3678
|
+
const st = storyboardScriptText(n);
|
|
3679
|
+
if (!st) throw new Error(T('aiNeedScript'));
|
|
3680
|
+
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.aiStatus = 'running'; nd.data.aiError = null; nd.data.warnings = []; } });
|
|
3681
|
+
aiStreamClear(n.id);
|
|
3682
|
+
flushSave();
|
|
3683
|
+
try {
|
|
3684
|
+
const shots = await aiStoryboardShots(st, n.data.requirement, n.data.count || 6, n.data.model, (kind, d) => aiStreamAppend(n.id, kind, d));
|
|
3685
|
+
const warnings = await checkShotConsistency(shots);
|
|
3686
|
+
update((s) => {
|
|
3687
|
+
const nd = s.graph.nodes[n.id];
|
|
3688
|
+
if (nd) { nd.data.shots = shots; nd.data.aiStatus = 'idle'; nd.data.warnings = warnings; nd.data.status = 'done'; nd.data.error = null; }
|
|
3689
|
+
s.assetsTick++;
|
|
3690
|
+
});
|
|
3691
|
+
// 分镜里出现的新角色/场景/道具 → 自动补进设定集(描述留空待补充),保证设定集始终是唯一事实源
|
|
3692
|
+
try { await autoRegisterBibleEntries(shots); } catch { /* 补录失败不影响分镜 */ }
|
|
3693
|
+
await saveTextAssetRecord(S.activeId, {
|
|
3694
|
+
name: (String(n.data.label || '').trim() || T('storyboardAssetPrefix')) + ' ' + nowStamp(),
|
|
3695
|
+
text: storyboardAssetText(shots),
|
|
3696
|
+
category: 'storyboard', prompt: st.slice(0, 200),
|
|
3697
|
+
});
|
|
3698
|
+
update((s) => { s.assetsTick++; });
|
|
3699
|
+
flushSave();
|
|
3700
|
+
return shots;
|
|
3701
|
+
} catch (e) {
|
|
3702
|
+
setNodeAiError(n.id, (e && e.message) || e);
|
|
3703
|
+
flushSave();
|
|
3704
|
+
throw e;
|
|
3705
|
+
} finally {
|
|
3706
|
+
aiStreamClear(n.id);
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3710
|
+
/** 成片节点:片段(+配音) → MP4。 */
|
|
3711
|
+
async function genFinalCutNode(n) {
|
|
3712
|
+
const segEdges = S.graph.edges.filter((x) => x.toNode === n.id && x.toPort === 'in');
|
|
3713
|
+
const segs = segEdges
|
|
3714
|
+
.map((e) => ({ e, nd: S.graph.nodes[e.fromNode] }))
|
|
3715
|
+
.filter((x) => x.nd)
|
|
3716
|
+
.sort((a, b) => (a.nd.y - b.nd.y) || (a.nd.x - b.nd.x));
|
|
3717
|
+
if (!segs.length) throw new Error(T('noSegments'));
|
|
3718
|
+
const getSub = (x) => {
|
|
3719
|
+
const defSub = x.nd.type === 'video-gen' ? (x.nd.data.prompt || '') : (x.nd.data.label || TYPE_LABEL()[x.nd.type]);
|
|
3720
|
+
const v = n.data.subs && n.data.subs[x.e.id];
|
|
3721
|
+
return v != null ? v : defSub;
|
|
3722
|
+
};
|
|
3723
|
+
const metas = [];
|
|
3724
|
+
for (const x of segs) {
|
|
3725
|
+
if (!x.nd.data.assetId) continue;
|
|
3726
|
+
try {
|
|
3727
|
+
const rec = await idbGet(x.nd.data.assetId);
|
|
3728
|
+
const blob = await assetBlob(x.nd.data.assetId);
|
|
3729
|
+
if (blob) {
|
|
3730
|
+
const ttsId = n.data.tts && n.data.tts[x.e.id] && n.data.tts[x.e.id].assetId;
|
|
3731
|
+
const ttsUrl = ttsId ? await assetUrl(ttsId) : null;
|
|
3732
|
+
// 分镜的编辑时长(1-3s 级)作为成片裁剪依据:API 生成的片段偏长,成片时裁到分镜时长
|
|
3733
|
+
const editDur = Number(x.nd.data.editDuration) || null;
|
|
3734
|
+
metas.push({ url: URL.createObjectURL(blob), name: (rec && rec.name) || 'seg', sub: getSub(x), ttsUrl, dur: editDur || undefined });
|
|
3735
|
+
}
|
|
3736
|
+
} catch { /* 跳过加载失败的片段 */ }
|
|
3737
|
+
}
|
|
3738
|
+
if (!metas.length) throw new Error(T('noSegments'));
|
|
3739
|
+
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.status = 'running'; nd.data.error = null; nd.data.progress = '0%'; } });
|
|
3740
|
+
flushSave();
|
|
3741
|
+
try {
|
|
3742
|
+
const dims = videoDimsFor({ resolution: n.data.resolution, ratio: n.data.ratio });
|
|
3743
|
+
const blob = await composeFinalCut(metas, {
|
|
3744
|
+
width: dims.w, height: dims.h, keepAudio: n.data.keepAudio !== false, transition: n.data.transition,
|
|
3745
|
+
bitrate: n.data.bitrate ? Number(n.data.bitrate) : undefined,
|
|
3746
|
+
}, (frac, name) => {
|
|
3747
|
+
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.progress = Math.round(frac * 100) + '% · ' + name; });
|
|
3748
|
+
});
|
|
3749
|
+
const rec = await saveAssetRecord(S.activeId, 'video', blob, {
|
|
3750
|
+
name: (String(n.data.label || '').trim() || T('finalCutDone')) + ' ' + nowStamp(),
|
|
3751
|
+
prompt: metas.length + ' segments', source: 'finalcut', category: 'finalcut',
|
|
3752
|
+
});
|
|
3753
|
+
metas.forEach((m) => URL.revokeObjectURL(m.url));
|
|
3754
|
+
update((s) => {
|
|
3755
|
+
const nd = s.graph.nodes[n.id];
|
|
3756
|
+
if (nd) { nd.data.status = 'done'; nd.data.assetId = rec.id; nd.data.progress = null; }
|
|
3757
|
+
s.assetsTick++;
|
|
3758
|
+
});
|
|
3759
|
+
flushSave();
|
|
3760
|
+
return rec.id;
|
|
3761
|
+
} catch (e) {
|
|
3762
|
+
metas.forEach((m) => URL.revokeObjectURL(m.url));
|
|
3763
|
+
update((s) => {
|
|
3764
|
+
const nd = s.graph.nodes[n.id];
|
|
3765
|
+
if (nd) { nd.data.status = 'error'; nd.data.error = String((e && e.message) || e); nd.data.progress = null; }
|
|
3766
|
+
});
|
|
3767
|
+
flushSave();
|
|
3768
|
+
throw e;
|
|
3769
|
+
}
|
|
3770
|
+
}
|
|
3771
|
+
|
|
2608
3772
|
async function runGenNode(n) {
|
|
2609
3773
|
const type = n.type;
|
|
2610
3774
|
update((s) => {
|
|
2611
3775
|
const nd = s.graph.nodes[n.id];
|
|
2612
3776
|
if (nd) { nd.data.status = 'running'; nd.data.error = null; }
|
|
2613
3777
|
});
|
|
3778
|
+
flushSave(); // 立刻落盘 + 推送 Host:切走画面/对话查询都能看到"运行中"
|
|
2614
3779
|
try {
|
|
3780
|
+
if (type === 'script') { await genScriptNode(n); return true; }
|
|
3781
|
+
if (type === 'storyboard') { await genStoryboardNode(n); return true; }
|
|
3782
|
+
if (type === 'finalcut') { await genFinalCutNode(n); return true; }
|
|
3783
|
+
if (type !== 'image-gen' && type !== 'video-gen') throw new Error(T('nodeNotRunnable'));
|
|
2615
3784
|
const prompt = resolvePrompt(n);
|
|
3785
|
+
const bible = loadBible(S.activeId);
|
|
2616
3786
|
if (type === 'image-gen') {
|
|
2617
|
-
|
|
2618
|
-
const
|
|
2619
|
-
const
|
|
2620
|
-
|
|
2621
|
-
const hit = (bible[key] || []).find((x) => (x.name || '').trim() === String(name || '').trim());
|
|
2622
|
-
return hit && hit.desc ? hit.desc.trim() : '';
|
|
2623
|
-
};
|
|
2624
|
-
const bind = metas.map((m, i) => {
|
|
2625
|
-
const d = bibleDesc(m.category, m.name);
|
|
2626
|
-
return `[图${i + 1}] ${catLabel(m.category)}:${m.name}${d ? '(' + d + ')' : ''}`;
|
|
2627
|
-
}).join(';');
|
|
2628
|
-
const p = [
|
|
2629
|
-
(bible.style || '').trim() ? '风格:' + bible.style.trim() : '',
|
|
2630
|
-
bind ? `${T('refCount')} → ${bind}` : '',
|
|
2631
|
-
prompt,
|
|
2632
|
-
].filter(Boolean).join('\n');
|
|
3787
|
+
// 参考图 = 连线(用户顺序)+ @引用命中的设定集资产(按出现顺序)
|
|
3788
|
+
const { refs, entities } = await collectRefs(n, prompt, bible);
|
|
3789
|
+
const metas = refs.map((r) => ({ blob: r.blob, name: r.name, category: r.category }));
|
|
3790
|
+
const p = buildEffectivePrompt(prompt, refs, entities, bible);
|
|
2633
3791
|
const { blob, demo } = await generateImage(p, metas, { size: n.data.size, ratio: n.data.ratio, apiId: n.data.imageApiId });
|
|
2634
|
-
const rec = await saveAssetRecord(S.activeId, 'image', blob, {
|
|
3792
|
+
const rec = await saveAssetRecord(S.activeId, 'image', blob, {
|
|
3793
|
+
name: assetNameFor(n, entities, 'image'),
|
|
3794
|
+
prompt: stripMentionMarks(prompt), effectivePrompt: p,
|
|
3795
|
+
source: demo ? 'demo' : 'api',
|
|
3796
|
+
entity: entities.length ? entities[0].name : undefined,
|
|
3797
|
+
entities: entities.map((e) => e.name),
|
|
3798
|
+
nodeLabel: (n.data && n.data.label) || '',
|
|
3799
|
+
category: entities.length ? entityCategoryOf(entities[0].kind) : 'general',
|
|
3800
|
+
});
|
|
2635
3801
|
commitGenResult(n.id, rec.id, demo, 'image');
|
|
2636
3802
|
} else {
|
|
2637
3803
|
const frameBlob = await resolveAssetBlob(n.id, 'frame');
|
|
2638
|
-
const
|
|
2639
|
-
const
|
|
2640
|
-
const bind = refMetas.map((m, i) => `[图${i + 1}] ${catLabel(m.category)}:${m.name}`).join(';');
|
|
2641
|
-
const p = [
|
|
2642
|
-
(bible.style || '').trim() ? '风格:' + bible.style.trim() : '',
|
|
2643
|
-
bind ? `${T('refCount')} → ${bind}` : '',
|
|
2644
|
-
prompt,
|
|
2645
|
-
].filter(Boolean).join('\n');
|
|
3804
|
+
const { refs, entities } = await collectRefs(n, prompt, bible);
|
|
3805
|
+
const p = buildEffectivePrompt(prompt, refs, entities, bible);
|
|
2646
3806
|
const { blob, demo } = await generateVideo(p, frameBlob, {
|
|
2647
3807
|
duration: n.data.duration, resolution: n.data.resolution, ratio: n.data.ratio, apiId: n.data.videoApiId,
|
|
2648
|
-
refs:
|
|
3808
|
+
refs: refs.map((r) => r.blob),
|
|
3809
|
+
});
|
|
3810
|
+
const rec = await saveAssetRecord(S.activeId, 'video', blob, {
|
|
3811
|
+
name: assetNameFor(n, entities, 'video'),
|
|
3812
|
+
prompt: stripMentionMarks(prompt), effectivePrompt: p,
|
|
3813
|
+
source: demo ? 'demo' : 'api',
|
|
3814
|
+
entity: entities.length ? entities[0].name : undefined,
|
|
3815
|
+
entities: entities.map((e) => e.name),
|
|
3816
|
+
nodeLabel: (n.data && n.data.label) || '',
|
|
3817
|
+
category: entities.length ? entityCategoryOf(entities[0].kind) : 'general',
|
|
2649
3818
|
});
|
|
2650
|
-
const rec = await saveAssetRecord(S.activeId, 'video', blob, { prompt, source: demo ? 'demo' : 'api' });
|
|
2651
3819
|
commitGenResult(n.id, rec.id, demo, 'video');
|
|
2652
3820
|
// 尾帧提取(通用):供 last 端口输出与链式下一镜使用
|
|
2653
3821
|
try {
|
|
@@ -2669,6 +3837,7 @@ window.__ModuleLoader__.load({
|
|
|
2669
3837
|
const nd = s.graph.nodes[n.id];
|
|
2670
3838
|
if (nd) { nd.data.status = 'error'; nd.data.error = String((err && err.message) || err); }
|
|
2671
3839
|
});
|
|
3840
|
+
flushSave(); // 让对话侧的 canvas_node_status 也能读到失败原因
|
|
2672
3841
|
return false;
|
|
2673
3842
|
}
|
|
2674
3843
|
}
|
|
@@ -2710,7 +3879,9 @@ window.__ModuleLoader__.load({
|
|
|
2710
3879
|
if (cancelRef.cancelled) break;
|
|
2711
3880
|
const n = S.graph.nodes[id];
|
|
2712
3881
|
if (!n) continue;
|
|
2713
|
-
|
|
3882
|
+
// 已在生成中的节点跳过:避免与对话触发的 canvas_run_node 并发调用同一节点(重复计费/重复资产)
|
|
3883
|
+
if (n.data && n.data.status === 'running') continue;
|
|
3884
|
+
if (n.type === 'image-gen' || n.type === 'video-gen' || n.type === 'script' || n.type === 'storyboard' || n.type === 'finalcut') {
|
|
2714
3885
|
await runGenNode(n);
|
|
2715
3886
|
}
|
|
2716
3887
|
}
|
|
@@ -3014,17 +4185,9 @@ window.__ModuleLoader__.load({
|
|
|
3014
4185
|
}
|
|
3015
4186
|
if ((e.key === 'Delete' || e.key === 'Backspace') && (S.sel.size || S.selEdge)) {
|
|
3016
4187
|
e.preventDefault();
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
if (s.selEdge) {
|
|
3021
|
-
s.graph.edges = s.graph.edges.filter((x) => x.id !== s.selEdge);
|
|
3022
|
-
s.selEdge = null;
|
|
3023
|
-
}
|
|
3024
|
-
s.graph.edges = s.graph.edges.filter((x) => !ids.includes(x.fromNode) && !ids.includes(x.toNode));
|
|
3025
|
-
s.sel = new Set();
|
|
3026
|
-
});
|
|
3027
|
-
pushHistory();
|
|
4188
|
+
// 必须走 removeNodes(记节点墓碑 + 立即推送)。此前这里是裸删:不记墓碑、不立即推送,
|
|
4189
|
+
// Host 合并把删除的节点当"Host 独有"并回来 —— 表现为 Delete 键删除后自动恢复,而 × 删除正常
|
|
4190
|
+
removeNodes([...S.sel], S.selEdge || undefined);
|
|
3028
4191
|
}
|
|
3029
4192
|
}
|
|
3030
4193
|
|
|
@@ -3140,9 +4303,13 @@ window.__ModuleLoader__.load({
|
|
|
3140
4303
|
}
|
|
3141
4304
|
}
|
|
3142
4305
|
delete s.graph.nodes[id];
|
|
4306
|
+
s.graph.edges = s.graph.edges.filter((e) => e.fromNode !== id && e.toNode !== id);
|
|
4307
|
+
// 解散分组也是删除节点:不记墓碑会被 Host 合并复活
|
|
4308
|
+
s.graph.tombstones = [...new Set([...(s.graph.tombstones || []), id])].slice(-200);
|
|
3143
4309
|
s.sel.delete(id);
|
|
3144
4310
|
});
|
|
3145
4311
|
pushHistory();
|
|
4312
|
+
flushSave();
|
|
3146
4313
|
}
|
|
3147
4314
|
function createGroupFromSelection() {
|
|
3148
4315
|
const ids = [...S.sel].filter((id) => S.graph.nodes[id] && S.graph.nodes[id].type !== 'group');
|
|
@@ -3176,25 +4343,112 @@ window.__ModuleLoader__.load({
|
|
|
3176
4343
|
nodeEls.delete(n.id);
|
|
3177
4344
|
}
|
|
3178
4345
|
}, [n.id]);
|
|
4346
|
+
// 【顺序要求】所有 hook 必须先无条件执行,再决定渲染什么:
|
|
4347
|
+
// 否则分组折叠切换 data.hidden 时 hook 数量变化,React 会抛错并卸载整个画布(白屏)
|
|
4348
|
+
const [renaming, setRenaming] = useState(false);
|
|
4349
|
+
const [nameVal, setNameVal] = useState('');
|
|
4350
|
+
// 注意:这里**不做**任何"随内容自动增高"——高度只能由用户拖节点边改变,
|
|
4351
|
+
// 文本框内容超出就在框内滚动。此前的自动增高会在流式刷新时被反复触发,
|
|
4352
|
+
// 造成"生成中节点自己不断变长"的 bug(用户实测)。
|
|
3179
4353
|
if (n.data.hidden) return null; // 分组折叠时隐藏成员
|
|
3180
4354
|
if (n.type === 'group') return h(GroupShell, { n });
|
|
4355
|
+
const commitName = () => {
|
|
4356
|
+
const v = nameVal.trim();
|
|
4357
|
+
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.label = v; });
|
|
4358
|
+
pushHistory();
|
|
4359
|
+
setRenaming(false);
|
|
4360
|
+
};
|
|
4361
|
+
const startRename = () => { setNameVal(n.data.label || ''); setRenaming(true); };
|
|
4362
|
+
// 统一的尺寸拖拽:dir = 'e' | 'w' | 's' | 'n' | 'se'
|
|
4363
|
+
// 左右改宽(左边缘同时改 x,视觉上"从左边拉"),上下改高,右下角同时改宽高。
|
|
4364
|
+
// 竖向一旦手动拖过 → 记 hManual(固定高度,内容超出则节点内滚动);双击底边恢复自适应。
|
|
4365
|
+
const startResize = (e, dir) => {
|
|
4366
|
+
e.stopPropagation();
|
|
4367
|
+
e.preventDefault();
|
|
4368
|
+
const sx = e.clientX;
|
|
4369
|
+
const sy = e.clientY;
|
|
4370
|
+
const scale = V.scale || 1;
|
|
4371
|
+
const cur = S.graph.nodes[n.id] || n;
|
|
4372
|
+
const w0 = cur.w || NODE_W;
|
|
4373
|
+
const h0 = cur.h || 120;
|
|
4374
|
+
const x0 = cur.x;
|
|
4375
|
+
const y0 = cur.y;
|
|
4376
|
+
const move = (ev) => {
|
|
4377
|
+
const dx = (ev.clientX - sx) / scale;
|
|
4378
|
+
const dy = (ev.clientY - sy) / scale;
|
|
4379
|
+
update((s) => {
|
|
4380
|
+
const nd = s.graph.nodes[n.id];
|
|
4381
|
+
if (!nd) return;
|
|
4382
|
+
if (dir.includes('e')) nd.w = Math.round(Math.max(200, Math.min(1200, w0 + dx)));
|
|
4383
|
+
if (dir.includes('w')) {
|
|
4384
|
+
const w = Math.round(Math.max(200, Math.min(1200, w0 - dx)));
|
|
4385
|
+
nd.w = w;
|
|
4386
|
+
nd.x = Math.round(x0 + (w0 - w));
|
|
4387
|
+
}
|
|
4388
|
+
if (dir.includes('s')) nd.hManual = Math.round(Math.max(96, Math.min(2400, h0 + dy)));
|
|
4389
|
+
if (dir.includes('n')) {
|
|
4390
|
+
const h = Math.round(Math.max(96, Math.min(2400, h0 - dy)));
|
|
4391
|
+
nd.hManual = h;
|
|
4392
|
+
nd.y = Math.round(y0 + (h0 - h));
|
|
4393
|
+
}
|
|
4394
|
+
});
|
|
4395
|
+
};
|
|
4396
|
+
const up = () => {
|
|
4397
|
+
window.removeEventListener('pointermove', move);
|
|
4398
|
+
window.removeEventListener('pointerup', up);
|
|
4399
|
+
pushHistory();
|
|
4400
|
+
};
|
|
4401
|
+
window.addEventListener('pointermove', move);
|
|
4402
|
+
window.addEventListener('pointerup', up);
|
|
4403
|
+
};
|
|
4404
|
+
const resetHeight = (e) => {
|
|
4405
|
+
e.stopPropagation();
|
|
4406
|
+
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.hManual = null; });
|
|
4407
|
+
pushHistory();
|
|
4408
|
+
};
|
|
3181
4409
|
return h('div', {
|
|
3182
4410
|
'data-node-id': n.id,
|
|
3183
|
-
className: cls('dshic-node', 'type-' + n.type, 'st-' + st, selected && 'is-selected'),
|
|
3184
|
-
style: { left: n.x, top: n.y, width: n.w },
|
|
4411
|
+
className: cls('dshic-node', 'type-' + n.type, 'st-' + st, selected && 'is-selected', n.hManual && 'h-manual'),
|
|
4412
|
+
style: { left: n.x, top: n.y, width: n.w, ...(n.hManual ? { height: n.hManual } : {}) },
|
|
3185
4413
|
ref: refCb,
|
|
3186
4414
|
onPointerDown: (e) => onNodePointerDown(e, n),
|
|
3187
4415
|
},
|
|
3188
4416
|
h('div', { className: 'dshic-node-head' },
|
|
3189
4417
|
h('span', { className: 'dshic-head-ic' }, Ic(TYPE_ICON[n.type] || 'text', 12)),
|
|
3190
|
-
|
|
4418
|
+
renaming
|
|
4419
|
+
? h('input', {
|
|
4420
|
+
className: 'dshic-input', style: { height: 22, flex: 1, minWidth: 0 }, autoFocus: true, value: nameVal,
|
|
4421
|
+
placeholder: TYPE_LABEL()[n.type],
|
|
4422
|
+
onChange: (e) => setNameVal(e.target.value),
|
|
4423
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
4424
|
+
onKeyDown: (e) => {
|
|
4425
|
+
if (e.key === 'Enter') commitName();
|
|
4426
|
+
if (e.key === 'Escape') setRenaming(false);
|
|
4427
|
+
},
|
|
4428
|
+
onBlur: commitName,
|
|
4429
|
+
})
|
|
4430
|
+
: h('span', {
|
|
4431
|
+
className: 'dshic-node-title', title: T('renameHint'),
|
|
4432
|
+
onDoubleClick: (e) => { e.stopPropagation(); startRename(); },
|
|
4433
|
+
}, n.data.label || TYPE_LABEL()[n.type]),
|
|
3191
4434
|
(n.data.status === 'running') ? h('span', { className: 'dshic-spin' }) : null,
|
|
4435
|
+
h('button', {
|
|
4436
|
+
type: 'button', className: 'dshic-node-x', title: T('renameHint'),
|
|
4437
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
4438
|
+
onClick: startRename,
|
|
4439
|
+
}, '✎'),
|
|
3192
4440
|
h('button', {
|
|
3193
4441
|
type: 'button', className: 'dshic-node-x', title: T('delete'),
|
|
3194
4442
|
onPointerDown: (e) => e.stopPropagation(),
|
|
3195
4443
|
onClick: () => removeNodes([n.id]),
|
|
3196
4444
|
}, '✕')),
|
|
3197
4445
|
h('div', { className: 'dshic-node-body' }, nodeBody(n)),
|
|
4446
|
+
// 尺寸把手:右/左/下/上四条边 + 右下角(双击底边恢复"随内容自适应")
|
|
4447
|
+
h('div', { className: 'dshic-rs dshic-rs-e', title: T('resizeHint'), onPointerDown: (e) => startResize(e, 'e') }),
|
|
4448
|
+
h('div', { className: 'dshic-rs dshic-rs-w', title: T('resizeHint'), onPointerDown: (e) => startResize(e, 'w') }),
|
|
4449
|
+
h('div', { className: 'dshic-rs dshic-rs-s', title: T('resizeHeightHint'), onPointerDown: (e) => startResize(e, 's'), onDoubleClick: resetHeight }),
|
|
4450
|
+
h('div', { className: 'dshic-rs dshic-rs-n', title: T('resizeHint'), onPointerDown: (e) => startResize(e, 'n') }),
|
|
4451
|
+
h('div', { className: 'dshic-rs dshic-rs-se', title: T('resizeHint'), onPointerDown: (e) => startResize(e, 'se') }),
|
|
3198
4452
|
...(PORTS[n.type].inputs || []).map((p, i) =>
|
|
3199
4453
|
h('div', { key: 'in-' + p.id, style: { position: 'absolute', left: -5.5, top: 32.5 + i * 22 }, className: 'dshic-port-wrap' },
|
|
3200
4454
|
h(PortEl, { n, port: p, dir: 'in' }),
|
|
@@ -3461,18 +4715,24 @@ window.__ModuleLoader__.load({
|
|
|
3461
4715
|
const [showMask, setShowMask] = useState(false);
|
|
3462
4716
|
const [showCraft, setShowCraft] = useState(false);
|
|
3463
4717
|
useEffect(() => { probeCraft(); }, []);
|
|
3464
|
-
const promptEdge = S.graph.edges.
|
|
4718
|
+
const promptEdge = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'prompt');
|
|
4719
|
+
const promptSrc = promptEdge && S.graph.nodes[promptEdge.fromNode];
|
|
4720
|
+
const fromStoryboard = !!(promptSrc && promptSrc.type === 'storyboard');
|
|
3465
4721
|
const refEdges = S.graph.edges.filter((x) => x.toNode === n.id && (kind === 'image' ? x.toPort === 'ref' : x.toPort === 'frame'));
|
|
3466
4722
|
const vrCount = S.graph.edges.filter((x) => x.toNode === n.id && x.toPort === 'ref').length;
|
|
3467
4723
|
const assetUrlV = useAssetUrl(n.data.assetId);
|
|
3468
4724
|
return [
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
4725
|
+
promptEdge
|
|
4726
|
+
? h('textarea', {
|
|
4727
|
+
key: 'p', className: 'dshic-textarea', value: n.data.prompt || '',
|
|
4728
|
+
placeholder: fromStoryboard ? T('multiShotHint') : T('connected'), disabled: true,
|
|
4729
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
4730
|
+
})
|
|
4731
|
+
: h(PromptArea, {
|
|
4732
|
+
key: 'p', value: n.data.prompt || '', minHeight: 72,
|
|
4733
|
+
placeholder: T('promptPlaceholder'),
|
|
4734
|
+
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.prompt = v; }),
|
|
4735
|
+
}),
|
|
3476
4736
|
h(GenSettingsRow, { key: 'set', n, kind }),
|
|
3477
4737
|
h('button', {
|
|
3478
4738
|
key: 'gen', type: 'button', className: 'dshic-gen', disabled: S.running || n.data.status === 'running',
|
|
@@ -3490,6 +4750,7 @@ window.__ModuleLoader__.load({
|
|
|
3490
4750
|
T('portFrame') + ' × ' + refEdges.length + ' · ' + T('connected'))
|
|
3491
4751
|
: null,
|
|
3492
4752
|
h(RefRows, { key: 'refrows', n, portId: 'ref' }),
|
|
4753
|
+
h(MentionInfo, { key: 'mn', n }),
|
|
3493
4754
|
n.data.error ? h('div', { key: 'err', className: 'dshic-err' }, n.data.error) : null,
|
|
3494
4755
|
assetUrlV && kind === 'image'
|
|
3495
4756
|
? h('img', { key: 'img', className: 'dshic-preview', src: assetUrlV, draggable: false, 'data-no-drag': '1' })
|
|
@@ -3581,6 +4842,36 @@ window.__ModuleLoader__.load({
|
|
|
3581
4842
|
}
|
|
3582
4843
|
|
|
3583
4844
|
/** 参考图排序列表:[图N] 顺序即提示词绑定顺序,可上移/下移/解除。 */
|
|
4845
|
+
/** @引用解析预览:提示词里 @名称 命中的设定集条目与其参考图资产(标注将成为第几张)。
|
|
4846
|
+
* 连线参考图排在前面,故 @ 资产的序号从连线数之后开始。 */
|
|
4847
|
+
function MentionInfo({ n }) {
|
|
4848
|
+
const prompt = n.data.prompt || '';
|
|
4849
|
+
const [rows, setRows] = useState([]);
|
|
4850
|
+
useEffect(() => {
|
|
4851
|
+
let ok = true;
|
|
4852
|
+
// 防抖:输入过程中不必每个按键都查一次资产库
|
|
4853
|
+
const timer = setTimeout(async () => {
|
|
4854
|
+
try {
|
|
4855
|
+
const bible = loadBible(S.activeId);
|
|
4856
|
+
const bundle = await resolveMentionBundle(prompt, bible, S.activeId);
|
|
4857
|
+
if (ok) setRows(bundle.entities.map((e) => ({ ...e, count: bundle.refs.filter((r) => r.entity === e.name).length })));
|
|
4858
|
+
} catch { if (ok) setRows([]); }
|
|
4859
|
+
}, 350);
|
|
4860
|
+
return () => { ok = false; clearTimeout(timer); };
|
|
4861
|
+
}, [prompt, S.assetsTick, S.activeId]);
|
|
4862
|
+
if (!rows.length) return null;
|
|
4863
|
+
const all = rows.reduce((a, r) => a + r.count, 0);
|
|
4864
|
+
return h('div', { className: 'dshic-shot-list' },
|
|
4865
|
+
h('div', { className: 'dshic-shot-label' }, T('mentionRefs') + '(' + rows.length + ')'),
|
|
4866
|
+
...rows.map((r, i) => h('div', { key: r.name, className: 'dshic-row', style: { gap: 5 } },
|
|
4867
|
+
h('span', { className: 'dshic-mention' }, '@' + r.name),
|
|
4868
|
+
h('span', { style: { flex: 1, minWidth: 0, fontSize: 11, color: 'var(--dsw-alias-label-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
|
4869
|
+
entityLabelOf(r.kind) + ' · ' + (r.count ? T('mentionAssets').replace('{n}', String(r.count)) : T('mentionNoAsset'))),
|
|
4870
|
+
)),
|
|
4871
|
+
all === 0 ? h('div', { className: 'dshic-hint' }, T('mentionNoAssetHint')) : null,
|
|
4872
|
+
);
|
|
4873
|
+
}
|
|
4874
|
+
|
|
3584
4875
|
function RefRows({ n, portId }) {
|
|
3585
4876
|
const [rows, setRows] = useState([]);
|
|
3586
4877
|
const es = S.graph.edges.filter((x) => x.toNode === n.id && x.toPort === portId);
|
|
@@ -3611,7 +4902,7 @@ window.__ModuleLoader__.load({
|
|
|
3611
4902
|
if (ok) setRows(out);
|
|
3612
4903
|
})();
|
|
3613
4904
|
return () => { ok = false; };
|
|
3614
|
-
}, [idsKey, S.assetsTick]);
|
|
4905
|
+
}, [idsKey, S.assetsTick, (n.data.refOrder || []).join(',')]); // refOrder 变化必须重建:否则 [图N] 与实际上送顺序不一致
|
|
3615
4906
|
if (!es.length) return null;
|
|
3616
4907
|
const move = (id, dir) => {
|
|
3617
4908
|
update((s) => {
|
|
@@ -3649,7 +4940,11 @@ window.__ModuleLoader__.load({
|
|
|
3649
4940
|
const isImg = f.type.startsWith('image/');
|
|
3650
4941
|
const isVid = f.type.startsWith('video/');
|
|
3651
4942
|
if (kind === 'image' ? !isImg : !isVid) { toast(kind === 'image' ? T('pickImageFile') : T('pickVideoFile')); return; }
|
|
3652
|
-
const rec = await saveAssetRecord(S.activeId, kind, f, { name: f.name, source: 'upload', category: 'general' })
|
|
4943
|
+
const rec = await saveAssetRecord(S.activeId, kind, f, { name: f.name, source: 'upload', category: 'general' }).catch((err) => {
|
|
4944
|
+
toast(T('uploadFailed') + ':' + String((err && err.message) || err));
|
|
4945
|
+
return null;
|
|
4946
|
+
});
|
|
4947
|
+
if (!rec) return;
|
|
3653
4948
|
update((s) => {
|
|
3654
4949
|
const nd = s.graph.nodes[n.id];
|
|
3655
4950
|
if (nd) { nd.data.assetId = rec.id; nd.data.status = 'done'; nd.data.placeholderName = null; }
|
|
@@ -3730,23 +5025,44 @@ window.__ModuleLoader__.load({
|
|
|
3730
5025
|
);
|
|
3731
5026
|
}
|
|
3732
5027
|
|
|
5028
|
+
/**
|
|
5029
|
+
* 提示词输入框:@名称 蓝色加粗高亮。
|
|
5030
|
+
* 与主流工具同一做法——透明文字的 textarea 叠在同步滚动的背景层上,
|
|
5031
|
+
* 背景层渲染同一段文本并给 @名称 上色,光标/选区/中文输入法都不受影响。
|
|
5032
|
+
*/
|
|
5033
|
+
function PromptArea({ value, onChange, placeholder, minHeight, style }) {
|
|
5034
|
+
const taRef = useRef(null);
|
|
5035
|
+
const backRef = useRef(null);
|
|
5036
|
+
const sync = () => {
|
|
5037
|
+
if (backRef.current && taRef.current) {
|
|
5038
|
+
backRef.current.scrollTop = taRef.current.scrollTop;
|
|
5039
|
+
backRef.current.scrollLeft = taRef.current.scrollLeft;
|
|
5040
|
+
}
|
|
5041
|
+
};
|
|
5042
|
+
const html = highlightMentions(value || '');
|
|
5043
|
+
const ph = placeholder ? '<span class="dshic-prompt-ph">' + escapeHtml(placeholder) + '</span>' : '';
|
|
5044
|
+
return h('div', { className: 'dshic-prompt' },
|
|
5045
|
+
h('div', {
|
|
5046
|
+
ref: backRef, className: 'dshic-prompt-back', 'aria-hidden': 'true',
|
|
5047
|
+
dangerouslySetInnerHTML: { __html: html || ph },
|
|
5048
|
+
}),
|
|
5049
|
+
h('textarea', {
|
|
5050
|
+
ref: taRef, className: 'dshic-textarea dshic-prompt-input', value: value || '',
|
|
5051
|
+
placeholder: html ? undefined : placeholder,
|
|
5052
|
+
style: { minHeight: minHeight || 72, ...(style || {}) },
|
|
5053
|
+
onChange: (e) => onChange(e.target.value),
|
|
5054
|
+
onScroll: sync,
|
|
5055
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
5056
|
+
onKeyUp: sync,
|
|
5057
|
+
onClick: sync,
|
|
5058
|
+
}),
|
|
5059
|
+
);
|
|
5060
|
+
}
|
|
5061
|
+
|
|
3733
5062
|
function ScriptNodeBody({ n }) {
|
|
3734
5063
|
const running = n.data.aiStatus === 'running';
|
|
3735
5064
|
const generate = async () => {
|
|
3736
|
-
|
|
3737
|
-
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.aiStatus = 'running'; nd.data.aiError = null; } });
|
|
3738
|
-
try {
|
|
3739
|
-
const text = await aiScriptNodes(n.data.idea, n.data.model);
|
|
3740
|
-
update((s) => {
|
|
3741
|
-
const nd = s.graph.nodes[n.id];
|
|
3742
|
-
if (nd) { nd.data.output = text; nd.data.aiStatus = 'idle'; }
|
|
3743
|
-
s.assetsTick++;
|
|
3744
|
-
});
|
|
3745
|
-
await saveTextAssetRecord(S.activeId, { name: T('scriptAssetPrefix') + new Date().toISOString().slice(5, 16).replace('T', ' '), text, category: 'script', prompt: n.data.idea });
|
|
3746
|
-
update((s) => { s.assetsTick++; });
|
|
3747
|
-
} catch (e) {
|
|
3748
|
-
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.aiStatus = 'error'; nd.data.aiError = String(e.message || e); } });
|
|
3749
|
-
}
|
|
5065
|
+
try { await genScriptNode(n); } catch (e) { toast(String((e && e.message) || e)); }
|
|
3750
5066
|
};
|
|
3751
5067
|
return [
|
|
3752
5068
|
h('div', { key: 'mrow', className: 'dshic-node-row' },
|
|
@@ -3755,20 +5071,24 @@ window.__ModuleLoader__.load({
|
|
|
3755
5071
|
),
|
|
3756
5072
|
h('span', { key: 'l1', className: 'dshic-shot-label' }, T('ideaLabel')),
|
|
3757
5073
|
h('textarea', {
|
|
3758
|
-
key: 'idea', className: 'dshic-textarea', style: { minHeight:
|
|
5074
|
+
key: 'idea', className: 'dshic-textarea', style: { minHeight: 72 }, value: n.data.idea || '',
|
|
3759
5075
|
placeholder: T('ideaPlaceholder'),
|
|
3760
5076
|
onChange: (e) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.idea = e.target.value; }),
|
|
3761
5077
|
onPointerDown: (e) => e.stopPropagation(),
|
|
3762
5078
|
}),
|
|
3763
5079
|
h('button', { key: 'gen', type: 'button', className: 'dshic-gen', disabled: running, onClick: generate, onPointerDown: (e) => e.stopPropagation() },
|
|
3764
5080
|
running ? h('span', { className: 'dshic-spin' }) : Ic('spark', 12), running ? T('aiRunning') : T('aiScript')),
|
|
5081
|
+
running && aiStreamLine(n.id)
|
|
5082
|
+
? h('div', { key: 'stream', className: 'dshic-stream', title: aiStreamLine(n.id) },
|
|
5083
|
+
h('span', { className: 'dshic-stream-dot' }), aiStreamLine(n.id))
|
|
5084
|
+
: null,
|
|
3765
5085
|
h('div', { key: 'btns', className: 'dshic-node-row' },
|
|
3766
5086
|
h('div', { className: 'dshic-flex' }),
|
|
3767
5087
|
n.data.output ? h('button', {
|
|
3768
5088
|
type: 'button', className: 'dshic-mini is-ghost', title: T('saveTextAsset'),
|
|
3769
5089
|
onPointerDown: (e) => e.stopPropagation(),
|
|
3770
5090
|
onClick: async () => {
|
|
3771
|
-
await saveTextAssetRecord(S.activeId, { name:
|
|
5091
|
+
await saveTextAssetRecord(S.activeId, { name: (String(n.data.label || '').trim() || T('scriptAssetPrefix')) + ' ' + nowStamp(), text: n.data.output, category: 'script', prompt: n.data.idea });
|
|
3772
5092
|
update((s) => { s.assetsTick++; });
|
|
3773
5093
|
toast(T('savedOk'));
|
|
3774
5094
|
},
|
|
@@ -3777,7 +5097,7 @@ window.__ModuleLoader__.load({
|
|
|
3777
5097
|
n.data.aiError ? h('div', { key: 'err', className: 'dshic-err' }, n.data.aiError) : null,
|
|
3778
5098
|
h('span', { key: 'l2', className: 'dshic-shot-label' }, T('scriptOutputLabel')),
|
|
3779
5099
|
h('textarea', {
|
|
3780
|
-
key: 'out', className: 'dshic-textarea', style: { minHeight:
|
|
5100
|
+
key: 'out', className: 'dshic-textarea', style: { minHeight: 180 }, value: n.data.output || '',
|
|
3781
5101
|
placeholder: running ? T('aiRunning') : '',
|
|
3782
5102
|
onChange: (e) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.output = e.target.value; }),
|
|
3783
5103
|
onPointerDown: (e) => e.stopPropagation(),
|
|
@@ -3800,24 +5120,7 @@ window.__ModuleLoader__.load({
|
|
|
3800
5120
|
const generate = async () => {
|
|
3801
5121
|
const st = scriptText();
|
|
3802
5122
|
if (!st) { toast(T('aiNeedScript')); return; }
|
|
3803
|
-
|
|
3804
|
-
try {
|
|
3805
|
-
const shots = await aiStoryboardShots(st, n.data.requirement, n.data.count || 6, n.data.model);
|
|
3806
|
-
const warnings = await checkShotConsistency(shots);
|
|
3807
|
-
update((s) => {
|
|
3808
|
-
const nd = s.graph.nodes[n.id];
|
|
3809
|
-
if (nd) { nd.data.shots = shots; nd.data.aiStatus = 'idle'; nd.data.warnings = warnings; }
|
|
3810
|
-
s.assetsTick++;
|
|
3811
|
-
});
|
|
3812
|
-
await saveTextAssetRecord(S.activeId, {
|
|
3813
|
-
name: T('storyboardAssetPrefix') + new Date().toISOString().slice(5, 16).replace('T', ' '),
|
|
3814
|
-
text: shots.map((x) => `【${x.title}】${x.duration}s\n画面:${x.visual}\n动作:${x.motion}\n角色:${x.characters.join('、') || '-'}|场景:${x.scene || '-'}|道具:${x.props.join('、') || '-'}`).join('\n\n'),
|
|
3815
|
-
category: 'storyboard', prompt: st.slice(0, 200),
|
|
3816
|
-
});
|
|
3817
|
-
update((s) => { s.assetsTick++; });
|
|
3818
|
-
} catch (e) {
|
|
3819
|
-
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.aiStatus = 'error'; nd.data.aiError = String(e.message || e); } });
|
|
3820
|
-
}
|
|
5123
|
+
try { await genStoryboardNode(n); } catch (e) { toast(String((e && e.message) || e)); }
|
|
3821
5124
|
};
|
|
3822
5125
|
const shots = n.data.shots || [];
|
|
3823
5126
|
return [
|
|
@@ -3842,13 +5145,17 @@ window.__ModuleLoader__.load({
|
|
|
3842
5145
|
h('div', { key: 'row', className: 'dshic-node-row' },
|
|
3843
5146
|
h(MiniSelect, {
|
|
3844
5147
|
value: String(n.data.count || 6), title: T('storyboardCountLabel'), width: 72,
|
|
3845
|
-
options: [3, 4,
|
|
5148
|
+
options: [3, 4, 6, 8, 10, 12, 16].map((x) => ({ value: String(x), label: x + ' ' + T('shotsUnit') })),
|
|
3846
5149
|
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.count = Number(v); }),
|
|
3847
5150
|
}),
|
|
3848
5151
|
h('div', { className: 'dshic-flex' }),
|
|
3849
5152
|
),
|
|
3850
5153
|
h('button', { key: 'gen', type: 'button', className: 'dshic-gen', disabled: running, onClick: generate, onPointerDown: (e) => e.stopPropagation() },
|
|
3851
5154
|
running ? h('span', { className: 'dshic-spin' }) : Ic('spark', 12), running ? T('aiRunning') : T('aiStoryboard')),
|
|
5155
|
+
running && aiStreamLine(n.id)
|
|
5156
|
+
? h('div', { key: 'stream', className: 'dshic-stream', title: aiStreamLine(n.id) },
|
|
5157
|
+
h('span', { className: 'dshic-stream-dot' }), aiStreamLine(n.id))
|
|
5158
|
+
: null,
|
|
3852
5159
|
n.data.aiError ? h('div', { key: 'err', className: 'dshic-err' }, n.data.aiError) : null,
|
|
3853
5160
|
(n.data.warnings || []).length
|
|
3854
5161
|
? h('div', { key: 'warn', className: 'dshic-card', style: { padding: 6, gap: 3 } },
|
|
@@ -3863,22 +5170,52 @@ window.__ModuleLoader__.load({
|
|
|
3863
5170
|
)
|
|
3864
5171
|
: null,
|
|
3865
5172
|
shots.length
|
|
3866
|
-
? h('
|
|
3867
|
-
|
|
5173
|
+
? h('div', { key: 'acts', className: 'dshic-node-row', style: { alignSelf: 'flex-start' } },
|
|
5174
|
+
// 手动"存为资产":AI 生成已自动入库(category=storyboard),这里用于留存当前分镜的一版快照
|
|
5175
|
+
h('button', {
|
|
5176
|
+
key: 'save', type: 'button', className: 'dshic-mini is-ghost', title: T('saveTextAsset'),
|
|
5177
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
5178
|
+
onClick: async () => {
|
|
5179
|
+
try {
|
|
5180
|
+
await saveTextAssetRecord(S.activeId, {
|
|
5181
|
+
name: (String(n.data.label || '').trim() || T('storyboardAssetPrefix')) + ' ' + nowStamp(),
|
|
5182
|
+
text: storyboardAssetText(shots), category: 'storyboard',
|
|
5183
|
+
prompt: String(n.data.script || '').slice(0, 200),
|
|
5184
|
+
});
|
|
5185
|
+
update((s) => { s.assetsTick++; });
|
|
5186
|
+
toast(T('savedOk'));
|
|
5187
|
+
} catch (err) { toast(String((err && err.message) || err)); }
|
|
5188
|
+
},
|
|
5189
|
+
}, Ic('download', 11), T('saveTextAsset')),
|
|
5190
|
+
h('button', { key: 'expand', type: 'button', className: 'dshic-mini', onClick: () => expandStoryboard(n), onPointerDown: (e) => e.stopPropagation() },
|
|
5191
|
+
Ic('expandAll', 11), T('expand') + ` (${shots.length})`),
|
|
5192
|
+
)
|
|
3868
5193
|
: null,
|
|
3869
5194
|
];
|
|
3870
5195
|
}
|
|
3871
5196
|
|
|
3872
|
-
// 分镜表 →
|
|
3873
|
-
//
|
|
5197
|
+
// 分镜表 → 画布(现代多镜头工作流):**一个分镜表 = 一次视频生成**。
|
|
5198
|
+
// 分镜表(镜头脚本)连到 1 个视频生成节点的 prompt 端口(运行时把全部镜头按顺序+时长组合成
|
|
5199
|
+
// 一条多镜头提示词,一次性生成整段视频);角色/场景/道具参考图按实体**跨镜去重**后连到 ref 端口;
|
|
5200
|
+
// 视频生成 → 1 个视频资产。不再一镜一视频、无需拼接。
|
|
3874
5201
|
async function expandStoryboard(n) {
|
|
3875
5202
|
const shots = (n.data.shots || []).slice();
|
|
3876
5203
|
if (!shots.length) { toast(T('expandNoShots')); return; }
|
|
3877
5204
|
// 项目资产名称索引(用于把角色/场景/道具连接到已有资产节点)
|
|
3878
5205
|
const recs = await idbByProject(S.activeId).catch(() => []);
|
|
3879
|
-
const recById = new Map(recs.map((r) => [r.id, r]));
|
|
3880
5206
|
const norm = (s) => String(s || '').trim().toLowerCase();
|
|
3881
5207
|
const nameIndex = recs.filter((r) => r.kind === 'image').map((r) => ({ id: r.id, name: norm(r.name), category: r.category }));
|
|
5208
|
+
// 跨镜去重的实体清单:所有镜头共用的角色/场景/道具各一个参考图节点
|
|
5209
|
+
const entityNames = [];
|
|
5210
|
+
const pushName = (nm) => {
|
|
5211
|
+
const q = String(nm || '').trim();
|
|
5212
|
+
if (q && !entityNames.some((x) => x.toLowerCase() === q.toLowerCase())) entityNames.push(q);
|
|
5213
|
+
};
|
|
5214
|
+
for (const sh of shots) {
|
|
5215
|
+
(sh.characters || []).forEach(pushName);
|
|
5216
|
+
if (sh.scene) pushName(sh.scene);
|
|
5217
|
+
(sh.props || []).forEach(pushName);
|
|
5218
|
+
}
|
|
3882
5219
|
update((s) => {
|
|
3883
5220
|
const findAssetNode = (name) => {
|
|
3884
5221
|
const q = norm(name);
|
|
@@ -3897,47 +5234,30 @@ window.__ModuleLoader__.load({
|
|
|
3897
5234
|
if (reaches(to.id, from.id)) return;
|
|
3898
5235
|
s.graph.edges.push({ id: uid('e'), fromNode: from.id, fromPort: fp, toNode: to.id, toPort: tp });
|
|
3899
5236
|
};
|
|
3900
|
-
const
|
|
3901
|
-
|
|
3902
|
-
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
const videoAsset = mk('video-asset', cx + 600, cy + 300);
|
|
3918
|
-
const wireRef = (names, port) => {
|
|
3919
|
-
let plcRow = 0;
|
|
3920
|
-
for (const nm of names) {
|
|
3921
|
-
let an = findAssetNode(nm);
|
|
3922
|
-
if (!an) {
|
|
3923
|
-
an = mk('image-asset', cx - 300, cy - 40 + plcRow * 120, { placeholderName: nm });
|
|
3924
|
-
plcRow++;
|
|
3925
|
-
}
|
|
3926
|
-
if (port) linkRaw(an, 'out', shotNode, port);
|
|
3927
|
-
linkRaw(an, 'out', imageGen, 'ref');
|
|
3928
|
-
}
|
|
3929
|
-
};
|
|
3930
|
-
wireRef(shot.characters, 'char');
|
|
3931
|
-
if (shot.scene) wireRef([shot.scene], 'scene');
|
|
3932
|
-
wireRef(shot.props, 'prop');
|
|
3933
|
-
linkRaw(shotNode, 'visual', imageGen, 'prompt');
|
|
3934
|
-
linkRaw(imageGen, 'out', videoGen, 'frame');
|
|
3935
|
-
linkRaw(shotNode, 'motion', videoGen, 'prompt');
|
|
3936
|
-
linkRaw(videoGen, 'out', videoAsset, 'in');
|
|
5237
|
+
const mk = (type, x, y, dataOver, label) => {
|
|
5238
|
+
const nd = newNode(type, { x, y });
|
|
5239
|
+
if (dataOver) nd.data = { ...nd.data, ...dataOver };
|
|
5240
|
+
if (label) nd.data.label = label;
|
|
5241
|
+
s.graph.nodes[nd.id] = nd;
|
|
5242
|
+
return nd;
|
|
5243
|
+
};
|
|
5244
|
+
const baseX = n.x + n.w + 140;
|
|
5245
|
+
const baseY = n.y - 40;
|
|
5246
|
+
// 生成时长 = 各镜时长之和(多镜头一次性生成的总时长),由视频模型内部切镜
|
|
5247
|
+
const total = shots.reduce((a, sh) => a + Math.max(1, Number(sh.duration) || 3), 0);
|
|
5248
|
+
const videoGen = mk('video-gen', baseX + 360, baseY + 20, { prompt: '', duration: Math.max(3, Math.min(20, total)) });
|
|
5249
|
+
const videoAsset = mk('video-asset', baseX + 720, baseY + 20);
|
|
5250
|
+
entityNames.forEach((nm, i) => {
|
|
5251
|
+
let an = findAssetNode(nm);
|
|
5252
|
+
if (!an) an = mk('image-asset', baseX, baseY + i * 130, { placeholderName: nm });
|
|
5253
|
+
linkRaw(an, 'out', videoGen, 'ref');
|
|
3937
5254
|
});
|
|
3938
|
-
|
|
5255
|
+
linkRaw(n, 'out', videoGen, 'prompt'); // 多镜头提示词来自上游分镜表(不复制文本)
|
|
5256
|
+
linkRaw(videoGen, 'out', videoAsset, 'in');
|
|
5257
|
+
s.sel = new Set([videoGen.id]);
|
|
3939
5258
|
});
|
|
3940
5259
|
pushHistory();
|
|
5260
|
+
flushSave(); // 批量铺开立即推送,Host 侧立即可见
|
|
3941
5261
|
toast(T('expandDone').replace('{n}', String(shots.length)));
|
|
3942
5262
|
setTimeout(() => fitView(), 60);
|
|
3943
5263
|
}
|
|
@@ -3966,14 +5286,15 @@ window.__ModuleLoader__.load({
|
|
|
3966
5286
|
if (!text) { toast(T('subPlaceholder')); return; }
|
|
3967
5287
|
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.tts = nd.data.tts || {}; nd.data.tts[x.e.id] = { status: 'running' }; } });
|
|
3968
5288
|
try {
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
headers: {
|
|
3972
|
-
body:
|
|
5289
|
+
// TTS 也走 Host 代理:浏览器直连 TTS 网关同样会被 CORS 拦
|
|
5290
|
+
const res = await fetchViaProxy((cfg.baseUrl || '').replace(/\/+$/, '') + '/audio/speech', {
|
|
5291
|
+
headers: { ...(cfg.apiKey ? { Authorization: 'Bearer ' + cfg.apiKey } : {}) },
|
|
5292
|
+
body: { ...(cfg.model ? { model: cfg.model } : {}), input: text, voice: cfg.voice || 'alloy', response_format: 'mp3' },
|
|
5293
|
+
timeoutMs: Number(cfg.timeoutMs) > 0 ? Number(cfg.timeoutMs) : 120000,
|
|
5294
|
+
label: 'TTS API',
|
|
3973
5295
|
});
|
|
3974
|
-
if (!res.ok) throw new Error('TTS HTTP ' + res.status + ':' +
|
|
3975
|
-
const
|
|
3976
|
-
const blob = new Blob([buf], { type: res.headers.get('content-type') || 'audio/mpeg' });
|
|
5296
|
+
if (!res.ok) throw new Error('TTS HTTP ' + res.status + ':' + res.text.slice(0, 200));
|
|
5297
|
+
const blob = /^audio\//.test(res.contentType) ? res.blob : new Blob([res.blob], { type: 'audio/mpeg' });
|
|
3977
5298
|
const rec = await saveAssetRecord(S.activeId, 'audio', blob, { name: 'TTS-' + text.slice(0, 12), prompt: text, source: 'api', category: 'audio' });
|
|
3978
5299
|
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.tts = nd.data.tts || {}; nd.data.tts[x.e.id] = { status: 'done', assetId: rec.id }; } s.assetsTick++; });
|
|
3979
5300
|
} catch (e) {
|
|
@@ -3982,41 +5303,7 @@ window.__ModuleLoader__.load({
|
|
|
3982
5303
|
};
|
|
3983
5304
|
const compose = async () => {
|
|
3984
5305
|
if (!segs.length) { toast(T('noSegments')); return; }
|
|
3985
|
-
|
|
3986
|
-
for (const x of segs) {
|
|
3987
|
-
if (!x.nd.data.assetId) continue;
|
|
3988
|
-
try {
|
|
3989
|
-
const rec = await idbGet(x.nd.data.assetId);
|
|
3990
|
-
const blob = await assetBlob(x.nd.data.assetId);
|
|
3991
|
-
if (blob) {
|
|
3992
|
-
const ttsId = n.data.tts && n.data.tts[x.e.id] && n.data.tts[x.e.id].assetId;
|
|
3993
|
-
const ttsUrl = ttsId ? await assetUrl(ttsId) : null;
|
|
3994
|
-
metas.push({ url: URL.createObjectURL(blob), name: (rec && rec.name) || 'seg', sub: getSub(x), ttsUrl });
|
|
3995
|
-
}
|
|
3996
|
-
} catch { /* 跳过加载失败的片段 */ }
|
|
3997
|
-
}
|
|
3998
|
-
if (!metas.length) { toast(T('noSegments')); return; }
|
|
3999
|
-
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.status = 'running'; nd.data.error = null; nd.data.progress = '0%'; } });
|
|
4000
|
-
try {
|
|
4001
|
-
const dims = videoDimsFor({ resolution: n.data.resolution, ratio: n.data.ratio });
|
|
4002
|
-
const blob = await composeFinalCut(metas, { width: dims.w, height: dims.h, keepAudio: n.data.keepAudio !== false, transition: n.data.transition }, (frac, name) => {
|
|
4003
|
-
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.progress = Math.round(frac * 100) + '% · ' + name; });
|
|
4004
|
-
});
|
|
4005
|
-
const rec = await saveAssetRecord(S.activeId, 'video', blob, {
|
|
4006
|
-
name: T('finalCutDone') + '-' + new Date().toISOString().slice(5, 16).replace('T', ' '),
|
|
4007
|
-
prompt: metas.length + ' segments', source: 'finalcut', category: 'finalcut',
|
|
4008
|
-
});
|
|
4009
|
-
metas.forEach((m) => URL.revokeObjectURL(m.url));
|
|
4010
|
-
update((s) => {
|
|
4011
|
-
const nd = s.graph.nodes[n.id];
|
|
4012
|
-
if (nd) { nd.data.status = 'done'; nd.data.assetId = rec.id; nd.data.progress = null; }
|
|
4013
|
-
s.assetsTick++;
|
|
4014
|
-
});
|
|
4015
|
-
toast(T('composeDone'));
|
|
4016
|
-
} catch (e) {
|
|
4017
|
-
metas.forEach((m) => URL.revokeObjectURL(m.url));
|
|
4018
|
-
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.status = 'error'; nd.data.error = String(e.message || e); nd.data.progress = null; } });
|
|
4019
|
-
}
|
|
5306
|
+
try { await genFinalCutNode(n); toast(T('composeDone')); } catch (e) { toast(String((e && e.message) || e)); }
|
|
4020
5307
|
};
|
|
4021
5308
|
const filmcraftExport = async () => {
|
|
4022
5309
|
const st = await craftStatusSafe();
|
|
@@ -4030,7 +5317,9 @@ window.__ModuleLoader__.load({
|
|
|
4030
5317
|
const blob = await assetBlob(x.nd.data.assetId);
|
|
4031
5318
|
if (!blob) continue;
|
|
4032
5319
|
const rel = await ensureAssetOnDisk({ ...(rec || {}), id: x.nd.data.assetId, blob });
|
|
4033
|
-
|
|
5320
|
+
const vd = await videoDurationOf(blob);
|
|
5321
|
+
const ed = Number(x.nd.data.editDuration) || 0;
|
|
5322
|
+
items.push({ rel, name: (rec && rec.name) || 'seg', sub: getSub(x), dur: ed > 0 ? Math.min(ed, vd) : vd });
|
|
4034
5323
|
} catch (e) { toast('Craft: ' + (e.message || e)); return; }
|
|
4035
5324
|
}
|
|
4036
5325
|
if (!items.length) { toast(T('noSegments')); return; }
|
|
@@ -4082,6 +5371,16 @@ window.__ModuleLoader__.load({
|
|
|
4082
5371
|
options: [{ value: 'on', label: T('keepAudio') + ' ' + T('on') }, { value: 'off', label: T('keepAudio') + ' ' + T('off') }],
|
|
4083
5372
|
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.keepAudio = v !== 'off'; }),
|
|
4084
5373
|
}),
|
|
5374
|
+
h(MiniSelect, {
|
|
5375
|
+
value: n.data.bitrate || '', title: T('bitrate'), width: 92,
|
|
5376
|
+
options: [
|
|
5377
|
+
{ value: '', label: T('bitrateAuto') },
|
|
5378
|
+
{ value: '4000000', label: '4 Mbps' },
|
|
5379
|
+
{ value: '8000000', label: '8 Mbps' },
|
|
5380
|
+
{ value: '12000000', label: '12 Mbps' },
|
|
5381
|
+
],
|
|
5382
|
+
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.bitrate = v; }),
|
|
5383
|
+
}),
|
|
4085
5384
|
h('div', { className: 'dshic-flex' }),
|
|
4086
5385
|
),
|
|
4087
5386
|
),
|
|
@@ -4213,6 +5512,7 @@ window.__ModuleLoader__.load({
|
|
|
4213
5512
|
ev.stopPropagation();
|
|
4214
5513
|
update((s) => { s.graph.edges = s.graph.edges.filter((x) => x.id !== e.id); if (s.selEdge === e.id) s.selEdge = null; });
|
|
4215
5514
|
pushHistory();
|
|
5515
|
+
flushSave(); // 边删除立即推送:压缩 450ms 防抖窗口内的"拉回复活"
|
|
4216
5516
|
},
|
|
4217
5517
|
},
|
|
4218
5518
|
h('circle', { r: 9, className: 'dshic-edge-delbg' }),
|
|
@@ -4343,6 +5643,7 @@ window.__ModuleLoader__.load({
|
|
|
4343
5643
|
const p = { id: uid('p'), name: nm, createdAt: Date.now(), updatedAt: Date.now() };
|
|
4344
5644
|
update((s) => { s.projects.push(p); });
|
|
4345
5645
|
switchProject(p.id);
|
|
5646
|
+
try { hostRpc('projectsPut', { id: p.id, name: nm, doc: emptyGraph() }).catch(() => { /* 静默 */ }); } catch { /* 忽略 */ }
|
|
4346
5647
|
setCreating(false); setName('');
|
|
4347
5648
|
};
|
|
4348
5649
|
const commitRename = () => {
|
|
@@ -4353,6 +5654,10 @@ window.__ModuleLoader__.load({
|
|
|
4353
5654
|
if (p) { p.name = v; p.updatedAt = Date.now(); }
|
|
4354
5655
|
});
|
|
4355
5656
|
lsSet(LS_PROJECTS, S.projects);
|
|
5657
|
+
try {
|
|
5658
|
+
const doc = loadGraphDoc(editId) || emptyGraph();
|
|
5659
|
+
hostRpc('projectsPut', { id: editId, name: v, doc }).catch(() => { /* 静默 */ });
|
|
5660
|
+
} catch { /* 忽略 */ }
|
|
4356
5661
|
}
|
|
4357
5662
|
setEditId(null);
|
|
4358
5663
|
};
|
|
@@ -4366,7 +5671,9 @@ window.__ModuleLoader__.load({
|
|
|
4366
5671
|
const confirmDel = async () => {
|
|
4367
5672
|
const pid = delTarget && delTarget.id;
|
|
4368
5673
|
if (!pid) return;
|
|
4369
|
-
flushSave
|
|
5674
|
+
// 只刷"非待删项目"的待存文档:flushSave 推的是 S.activeId 的全量文档,
|
|
5675
|
+
// 若推的正是待删项目,会与 projectsDelete 竞速把项目原地复活(资产/设定集已清的残废形态)
|
|
5676
|
+
if (pid !== S.activeId) flushSave();
|
|
4370
5677
|
update((s) => {
|
|
4371
5678
|
s.projects = s.projects.filter((p) => p.id !== pid);
|
|
4372
5679
|
try { localStorage.removeItem(LS_GRAPH + pid); } catch { /* 忽略 */ }
|
|
@@ -4390,6 +5697,7 @@ window.__ModuleLoader__.load({
|
|
|
4390
5697
|
});
|
|
4391
5698
|
lsSet(LS_PROJECTS, S.projects);
|
|
4392
5699
|
lsSet(LS_ACTIVE, S.activeId);
|
|
5700
|
+
try { hostRpc('projectsDelete', { id: pid }).catch(() => { /* 静默 */ }); } catch { /* 忽略 */ }
|
|
4393
5701
|
try {
|
|
4394
5702
|
const recs = await idbByProject(pid);
|
|
4395
5703
|
for (const r of recs) {
|
|
@@ -4504,25 +5812,46 @@ window.__ModuleLoader__.load({
|
|
|
4504
5812
|
const [assets, setAssets] = useState([]);
|
|
4505
5813
|
const [tick, setTick] = useState(0);
|
|
4506
5814
|
const [filter, setFilter] = useState('all');
|
|
4507
|
-
const [scope, setScope] = useState('
|
|
5815
|
+
const [scope, setScope] = useState(S.activeId || 'all'); // 打开资产面板默认定位到当前项目
|
|
5816
|
+
const prevActiveRef = useRef(S.activeId);
|
|
5817
|
+
useEffect(() => {
|
|
5818
|
+
// 当前项目变化时,资产筛选跟随(面板重开也会重置为当前项目)
|
|
5819
|
+
if (S.activeId && S.activeId !== prevActiveRef.current) { prevActiveRef.current = S.activeId; setScope(S.activeId); }
|
|
5820
|
+
}, [S.activeId]);
|
|
4508
5821
|
const [search, setSearch] = useState('');
|
|
4509
5822
|
const [craftFor, setCraftFor] = useState(null);
|
|
5823
|
+
const [delTarget, setDelTarget] = useState(null); // 待确认删除的资产(确认框见 DeleteAssetModal)
|
|
5824
|
+
const [assetName, setAssetName] = useState(''); // 删除确认框的输入内容
|
|
4510
5825
|
const fileRef = useRef(null);
|
|
4511
5826
|
useEffect(() => {
|
|
4512
5827
|
probeCraft();
|
|
4513
5828
|
let ok = true;
|
|
4514
5829
|
(async () => {
|
|
4515
5830
|
try {
|
|
4516
|
-
|
|
5831
|
+
let list;
|
|
5832
|
+
if (scope === 'all') list = await idbAll();
|
|
5833
|
+
else list = (await idbAll()).filter((a) => a.projectId === scope || a.projectId === GENERAL_PID);
|
|
4517
5834
|
if (ok) setAssets(list);
|
|
4518
5835
|
} catch { if (ok) setAssets([]); }
|
|
4519
5836
|
})();
|
|
4520
5837
|
return () => { ok = false; };
|
|
4521
5838
|
}, [S.activeId, tick, S.assetsTick, scope]);
|
|
4522
5839
|
const projectName = (pid) => {
|
|
5840
|
+
if (pid === GENERAL_PID) return T('generalProject');
|
|
4523
5841
|
const p = S.projects.find((x) => x.id === pid);
|
|
4524
5842
|
return p ? p.name : '';
|
|
4525
5843
|
};
|
|
5844
|
+
/** 资产改所属项目(通用/其它项目):通用资产多项目共用。 */
|
|
5845
|
+
const moveAsset = async (a, targetPid) => {
|
|
5846
|
+
if (!targetPid || targetPid === (a.projectId || '')) return;
|
|
5847
|
+
const rec = await idbGet(a.id);
|
|
5848
|
+
if (!rec) return;
|
|
5849
|
+
rec.projectId = targetPid;
|
|
5850
|
+
await idbPut(rec);
|
|
5851
|
+
update((s) => { s.assetsTick++; });
|
|
5852
|
+
setTick((x) => x + 1);
|
|
5853
|
+
toast(T('moved'));
|
|
5854
|
+
};
|
|
4526
5855
|
const addNodeFor = (a) => {
|
|
4527
5856
|
if (a.kind === 'audio') return; // 音频资产在卡片内直接试听
|
|
4528
5857
|
if (a.kind === 'text') {
|
|
@@ -4547,15 +5876,23 @@ window.__ModuleLoader__.load({
|
|
|
4547
5876
|
const onImport = async (e) => {
|
|
4548
5877
|
const files = [...((e.target.files) || [])];
|
|
4549
5878
|
e.target.value = '';
|
|
5879
|
+
let failed = 0;
|
|
4550
5880
|
for (const f of files) {
|
|
4551
5881
|
const kind = f.type.startsWith('video/') ? 'video' : f.type.startsWith('image/') ? 'image' : null;
|
|
4552
5882
|
if (!kind) continue;
|
|
4553
|
-
|
|
5883
|
+
try {
|
|
5884
|
+
await saveAssetRecord(S.activeId, kind, f, { name: f.name, source: 'upload', category: 'general' });
|
|
5885
|
+
} catch { failed++; } // 单个失败不影响其余文件
|
|
4554
5886
|
}
|
|
5887
|
+
if (failed) toast(T('uploadFailed') + '(' + failed + '/' + files.length + ')');
|
|
4555
5888
|
if (files.length) setTick((x) => x + 1);
|
|
4556
5889
|
};
|
|
4557
5890
|
const del = async (a) => {
|
|
4558
|
-
|
|
5891
|
+
// 数据目录同步:删文件(逻辑路径由 Host 映射到项目名文件夹)+ 清 assets.json 索引
|
|
5892
|
+
const proj = S.projects.find((p) => p.id === (a.projectId || S.activeId));
|
|
5893
|
+
try {
|
|
5894
|
+
await hostRpc('assetDelete', { projectId: a.projectId || S.activeId, projectName: (proj && proj.name) || '', id: a.id, rel: a.rel || '' });
|
|
5895
|
+
} catch { /* 磁盘/索引清理失败不阻塞 */ }
|
|
4559
5896
|
await idbDelete(a.id);
|
|
4560
5897
|
update((s) => {
|
|
4561
5898
|
for (const nd of Object.values(s.graph.nodes)) {
|
|
@@ -4565,6 +5902,17 @@ window.__ModuleLoader__.load({
|
|
|
4565
5902
|
});
|
|
4566
5903
|
setTick((x) => x + 1);
|
|
4567
5904
|
};
|
|
5905
|
+
// 资产重命名:写回 IndexedDB 记录并刷新名称缓存(@引用与连线名称都吃这份缓存)
|
|
5906
|
+
const rename = async (a, name) => {
|
|
5907
|
+
const rec = await idbGet(a.id);
|
|
5908
|
+
if (!rec) return;
|
|
5909
|
+
rec.name = name;
|
|
5910
|
+
await idbPut(rec);
|
|
5911
|
+
assetNameCache.set(a.id, name);
|
|
5912
|
+
update((s) => { s.assetsTick++; });
|
|
5913
|
+
setTick((x) => x + 1);
|
|
5914
|
+
toast(T('renamed'));
|
|
5915
|
+
};
|
|
4568
5916
|
const q = search.trim().toLowerCase();
|
|
4569
5917
|
const syncAll = async () => {
|
|
4570
5918
|
const st = await craftStatusSafe();
|
|
@@ -4577,7 +5925,7 @@ window.__ModuleLoader__.load({
|
|
|
4577
5925
|
toast(T('craftSyncDone').replace('{ok}', String(okN)).replace('{fail}', String(failN)));
|
|
4578
5926
|
};
|
|
4579
5927
|
const shown = assets.filter((a) => {
|
|
4580
|
-
if (scope !== 'all' && scope !==
|
|
5928
|
+
if (scope !== 'all' && scope !== GENERAL_PID && a.projectId !== scope && a.projectId !== GENERAL_PID) return false;
|
|
4581
5929
|
if (filter !== 'all' && (a.category || 'general') !== filter) return false;
|
|
4582
5930
|
if (!q) return true;
|
|
4583
5931
|
const hay = [
|
|
@@ -4603,8 +5951,8 @@ window.__ModuleLoader__.load({
|
|
|
4603
5951
|
}),
|
|
4604
5952
|
h(MiniSelect, {
|
|
4605
5953
|
key: 'scope', value: scope, title: T('projectFilter'),
|
|
4606
|
-
options: [{ value: '
|
|
4607
|
-
.concat(S.projects.
|
|
5954
|
+
options: [{ value: 'all', label: T('allProjects') }, { value: GENERAL_PID, label: T('generalProject') }]
|
|
5955
|
+
.concat(S.projects.map((p) => ({ value: p.id, label: p.name }))),
|
|
4608
5956
|
onChange: (v) => setScope(v),
|
|
4609
5957
|
}),
|
|
4610
5958
|
h('button', { key: 'craftsync', type: 'button', className: 'dshic-mini', style: { alignSelf: 'flex-start' }, disabled: !craftOkCached(), title: craftOkCached() ? T('craftSyncAll') : T('craftUnreachable'), onClick: syncAll, onPointerDown: (e) => e.stopPropagation() }, Ic('upload', 11), T('craftSyncAll')),
|
|
@@ -4614,25 +5962,110 @@ window.__ModuleLoader__.load({
|
|
|
4614
5962
|
? h('div', { key: 'empty', className: 'dshic-hint' }, T('emptyAssets'))
|
|
4615
5963
|
: h('div', { key: 'grid', className: 'dshic-asset-grid' },
|
|
4616
5964
|
shown.map((a) => h(AssetCard, {
|
|
4617
|
-
key: a.id, a, onAdd: addNodeFor, onDel:
|
|
4618
|
-
|
|
5965
|
+
key: a.id, a, onAdd: addNodeFor, onDel: (x) => setDelTarget(x), onCat: changeCategory,
|
|
5966
|
+
onRename: rename, onMove: moveAsset,
|
|
5967
|
+
projectName: projectName(a.projectId),
|
|
4619
5968
|
})),
|
|
4620
5969
|
),
|
|
5970
|
+
delTarget ? h(DeleteAssetModal, {
|
|
5971
|
+
key: 'delasset', target: delTarget, name: assetName, setName: setAssetName,
|
|
5972
|
+
onCancel: () => { setDelTarget(null); setAssetName(''); },
|
|
5973
|
+
onConfirm: async () => { const t = delTarget; setDelTarget(null); setAssetName(''); await del(t); },
|
|
5974
|
+
}) : null,
|
|
4621
5975
|
];
|
|
4622
5976
|
}
|
|
4623
|
-
|
|
5977
|
+
/** 删除资产确认框:与项目删除同一套交互 —— 名字可选中,手打确认,不做一键填入。 */
|
|
5978
|
+
function DeleteAssetModal({ target, name = '', setName = () => {}, onCancel = () => {}, onConfirm = () => {} }) {
|
|
5979
|
+
const matched = String(name || '').trim() === (target.name || '') && String(name || '').trim() !== '';
|
|
5980
|
+
useEffect(() => {
|
|
5981
|
+
const fn = (e) => { if (e.key === 'Escape') onCancel(); };
|
|
5982
|
+
window.addEventListener('keydown', fn);
|
|
5983
|
+
return () => window.removeEventListener('keydown', fn);
|
|
5984
|
+
}, [onCancel]);
|
|
5985
|
+
return h('div', { className: 'dshic-modal-overlay', onPointerDown: (e) => { if (e.target === e.currentTarget) onCancel(); } },
|
|
5986
|
+
h('div', { className: 'dshic-modal' },
|
|
5987
|
+
h('div', { className: 'dshic-modal-title' }, T('delAssetTitle')),
|
|
5988
|
+
h('div', { className: 'dshic-modal-warn' },
|
|
5989
|
+
T('delAssetWarn'),
|
|
5990
|
+
h('div', { className: 'dshic-modal-name', style: { marginTop: 4 } }, target.name || '—')),
|
|
5991
|
+
h('div', { className: 'dshic-field' },
|
|
5992
|
+
h('span', { className: 'dshic-field-label' }, T('delAssetInput')),
|
|
5993
|
+
h('input', {
|
|
5994
|
+
className: 'dshic-input', autoFocus: true, value: name,
|
|
5995
|
+
placeholder: target.name,
|
|
5996
|
+
onChange: (e) => setName(e.target.value),
|
|
5997
|
+
onKeyDown: (e) => { if (e.key === 'Enter' && matched) onConfirm(); },
|
|
5998
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
5999
|
+
})),
|
|
6000
|
+
h('div', { className: 'dshic-modal-actions' },
|
|
6001
|
+
h('button', { type: 'button', className: 'dshic-btn dshic-btn-outline', onClick: onCancel }, T('cancel')),
|
|
6002
|
+
h('button', {
|
|
6003
|
+
type: 'button', className: 'dshic-btn is-danger dshic-btn-outline', disabled: !matched,
|
|
6004
|
+
style: matched ? { borderColor: 'var(--dsw-alias-state-error-primary)', color: 'var(--dsw-alias-state-error-primary)' } : null,
|
|
6005
|
+
onClick: () => { if (matched) onConfirm(); },
|
|
6006
|
+
}, T('doDelete'))),
|
|
6007
|
+
));
|
|
6008
|
+
}
|
|
6009
|
+
|
|
6010
|
+
function AssetCard({ a, onAdd, onDel, onCat, onRename, onMove, projectName }) {
|
|
4624
6011
|
const url = useAssetUrl(a.kind === 'text' ? null : a.id);
|
|
4625
6012
|
const [craftFor, setCraftFor] = useState(false);
|
|
6013
|
+
const [renaming, setRenaming] = useState(false);
|
|
6014
|
+
const [nameVal, setNameVal] = useState('');
|
|
6015
|
+
const startRename = (e) => { e.stopPropagation(); setNameVal(a.name || ''); setRenaming(true); };
|
|
6016
|
+
const commitRename = () => {
|
|
6017
|
+
const v = nameVal.trim();
|
|
6018
|
+
setRenaming(false);
|
|
6019
|
+
if (v && v !== (a.name || '')) onRename(a, v);
|
|
6020
|
+
};
|
|
6021
|
+
const nameInput = h('input', {
|
|
6022
|
+
className: 'dshic-input', autoFocus: true, value: nameVal,
|
|
6023
|
+
onChange: (e) => setNameVal(e.target.value),
|
|
6024
|
+
onKeyDown: (e) => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setRenaming(false); },
|
|
6025
|
+
onBlur: commitRename,
|
|
6026
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
6027
|
+
onClick: (e) => e.stopPropagation(),
|
|
6028
|
+
});
|
|
6029
|
+
const renameBtn = h('button', {
|
|
6030
|
+
type: 'button', className: 'dshic-iconbtn', title: T('renameAsset'),
|
|
6031
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
6032
|
+
onClick: startRename,
|
|
6033
|
+
}, Ic('pencil', 11));
|
|
4626
6034
|
const downloadName = (a.name || 'asset') + (a.kind === 'image' ? '.png' : a.kind === 'video' ? '.webm' : a.kind === 'audio' ? '.mp3' : '.md');
|
|
4627
6035
|
return h('div', { className: 'dshic-asset', onClick: a.kind === 'audio' ? undefined : () => onAdd(a), title: a.kind === 'text' ? T('textAssetHint') : a.kind === 'audio' ? '' : T('addAssetNode') },
|
|
4628
6036
|
a.kind === 'text'
|
|
4629
|
-
? h('div', { className: 'dshic-textcard' },
|
|
6037
|
+
? h('div', { className: 'dshic-textcard is-title' },
|
|
6038
|
+
renaming ? nameInput : h('div', { className: 'dshic-textcard-title', title: a.name || '' }, a.name || T('scriptAssetPrefix')),
|
|
6039
|
+
h('div', { className: 'dshic-textcard-sub' },
|
|
6040
|
+
projectName
|
|
6041
|
+
? h('span', { className: 'dshic-projchip', title: projectName }, projectName)
|
|
6042
|
+
: h('span', { className: 'dshic-projchip' }, catLabel(a.category || 'script')),
|
|
6043
|
+
h('span', { style: { flex: 1 } }),
|
|
6044
|
+
renameBtn),
|
|
6045
|
+
)
|
|
4630
6046
|
: a.kind === 'image'
|
|
4631
6047
|
? h('img', { className: 'dshic-asset-thumb', src: url || '', draggable: false })
|
|
4632
6048
|
: a.kind === 'video'
|
|
4633
6049
|
? h('video', { className: 'dshic-asset-thumb', src: url ? url + '#t=0.1' : '', muted: true, preload: 'metadata' })
|
|
4634
6050
|
: h('div', { className: 'dshic-asset-thumb', style: { display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--dsw-alias-brand-primary)' } },
|
|
4635
6051
|
Ic('audio', 26)),
|
|
6052
|
+
a.kind !== 'text'
|
|
6053
|
+
? h('div', { className: 'dshic-asset-name' },
|
|
6054
|
+
renaming ? nameInput : h('span', { className: 'dshic-asset-name-text', title: a.name || '' }, a.name || '—'),
|
|
6055
|
+
renameBtn)
|
|
6056
|
+
: null,
|
|
6057
|
+
a.kind !== 'text'
|
|
6058
|
+
? h('select', {
|
|
6059
|
+
className: 'dshic-asset-project', title: T('projectFilter'),
|
|
6060
|
+
value: a.projectId || GENERAL_PID,
|
|
6061
|
+
onClick: (e) => e.stopPropagation(),
|
|
6062
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
6063
|
+
onChange: (e) => { e.stopPropagation(); onMove(a, e.target.value); },
|
|
6064
|
+
},
|
|
6065
|
+
h('option', { value: GENERAL_PID }, T('generalProject')),
|
|
6066
|
+
S.projects.map((pp) => h('option', { key: pp.id, value: pp.id }, pp.name)),
|
|
6067
|
+
)
|
|
6068
|
+
: null,
|
|
4636
6069
|
h('div', { className: 'dshic-asset-actions' },
|
|
4637
6070
|
a.kind === 'image'
|
|
4638
6071
|
? h('button', {
|
|
@@ -4671,7 +6104,6 @@ window.__ModuleLoader__.load({
|
|
|
4671
6104
|
onChange: (e) => { e.stopPropagation(); onCat(a, e.target.value); },
|
|
4672
6105
|
},
|
|
4673
6106
|
CATEGORIES.map((c) => h('option', { key: c, value: c }, catLabel(c)))),
|
|
4674
|
-
showProject && a.projectId ? h('span', { className: 'dshic-projchip', title: projectName }, projectName) : null,
|
|
4675
6107
|
h('div', { style: { flex: 1 } }),
|
|
4676
6108
|
h('span', {}, fmtTime(a.createdAt))),
|
|
4677
6109
|
craftFor ? h(PhotoCraftDialog, { key: 'pc', assetId: a.id, onClose: () => setCraftFor(false) }) : null,
|
|
@@ -4696,6 +6128,7 @@ window.__ModuleLoader__.load({
|
|
|
4696
6128
|
}
|
|
4697
6129
|
function SkillSelect({ label, value, onChange }) {
|
|
4698
6130
|
const data = useSkills();
|
|
6131
|
+
// 默认项 = 内置专家 persona + 内置创作方法论 canvas-craft(自动叠加)
|
|
4699
6132
|
const options = [{ value: '', label: T('builtinExpert') }].concat((data.skills || []).map((x) => ({ value: x.name, label: x.name })));
|
|
4700
6133
|
return h('div', { className: 'dshic-field' },
|
|
4701
6134
|
h('span', { className: 'dshic-field-label' }, label),
|
|
@@ -4703,7 +6136,8 @@ window.__ModuleLoader__.load({
|
|
|
4703
6136
|
className: 'dshic-select', value: value || '',
|
|
4704
6137
|
onChange: (e) => onChange(e.target.value),
|
|
4705
6138
|
onPointerDown: (e) => e.stopPropagation(),
|
|
4706
|
-
}, options.map((o) => h('option', { key: o.value, value: o.value }, o.label)))
|
|
6139
|
+
}, options.map((o) => h('option', { key: o.value, value: o.value }, o.label))),
|
|
6140
|
+
h('div', { className: 'dshic-hint' }, T('skillHint')));
|
|
4707
6141
|
}
|
|
4708
6142
|
|
|
4709
6143
|
function LlmModelSelect() {
|
|
@@ -4853,6 +6287,17 @@ window.__ModuleLoader__.load({
|
|
|
4853
6287
|
h(Field, { key: 'u', label: T('baseUrl'), value: cfg[kcUrlKey(kind)] || '', onChange: (v) => patch(cfg.id, urlKey, v), placeholder: urlPh }),
|
|
4854
6288
|
h(Field, { key: 'k', label: T('apiKey'), value: cfg.apiKey || '', onChange: (v) => patch(cfg.id, 'apiKey', v), placeholder: 'sk-…', password: true }),
|
|
4855
6289
|
h(Field, { key: 'm', label: T('model'), value: cfg.model || '', onChange: (v) => patch(cfg.id, 'model', v) }),
|
|
6290
|
+
h(Field, {
|
|
6291
|
+
key: 'to', label: T('apiTimeout'), value: cfg.timeoutMs || '',
|
|
6292
|
+
placeholder: T('llmParamAuto'),
|
|
6293
|
+
onChange: (v) => patch(cfg.id, 'timeoutMs', v),
|
|
6294
|
+
}),
|
|
6295
|
+
kind === 'image'
|
|
6296
|
+
? h(Field, { key: 'size', label: T('imgSizeOverride'), value: cfg.size || '', onChange: (v) => patch(cfg.id, 'size', v), placeholder: T('imgSizePlaceholder') })
|
|
6297
|
+
: null,
|
|
6298
|
+
kind === 'image'
|
|
6299
|
+
? h(Field, { key: 'rf', label: T('imgResponseFormat'), value: cfg.responseFormat || '', onChange: (v) => patch(cfg.id, 'responseFormat', v), placeholder: 'b64_json / url(留空=API 默认)' })
|
|
6300
|
+
: null,
|
|
4856
6301
|
kind === 'image'
|
|
4857
6302
|
? h('div', { key: 'mode', className: 'dshic-field' },
|
|
4858
6303
|
h('span', { className: 'dshic-field-label' }, T('imgMode')),
|
|
@@ -4878,6 +6323,24 @@ window.__ModuleLoader__.load({
|
|
|
4878
6323
|
kind === 'tts'
|
|
4879
6324
|
? h(Field, { key: 'voice', label: T('voice'), value: cfg.voice || '', onChange: (v) => patch(cfg.id, 'voice', v), placeholder: 'alloy' })
|
|
4880
6325
|
: null,
|
|
6326
|
+
(kind === 'video' && (cfg.protocol === 'seedance'))
|
|
6327
|
+
? h('div', { key: 'seedopts', className: 'dshic-field' },
|
|
6328
|
+
h('label', { className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
|
|
6329
|
+
h('input', {
|
|
6330
|
+
type: 'checkbox', checked: cfg.generateAudio !== false,
|
|
6331
|
+
onChange: (e) => patch(cfg.id, 'generateAudio', e.target.checked),
|
|
6332
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
6333
|
+
}),
|
|
6334
|
+
h('span', {}, T('seedanceAudio'))),
|
|
6335
|
+
h('label', { className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
|
|
6336
|
+
h('input', {
|
|
6337
|
+
type: 'checkbox', checked: cfg.watermark === true,
|
|
6338
|
+
onChange: (e) => patch(cfg.id, 'watermark', e.target.checked),
|
|
6339
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
6340
|
+
}),
|
|
6341
|
+
h('span', {}, T('seedanceWatermark'))),
|
|
6342
|
+
h('div', { className: 'dshic-hint' }, T('seedanceHint')))
|
|
6343
|
+
: null,
|
|
4881
6344
|
] : null,
|
|
4882
6345
|
);
|
|
4883
6346
|
}),
|
|
@@ -5060,6 +6523,8 @@ window.__ModuleLoader__.load({
|
|
|
5060
6523
|
await hostRpc('dataRootSet', { root: val.trim() });
|
|
5061
6524
|
await refreshDataRoot();
|
|
5062
6525
|
toast(T('dataRootSaved') + (S.dataRoot ? ':' + S.dataRoot : ''));
|
|
6526
|
+
assetIndexSynced = false; // 换了目录:索引要往新目录重建
|
|
6527
|
+
syncAssetIndexToHost();
|
|
5063
6528
|
} catch (e) { toast('设置失败:' + ((e && e.message) || e)); }
|
|
5064
6529
|
setBusy(false);
|
|
5065
6530
|
};
|
|
@@ -5068,6 +6533,8 @@ window.__ModuleLoader__.load({
|
|
|
5068
6533
|
await refreshDataRoot();
|
|
5069
6534
|
setVal(S.dataRoot || root);
|
|
5070
6535
|
toast(T('dataRootSaved') + ':' + S.dataRoot);
|
|
6536
|
+
assetIndexSynced = false; // 换了目录:索引要往新目录重建
|
|
6537
|
+
syncAssetIndexToHost();
|
|
5071
6538
|
};
|
|
5072
6539
|
const browse = async () => {
|
|
5073
6540
|
if (busy || picking) return;
|
|
@@ -5084,6 +6551,31 @@ window.__ModuleLoader__.load({
|
|
|
5084
6551
|
setPicking(false); setBusy(false);
|
|
5085
6552
|
};
|
|
5086
6553
|
const openDir = async () => { try { await hostRpc('dataRootReveal'); } catch (e) { toast((e && e.message) || e); } };
|
|
6554
|
+
const importFromRoot = async () => {
|
|
6555
|
+
if (busy || picking) return;
|
|
6556
|
+
setBusy(true);
|
|
6557
|
+
try {
|
|
6558
|
+
const r = await hostRpc('dataRootImport');
|
|
6559
|
+
// 资产元数据 → IndexedDB(本机已存在的同 id 资产跳过,绝不覆盖)
|
|
6560
|
+
let assets = 0;
|
|
6561
|
+
const byProject = r.assets || {};
|
|
6562
|
+
for (const pid of Object.keys(byProject)) {
|
|
6563
|
+
for (const rec of byProject[pid] || []) {
|
|
6564
|
+
if (!rec || !rec.id) continue;
|
|
6565
|
+
const cur = await idbGet(rec.id).catch(() => null);
|
|
6566
|
+
if (cur && (cur.rel || cur.blob)) continue;
|
|
6567
|
+
await idbPut({ ...rec, projectId: pid, stored: rec.rel ? 'disk' : 'idb' });
|
|
6568
|
+
assets++;
|
|
6569
|
+
}
|
|
6570
|
+
}
|
|
6571
|
+
if (assets) update((s) => { s.assetsTick++; });
|
|
6572
|
+
toast(T('dataRootImported').replace('{n}', String((r.imported || []).length)).replace('{a}', String(assets)));
|
|
6573
|
+
// 项目文档 Host 侧已合并:立即拉一轮同步,把新项目带进画布
|
|
6574
|
+
try { await hostSyncTick(); } catch { /* 下一轮 3s 轮询会带 */ }
|
|
6575
|
+
await refreshDataRoot();
|
|
6576
|
+
} catch (e) { toast('导入失败:' + ((e && e.message) || e)); }
|
|
6577
|
+
setBusy(false);
|
|
6578
|
+
};
|
|
5087
6579
|
const migrate = async () => {
|
|
5088
6580
|
if (busy || picking || mig) return;
|
|
5089
6581
|
setBusy(true); setMig({ done: 0, total: 0 });
|
|
@@ -5114,6 +6606,11 @@ window.__ModuleLoader__.load({
|
|
|
5114
6606
|
S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini', disabled: busy || picking || !!mig, onPointerDown: (e) => e.stopPropagation(), onClick: migrate }, mig === 'done' ? T('dataRootMigratedDone') : T('dataRootMigrate')) : null,
|
|
5115
6607
|
(mig && mig !== 'done') ? h('span', { style: { fontSize: 11, opacity: 0.85 } }, T('dataRootMigrating') + ' ' + mig.done + '/' + mig.total) : null,
|
|
5116
6608
|
),
|
|
6609
|
+
h('div', { className: 'dshic-row', key: 'row3' },
|
|
6610
|
+
S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini is-ghost', disabled: busy || picking, onPointerDown: (e) => e.stopPropagation(), onClick: importFromRoot }, Ic('folder', 11), T('dataRootImportBtn')) : null,
|
|
6611
|
+
h('div', { className: 'dshic-flex' }),
|
|
6612
|
+
),
|
|
6613
|
+
S.dataRoot ? h('div', { className: 'dshic-hint', key: 'importHint' }, T('dataRootImportHint')) : null,
|
|
5117
6614
|
],
|
|
5118
6615
|
);
|
|
5119
6616
|
}
|
|
@@ -5149,6 +6646,17 @@ window.__ModuleLoader__.load({
|
|
|
5149
6646
|
h('div', { key: 'llm', className: 'dshic-card' },
|
|
5150
6647
|
h('div', { className: 'dshic-row' }, Ic('spark', 12), h('strong', { style: { fontSize: 12 } }, T('llmLabel'))),
|
|
5151
6648
|
h(LlmModelSelect),
|
|
6649
|
+
h('div', { className: 'dshic-row' },
|
|
6650
|
+
h(Field, {
|
|
6651
|
+
label: T('llmMaxTokens'), value: s.textLlm.maxTokens || '', placeholder: T('llmParamAuto'),
|
|
6652
|
+
onChange: (v) => apply((d) => { d.textLlm.maxTokens = v; }),
|
|
6653
|
+
}),
|
|
6654
|
+
h(Field, {
|
|
6655
|
+
label: T('llmTemperature'), value: s.textLlm.temperature || '', placeholder: T('llmParamAuto'),
|
|
6656
|
+
onChange: (v) => apply((d) => { d.textLlm.temperature = v; }),
|
|
6657
|
+
}),
|
|
6658
|
+
),
|
|
6659
|
+
h('div', { className: 'dshic-hint' }, T('llmParamHint')),
|
|
5152
6660
|
h('div', { className: 'dshic-hint' }, T('skillEnhance')),
|
|
5153
6661
|
h(SkillSelect, { label: T('skillScript'), value: s.skillScript, onChange: (v) => apply((d) => { d.skillScript = v; }) }),
|
|
5154
6662
|
h(SkillSelect, { label: T('skillStoryboard'), value: s.skillStoryboard, onChange: (v) => apply((d) => { d.skillStoryboard = v; }) }),
|
|
@@ -5160,11 +6668,160 @@ window.__ModuleLoader__.load({
|
|
|
5160
6668
|
];
|
|
5161
6669
|
}
|
|
5162
6670
|
|
|
5163
|
-
/**
|
|
6671
|
+
/** 从项目现有创作意图/剧本一键起草设定集(世界观/风格/角色/场景/道具)。 */
|
|
6672
|
+
async function aiDraftBible(cur) {
|
|
6673
|
+
const settings = loadSettings();
|
|
6674
|
+
const zh = effLang() !== 'en';
|
|
6675
|
+
const ideas = Object.values(S.graph.nodes || {})
|
|
6676
|
+
.filter((n) => n && (n.type === 'script' || n.type === 'text'))
|
|
6677
|
+
.map((n) => String((n.type === 'script' ? n.data.idea : n.data.text) || '').trim())
|
|
6678
|
+
.filter(Boolean)
|
|
6679
|
+
.join('\n---\n')
|
|
6680
|
+
.slice(0, 6000);
|
|
6681
|
+
const existing = bibleBlock(cur);
|
|
6682
|
+
const system = [
|
|
6683
|
+
await loadSkillRules(settings.skillScript, zh) || await loadSkillRules(CRAFT_SKILL, zh) || '',
|
|
6684
|
+
zh
|
|
6685
|
+
? '你是短剧设定集策划:根据创作意图提炼世界观与风格,并列出角色/场景/道具设定,供后续剧本与画面生成保持一致。只输出 JSON。'
|
|
6686
|
+
: 'You are a series-bible planner for short drama: derive worldview and style from the intent, and list characters/scenes/props so later script and image generation stay consistent. Output JSON only.',
|
|
6687
|
+
].filter(Boolean).join('\n');
|
|
6688
|
+
const user = zh ? [
|
|
6689
|
+
ideas ? '项目现有创作意图/剧本:\n' + ideas : '(项目暂无创作意图,请给出一个可用的短剧设定草案)',
|
|
6690
|
+
existing ? '已有设定集(请在其基础上补全,不要丢弃已有条目):\n' + existing : '',
|
|
6691
|
+
'输出 JSON:{"worldview":"世界观/核心设定(题材、年代、核心规则、主线暗线)","style":"风格基调(画风、色调、质感)",'
|
|
6692
|
+
+ '"characters":[{"name":"…","desc":"外观与性格关键词"}],"scenes":[{"name":"…","desc":"外观关键词"}],"props":[{"name":"…","desc":"外观关键词"}]}',
|
|
6693
|
+
'要求:名称唯一且可直接用于检索;desc 写可复现的外观关键词(年龄/发型/服装/材质/光线),不要写抽象评价。',
|
|
6694
|
+
].filter(Boolean).join('\n') : [
|
|
6695
|
+
ideas ? 'Existing intent/script:\n' + ideas : '(No intent yet — produce a usable draft)',
|
|
6696
|
+
existing ? 'Existing bible (extend it, keep existing entries):\n' + existing : '',
|
|
6697
|
+
'Output JSON: {"worldview":"…","style":"…","characters":[{"name":"…","desc":"…"}],"scenes":[…],"props":[…]}',
|
|
6698
|
+
'Names must be unique and searchable; desc must be reproducible visual keywords.',
|
|
6699
|
+
].filter(Boolean).join('\n');
|
|
6700
|
+
const text = await aiComplete({ system, user });
|
|
6701
|
+
const raw = parseJsonBalanced(text);
|
|
6702
|
+
const obj = raw && typeof raw === 'object' ? raw : {};
|
|
6703
|
+
const list = (v) => (Array.isArray(v) ? v : [])
|
|
6704
|
+
.map((x) => ({ name: String((x && x.name) || '').trim(), desc: String((x && x.desc) || '').trim() }))
|
|
6705
|
+
.filter((x) => x.name);
|
|
6706
|
+
const merge = (base, incoming) => {
|
|
6707
|
+
const out = [...(base || [])];
|
|
6708
|
+
for (const item of incoming) {
|
|
6709
|
+
const i = out.findIndex((x) => String(x.name).trim() === item.name);
|
|
6710
|
+
if (i >= 0) out[i] = { ...out[i], ...item };
|
|
6711
|
+
else out.push(item);
|
|
6712
|
+
}
|
|
6713
|
+
return out;
|
|
6714
|
+
};
|
|
6715
|
+
const next = {
|
|
6716
|
+
worldview: String(obj.worldview || cur.worldview || '').trim(),
|
|
6717
|
+
style: String(obj.style || cur.style || '').trim(),
|
|
6718
|
+
characters: merge(cur.characters, list(obj.characters)),
|
|
6719
|
+
scenes: merge(cur.scenes, list(obj.scenes)),
|
|
6720
|
+
props: merge(cur.props, list(obj.props)),
|
|
6721
|
+
};
|
|
6722
|
+
if (!next.worldview && !next.style && !next.characters.length) throw new Error(T('bibleDraftEmpty'));
|
|
6723
|
+
return next;
|
|
6724
|
+
}
|
|
6725
|
+
|
|
6726
|
+
/** 设定条目 ↔ 资产绑定对话框:从当前项目的资产库里多选图片/音频/视频。 */
|
|
6727
|
+
/** 绑定对话框的资产行:组件化以便用 useAssetUrl 显示缩略图。 */
|
|
6728
|
+
function AssetPickRow({ a, checked, onToggle }) {
|
|
6729
|
+
const url = useAssetUrl(a.kind === 'image' ? a.id : null);
|
|
6730
|
+
return h('label', { className: 'dshic-row', style: { gap: 6, fontSize: 11, cursor: 'pointer', alignItems: 'center' }, onPointerDown: (e) => e.stopPropagation(), onClick: () => onToggle() },
|
|
6731
|
+
h('input', { type: 'checkbox', checked, onChange: () => onToggle(), onPointerDown: (e) => e.stopPropagation() }),
|
|
6732
|
+
a.kind === 'image' && url
|
|
6733
|
+
? h('img', { src: url, style: { width: 26, height: 26, objectFit: 'cover', borderRadius: 4, flex: 'none' }, draggable: false })
|
|
6734
|
+
: h('span', { className: 'dshic-tag st', style: { flex: 'none' } }, a.kind === 'video' ? T('kindVideo') : T('kindAudio')),
|
|
6735
|
+
h('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, a.name || a.id),
|
|
6736
|
+
a.projectId === GENERAL_PID ? h('span', { className: 'dshic-projchip' }, T('generalProject')) : null,
|
|
6737
|
+
);
|
|
6738
|
+
}
|
|
6739
|
+
|
|
6740
|
+
/** 设定条目 ↔ 资产绑定对话框:项目可切换、搜索、分页(资产会越来越多)。 */
|
|
6741
|
+
function BibleLinkDialog({ entry, onApply, onClose }) {
|
|
6742
|
+
const PAGE_SIZE = 24;
|
|
6743
|
+
const [assets, setAssets] = useState(null);
|
|
6744
|
+
const [picked, setPicked] = useState(() => new Set((entry.assetIds || []).map(String)));
|
|
6745
|
+
const [q, setQ] = useState('');
|
|
6746
|
+
const [scope, setScope] = useState(S.activeId || 'all');
|
|
6747
|
+
const [page, setPage] = useState(0);
|
|
6748
|
+
useEffect(() => {
|
|
6749
|
+
let ok = true;
|
|
6750
|
+
(async () => {
|
|
6751
|
+
try {
|
|
6752
|
+
const all = await idbAll();
|
|
6753
|
+
if (ok) setAssets(all.filter((a) => a.kind !== 'text'));
|
|
6754
|
+
} catch { if (ok) setAssets([]); }
|
|
6755
|
+
})();
|
|
6756
|
+
return () => { ok = false; };
|
|
6757
|
+
}, []);
|
|
6758
|
+
const toggle = (id) => {
|
|
6759
|
+
const next = new Set(picked);
|
|
6760
|
+
if (next.has(id)) next.delete(id);
|
|
6761
|
+
else next.add(id);
|
|
6762
|
+
setPicked(next);
|
|
6763
|
+
};
|
|
6764
|
+
const ql = q.trim().toLowerCase();
|
|
6765
|
+
const scoped = (assets || []).filter((a) => {
|
|
6766
|
+
if (scope === GENERAL_PID) return a.projectId === GENERAL_PID;
|
|
6767
|
+
if (scope === 'all') return true;
|
|
6768
|
+
return a.projectId === scope || a.projectId === GENERAL_PID; // 项目视图含通用资产(多项目共用)
|
|
6769
|
+
});
|
|
6770
|
+
const list = scoped.filter((a) => !ql || String(a.name || '').toLowerCase().includes(ql));
|
|
6771
|
+
const pages = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
|
|
6772
|
+
const pageSafe = Math.min(page, pages - 1);
|
|
6773
|
+
const pageItems = list.slice(pageSafe * PAGE_SIZE, pageSafe * PAGE_SIZE + PAGE_SIZE);
|
|
6774
|
+
return h('div', { className: 'dshic-modal-overlay', onPointerDown: (e) => { if (e.target === e.currentTarget) onClose(); } },
|
|
6775
|
+
h('div', { className: 'dshic-modal', style: { width: 560 } },
|
|
6776
|
+
h('div', { className: 'dshic-modal-title' }, T('bibleLinkTitle') + ' — ' + (entry.name || '…')),
|
|
6777
|
+
h('div', { className: 'dshic-hint' }, T('bibleLinkHint')),
|
|
6778
|
+
h('div', { className: 'dshic-node-row' },
|
|
6779
|
+
h(MiniSelect, {
|
|
6780
|
+
value: scope, title: T('projectFilter'), width: 150,
|
|
6781
|
+
options: [{ value: 'all', label: T('allProjects') }, { value: GENERAL_PID, label: T('generalProject') }]
|
|
6782
|
+
.concat(S.projects.map((pp) => ({ value: pp.id, label: pp.name }))),
|
|
6783
|
+
onChange: (v) => { setScope(v); setPage(0); },
|
|
6784
|
+
}),
|
|
6785
|
+
h('input', { className: 'dshic-input', style: { flex: 1 }, value: q, placeholder: T('searchLabel'), onChange: (e) => { setQ(e.target.value); setPage(0); }, onPointerDown: (e) => e.stopPropagation() }),
|
|
6786
|
+
),
|
|
6787
|
+
h('div', { className: 'dshic-shot-list', style: { maxHeight: 420 } },
|
|
6788
|
+
assets === null
|
|
6789
|
+
? h('div', { className: 'dshic-hint' }, T('aiRunning'))
|
|
6790
|
+
: pageItems.length === 0
|
|
6791
|
+
? h('div', { className: 'dshic-hint' }, T('emptyAssets'))
|
|
6792
|
+
: pageItems.map((a) => h(AssetPickRow, { key: a.id, a, checked: picked.has(a.id), onToggle: () => toggle(a.id) }))),
|
|
6793
|
+
h('div', { className: 'dshic-node-row' },
|
|
6794
|
+
h('button', { type: 'button', className: 'dshic-mini', disabled: pageSafe <= 0, onClick: () => setPage(pageSafe - 1), onPointerDown: (e) => e.stopPropagation() }, '‹ ' + T('pagePrev')),
|
|
6795
|
+
h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } }, (pageSafe + 1) + ' / ' + pages + ' · ' + list.length),
|
|
6796
|
+
h('button', { type: 'button', className: 'dshic-mini', disabled: pageSafe >= pages - 1, onClick: () => setPage(pageSafe + 1), onPointerDown: (e) => e.stopPropagation() }, T('pageNext') + ' ›'),
|
|
6797
|
+
h('div', { className: 'dshic-flex' }),
|
|
6798
|
+
),
|
|
6799
|
+
h('div', { className: 'dshic-modal-actions' },
|
|
6800
|
+
h('button', { type: 'button', className: 'dshic-btn dshic-btn-outline', onClick: onClose }, T('cancel')),
|
|
6801
|
+
h('button', {
|
|
6802
|
+
type: 'button', className: 'dshic-btn',
|
|
6803
|
+
onClick: () => onApply([...picked]),
|
|
6804
|
+
}, T('bibleLinkApply') + (picked.size ? '(' + picked.size + ')' : ''))),
|
|
6805
|
+
));
|
|
6806
|
+
}
|
|
6807
|
+
/** 项目设定集(Series Bible):世界观 + 风格 + 角色/场景/道具卡,注入所有 AI 生成保证跨集一致。 */
|
|
5164
6808
|
function BibleTab() {
|
|
5165
6809
|
const [bible, setBible] = useState(() => loadBible(S.activeId));
|
|
5166
|
-
|
|
6810
|
+
const [busy, setBusy] = useState(false);
|
|
6811
|
+
useEffect(() => { setBible(loadBible(S.activeId)); }, [S.activeId, S.assetsTick]);
|
|
5167
6812
|
const persist = (next) => { setBible(next); saveBible(S.activeId, next); };
|
|
6813
|
+
/** 从项目里的创作意图/剧本一键起草设定集(设定集先行)。 */
|
|
6814
|
+
const draft = async () => {
|
|
6815
|
+
if (busy) return;
|
|
6816
|
+
setBusy(true);
|
|
6817
|
+
try {
|
|
6818
|
+
const next = await aiDraftBible(bible);
|
|
6819
|
+
persist(next);
|
|
6820
|
+
toast(T('bibleDrafted'));
|
|
6821
|
+
} catch (e) { toast(String((e && e.message) || e)); }
|
|
6822
|
+
setBusy(false);
|
|
6823
|
+
};
|
|
6824
|
+
const [linkFor, setLinkFor] = useState(null); // { key, index, entry } —— 正在编辑链接资产的设定条目
|
|
5168
6825
|
const listEditor = (key, labelKey) => h('div', { className: 'dshic-card' },
|
|
5169
6826
|
h('div', { className: 'dshic-row' },
|
|
5170
6827
|
h('strong', { style: { fontSize: 12 } }, T(labelKey)),
|
|
@@ -5172,16 +6829,23 @@ window.__ModuleLoader__.load({
|
|
|
5172
6829
|
h('button', {
|
|
5173
6830
|
type: 'button', className: 'dshic-mini',
|
|
5174
6831
|
onPointerDown: (e) => e.stopPropagation(),
|
|
5175
|
-
onClick: () => persist({ ...bible, [key]: [...(bible[key] || []), { name: '', desc: '' }] }),
|
|
6832
|
+
onClick: () => persist({ ...bible, [key]: [...(bible[key] || []), { name: '', desc: '', assetIds: [] }] }),
|
|
5176
6833
|
}, Ic('plus', 11), T('bibleAdd')),
|
|
5177
6834
|
),
|
|
5178
6835
|
...(bible[key] || []).map((item, i) => h('div', { key: i, className: 'dshic-row' },
|
|
5179
6836
|
h('input', {
|
|
5180
|
-
className: 'dshic-input', style: { width:
|
|
6837
|
+
className: 'dshic-input', style: { width: 76, flex: 'none' }, value: item.name || '',
|
|
5181
6838
|
placeholder: T('bibleNamePh'),
|
|
5182
6839
|
onChange: (e) => { const arr = [...bible[key]]; arr[i] = { ...arr[i], name: e.target.value }; persist({ ...bible, [key]: arr }); },
|
|
5183
6840
|
onPointerDown: (e) => e.stopPropagation(),
|
|
5184
6841
|
}),
|
|
6842
|
+
// 链接资产栏:把该条目绑定到资产库的图片/音频/视频(多选),@引用与参考图解析会优先用它
|
|
6843
|
+
h('button', {
|
|
6844
|
+
type: 'button', className: cls('dshic-mini', (item.assetIds || []).length && 'is-active'),
|
|
6845
|
+
title: T('bibleLinkAssets'),
|
|
6846
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
6847
|
+
onClick: () => setLinkFor({ key, index: i, entry: item }),
|
|
6848
|
+
}, Ic('image', 11), (item.assetIds || []).length ? ' ' + item.assetIds.length : ''),
|
|
5185
6849
|
h('input', {
|
|
5186
6850
|
className: 'dshic-input', style: { flex: 1, minWidth: 0 }, value: item.desc || '',
|
|
5187
6851
|
placeholder: T('bibleDescPh'),
|
|
@@ -5194,10 +6858,37 @@ window.__ModuleLoader__.load({
|
|
|
5194
6858
|
onClick: () => { const arr = [...bible[key]]; arr.splice(i, 1); persist({ ...bible, [key]: arr }); },
|
|
5195
6859
|
}, '✕'),
|
|
5196
6860
|
)),
|
|
6861
|
+
linkFor && linkFor.key === key
|
|
6862
|
+
? h(BibleLinkDialog, {
|
|
6863
|
+
key: 'link', entry: linkFor.entry, onClose: () => setLinkFor(null),
|
|
6864
|
+
onApply: (assetIds) => {
|
|
6865
|
+
const arr = [...bible[key]];
|
|
6866
|
+
arr[linkFor.index] = { ...arr[linkFor.index], assetIds };
|
|
6867
|
+
persist({ ...bible, [key]: arr });
|
|
6868
|
+
setLinkFor(null);
|
|
6869
|
+
},
|
|
6870
|
+
})
|
|
6871
|
+
: null,
|
|
5197
6872
|
);
|
|
5198
6873
|
return [
|
|
5199
6874
|
h('div', { key: 'proj', className: 'dshic-hint' },
|
|
5200
6875
|
`${T('bibleTab')} · ${T('currentProject')}:${(S.projects.find((p) => p.id === S.activeId) || {}).name || ''}`),
|
|
6876
|
+
h('div', { key: 'order', className: 'dshic-hint' }, T('bibleOrderHint')),
|
|
6877
|
+
h('div', { key: 'draft', className: 'dshic-row' },
|
|
6878
|
+
h('button', {
|
|
6879
|
+
type: 'button', className: 'dshic-mini', disabled: busy,
|
|
6880
|
+
onPointerDown: (e) => e.stopPropagation(), onClick: draft,
|
|
6881
|
+
}, busy ? h('span', { className: 'dshic-spin' }) : Ic('spark', 11), busy ? T('aiRunning') : T('bibleDraft')),
|
|
6882
|
+
),
|
|
6883
|
+
h('div', { key: 'world', className: 'dshic-card' },
|
|
6884
|
+
h('span', { className: 'dshic-shot-label' }, T('bibleWorldview')),
|
|
6885
|
+
h('textarea', {
|
|
6886
|
+
className: 'dshic-textarea', style: { minHeight: 110 }, value: bible.worldview || '',
|
|
6887
|
+
placeholder: T('bibleWorldviewPh'),
|
|
6888
|
+
onChange: (e) => persist({ ...bible, worldview: e.target.value }),
|
|
6889
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
6890
|
+
}),
|
|
6891
|
+
),
|
|
5201
6892
|
h('div', { key: 'style', className: 'dshic-card' },
|
|
5202
6893
|
h('span', { className: 'dshic-shot-label' }, T('bibleStyle')),
|
|
5203
6894
|
h('textarea', {
|
|
@@ -5301,6 +6992,39 @@ window.__ModuleLoader__.load({
|
|
|
5301
6992
|
useEffect(() => subscribe(() => force((x) => x + 1)), []);
|
|
5302
6993
|
return S;
|
|
5303
6994
|
}
|
|
6995
|
+
// 错误边界:任何渲染期崩溃都不再白屏——显示错误与堆栈 + 重载按钮(用户可直接把内容发回来修)
|
|
6996
|
+
const BoundaryBase = (React && React.Component) ? React.Component : class {}; // 冒烟桩 React 没有 Component,兜底
|
|
6997
|
+
class PanelErrorBoundary extends BoundaryBase {
|
|
6998
|
+
constructor(props) {
|
|
6999
|
+
super(props);
|
|
7000
|
+
this.state = { error: null };
|
|
7001
|
+
}
|
|
7002
|
+
static getDerivedStateFromError(error) {
|
|
7003
|
+
return { error: error || new Error('未知渲染错误') };
|
|
7004
|
+
}
|
|
7005
|
+
componentDidCatch(error, info) {
|
|
7006
|
+
try { console.error('[infinite-canvas] 渲染错误', error, info && info.componentStack); } catch { /* 忽略 */ }
|
|
7007
|
+
}
|
|
7008
|
+
render() {
|
|
7009
|
+
if (!this.state.error) return this.props.children;
|
|
7010
|
+
const msg = String(this.state.error && this.state.error.message || this.state.error);
|
|
7011
|
+
const stack = String(this.state.error && this.state.error.stack || '').split('\n').slice(0, 6).join('\n');
|
|
7012
|
+
return h('div', { style: { padding: 24, color: 'var(--dsw-alias-state-error-primary, #e5544b)', fontSize: 13, lineHeight: 20, whiteSpace: 'pre-wrap', userSelect: 'text' } },
|
|
7013
|
+
h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '⚠ 画布界面出现了一个渲染错误(已拦截,不会丢失已保存的数据)'),
|
|
7014
|
+
h('div', { style: { marginBottom: 8 } }, msg),
|
|
7015
|
+
stack ? h('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', marginBottom: 12, userSelect: 'text' } }, stack) : null,
|
|
7016
|
+
h('button', {
|
|
7017
|
+
type: 'button', className: 'dshic-btn',
|
|
7018
|
+
onClick: () => { this.setState({ error: null }); },
|
|
7019
|
+
}, '恢复界面'),
|
|
7020
|
+
h('button', {
|
|
7021
|
+
type: 'button', className: 'dshic-btn', style: { marginLeft: 8 },
|
|
7022
|
+
onClick: () => { try { window.location.reload(); } catch { /* 忽略 */ } },
|
|
7023
|
+
}, '重载页面'),
|
|
7024
|
+
);
|
|
7025
|
+
}
|
|
7026
|
+
}
|
|
7027
|
+
|
|
5304
7028
|
function CanvasPage() {
|
|
5305
7029
|
useApp();
|
|
5306
7030
|
const rootRef = useRef(null);
|
|
@@ -5490,15 +7214,18 @@ window.__ModuleLoader__.load({
|
|
|
5490
7214
|
probeCraft();
|
|
5491
7215
|
// 检查更新(Host RPC;Host 旧版未带此方法时静默)
|
|
5492
7216
|
checkUpdate(true);
|
|
5493
|
-
// 数据根目录(Host 旧版静默降级为 IndexedDB
|
|
5494
|
-
refreshDataRoot();
|
|
7217
|
+
// 数据根目录(Host 旧版静默降级为 IndexedDB 模式);就绪后把 IDB 资产元数据补齐进数据目录索引
|
|
7218
|
+
refreshDataRoot().then(() => syncAssetIndexToHost()).catch(() => { /* 静默 */ });
|
|
7219
|
+
// 对话 ↔ 画布同步(启动一次 + 3s 轮询;旧 Host 自动停轮询)
|
|
7220
|
+
hostSyncTick().then(() => pushAllProjectsToHost());
|
|
7221
|
+
const hostSyncTimer = setInterval(() => hostSyncTick(), 3000);
|
|
5495
7222
|
// 后台路由探活:失败时在画布顶部给出可操作的提示
|
|
5496
7223
|
hostRpc('ping').then(() => { S.hostOk = true; S.hostError = null; notify(); }).catch((e) => {
|
|
5497
7224
|
S.hostOk = false;
|
|
5498
7225
|
S.hostError = String((e && e.message) || e);
|
|
5499
7226
|
notify();
|
|
5500
7227
|
});
|
|
5501
|
-
return () => { unsub(); };
|
|
7228
|
+
return () => { unsub(); clearInterval(hostSyncTimer); };
|
|
5502
7229
|
}, 'infinite-canvas: store & locale');
|
|
5503
7230
|
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
5504
7231
|
name: 'sidebar.panellist',
|
|
@@ -5507,11 +7234,27 @@ window.__ModuleLoader__.load({
|
|
|
5507
7234
|
label: () => T('panel'),
|
|
5508
7235
|
locale: NS,
|
|
5509
7236
|
}, PanelIcon));
|
|
7237
|
+
// 面板根:包一层错误边界——任何渲染期崩溃都不再白屏,而是显示可复制的错误卡片
|
|
7238
|
+
PanelErrorBoundary.__dshicClass = true; // 冒烟 walker 标记
|
|
7239
|
+
function PanelRoot() {
|
|
7240
|
+
return h(PanelErrorBoundary, null, h(CanvasPage));
|
|
7241
|
+
}
|
|
5510
7242
|
ctx.slots.inject('main', () => ctx.slots.register({
|
|
5511
7243
|
name: 'main',
|
|
5512
7244
|
key: PANEL_ID,
|
|
5513
7245
|
locale: NS,
|
|
5514
|
-
},
|
|
7246
|
+
}, PanelRoot));
|
|
7247
|
+
// 冒烟测试专用缝隙(仅在 smoke-render.mjs 设置了该标记时存在):
|
|
7248
|
+
// 直接暴露各页签组件与同步入口,让测试能逐个渲染/驱动,抓白屏与同步断链问题
|
|
7249
|
+
if (typeof window !== 'undefined' && window.__DSH_IC_SMOKE__) {
|
|
7250
|
+
window.__DSH_IC_TABS__ = { ProjectsTab, AssetsTab, BibleTab, SettingsTab, CanvasPage };
|
|
7251
|
+
window.__DSH_IC_SYNC__ = {
|
|
7252
|
+
hostSyncTick, mergeHostDoc, loadBible, loadGraphDoc,
|
|
7253
|
+
removeNodes, ungroupNode, undo, redo, pushHistory,
|
|
7254
|
+
graph: () => JSON.parse(JSON.stringify(S.graph)),
|
|
7255
|
+
state: () => ({ stopped: hostSyncStopped, busy: hostSyncBusy, projects: S.projects.map((p) => p.id), activeId: S.activeId, ready: S.ready }),
|
|
7256
|
+
};
|
|
7257
|
+
}
|
|
5515
7258
|
},
|
|
5516
7259
|
};
|
|
5517
7260
|
},
|