@ninkch/dsh-infinite-canvas 0.17.3 → 0.17.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/README.zh-CN.md +1 -1
- package/assets/canvas-craft/SKILL.md +106 -0
- package/assets/canvas-studio/SKILL.md +169 -0
- package/client.js +2030 -283
- package/docs/release.md +40 -45
- package/index.js +1421 -50
- 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
|
// ============================================================
|
|
@@ -246,8 +248,10 @@ window.__ModuleLoader__.load({
|
|
|
246
248
|
'projectFilter': '项目',
|
|
247
249
|
'allProjects': '全部项目',
|
|
248
250
|
'currentProject': '当前项目',
|
|
249
|
-
'expandDone': '
|
|
250
|
-
'
|
|
251
|
+
'expandDone': '已铺开为一次多镜头生成:{n} 镜合成为一条视频(时长 = 各镜之和)',
|
|
252
|
+
'multiShotHint': '多镜头模式:提示词由上游分镜表组合(全部镜头按顺序+时长合成一条提示词),一次性生成整段视频',
|
|
253
|
+
'aiNeedScript': '分镜节点还没有剧本来源:连线上游剧本节点,或在节点里粘贴剧本',
|
|
254
|
+
'upstreamStillRunning': '上游剧本节点还没生成完成(有连线时内容只从连线取);等它完成后再生成分镜',
|
|
251
255
|
'expandNoShots': '还没有分镜,请先 AI 生成',
|
|
252
256
|
'aiRunning': 'AI 生成中…',
|
|
253
257
|
'langLabel': '界面语言',
|
|
@@ -338,6 +342,70 @@ window.__ModuleLoader__.load({
|
|
|
338
342
|
'updateInstalledHint': '重启 Harness 后生效(网页部分刷新页面即可)',
|
|
339
343
|
'updateFailGuide': '自动安装未成功,请在 DSH 插件管理 中升级',
|
|
340
344
|
'updatePolicySkipped': '(已按你的点击跳过 24 小时新版本冷却期)',
|
|
345
|
+
'dataRootLabel': '数据根目录',
|
|
346
|
+
'dataRootPlaceholder': '如 D:\\canvas-data',
|
|
347
|
+
'dataRootHint': '资产与视频按项目落盘到该目录(不再占用 C 盘 IndexedDB)。留空 = 浏览器 IndexedDB。',
|
|
348
|
+
'dataRootSave': '设置此目录',
|
|
349
|
+
'dataRootOpen': '打开目录',
|
|
350
|
+
'dataRootMigrate': '迁移历史资产',
|
|
351
|
+
'dataRootMigrated': '已迁移 {n} 个资产到数据根目录',
|
|
352
|
+
'dataRootMigratedDone': '已迁移',
|
|
353
|
+
'dataRootMigrating': '迁移中',
|
|
354
|
+
'dataRootUnsupported': '当前插件后台版本较旧(不支持数据根目录):重启 Harness 后可用',
|
|
355
|
+
'dataRootSaved': '数据根目录已设置',
|
|
356
|
+
'resizeHint': '拖动左右上下边可调宽高(右键头调宽、底边调高、右下角同时)',
|
|
357
|
+
'resizeHeightHint': '拖动调高;双击恢复「随内容自适应」',
|
|
358
|
+
'aiEmptyResult': '模型返回了空内容——请检查画布设置里的文本模型,或换一个模型重试',
|
|
359
|
+
'aiInterrupted': '上次生成被页面刷新或关闭中断,请重新生成',
|
|
360
|
+
'skillHint': '默认已含内置专家 + 创作方法论(canvas-craft);也可选择其它已安装技能覆盖',
|
|
361
|
+
'nodeNotRunnable': '该节点类型不支持运行(请用画布上的对应按钮)',
|
|
362
|
+
'delAssetTitle': '删除资产',
|
|
363
|
+
'delAssetWarn': '将删除该资产及其文件,且无法恢复;引用它的节点会清空引用。请输入资产名以确认:',
|
|
364
|
+
'delAssetInput': '资产名',
|
|
365
|
+
'renameAsset': '重命名',
|
|
366
|
+
'renamed': '已重命名',
|
|
367
|
+
'llmMaxTokens': '输出上限 maxTokens',
|
|
368
|
+
'llmTemperature': '温度 temperature',
|
|
369
|
+
'llmParamAuto': '留空 = 用 Harness/模型默认',
|
|
370
|
+
'llmParamHint': '留空则不下发该参数,直接使用你在 Harness 里为该模型配置的默认值(插件不会写死任何生成参数)',
|
|
371
|
+
'imgSizeOverride': '尺寸覆盖 size',
|
|
372
|
+
'imgSizePlaceholder': '如 1024x1024(留空=按清晰度×比例)',
|
|
373
|
+
'imgResponseFormat': '响应格式 response_format',
|
|
374
|
+
'seedanceAudio': '生成音频(Seedance)',
|
|
375
|
+
'seedanceWatermark': '添加水印(Seedance)',
|
|
376
|
+
'seedanceHint': '仅 Seedance 协议生效;模型名必填(协议要求显式指定)',
|
|
377
|
+
'bibleWorldview': '世界观 / 核心设定',
|
|
378
|
+
'bibleWorldviewPh': '题材、年代、核心规则、主线与暗线(会注入所有 AI 生成,保证跨集一致)',
|
|
379
|
+
'bibleDraft': 'AI 起草设定集',
|
|
380
|
+
'bibleDrafted': '已根据创作意图起草设定集,请检查后再生成本',
|
|
381
|
+
'bibleDraftEmpty': '没能起草出设定集:请先在剧本/文本节点写点创作意图,或检查文本模型配置',
|
|
382
|
+
'bibleOrderHint': '设定集先行:世界观/风格/角色/场景/道具先定好 → 生成剧本 → 为角色/场景/道具生成参考图 → 再拆解分镜',
|
|
383
|
+
'bibleLinkAssets': '链接资产',
|
|
384
|
+
'bibleLinkTitle': '链接资产到设定条目',
|
|
385
|
+
'bibleLinkHint': '勾选要绑定的图片/音频/视频(可多选);@引用该条目时会优先把这些资产作为参考图',
|
|
386
|
+
'bibleLinkApply': '完成',
|
|
387
|
+
'generalProject': '通用项目',
|
|
388
|
+
'moved': '已移动到指定项目',
|
|
389
|
+
'pagePrev': '上一页',
|
|
390
|
+
'pageNext': '下一页',
|
|
391
|
+
'bibleAutoAdd': '已把 {n} 个新元素补进设定集(描述待补充)',
|
|
392
|
+
'kindAudio': '音频',
|
|
393
|
+
'mentionRefs': '@引用命中',
|
|
394
|
+
'mentionAssets': '可用参考图 {n} 张',
|
|
395
|
+
'mentionNoAsset': '暂无参考图',
|
|
396
|
+
'mentionNoAssetHint': '该实体还没有参考图:先建一个图片节点生成(建议命名「角色-名称 多视图」),之后所有 @名称 的提示词都会自动带上它',
|
|
397
|
+
'craftTimeout': 'Craft 任务超时(可在任务面板重试或取消)',
|
|
398
|
+
'errNoImageApi': '未配置图片 API:请到画布设置里添加(未配置时可用演示模式)',
|
|
399
|
+
'photoCmd': 'Craft 命令',
|
|
400
|
+
'bitrate': '码率',
|
|
401
|
+
'bitrateAuto': '自动(浏览器默认)',
|
|
402
|
+
'runRequestFailed': '对话触发的生成未能执行',
|
|
403
|
+
'apiTimeout': '请求超时(毫秒)',
|
|
404
|
+
'uploadFailed': '导入失败',
|
|
405
|
+
'syncFailed': '与 Host 同步失败',
|
|
406
|
+
'dataRootBrowse': '浏览选择目录',
|
|
407
|
+
'dataRootPicking': '请在弹出的窗口中选择目录(可直接取消)…',
|
|
408
|
+
'dataRootPickUnsupported': '当前平台不支持目录选择器,请手动填写路径',
|
|
341
409
|
'updateCurrent': '当前',
|
|
342
410
|
'updateGo': '去升级',
|
|
343
411
|
'npmUpgradeHint': '请在 DSH 插件管理 中升级到最新版(本插件绝不自动安装或重启);安装完成后重启 Harness 生效',
|
|
@@ -508,8 +576,10 @@ window.__ModuleLoader__.load({
|
|
|
508
576
|
'projectFilter': 'Project',
|
|
509
577
|
'allProjects': 'All projects',
|
|
510
578
|
'currentProject': 'Current project',
|
|
511
|
-
'expandDone': 'Expanded {n} shots
|
|
512
|
-
'
|
|
579
|
+
'expandDone': 'Expanded to one multi-shot generation: {n} shots composed into a single video',
|
|
580
|
+
'multiShotHint': 'Multi-shot mode: the prompt is composed from the upstream storyboard (all shots, in order, with durations) and generated as one clip',
|
|
581
|
+
'aiNeedScript': 'The storyboard has no script source: connect an upstream script node, or paste a script into the node',
|
|
582
|
+
'upstreamStillRunning': 'The upstream script node is still generating (with a wire, content comes only from the wire); wait for it before splitting shots',
|
|
513
583
|
'expandNoShots': 'No shots yet — run AI generation first',
|
|
514
584
|
'aiRunning': 'AI generating…',
|
|
515
585
|
'langLabel': 'UI language',
|
|
@@ -600,6 +670,70 @@ window.__ModuleLoader__.load({
|
|
|
600
670
|
'updateInstalledHint': 'restart Harness to take effect (refresh the page for the web part)',
|
|
601
671
|
'updateFailGuide': 'Auto-install failed — please upgrade via the DSH Plugin Manager',
|
|
602
672
|
'updatePolicySkipped': '(skipped the 24-hour release cool-down at your click)',
|
|
673
|
+
'dataRootLabel': 'Data root',
|
|
674
|
+
'dataRootPlaceholder': 'e.g. D:\\canvas-data',
|
|
675
|
+
'dataRootHint': 'Assets and videos are stored per project under this folder (no more C: IndexedDB). Empty = browser IndexedDB.',
|
|
676
|
+
'dataRootSave': 'Set directory',
|
|
677
|
+
'dataRootOpen': 'Open folder',
|
|
678
|
+
'dataRootMigrate': 'Migrate existing assets',
|
|
679
|
+
'dataRootMigrated': 'Migrated {n} assets to the data root',
|
|
680
|
+
'dataRootMigratedDone': 'Migrated',
|
|
681
|
+
'dataRootMigrating': 'Migrating',
|
|
682
|
+
'dataRootUnsupported': 'The plugin backend is older and lacks data-root support — restart Harness to enable',
|
|
683
|
+
'dataRootSaved': 'Data root set',
|
|
684
|
+
'resizeHint': 'Drag any edge to resize (top/bottom = height, left/right = width, corner = both)',
|
|
685
|
+
'resizeHeightHint': 'Drag to set height; double-click to fit content again',
|
|
686
|
+
'aiEmptyResult': 'The model returned empty content — check the text model in canvas settings or try another model',
|
|
687
|
+
'aiInterrupted': 'The previous generation was interrupted by a page reload; please run it again',
|
|
688
|
+
'skillHint': 'Defaults to built-in experts plus the canvas-craft methodology; pick another installed skill to override',
|
|
689
|
+
'nodeNotRunnable': 'This node type cannot be run (use its button on the canvas)',
|
|
690
|
+
'delAssetTitle': 'Delete asset',
|
|
691
|
+
'delAssetWarn': 'The asset and its file will be deleted permanently; nodes referencing it will lose the reference. Type the asset name to confirm:',
|
|
692
|
+
'delAssetInput': 'Asset name',
|
|
693
|
+
'renameAsset': 'Rename',
|
|
694
|
+
'renamed': 'Renamed',
|
|
695
|
+
'llmMaxTokens': 'Output cap (maxTokens)',
|
|
696
|
+
'llmTemperature': 'Temperature',
|
|
697
|
+
'llmParamAuto': 'empty = Harness/model default',
|
|
698
|
+
'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.',
|
|
699
|
+
'imgSizeOverride': 'Size override',
|
|
700
|
+
'imgSizePlaceholder': 'e.g. 1024x1024 (empty = clarity × ratio)',
|
|
701
|
+
'imgResponseFormat': 'response_format',
|
|
702
|
+
'seedanceAudio': 'Generate audio (Seedance)',
|
|
703
|
+
'seedanceWatermark': 'Watermark (Seedance)',
|
|
704
|
+
'seedanceHint': 'Seedance protocol only; the model name is required by the protocol',
|
|
705
|
+
'bibleWorldview': 'Worldview / core settings',
|
|
706
|
+
'bibleWorldviewPh': 'Genre, era, core rules, main and hidden threads (injected into all generation for consistency)',
|
|
707
|
+
'bibleDraft': 'Draft bible with AI',
|
|
708
|
+
'bibleDrafted': 'Bible drafted from your intent — review it before generating the script',
|
|
709
|
+
'bibleDraftEmpty': 'Nothing drafted: write some intent in a script/text node first, or check the text model',
|
|
710
|
+
'bibleOrderHint': 'Bible first: set worldview/style/characters/scenes/props → generate the script → generate reference images → then break down the storyboard',
|
|
711
|
+
'bibleLinkAssets': 'Link assets',
|
|
712
|
+
'bibleLinkTitle': 'Link assets to this bible entry',
|
|
713
|
+
'bibleLinkHint': 'Pick images/audio/video to bind (multi-select); @mentions of this entry will use them as references first',
|
|
714
|
+
'bibleLinkApply': 'Done',
|
|
715
|
+
'generalProject': 'General',
|
|
716
|
+
'moved': 'Moved to the target project',
|
|
717
|
+
'pagePrev': 'Prev page',
|
|
718
|
+
'pageNext': 'Next page',
|
|
719
|
+
'bibleAutoAdd': 'Added {n} new entries to the bible (descriptions pending)',
|
|
720
|
+
'kindAudio': 'Audio',
|
|
721
|
+
'mentionRefs': '@mention hits',
|
|
722
|
+
'mentionAssets': '{n} reference image(s) available',
|
|
723
|
+
'mentionNoAsset': 'no reference image yet',
|
|
724
|
+
'mentionNoAssetHint': 'No reference image for this entity yet: create an image node (name it like "角色-张山 多视图") and every later @mention will pick it up automatically',
|
|
725
|
+
'craftTimeout': 'Craft job timed out (retry or cancel it in the job panel)',
|
|
726
|
+
'errNoImageApi': 'No image API configured — add one in canvas settings (demo mode is available without one)',
|
|
727
|
+
'photoCmd': 'Craft command',
|
|
728
|
+
'bitrate': 'Bitrate',
|
|
729
|
+
'bitrateAuto': 'Auto (browser default)',
|
|
730
|
+
'runRequestFailed': 'Chat-triggered generation failed',
|
|
731
|
+
'apiTimeout': 'Request timeout (ms)',
|
|
732
|
+
'uploadFailed': 'Import failed',
|
|
733
|
+
'syncFailed': 'Failed to sync with Host',
|
|
734
|
+
'dataRootBrowse': 'Browse for folder',
|
|
735
|
+
'dataRootPicking': 'Pick a folder in the dialog (cancel anytime)…',
|
|
736
|
+
'dataRootPickUnsupported': 'No folder picker on this platform — type the path instead',
|
|
603
737
|
'updateCurrent': 'current',
|
|
604
738
|
'updateGo': 'Upgrade',
|
|
605
739
|
'npmUpgradeHint': 'Please upgrade via the DSH Plugin Manager (this plugin never auto-installs or restarts); restart Harness after installing',
|
|
@@ -687,7 +821,7 @@ window.__ModuleLoader__.load({
|
|
|
687
821
|
.dshic-edge-temp{fill:none;stroke:var(--dsw-alias-label-secondary);stroke-width:2;stroke-dasharray:6 5;pointer-events:none;}
|
|
688
822
|
.dshic-node{position:absolute;box-sizing:border-box;width:${NODE_W}px;background:var(--dsw-alias-bg-layer-1);
|
|
689
823
|
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);
|
|
690
|
-
cursor:grab;min-height:96px;}
|
|
824
|
+
cursor:grab;min-height:96px;display:flex;flex-direction:column;}
|
|
691
825
|
.dshic-node:active{cursor:grabbing;}
|
|
692
826
|
.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);}
|
|
693
827
|
.dshic-node.st-running{border-color:var(--dsw-alias-brand-primary);}
|
|
@@ -704,10 +838,24 @@ window.__ModuleLoader__.load({
|
|
|
704
838
|
.dshic-node-x{flex:none;width:18px;height:18px;border:none;border-radius:6px;background:transparent;color:var(--dsw-alias-label-secondary);
|
|
705
839
|
cursor:pointer;font-size:13px;line-height:18px;padding:0;display:inline-flex;align-items:center;justify-content:center;}
|
|
706
840
|
.dshic-node-x:hover{background:color-mix(in srgb,var(--dsw-alias-state-error-primary) 16%,transparent);color:var(--dsw-alias-state-error-primary);}
|
|
707
|
-
|
|
841
|
+
/* 节点尺寸把手:上下左右四条边 + 右下角;双击底边恢复随内容自适应 */
|
|
842
|
+
.dshic-rs{position:absolute;z-index:6;}
|
|
843
|
+
.dshic-rs:hover{background:color-mix(in srgb,var(--dsw-alias-brand-primary) 35%,transparent);border-radius:4px;}
|
|
844
|
+
.dshic-rs-e{right:-3px;top:14px;bottom:14px;width:7px;cursor:ew-resize;}
|
|
845
|
+
.dshic-rs-w{left:-3px;top:14px;bottom:14px;width:7px;cursor:ew-resize;}
|
|
846
|
+
.dshic-rs-s{bottom:-3px;left:14px;right:14px;height:7px;cursor:ns-resize;}
|
|
847
|
+
.dshic-rs-n{top:-3px;left:14px;right:14px;height:7px;cursor:ns-resize;}
|
|
848
|
+
.dshic-rs-se{right:-4px;bottom:-4px;width:16px;height:16px;cursor:nwse-resize;}
|
|
849
|
+
.dshic-node-body{padding:8px;display:flex;flex-direction:column;gap:6px;font-size:12px;flex:1;min-height:0;overflow:auto;}
|
|
850
|
+
/* 手动拉过高高度的节点:主体内的文本框随节点一起拉伸(拖高=对话框变高,而不是只撑外框) */
|
|
851
|
+
.dshic-node.h-manual .dshic-node-body{overflow:hidden;}
|
|
852
|
+
.dshic-node.h-manual .dshic-node-body > .dshic-textarea{flex:1 1 auto;min-height:0;}
|
|
853
|
+
.dshic-node.h-manual .dshic-node-body > .dshic-prompt{flex:1 1 auto;display:flex;flex-direction:column;}
|
|
854
|
+
.dshic-node.h-manual .dshic-node-body > .dshic-prompt > .dshic-prompt-input{flex:1 1 auto;min-height:0;}
|
|
708
855
|
.dshic-textarea{width:100%;box-sizing:border-box;min-height:56px;resize:vertical;border:1px solid var(--dsw-alias-border-l2);
|
|
709
|
-
border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;
|
|
710
|
-
|
|
856
|
+
border-radius:8px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;
|
|
857
|
+
/* 节点内文本统一字号/行高/内距:与 .dshic-prompt-back/.dshic-prompt-input 必须逐项一致(透明叠层对齐) */
|
|
858
|
+
font-size:12px;line-height:18px;padding:7px 9px;outline:none;user-select:text;-webkit-user-select:text;cursor:text;}
|
|
711
859
|
.dshic-textarea:focus{border-color:var(--dsw-alias-brand-primary);}
|
|
712
860
|
.dshic-textarea:disabled{opacity:.6;}
|
|
713
861
|
.dshic-input{width:100%;box-sizing:border-box;height:28px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;
|
|
@@ -734,11 +882,12 @@ window.__ModuleLoader__.load({
|
|
|
734
882
|
.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;}
|
|
735
883
|
.dshic-hint{color:var(--dsw-alias-label-secondary);font-size:11px;line-height:16px;}
|
|
736
884
|
.dshic-port{position:absolute;width:11px;height:11px;border-radius:50%;background:var(--dsw-alias-bg-layer-1);
|
|
737
|
-
border:2px solid var(--dsw-alias-label-secondary);cursor:crosshair;z-index:
|
|
885
|
+
border:2px solid var(--dsw-alias-label-secondary);cursor:crosshair;z-index:9;box-sizing:border-box;padding:0;}
|
|
738
886
|
.dshic-port.k-image{border-color:var(--dsw-alias-brand-primary);}
|
|
739
887
|
.dshic-port.k-video{border-color:var(--dsw-alias-state-success-primary);}
|
|
740
888
|
.dshic-port:hover,.dshic-port.is-active{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);
|
|
741
889
|
box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,transparent);}
|
|
890
|
+
.dshic-port-wrap{position:absolute;width:0;height:0;}
|
|
742
891
|
.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;}
|
|
743
892
|
.dshic-node:hover .dshic-port-label{opacity:1;}
|
|
744
893
|
.dshic-marquee{position:absolute;border:1px solid var(--dsw-alias-brand-primary);
|
|
@@ -772,6 +921,15 @@ window.__ModuleLoader__.load({
|
|
|
772
921
|
.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;}
|
|
773
922
|
.dshic-asset-actions{position:absolute;top:4px;right:4px;display:flex;gap:3px;opacity:0;transition:opacity .12s;}
|
|
774
923
|
.dshic-asset:hover .dshic-asset-actions{opacity:1;}
|
|
924
|
+
/* 资产名称行:所有媒体类资产统一显示名称,旁边 ✎ 重命名(与节点改名同一支笔图标) */
|
|
925
|
+
.dshic-asset-name{display:flex;align-items:center;gap:4px;padding:0 7px 6px;}
|
|
926
|
+
.dshic-asset-name-text{flex:1;min-width:0;font-size:11px;line-height:15px;color:var(--dsw-alias-label-primary);
|
|
927
|
+
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;user-select:text;-webkit-user-select:text;}
|
|
928
|
+
.dshic-asset-name .dshic-input{height:24px;font-size:11px;padding:0 6px;}
|
|
929
|
+
/* 资产所属项目下拉:改到通用/其它项目(多项目共用) */
|
|
930
|
+
.dshic-asset-project{width:100%;box-sizing:border-box;height:22px;margin:0 0 6px;border:1px solid var(--dsw-alias-border-l2);
|
|
931
|
+
border-radius:6px;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);font:inherit;font-size:10px;
|
|
932
|
+
padding:0 5px;outline:none;cursor:pointer;text-align:left;}
|
|
775
933
|
.dshic-asset-actions .dshic-iconbtn{width:20px;height:20px;background:color-mix(in srgb,var(--dsw-alias-bg-base) 70%,transparent);
|
|
776
934
|
color:var(--dsw-alias-label-primary);backdrop-filter:blur(4px);}
|
|
777
935
|
.dshic-field{display:flex;flex-direction:column;gap:3px;}
|
|
@@ -807,7 +965,7 @@ window.__ModuleLoader__.load({
|
|
|
807
965
|
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;}
|
|
808
966
|
.dshic-modal-title{font-size:14px;font-weight:600;}
|
|
809
967
|
.dshic-modal-warn{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);}
|
|
810
|
-
.dshic-modal-name{font-weight:600;color:var(--dsw-alias-label-primary);}
|
|
968
|
+
.dshic-modal-name{font-weight:600;color:var(--dsw-alias-label-primary);user-select:text;-webkit-user-select:text;cursor:text;}
|
|
811
969
|
.dshic-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:2px;}
|
|
812
970
|
.dshic-btn-outline{border:1px solid var(--dsw-alias-border-l2);background:transparent;color:var(--dsw-alias-label-primary);}
|
|
813
971
|
.dshic-btn-outline:hover{background:color-mix(in srgb,var(--dsw-alias-label-primary) 8%,transparent);}
|
|
@@ -820,7 +978,28 @@ window.__ModuleLoader__.load({
|
|
|
820
978
|
color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);}
|
|
821
979
|
.dshic-shot-item strong{color:var(--dsw-alias-label-primary);font-size:11px;}
|
|
822
980
|
.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);
|
|
823
|
-
font:inherit;font-size:
|
|
981
|
+
font:inherit;font-size:12px;line-height:18px;padding:7px 9px;overflow:hidden;text-align:left;display:block;white-space:pre-wrap;}
|
|
982
|
+
/* 文本(剧本等)资产卡:只展示标题与项目标签,不展开正文 */
|
|
983
|
+
.dshic-textcard.is-title{display:flex;flex-direction:column;gap:6px;justify-content:center;padding:10px 9px;color:inherit;}
|
|
984
|
+
.dshic-textcard-title{font-size:12px;font-weight:600;line-height:17px;color:var(--dsw-alias-label-primary);word-break:break-word;
|
|
985
|
+
display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden;}
|
|
986
|
+
.dshic-textcard-sub{font-size:10px;line-height:14px;color:var(--dsw-alias-label-secondary);}
|
|
987
|
+
/* @引用高亮输入框:字号/行高/内距与 .dshic-textarea 完全一致(背景层与透明输入框必须逐像素对齐) */
|
|
988
|
+
.dshic-prompt{position:relative;display:block;width:100%;}
|
|
989
|
+
.dshic-prompt-back,.dshic-prompt-input{font:inherit;font-size:12px;line-height:18px;padding:7px 9px;box-sizing:border-box;
|
|
990
|
+
white-space:pre-wrap;word-break:break-word;overflow-wrap:anywhere;}
|
|
991
|
+
.dshic-prompt-back{position:absolute;inset:0;overflow:hidden;pointer-events:none;color:var(--dsw-alias-label-primary);
|
|
992
|
+
border:1px solid transparent;border-radius:6px;}
|
|
993
|
+
.dshic-prompt-ph{color:var(--dsw-alias-label-tertiary);}
|
|
994
|
+
.dshic-prompt-input{position:relative;background:transparent;color:transparent;caret-color:var(--dsw-alias-label-primary);
|
|
995
|
+
resize:vertical;}
|
|
996
|
+
.dshic-prompt-input::placeholder{color:transparent;}
|
|
997
|
+
.dshic-mention{color:#2f7cf6;font-weight:700;}
|
|
998
|
+
/* 生成中的一行思考内容:单行、省略号,让用户看到"在动"而不是卡住 */
|
|
999
|
+
.dshic-stream{display:flex;align-items:center;gap:5px;font-size:10px;line-height:14px;color:var(--dsw-alias-label-secondary);
|
|
1000
|
+
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding:0 2px;font-variant-numeric:tabular-nums;}
|
|
1001
|
+
.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;}
|
|
1002
|
+
@keyframes dshic-pulse{0%,100%{opacity:.25;}50%{opacity:1;}}
|
|
824
1003
|
.dshic-projchip{flex:none;height:15px;padding:0 5px;border-radius:4px;font-size:9px;line-height:15px;
|
|
825
1004
|
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;}
|
|
826
1005
|
/* 节点类型色条:左缘 accent */
|
|
@@ -900,9 +1079,10 @@ window.__ModuleLoader__.load({
|
|
|
900
1079
|
try { localStorage.setItem(key, JSON.stringify(value)); } catch { /* 忽略配额错误 */ }
|
|
901
1080
|
}
|
|
902
1081
|
const DEFAULT_SETTINGS = {
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1082
|
+
// 生成参数一律"留空即不发送",由 Harness 适配器/模型用已配置的默认值(不在代码里写死)
|
|
1083
|
+
textLlm: { provider: '', model: '', maxTokens: '', temperature: '' },
|
|
1084
|
+
imageApis: [], // [{id, name, baseUrl, apiKey, model, mode, size?, responseFormat?}]
|
|
1085
|
+
videoApis: [], // [{id, name, url, apiKey, model, protocol?, watermark?, generateAudio?}]
|
|
906
1086
|
ttsApis: [], // [{id, name, baseUrl, apiKey, model, voice}]
|
|
907
1087
|
imageApiId: '',
|
|
908
1088
|
videoApiId: '',
|
|
@@ -926,20 +1106,22 @@ window.__ModuleLoader__.load({
|
|
|
926
1106
|
skillStoryboard: s.skillStoryboard || '',
|
|
927
1107
|
};
|
|
928
1108
|
// v0.3 及以前的单配置自动迁移为列表
|
|
1109
|
+
// 注意:id 必须**确定性**——loadSettings 是高频调用(每次渲染/生成都会走),
|
|
1110
|
+
// 若这里用 uid() 每次生成新 id,节点上保存的 apiId 会永远匹配不上、默认项也会漂移
|
|
929
1111
|
if (!out.imageApis.length && s.imageApi && s.imageApi.baseUrl) {
|
|
930
1112
|
out.imageApis = [{
|
|
931
|
-
id:
|
|
1113
|
+
id: 'legacy-image', name: T('defaultImageCfg'),
|
|
932
1114
|
baseUrl: s.imageApi.baseUrl || '', apiKey: s.imageApi.apiKey || '',
|
|
933
1115
|
model: s.imageApi.model || '', mode: s.imageApi.mode || 'generations',
|
|
934
1116
|
}];
|
|
935
|
-
out.imageApiId =
|
|
1117
|
+
out.imageApiId = s.imageApiId || 'legacy-image';
|
|
936
1118
|
}
|
|
937
1119
|
if (!out.videoApis.length && s.videoApi && s.videoApi.url) {
|
|
938
1120
|
out.videoApis = [{
|
|
939
|
-
id:
|
|
1121
|
+
id: 'legacy-video', name: T('defaultVideoCfg'),
|
|
940
1122
|
url: s.videoApi.url || '', apiKey: s.videoApi.apiKey || '', model: s.videoApi.model || '',
|
|
941
1123
|
}];
|
|
942
|
-
out.videoApiId =
|
|
1124
|
+
out.videoApiId = s.videoApiId || 'legacy-video';
|
|
943
1125
|
}
|
|
944
1126
|
return out;
|
|
945
1127
|
}
|
|
@@ -953,28 +1135,232 @@ window.__ModuleLoader__.load({
|
|
|
953
1135
|
// 项目设定集(Series Bible):跨集/跨镜一致性的唯一事实源
|
|
954
1136
|
// ============================================================
|
|
955
1137
|
const LS_BIBLE = 'dsh-ic:bible:';
|
|
956
|
-
|
|
1138
|
+
const LS_BIBLE_SYNC = 'dsh-ic:biblesync:';
|
|
1139
|
+
function emptyBible() { return { worldview: '', style: '', characters: [], scenes: [], props: [] }; }
|
|
957
1140
|
function loadBible(pid) {
|
|
958
1141
|
if (!pid) return emptyBible();
|
|
959
1142
|
const b = lsGet(LS_BIBLE + pid, null);
|
|
960
1143
|
if (!b) return emptyBible();
|
|
961
1144
|
return {
|
|
1145
|
+
worldview: b.worldview || '',
|
|
962
1146
|
style: b.style || '',
|
|
963
1147
|
characters: Array.isArray(b.characters) ? b.characters : [],
|
|
964
1148
|
scenes: Array.isArray(b.scenes) ? b.scenes : [],
|
|
965
1149
|
props: Array.isArray(b.props) ? b.props : [],
|
|
966
1150
|
};
|
|
967
1151
|
}
|
|
968
|
-
|
|
1152
|
+
/** 只写本地(用于接收 Host 侧改写,避免回推形成环)。 */
|
|
1153
|
+
function saveBibleLocal(pid, b) { if (pid) lsSet(LS_BIBLE + pid, b); }
|
|
1154
|
+
// 上一次已同步给 Host 的设定集快照:避免每次画布保存都把(可能已被对话改过的)本地版本推回去覆盖
|
|
1155
|
+
const biblePushSnap = new Map();
|
|
1156
|
+
function bibleSnapshot(pid) { try { return JSON.stringify(loadBible(pid)); } catch { return ''; } }
|
|
1157
|
+
/** 需要时才推送设定集:本地内容与上次同步的一致 → 不带 bible 字段,Host 保留对话侧的新版本。 */
|
|
1158
|
+
function bibleFieldForPush(pid) {
|
|
1159
|
+
const snap = bibleSnapshot(pid);
|
|
1160
|
+
if (biblePushSnap.get(pid) === snap) return {};
|
|
1161
|
+
return { bible: loadBible(pid), __bibleChanged: true };
|
|
1162
|
+
}
|
|
1163
|
+
/** 把设定集同步到 Host:对话侧才能读写设定集(设定集先行)。内容未变则不推送(避免覆盖对话侧的新版本)。 */
|
|
1164
|
+
function pushBible(pid, b) {
|
|
1165
|
+
if (!pid) return;
|
|
1166
|
+
const bible = b || loadBible(pid);
|
|
1167
|
+
if (biblePushSnap.get(pid) === JSON.stringify(bible)) return;
|
|
1168
|
+
try {
|
|
1169
|
+
const proj = S.projects.find((p) => p.id === pid);
|
|
1170
|
+
const doc = loadGraphDoc(pid) || emptyGraph();
|
|
1171
|
+
hostRpc('projectsPut', { id: pid, name: (proj && proj.name) || pid, doc: { ...doc, bible } })
|
|
1172
|
+
.then((r) => {
|
|
1173
|
+
if (r && r.bibleUpdatedAt) {
|
|
1174
|
+
lsSet(LS_BIBLE_SYNC + pid, r.bibleUpdatedAt);
|
|
1175
|
+
biblePushSnap.set(pid, JSON.stringify(bible));
|
|
1176
|
+
}
|
|
1177
|
+
})
|
|
1178
|
+
.catch(() => { /* 静默 */ });
|
|
1179
|
+
} catch { /* 忽略 */ }
|
|
1180
|
+
}
|
|
1181
|
+
const biblePushTimers = new Map();
|
|
1182
|
+
/** 设定集保存:本地立即写,推送防抖(输入过程中不逐字打网络)。 */
|
|
1183
|
+
function saveBible(pid, b) {
|
|
1184
|
+
if (!pid) return;
|
|
1185
|
+
saveBibleLocal(pid, b);
|
|
1186
|
+
if (biblePushTimers.has(pid)) clearTimeout(biblePushTimers.get(pid));
|
|
1187
|
+
biblePushTimers.set(pid, setTimeout(() => { biblePushTimers.delete(pid); pushBible(pid, loadBible(pid)); }, 800));
|
|
1188
|
+
}
|
|
969
1189
|
function bibleBlock(b) {
|
|
970
1190
|
if (!b) return '';
|
|
971
1191
|
const lines = [];
|
|
1192
|
+
if ((b.worldview || '').trim()) lines.push('世界观/核心设定:' + b.worldview.trim());
|
|
972
1193
|
if ((b.style || '').trim()) lines.push('风格基调:' + b.style.trim());
|
|
973
1194
|
for (const c of b.characters || []) if ((c.name || '').trim()) lines.push(`角色 ${c.name.trim()}:${(c.desc || '').trim()}`);
|
|
974
1195
|
for (const c of b.scenes || []) if ((c.name || '').trim()) lines.push(`场景 ${c.name.trim()}:${(c.desc || '').trim()}`);
|
|
975
1196
|
for (const c of b.props || []) if ((c.name || '').trim()) lines.push(`道具 ${c.name.trim()}:${(c.desc || '').trim()}`);
|
|
976
1197
|
return lines.join('\n');
|
|
977
1198
|
}
|
|
1199
|
+
|
|
1200
|
+
// ============================================================
|
|
1201
|
+
// @ 引用(mention):提示词里写 @张山 → 自动带上该实体的设定集描述与其参考图资产
|
|
1202
|
+
// 以下为纯函数(无副作用),供 scripts/test-mentions.mjs 抽取单测
|
|
1203
|
+
// <<<MENTION_PURE_BEGIN>>>
|
|
1204
|
+
/** @名称 的词法:到空白或常见标点为止(支持中英文名与 - _ ·) */
|
|
1205
|
+
const MENTION_RE = /@([^\s@,。;:、,.;:!?!?()()\[\]【】"'“”<>《》/\\|]{1,24})/g;
|
|
1206
|
+
/** 解析提示词里的 @名称(含位置,按出现顺序)。 */
|
|
1207
|
+
function parseMentions(text) {
|
|
1208
|
+
const out = [];
|
|
1209
|
+
const src = String(text || '');
|
|
1210
|
+
const re = new RegExp(MENTION_RE.source, 'g');
|
|
1211
|
+
let m;
|
|
1212
|
+
while ((m = re.exec(src)) !== null) {
|
|
1213
|
+
const name = String(m[1] || '').trim();
|
|
1214
|
+
if (name) out.push({ name, start: m.index, end: m.index + m[0].length });
|
|
1215
|
+
}
|
|
1216
|
+
return out;
|
|
1217
|
+
}
|
|
1218
|
+
function escapeHtml(s) {
|
|
1219
|
+
return String(s || '')
|
|
1220
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
1221
|
+
.replace(/"/g, '"').replace(/'/g, ''');
|
|
1222
|
+
}
|
|
1223
|
+
/** 把提示词转成高亮 HTML:@名称 用 .dshic-mention(蓝色加粗)标记。 */
|
|
1224
|
+
function highlightMentions(text) {
|
|
1225
|
+
const src = String(text || '');
|
|
1226
|
+
let out = '';
|
|
1227
|
+
let last = 0;
|
|
1228
|
+
const re = new RegExp(MENTION_RE.source, 'g');
|
|
1229
|
+
let m;
|
|
1230
|
+
while ((m = re.exec(src)) !== null) {
|
|
1231
|
+
out += escapeHtml(src.slice(last, m.index));
|
|
1232
|
+
out += '<span class="dshic-mention">' + escapeHtml(m[0]) + '</span>';
|
|
1233
|
+
last = m.index + m[0].length;
|
|
1234
|
+
}
|
|
1235
|
+
out += escapeHtml(src.slice(last));
|
|
1236
|
+
return out === '' ? '' : out + '\n'; // 末尾补换行,保证背景层与 textarea 行高对齐
|
|
1237
|
+
}
|
|
1238
|
+
/** 送给模型前去掉 @ 标记(保留名称本体)。 */
|
|
1239
|
+
function stripMentionMarks(text) {
|
|
1240
|
+
return String(text || '').replace(new RegExp(MENTION_RE.source, 'g'), '$1');
|
|
1241
|
+
}
|
|
1242
|
+
function entityLabelOf(kind) {
|
|
1243
|
+
return kind === 'character' ? '角色' : kind === 'scene' ? '场景' : kind === 'prop' ? '道具' : '元素';
|
|
1244
|
+
}
|
|
1245
|
+
function entityCategoryOf(kind) {
|
|
1246
|
+
return kind === 'character' ? 'character' : kind === 'scene' ? 'scene' : kind === 'prop' ? 'prop' : 'general';
|
|
1247
|
+
}
|
|
1248
|
+
/** 在设定集里按名称找实体(角色/场景/道具),携带其链接的资产 id。 */
|
|
1249
|
+
function matchBibleEntity(name, bible) {
|
|
1250
|
+
const want = String(name || '').trim().toLowerCase();
|
|
1251
|
+
if (!want) return null;
|
|
1252
|
+
for (const [kind, key] of [['character', 'characters'], ['scene', 'scenes'], ['prop', 'props']]) {
|
|
1253
|
+
for (const it of (bible && bible[key]) || []) {
|
|
1254
|
+
if (String((it && it.name) || '').trim().toLowerCase() === want) {
|
|
1255
|
+
return {
|
|
1256
|
+
kind, name: String(it.name).trim(), desc: String((it.desc || '')).trim(),
|
|
1257
|
+
assetIds: Array.isArray(it.assetIds) ? it.assetIds.map(String).filter(Boolean) : [],
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
return null;
|
|
1263
|
+
}
|
|
1264
|
+
/** 资产是否属于某实体:优先显式 entity 字段,其次标准命名「角色-名称 …」(名称后须为边界,避免张山/张山峰混淆)。 */
|
|
1265
|
+
function assetBelongsToEntity(rec, ent) {
|
|
1266
|
+
if (!rec || !ent) return false;
|
|
1267
|
+
if (rec.entity && String(rec.entity).trim().toLowerCase() === ent.name.toLowerCase()) return true;
|
|
1268
|
+
const nm = String(rec.name || '').trim();
|
|
1269
|
+
if (!nm) return false;
|
|
1270
|
+
if (nm.toLowerCase() === ent.name.toLowerCase()) return true;
|
|
1271
|
+
const pref = entityLabelOf(ent.kind) + '-' + ent.name;
|
|
1272
|
+
if (!nm.startsWith(pref)) return false;
|
|
1273
|
+
const rest = nm.slice(pref.length);
|
|
1274
|
+
return rest === '' || !/[\p{L}\p{N}]/u.test(rest[0]);
|
|
1275
|
+
}
|
|
1276
|
+
/** 生成用提示词:@标记去掉 + [图N] 绑定 + 命中实体的设定描述。 */
|
|
1277
|
+
function buildEffectivePrompt(prompt, refs, entities, bible) {
|
|
1278
|
+
const zh = effLang() !== 'en';
|
|
1279
|
+
const bind = refs.map((r, i) => {
|
|
1280
|
+
if (r.entity) {
|
|
1281
|
+
const ent = matchBibleEntity(r.entity, bible);
|
|
1282
|
+
const desc = ent ? ent.desc : '';
|
|
1283
|
+
return `[图${i + 1}] ${entityLabelOf(ent ? ent.kind : 'prop')} ${r.entity}${desc ? '(' + desc + ')' : ''}`;
|
|
1284
|
+
}
|
|
1285
|
+
return `[图${i + 1}] ${catLabel(r.category)}:${r.name}`;
|
|
1286
|
+
}).join(';');
|
|
1287
|
+
const entLines = entities.map((e) => `${entityLabelOf(e.kind)} ${e.name}:${e.desc}`);
|
|
1288
|
+
return [
|
|
1289
|
+
(bible.style || '').trim() ? (zh ? '风格:' : 'Style: ') + bible.style.trim() : '',
|
|
1290
|
+
bind ? (zh ? '参考图 → ' : 'Refs -> ') + bind : '',
|
|
1291
|
+
stripMentionMarks(prompt),
|
|
1292
|
+
entLines.length ? (zh ? '设定集:' : 'Series bible: ') + entLines.join(';') : '',
|
|
1293
|
+
].filter(Boolean).join('\n');
|
|
1294
|
+
}
|
|
1295
|
+
/** 资产命名:优先节点名(如「角色-张山 多视图」),否则用命中实体,最后兜底。 */
|
|
1296
|
+
function assetNameFor(node, entities, fallbackKind) {
|
|
1297
|
+
const label = String((node && node.data && node.data.label) || '').trim();
|
|
1298
|
+
if (label) return label;
|
|
1299
|
+
if (entities && entities.length) return entityLabelOf(entities[0].kind) + '-' + entities[0].name;
|
|
1300
|
+
return (fallbackKind === 'video' ? '视频' : '图片') + '-' + new Date().toISOString().slice(5, 16).replace('T', ' ');
|
|
1301
|
+
}
|
|
1302
|
+
// <<<MENTION_PURE_END>>>
|
|
1303
|
+
|
|
1304
|
+
/** 解析提示词里的 @引用:返回命中实体 + 其名下的参考图资产(按实体出现顺序、各实体内按生成时间)。
|
|
1305
|
+
* 实体的参考图来源:① 设定条目里**链接的资产**(assetIds,绑定关系优先)
|
|
1306
|
+
* ② 其次按命名/实体字段匹配的资产(角色-张山 多视图 等)。 */
|
|
1307
|
+
async function resolveMentionBundle(prompt, bible, projectId) {
|
|
1308
|
+
const entities = [];
|
|
1309
|
+
for (const m of parseMentions(prompt)) {
|
|
1310
|
+
const hit = matchBibleEntity(m.name, bible);
|
|
1311
|
+
if (hit && !entities.some((e) => e.name === hit.name)) entities.push(hit);
|
|
1312
|
+
}
|
|
1313
|
+
if (!entities.length) return { entities: [], refs: [] };
|
|
1314
|
+
let all = [];
|
|
1315
|
+
try { all = (await idbByProject(projectId)).concat(await idbByProject(GENERAL_PID)); } catch { all = []; }
|
|
1316
|
+
const byId = new Map(all.map((r) => [r.id, r]));
|
|
1317
|
+
const refs = [];
|
|
1318
|
+
for (const ent of entities) {
|
|
1319
|
+
const owned = [];
|
|
1320
|
+
// ① 条目链接的资产(绑定优先,任何类型都认;生成时只有图片能当参考图)
|
|
1321
|
+
for (const aid of ent.assetIds || []) {
|
|
1322
|
+
const rec = byId.get(aid);
|
|
1323
|
+
if (rec && rec.kind === 'image') owned.push(rec);
|
|
1324
|
+
}
|
|
1325
|
+
// ② 命名/实体匹配的资产
|
|
1326
|
+
for (const r of all) {
|
|
1327
|
+
if (r.kind === 'image' && assetBelongsToEntity(r, ent) && !owned.some((x) => x.id === r.id)) owned.push(r);
|
|
1328
|
+
}
|
|
1329
|
+
owned.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
|
|
1330
|
+
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 });
|
|
1331
|
+
}
|
|
1332
|
+
return { entities, refs };
|
|
1333
|
+
}
|
|
1334
|
+
/** 合成参考图列表:连线(用户显式顺序)优先,其后追加 @引用命中的设定集资产(去重)。 */
|
|
1335
|
+
async function collectRefs(n, prompt, bible) {
|
|
1336
|
+
const out = [];
|
|
1337
|
+
const edges = S.graph.edges.filter((x) => x.toNode === n.id && x.toPort === 'ref');
|
|
1338
|
+
const order = (n.data.refOrder || []).filter((id) => edges.some((e) => e.id === id));
|
|
1339
|
+
for (const e of edges) if (!order.includes(e.id)) order.push(e.id);
|
|
1340
|
+
for (const id of order) {
|
|
1341
|
+
const e = edges.find((x) => x.id === id);
|
|
1342
|
+
const src = e && S.graph.nodes[e.fromNode];
|
|
1343
|
+
if (!src) continue;
|
|
1344
|
+
const aid = servedAssetId(src, e.fromPort);
|
|
1345
|
+
if (!aid) continue;
|
|
1346
|
+
try {
|
|
1347
|
+
const rec = await idbGet(aid);
|
|
1348
|
+
const blob = await assetBlob(aid);
|
|
1349
|
+
if (blob) out.push({ assetId: aid, blob, name: (rec && rec.name) || '', category: (rec && rec.category) || 'general', source: 'wired' });
|
|
1350
|
+
} catch { /* 单个失败不影响其余 */ }
|
|
1351
|
+
}
|
|
1352
|
+
const bundle = await resolveMentionBundle(prompt, bible, S.activeId);
|
|
1353
|
+
for (const r of bundle.refs) {
|
|
1354
|
+
if (out.some((x) => x.assetId === r.assetId)) continue;
|
|
1355
|
+
try {
|
|
1356
|
+
const rec = await idbGet(r.assetId);
|
|
1357
|
+
const blob = await assetBlob(r.assetId);
|
|
1358
|
+
if (blob) out.push({ assetId: r.assetId, blob, name: (rec && rec.name) || r.name, category: (rec && rec.category) || 'general', source: 'mention', entity: r.entity });
|
|
1359
|
+
} catch { /* 忽略 */ }
|
|
1360
|
+
}
|
|
1361
|
+
return { refs: out, entities: bundle.entities };
|
|
1362
|
+
}
|
|
1363
|
+
|
|
978
1364
|
function outLangLine() {
|
|
979
1365
|
return effLang() === 'en'
|
|
980
1366
|
? 'Write all narrative content in English.'
|
|
@@ -1020,6 +1406,12 @@ window.__ModuleLoader__.load({
|
|
|
1020
1406
|
}
|
|
1021
1407
|
async function idbDelete(id) {
|
|
1022
1408
|
assetNameCache.delete(id);
|
|
1409
|
+
// 释放该资产的 objectURL:否则 Blob(视频可达几十 MB)会被 URL 钉在内存里直到刷新
|
|
1410
|
+
if (assetUrlCache.has(id)) {
|
|
1411
|
+
const u = assetUrlCache.get(id);
|
|
1412
|
+
assetUrlCache.delete(id);
|
|
1413
|
+
if (typeof u === 'string' && u.startsWith('blob:')) { try { URL.revokeObjectURL(u); } catch { /* 忽略 */ } }
|
|
1414
|
+
}
|
|
1023
1415
|
const db = await openDb();
|
|
1024
1416
|
return new Promise((resolve, reject) => {
|
|
1025
1417
|
const tx = db.transaction(DB_STORE, 'readwrite');
|
|
@@ -1047,30 +1439,97 @@ window.__ModuleLoader__.load({
|
|
|
1047
1439
|
}
|
|
1048
1440
|
const assetUrlCache = new Map();
|
|
1049
1441
|
const assetNameCache = new Map(); // assetId -> name(连线绑定的同步名称查询)
|
|
1442
|
+
function fileUrlFor(rel) { return '/' + NS + '/api/file?rel=' + encodeURIComponent(rel); }
|
|
1443
|
+
function extFromMime(mime) {
|
|
1444
|
+
const sub = String(mime || '').split(';')[0].split('/')[1] || 'bin';
|
|
1445
|
+
return /^[a-z0-9]{1,8}$/i.test(sub) ? sub : 'bin';
|
|
1446
|
+
}
|
|
1050
1447
|
async function assetUrl(id) {
|
|
1051
1448
|
if (!id) return null;
|
|
1052
1449
|
if (assetUrlCache.has(id)) return assetUrlCache.get(id);
|
|
1053
1450
|
const rec = await idbGet(id);
|
|
1054
|
-
if (!rec
|
|
1055
|
-
|
|
1056
|
-
|
|
1451
|
+
if (!rec) return null;
|
|
1452
|
+
let url = null;
|
|
1453
|
+
if (rec.rel) url = fileUrlFor(rec.rel); // 磁盘资产:Host 文件服务(支持 Range)
|
|
1454
|
+
else if (rec.blob) {
|
|
1455
|
+
// IndexedDB 资产:objectURL 有内存成本 → 缓存总量设上限,超出时释放最早的一批
|
|
1456
|
+
if (assetUrlCache.size >= 60) {
|
|
1457
|
+
const first = assetUrlCache.keys().next();
|
|
1458
|
+
if (!first.done) {
|
|
1459
|
+
const oldUrl = assetUrlCache.get(first.value);
|
|
1460
|
+
assetUrlCache.delete(first.value);
|
|
1461
|
+
if (typeof oldUrl === 'string' && oldUrl.startsWith('blob:')) { try { URL.revokeObjectURL(oldUrl); } catch { /* 忽略 */ } }
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
url = URL.createObjectURL(rec.blob);
|
|
1465
|
+
}
|
|
1466
|
+
if (url) assetUrlCache.set(id, url);
|
|
1057
1467
|
return url;
|
|
1058
1468
|
}
|
|
1469
|
+
/** 取资产 Blob:磁盘资产经 Host 文件服务拉取,旧资产直接读 IndexedDB。 */
|
|
1470
|
+
async function assetBlob(id) {
|
|
1471
|
+
const rec = await idbGet(id);
|
|
1472
|
+
if (!rec) return null;
|
|
1473
|
+
if (rec.blob) return rec.blob;
|
|
1474
|
+
if (rec.rel) {
|
|
1475
|
+
const r = await fetch(fileUrlFor(rec.rel));
|
|
1476
|
+
if (!r.ok) throw new Error('资产读取失败 HTTP ' + r.status);
|
|
1477
|
+
return await r.blob();
|
|
1478
|
+
}
|
|
1479
|
+
return null;
|
|
1480
|
+
}
|
|
1059
1481
|
async function saveAssetRecord(projectId, kind, blob, meta) {
|
|
1482
|
+
const id = uid('a');
|
|
1483
|
+
const mime = blob.type || (kind === 'image' ? 'image/png' : 'video/webm');
|
|
1060
1484
|
const rec = {
|
|
1061
|
-
id
|
|
1062
|
-
mime
|
|
1485
|
+
id, projectId, kind,
|
|
1486
|
+
mime,
|
|
1063
1487
|
name: (meta && meta.name) || (kind === 'image' ? 'image' : 'video') + '-' + Date.now().toString(36),
|
|
1064
|
-
blob,
|
|
1065
1488
|
createdAt: Date.now(),
|
|
1066
1489
|
prompt: (meta && meta.prompt) || '',
|
|
1067
1490
|
source: (meta && meta.source) || 'demo',
|
|
1068
1491
|
category: (meta && meta.category) || 'general',
|
|
1069
1492
|
};
|
|
1493
|
+
// 设定集绑定(@引用解析依据):entity=主实体名,entities=全部命中实体,nodeLabel=来源节点名
|
|
1494
|
+
if (meta && meta.entity) rec.entity = meta.entity;
|
|
1495
|
+
if (meta && Array.isArray(meta.entities) && meta.entities.length) rec.entities = meta.entities;
|
|
1496
|
+
if (meta && meta.nodeLabel) rec.nodeLabel = meta.nodeLabel;
|
|
1497
|
+
if (meta && meta.effectivePrompt) rec.effectivePrompt = meta.effectivePrompt;
|
|
1498
|
+
if (S.dataRoot) {
|
|
1499
|
+
// 数据根目录已配置:落盘到 <root>/projects/<项目>/<类型>/<可读名>-<id尾号>.<ext>,IndexedDB 只存元数据
|
|
1500
|
+
const rel = `projects/${projectId}/${kind}/${assetFileBase(rec)}.${extFromMime(mime)}`;
|
|
1501
|
+
const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: blob });
|
|
1502
|
+
if (!r.ok) throw new Error('资产落盘失败: ' + (await r.text()));
|
|
1503
|
+
rec.rel = rel;
|
|
1504
|
+
rec.size = blob.size;
|
|
1505
|
+
rec.stored = 'disk';
|
|
1506
|
+
} else {
|
|
1507
|
+
rec.blob = blob;
|
|
1508
|
+
rec.stored = 'idb';
|
|
1509
|
+
}
|
|
1070
1510
|
await idbPut(rec);
|
|
1071
1511
|
assetNameCache.set(rec.id, rec.name);
|
|
1072
1512
|
return rec;
|
|
1073
1513
|
}
|
|
1514
|
+
/** 一次性把 IndexedDB 里的历史资产迁移到数据根目录(保留元数据,移除 blob)。 */
|
|
1515
|
+
async function migrateAssetsToDisk(onProgress) {
|
|
1516
|
+
if (!S.dataRoot) throw new Error('请先设置数据根目录');
|
|
1517
|
+
const all = await idbAll();
|
|
1518
|
+
const targets = all.filter((r) => r.blob && !r.rel);
|
|
1519
|
+
let done = 0;
|
|
1520
|
+
for (const rec of targets) {
|
|
1521
|
+
const rel = `projects/${rec.projectId}/${rec.kind}/${assetFileBase(rec)}.${extFromMime(rec.mime)}`;
|
|
1522
|
+
const r = await fetch('/' + NS + '/api/asset?rel=' + encodeURIComponent(rel), { method: 'POST', body: rec.blob });
|
|
1523
|
+
if (!r.ok) throw new Error('迁移失败: ' + rel + ' — ' + (await r.text()));
|
|
1524
|
+
const next = { ...rec, rel, size: rec.blob.size, stored: 'disk' };
|
|
1525
|
+
delete next.blob;
|
|
1526
|
+
await idbPut(next);
|
|
1527
|
+
assetUrlCache.delete(rec.id); // 旧 objectURL 失效,改走文件 URL
|
|
1528
|
+
done++;
|
|
1529
|
+
if (onProgress) onProgress(done, targets.length);
|
|
1530
|
+
}
|
|
1531
|
+
return { migrated: done, total: targets.length };
|
|
1532
|
+
}
|
|
1074
1533
|
function useAssetUrl(id) {
|
|
1075
1534
|
const [url, setUrl] = useState(null);
|
|
1076
1535
|
useEffect(() => {
|
|
@@ -1202,13 +1661,13 @@ window.__ModuleLoader__.load({
|
|
|
1202
1661
|
fd.append('n', '1');
|
|
1203
1662
|
if (s.size) fd.append('size', s.size);
|
|
1204
1663
|
refs.forEach((b, i) => fd.append('image[]', b, 'ref-' + i + '.png'));
|
|
1205
|
-
const res = await
|
|
1664
|
+
const res = await fetchT(base + '/images/edits', { method: 'POST', headers: auth, body: fd }, s.timeoutMs, '图片编辑 API');
|
|
1206
1665
|
if (!res.ok) throw new Error('图片编辑 API HTTP ' + res.status + ':' + (await res.text()).slice(0, 300));
|
|
1207
1666
|
const j = await res.json();
|
|
1208
1667
|
const d = j && j.data && j.data[0];
|
|
1209
1668
|
if (d && d.b64_json) return base64ToBlob(d.b64_json, 'image/png');
|
|
1210
1669
|
if (d && d.url) {
|
|
1211
|
-
const r2 = await
|
|
1670
|
+
const r2 = await fetchT(d.url, null, 60000, '下载图片');
|
|
1212
1671
|
if (!r2.ok) throw new Error('下载生成图片失败 HTTP ' + r2.status);
|
|
1213
1672
|
return await r2.blob();
|
|
1214
1673
|
}
|
|
@@ -1217,30 +1676,45 @@ window.__ModuleLoader__.load({
|
|
|
1217
1676
|
const body = {
|
|
1218
1677
|
...(s.model ? { model: s.model } : {}),
|
|
1219
1678
|
prompt, n: 1,
|
|
1220
|
-
response_format: 'b64_json',
|
|
1221
1679
|
};
|
|
1222
|
-
//
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
if (
|
|
1227
|
-
const
|
|
1680
|
+
// 响应格式:留空 = 用 API 默认(响应解析同时支持 b64_json 与 url)
|
|
1681
|
+
if (String(s.responseFormat || '').trim()) body.response_format = String(s.responseFormat).trim();
|
|
1682
|
+
// 尺寸:留空 = 按节点上的「清晰度 × 比例」计算;填了就用你配置的值
|
|
1683
|
+
const sizeOverride = String(s.size || '').trim();
|
|
1684
|
+
if (sizeOverride) body.size = sizeOverride;
|
|
1685
|
+
else { const dims = imageDimsFor(opts); body.size = `${dims.w}x${dims.h}`; }
|
|
1686
|
+
// 文生图模式(generations)按界面说明**忽略参考图**;参考图只在 edits 模式使用,
|
|
1687
|
+
// 否则会把大体积 base64 数组发给不识别的网关 → HTTP 400,用户看到的却是"没反应"
|
|
1688
|
+
if (refs.length && (s.mode === 'edits')) body.images = await Promise.all(refs.map((b) => blobToDataUrl(b)));
|
|
1689
|
+
const res = await fetchT(base + '/images/generations', {
|
|
1228
1690
|
method: 'POST',
|
|
1229
1691
|
headers: { 'Content-Type': 'application/json', ...auth },
|
|
1230
1692
|
body: JSON.stringify(body),
|
|
1231
|
-
});
|
|
1693
|
+
}, s.timeoutMs, '图片 API');
|
|
1232
1694
|
if (!res.ok) throw new Error('图片 API HTTP ' + res.status + ':' + (await res.text()).slice(0, 300));
|
|
1233
1695
|
const j = await res.json();
|
|
1234
1696
|
const d = j && j.data && j.data[0];
|
|
1235
1697
|
if (d && d.b64_json) return base64ToBlob(d.b64_json, 'image/png');
|
|
1236
1698
|
if (d && d.url) {
|
|
1237
|
-
const r2 = await
|
|
1699
|
+
const r2 = await fetchT(d.url, null, 60000, '下载图片');
|
|
1238
1700
|
if (!r2.ok) throw new Error('下载生成图片失败 HTTP ' + r2.status);
|
|
1239
1701
|
return await r2.blob();
|
|
1240
1702
|
}
|
|
1241
1703
|
throw new Error('图片 API 响应中没有 data[0].b64_json / url');
|
|
1242
1704
|
}
|
|
1243
1705
|
|
|
1706
|
+
/** 带超时的 fetch:网关挂住时不会让节点永久处于 running(停止/刷新仍能恢复)。 */
|
|
1707
|
+
async function fetchT(url, init, timeoutMs, label) {
|
|
1708
|
+
const ms = Number(timeoutMs) > 0 ? Number(timeoutMs) : 120000;
|
|
1709
|
+
const ctrl = typeof AbortController === 'function' ? new AbortController() : null;
|
|
1710
|
+
const timer = setTimeout(() => { try { if (ctrl) ctrl.abort(); } catch { /* 忽略 */ } }, ms);
|
|
1711
|
+
try {
|
|
1712
|
+
return await fetch(url, { ...(init || {}), ...(ctrl ? { signal: ctrl.signal } : {}) });
|
|
1713
|
+
} catch (e) {
|
|
1714
|
+
if (ctrl && ctrl.signal && ctrl.signal.aborted) throw new Error((label || '请求') + '超时(' + Math.round(ms / 1000) + ' 秒)');
|
|
1715
|
+
throw e;
|
|
1716
|
+
} finally { clearTimeout(timer); }
|
|
1717
|
+
}
|
|
1244
1718
|
function extractVideoUrl(j) {
|
|
1245
1719
|
if (typeof j === 'string' && /^https?:/.test(j)) return j;
|
|
1246
1720
|
if (!j || typeof j !== 'object') return null;
|
|
@@ -1260,13 +1734,14 @@ window.__ModuleLoader__.load({
|
|
|
1260
1734
|
?? (j.output && (j.output.task_id ?? j.output.id));
|
|
1261
1735
|
}
|
|
1262
1736
|
async function downloadVideo(url, headers) {
|
|
1263
|
-
const r = await
|
|
1737
|
+
const r = await fetchT(url, { headers }, 300000, '下载视频');
|
|
1264
1738
|
if (!r.ok) throw new Error('下载视频失败 HTTP ' + r.status);
|
|
1265
1739
|
return await r.blob();
|
|
1266
1740
|
}
|
|
1267
1741
|
/** 火山方舟 Seedance 官方协议:创建任务 → 轮询 → 下载(支持首帧 + 最多 4 张参考图)。 */
|
|
1268
1742
|
async function seedanceVideo(prompt, frameBlob, refs, s, opts) {
|
|
1269
1743
|
if (!s.url) throw new Error('未配置视频 API');
|
|
1744
|
+
if (!s.model) throw new Error('请在设置里为该视频 API 填写模型名(Seedance 协议要求显式指定模型)');
|
|
1270
1745
|
const base = s.url.replace(/\/+$/, '');
|
|
1271
1746
|
const headers = { 'Content-Type': 'application/json', ...(s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {}) };
|
|
1272
1747
|
const o = opts || {};
|
|
@@ -1279,15 +1754,15 @@ window.__ModuleLoader__.load({
|
|
|
1279
1754
|
if (frameBlob) content.push({ type: 'image_url', image_url: { url: await blobToDataUrl(frameBlob) }, role: 'first_frame' });
|
|
1280
1755
|
for (const b of refBlobs) content.push({ type: 'image_url', image_url: { url: await blobToDataUrl(b) }, role: 'reference_image' });
|
|
1281
1756
|
const body = {
|
|
1282
|
-
model: s.model
|
|
1757
|
+
model: s.model,
|
|
1283
1758
|
content,
|
|
1284
1759
|
duration: Number(o.duration) || 5,
|
|
1285
1760
|
resolution: (o.resolution || '720P').toLowerCase(),
|
|
1286
|
-
watermark:
|
|
1287
|
-
generate_audio:
|
|
1761
|
+
watermark: s.watermark === true,
|
|
1762
|
+
generate_audio: s.generateAudio !== false,
|
|
1288
1763
|
};
|
|
1289
1764
|
body.ratio = frameBlob ? 'adaptive' : (o.ratio || '16:9');
|
|
1290
|
-
const res = await
|
|
1765
|
+
const res = await fetchT(base, { method: 'POST', headers, body: JSON.stringify(body) }, s.timeoutMs, 'Seedance 视频 API');
|
|
1291
1766
|
if (!res.ok) throw new Error('Seedance HTTP ' + res.status + ':' + (await res.text()).slice(0, 300));
|
|
1292
1767
|
const j = await res.json();
|
|
1293
1768
|
const taskId = j && j.id;
|
|
@@ -1295,7 +1770,7 @@ window.__ModuleLoader__.load({
|
|
|
1295
1770
|
const deadline = Date.now() + 15 * 60 * 1000;
|
|
1296
1771
|
while (Date.now() < deadline) {
|
|
1297
1772
|
await sleep(10000);
|
|
1298
|
-
const r2 = await
|
|
1773
|
+
const r2 = await fetchT(base + '/' + taskId, { headers }, 60000, '查询视频任务');
|
|
1299
1774
|
if (!r2.ok) throw new Error('查询 Seedance 任务失败 HTTP ' + r2.status);
|
|
1300
1775
|
const st = await r2.json();
|
|
1301
1776
|
if (st.status === 'succeeded') {
|
|
@@ -1303,7 +1778,7 @@ window.__ModuleLoader__.load({
|
|
|
1303
1778
|
if (!u) throw new Error('Seedance 任务成功但未返回视频地址');
|
|
1304
1779
|
return await downloadVideo(u, {});
|
|
1305
1780
|
}
|
|
1306
|
-
if (
|
|
1781
|
+
if (['failed', 'expired', 'canceled', 'cancelled', 'error'].includes(st.status)) {
|
|
1307
1782
|
throw new Error('Seedance 任务失败:' + ((st.error && st.error.message) || st.status));
|
|
1308
1783
|
}
|
|
1309
1784
|
}
|
|
@@ -1330,7 +1805,7 @@ window.__ModuleLoader__.load({
|
|
|
1330
1805
|
if (frameBlob) {
|
|
1331
1806
|
try { body.image = await blobToDataUrl(frameBlob); } catch { /* 忽略首帧编码失败 */ }
|
|
1332
1807
|
}
|
|
1333
|
-
const res = await
|
|
1808
|
+
const res = await fetchT(base, { method: 'POST', headers, body: JSON.stringify(body) }, s.timeoutMs, '视频 API');
|
|
1334
1809
|
if (!res.ok) throw new Error('视频 API HTTP ' + res.status + ':' + (await res.text()).slice(0, 300));
|
|
1335
1810
|
let j = null;
|
|
1336
1811
|
try { j = await res.json(); } catch { /* 非 JSON 响应 */ }
|
|
@@ -1341,17 +1816,44 @@ window.__ModuleLoader__.load({
|
|
|
1341
1816
|
const deadline = Date.now() + 15 * 60 * 1000;
|
|
1342
1817
|
while (Date.now() < deadline) {
|
|
1343
1818
|
await sleep(3000);
|
|
1344
|
-
const r2 = await
|
|
1819
|
+
const r2 = await fetchT(base + '/' + taskId, { headers }, 60000, '查询视频任务');
|
|
1345
1820
|
if (!r2.ok) throw new Error('查询视频任务失败 HTTP ' + r2.status);
|
|
1346
1821
|
let st = null;
|
|
1347
1822
|
try { st = await r2.json(); } catch { /* 忽略 */ }
|
|
1348
1823
|
const u = extractVideoUrl(st);
|
|
1349
1824
|
if (u) return await downloadVideo(u, {});
|
|
1350
|
-
|
|
1351
|
-
|
|
1825
|
+
// 失败判定必须看状态字段,不能拿整串 JSON 做子串匹配:
|
|
1826
|
+
// 很多网关在 processing 阶段返回 {"status":"processing","error":null},子串匹配会误判失败
|
|
1827
|
+
const failed = taskFailureOf(st);
|
|
1828
|
+
if (failed) throw new Error('视频任务失败:' + failed);
|
|
1352
1829
|
}
|
|
1353
1830
|
throw new Error('视频生成超时(15 分钟)');
|
|
1354
1831
|
}
|
|
1832
|
+
/** 从任务轮询响应里判断是否真的失败(白名单状态 + 显式错误信息),返回失败原因或 null。 */
|
|
1833
|
+
function taskFailureOf(st) {
|
|
1834
|
+
if (!st || typeof st !== 'object') return null;
|
|
1835
|
+
const pick = (v) => {
|
|
1836
|
+
if (typeof v === 'string') return v.toLowerCase();
|
|
1837
|
+
if (v && typeof v === 'object' && typeof v.status === 'string') return v.status.toLowerCase();
|
|
1838
|
+
return '';
|
|
1839
|
+
};
|
|
1840
|
+
const status = pick(st.status) || pick(st.state) || pick(st.task_status)
|
|
1841
|
+
|| pick(st.data && st.data.status) || pick(st.output && st.output.status) || pick(st.result && st.result.status);
|
|
1842
|
+
const FAIL = ['failed', 'fail', 'error', 'canceled', 'cancelled', 'expired', 'rejected'];
|
|
1843
|
+
const DONE = ['succeeded', 'success', 'completed', 'complete', 'done', 'finished'];
|
|
1844
|
+
if (status && FAIL.includes(status)) {
|
|
1845
|
+
const msg = (st.error && (st.error.message || st.error.msg)) || st.message || st.msg || st.reason
|
|
1846
|
+
|| (st.data && st.data.error && (st.data.error.message || st.data.error)) || status;
|
|
1847
|
+
return String(msg).slice(0, 300);
|
|
1848
|
+
}
|
|
1849
|
+
if (status && DONE.includes(status)) return null; // 成功但没解析出地址:交给调用方超时/后续处理
|
|
1850
|
+
// 状态未知(如 processing/queued/running/空):仅在明确带有错误对象时才算失败
|
|
1851
|
+
const errObj = st.error || (st.data && st.data.error) || (st.output && st.output.error);
|
|
1852
|
+
if (errObj && (typeof errObj === 'string' ? errObj.trim() : Object.keys(errObj).length)) {
|
|
1853
|
+
return typeof errObj === 'string' ? errObj.slice(0, 300) : JSON.stringify(errObj).slice(0, 300);
|
|
1854
|
+
}
|
|
1855
|
+
return null;
|
|
1856
|
+
}
|
|
1355
1857
|
|
|
1356
1858
|
function resolveApiCfg(list, defId, wantId) {
|
|
1357
1859
|
if (!Array.isArray(list) || !list.length) return null;
|
|
@@ -1367,7 +1869,7 @@ window.__ModuleLoader__.load({
|
|
|
1367
1869
|
async function generateVideo(prompt, frameBlob, opts) {
|
|
1368
1870
|
const settings = loadSettings();
|
|
1369
1871
|
const cfg = resolveApiCfg(settings.videoApis, settings.videoApiId, opts && opts.apiId);
|
|
1370
|
-
const blob = cfg ? await apiVideo(prompt, frameBlob, cfg, opts || {}) : await demoVideoBlob(prompt, videoDimsFor(opts), ((opts && opts.duration) || 5) * 1000);
|
|
1872
|
+
const blob = cfg ? await apiVideo(prompt, frameBlob, cfg, opts || {}) : await demoVideoBlob(prompt, videoDimsFor(opts, { capLong: 1280 }), ((opts && opts.duration) || 5) * 1000);
|
|
1371
1873
|
return { blob, demo: !cfg };
|
|
1372
1874
|
}
|
|
1373
1875
|
|
|
@@ -1475,8 +1977,9 @@ window.__ModuleLoader__.load({
|
|
|
1475
1977
|
v.onerror = () => rej(new Error('视频加载失败: ' + seg.name));
|
|
1476
1978
|
setTimeout(res, 15000);
|
|
1477
1979
|
});
|
|
1478
|
-
|
|
1479
|
-
|
|
1980
|
+
const trimSec = seg.dur && seg.dur > 0 ? Math.min(seg.dur, (isFinite(v.duration) && v.duration > 0 ? v.duration : seg.dur)) : 0;
|
|
1981
|
+
totalMs += trimSec * 1000 || (isFinite(v.duration) && v.duration > 0 ? v.duration * 1000 : 8000);
|
|
1982
|
+
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 });
|
|
1480
1983
|
}
|
|
1481
1984
|
const cv = document.createElement('canvas'); cv.width = W; cv.height = H;
|
|
1482
1985
|
const g = cv.getContext('2d');
|
|
@@ -1492,7 +1995,11 @@ window.__ModuleLoader__.load({
|
|
|
1492
1995
|
stream.addTrack(dest.stream.getAudioTracks()[0]);
|
|
1493
1996
|
audio = { ac, dest };
|
|
1494
1997
|
} catch { audio = null; }
|
|
1495
|
-
const rec = new MediaRecorder(stream, {
|
|
1998
|
+
const rec = new MediaRecorder(stream, {
|
|
1999
|
+
mimeType: mime,
|
|
2000
|
+
// 码率:仅在用户显式选择时指定,否则交给浏览器/编码器默认(不在代码里写死)
|
|
2001
|
+
...(opts.bitrate ? { videoBitsPerSecond: Number(opts.bitrate) } : {}),
|
|
2002
|
+
});
|
|
1496
2003
|
const chunks = [];
|
|
1497
2004
|
rec.ondataavailable = (e) => { if (e.data && e.data.size) chunks.push(e.data); };
|
|
1498
2005
|
const stopped = new Promise((res) => { rec.onstop = res; });
|
|
@@ -1500,6 +2007,7 @@ window.__ModuleLoader__.load({
|
|
|
1500
2007
|
function startPlayback(item) {
|
|
1501
2008
|
if (item.started) return;
|
|
1502
2009
|
item.started = true;
|
|
2010
|
+
item.startAt = performance.now();
|
|
1503
2011
|
const v = item.v;
|
|
1504
2012
|
const wantAudio = !!audio && opts.keepAudio !== false && item.withAudio;
|
|
1505
2013
|
if (wantAudio) {
|
|
@@ -1555,7 +2063,9 @@ window.__ModuleLoader__.load({
|
|
|
1555
2063
|
blackUntil = 0;
|
|
1556
2064
|
startPlayback(item);
|
|
1557
2065
|
if (lastProgressIdx !== idx) { lastProgressIdx = idx; if (onProgress) onProgress(idx / Math.max(1, vids.length), item.name); }
|
|
1558
|
-
const
|
|
2066
|
+
const clipDur = isFinite(v.duration) && v.duration > 0 ? v.duration : 0;
|
|
2067
|
+
const effDur = item.dur && item.dur > 0 ? Math.min(item.dur, clipDur || item.dur) : clipDur;
|
|
2068
|
+
const elapsed = item.startAt ? (now - item.startAt) / 1000 : 0;
|
|
1559
2069
|
const remainMs = dur ? (dur - v.currentTime) * 1000 : Infinity;
|
|
1560
2070
|
// 叠化:临近结束时提前启动下一片段
|
|
1561
2071
|
let next = null;
|
|
@@ -1580,7 +2090,7 @@ window.__ModuleLoader__.load({
|
|
|
1580
2090
|
g.save(); g.globalAlpha = 1 - fadeT; drawSubtitle(g, W, H, item.sub); g.restore();
|
|
1581
2091
|
}
|
|
1582
2092
|
// 推进
|
|
1583
|
-
const ended = v.ended || (
|
|
2093
|
+
const ended = v.ended || (effDur > 0 && elapsed >= effDur - 0.03);
|
|
1584
2094
|
if (ended) {
|
|
1585
2095
|
stopItem(item);
|
|
1586
2096
|
idx++;
|
|
@@ -1691,12 +2201,21 @@ window.__ModuleLoader__.load({
|
|
|
1691
2201
|
return '.bin';
|
|
1692
2202
|
}
|
|
1693
2203
|
/** 资产落盘到 <项目>/media/(force=false 时已有映射则复用;force=true 覆盖写)。 */
|
|
2204
|
+
/** ArtCraft 工作区里的文件名:资产名(人工可读,如「角色-张山 多视图」)+ id 尾号(唯一,避免同名覆盖)。 */
|
|
2205
|
+
function assetFileBase(rec) {
|
|
2206
|
+
const raw = String((rec && rec.name) || '').trim() || String((rec && rec.id) || 'asset');
|
|
2207
|
+
const safe = raw.replace(/[\\/:*?"<>|\u0000-\u001f]/g, '_').replace(/\s+/g, ' ').trim().slice(0, 60);
|
|
2208
|
+
const tail = String((rec && rec.id) || '').slice(-4);
|
|
2209
|
+
return (safe || 'asset') + '-' + tail;
|
|
2210
|
+
}
|
|
1694
2211
|
async function syncAssetToDisk(rec, opts = {}) {
|
|
1695
2212
|
const pid = S.activeId;
|
|
1696
2213
|
const map = craftMapLoad(pid);
|
|
1697
2214
|
if (!opts.force && map[rec.id]) return map[rec.id];
|
|
1698
|
-
const rel = craftProjectSlug() + '/media/' + rec
|
|
1699
|
-
await
|
|
2215
|
+
const rel = craftProjectSlug() + '/media/' + assetFileBase(rec) + extForMime(rec.mime, rec.name);
|
|
2216
|
+
const blob = rec.blob || await assetBlob(rec.id);
|
|
2217
|
+
if (!blob) throw new Error('资产内容不可用: ' + rec.id);
|
|
2218
|
+
await craft.write(rel, blob);
|
|
1700
2219
|
craftMapSet(pid, rec.id, rel);
|
|
1701
2220
|
return rel;
|
|
1702
2221
|
}
|
|
@@ -1795,7 +2314,12 @@ window.__ModuleLoader__.load({
|
|
|
1795
2314
|
}
|
|
1796
2315
|
/** FilmCraft 任务后台轮询:状态写回节点,done 后回收产物入资产库。 */
|
|
1797
2316
|
async function pollFilmcraftJob(nodeId, jobId, outRel) {
|
|
2317
|
+
const deadline = Date.now() + 30 * 60 * 1000; // 与 jobStart 的 timeoutMs 对齐:绝不无限轮询
|
|
1798
2318
|
for (;;) {
|
|
2319
|
+
if (Date.now() > deadline) {
|
|
2320
|
+
update((s) => { const nd = s.graph.nodes[nodeId]; if (nd) { nd.data.fcStatus = T('craftTimeout'); nd.data.fcJobId = null; } });
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
1799
2323
|
await sleep(2000);
|
|
1800
2324
|
let snap;
|
|
1801
2325
|
try { snap = await craft.jobGet(jobId, 10); } catch (e) {
|
|
@@ -1825,7 +2349,7 @@ window.__ModuleLoader__.load({
|
|
|
1825
2349
|
}
|
|
1826
2350
|
return;
|
|
1827
2351
|
}
|
|
1828
|
-
if (
|
|
2352
|
+
if (['failed', 'timeout', 'canceled', 'cancelled', 'error'].includes(snap.status)) {
|
|
1829
2353
|
const detail = (snap.stderr || '').split('\n').filter(Boolean).slice(-3).join(' | ').slice(0, 300);
|
|
1830
2354
|
update((s) => { const nd = s.graph.nodes[nodeId]; if (nd) nd.data.fcStatus = snap.status + ':' + detail; nd.data.fcJobId = null; });
|
|
1831
2355
|
return;
|
|
@@ -1880,6 +2404,232 @@ window.__ModuleLoader__.load({
|
|
|
1880
2404
|
update((s) => { s.updateBusy = false; s.updateMsg = T('npmUpgradeHint') + ((S.updateInfo && S.updateInfo.remoteVersion) ? ' → v' + S.updateInfo.remoteVersion : ''); });
|
|
1881
2405
|
}
|
|
1882
2406
|
}
|
|
2407
|
+
/** 从 Host 拉取数据根目录配置(Host 旧版无此 RPC → 标记不支持)。 */
|
|
2408
|
+
async function refreshDataRoot() {
|
|
2409
|
+
try {
|
|
2410
|
+
const r = await hostRpc('dataRootGet');
|
|
2411
|
+
update((s) => { s.dataRoot = r.dataRoot || null; s.dataRootExists = !!r.exists; s.dataRootSupported = true; });
|
|
2412
|
+
} catch (e) {
|
|
2413
|
+
update((s) => { s.dataRoot = null; s.dataRootSupported = !/未知操作/.test(String((e && e.message) || e)); });
|
|
2414
|
+
}
|
|
2415
|
+
}
|
|
2416
|
+
// ---------- 对话 ↔ 画布同步:轮询 Host 项目索引 + 跳转请求 ----------
|
|
2417
|
+
let hostSyncStopped = false;
|
|
2418
|
+
let hostSyncBusy = false; // hostSyncTick 互斥:单次 tick 可能超过 3 秒
|
|
2419
|
+
let syncWarned = false; // 同步失败只提示一次,避免刷屏
|
|
2420
|
+
async function importHostProject(hp) {
|
|
2421
|
+
const full = await hostRpc('projectsGet', { id: hp.id });
|
|
2422
|
+
update((s) => {
|
|
2423
|
+
if (!s.projects.some((p) => p.id === hp.id)) {
|
|
2424
|
+
s.projects.push({ id: hp.id, name: hp.name || hp.id, createdAt: full.createdAt || Date.now(), updatedAt: full.updatedAt || Date.now() });
|
|
2425
|
+
}
|
|
2426
|
+
});
|
|
2427
|
+
lsSet(LS_PROJECTS, S.projects);
|
|
2428
|
+
saveGraphDoc(hp.id, { nodes: full.nodes || {}, edges: full.edges || [], view: full.view || { x: 60, y: 40, scale: 1 } });
|
|
2429
|
+
if (full.bible) {
|
|
2430
|
+
saveBibleLocal(hp.id, full.bible);
|
|
2431
|
+
if (full.bibleUpdatedAt) lsSet(LS_BIBLE_SYNC + hp.id, full.bibleUpdatedAt);
|
|
2432
|
+
}
|
|
2433
|
+
}
|
|
2434
|
+
const LS_DOCSYNC = 'dsh-ic:docsync:';
|
|
2435
|
+
/** Host 侧改动了项目文档(Agent 建节点/连线/写内容/补设定集)→ 拉回并合并到本地画布。 */
|
|
2436
|
+
function mergeHostDoc(pid, full) {
|
|
2437
|
+
if (!full) return;
|
|
2438
|
+
const local = loadGraphDoc(pid) || emptyGraph();
|
|
2439
|
+
// 节点墓碑:本地已删除的节点,绝不允许从 Host 拉回复活(删除后 3 秒内的同步拉取曾把节点又带回来)。
|
|
2440
|
+
// 墓碑以**内存中的当前画布**为准(可能还没落盘),否则 450ms 防抖窗口内的拉取会复活节点。
|
|
2441
|
+
const live = (pid === S.activeId && S.graph) ? S.graph : null;
|
|
2442
|
+
const deadNodes = new Set([
|
|
2443
|
+
...((local.tombstones) || []),
|
|
2444
|
+
...((full.tombstones) || []),
|
|
2445
|
+
...((live && live.tombstones) || []),
|
|
2446
|
+
]);
|
|
2447
|
+
// 节点以内存画布为种子(与下方 edges 的 live 优先同理):450ms 防抖窗口内新建的节点、
|
|
2448
|
+
// 刚被 Ctrl+Z 恢复的节点都只存在于内存,若从落盘文档起合并会被本次合并结果抹掉
|
|
2449
|
+
const nodes = { ...((live && live.nodes) ? live.nodes : local.nodes) };
|
|
2450
|
+
for (const [nid, n] of Object.entries(full.nodes || {})) {
|
|
2451
|
+
if (deadNodes.has(nid)) continue;
|
|
2452
|
+
nodes[nid] = n; // Host 为准(Agent 写入是有意的)
|
|
2453
|
+
}
|
|
2454
|
+
const deadEdges = new Set([...(local.edgeTombstones || []), ...(full.edgeTombstones || []), ...((live && live.edgeTombstones) || [])]);
|
|
2455
|
+
const edges = (live && live.edges) ? [...live.edges] : [...(local.edges || [])];
|
|
2456
|
+
for (const e of (full.edges || [])) {
|
|
2457
|
+
if (deadEdges.has(e.id)) continue;
|
|
2458
|
+
const i = edges.findIndex((x) => x.id === e.id);
|
|
2459
|
+
if (i >= 0) edges[i] = e;
|
|
2460
|
+
else edges.push(e);
|
|
2461
|
+
}
|
|
2462
|
+
const keep = edges.filter((e) => !deadEdges.has(e.id) && nodes[e.fromNode] && nodes[e.toNode]);
|
|
2463
|
+
const tombOut = [...new Set([...(local.tombstones || []), ...((live && live.tombstones) || [])])].slice(-200);
|
|
2464
|
+
saveGraphDoc(pid, { nodes, edges: keep, view: local.view, tombstones: tombOut, edgeTombstones: local.edgeTombstones || [] });
|
|
2465
|
+
// 设定集随全量文档一起合并:Agent 用 canvas_update_bible 补的设定集在这里落地
|
|
2466
|
+
if (full.bible) {
|
|
2467
|
+
saveBibleLocal(pid, full.bible);
|
|
2468
|
+
if (full.bibleUpdatedAt) lsSet(LS_BIBLE_SYNC + pid, full.bibleUpdatedAt);
|
|
2469
|
+
}
|
|
2470
|
+
if (pid === S.activeId) update((s) => { s.graph = { nodes, edges: keep, view: s.graph.view, tombstones: tombOut, edgeTombstones: local.edgeTombstones || [] }; s.assetsTick++; });
|
|
2471
|
+
}
|
|
2472
|
+
async function hostSyncTick() {
|
|
2473
|
+
if (hostSyncStopped) return;
|
|
2474
|
+
if (hostSyncBusy) return; // 单次 tick 可能超过 3 秒:加互斥,避免两个 tick 交错写回旧文档副本
|
|
2475
|
+
hostSyncBusy = true;
|
|
2476
|
+
try {
|
|
2477
|
+
const r = await hostRpc('projectsSync');
|
|
2478
|
+
// 对话删除的项目:从本地一并移除(否则本地下次推送会把它复活)
|
|
2479
|
+
if (Array.isArray(r.deletedProjects) && r.deletedProjects.length) {
|
|
2480
|
+
const gone = new Set(r.deletedProjects);
|
|
2481
|
+
update((s) => {
|
|
2482
|
+
s.projects = s.projects.filter((p) => !gone.has(p.id));
|
|
2483
|
+
if (gone.has(s.activeId)) {
|
|
2484
|
+
if (s.projects.length === 0) {
|
|
2485
|
+
const p = { id: uid('p'), name: T('defaultProject'), createdAt: Date.now(), updatedAt: Date.now() };
|
|
2486
|
+
s.projects.push(p);
|
|
2487
|
+
s.activeId = p.id;
|
|
2488
|
+
s.graph = emptyGraph();
|
|
2489
|
+
} else {
|
|
2490
|
+
s.activeId = s.projects[0].id;
|
|
2491
|
+
const d0 = loadGraphDoc(s.activeId) || emptyGraph();
|
|
2492
|
+
s.graph = { nodes: d0.nodes || {}, edges: d0.edges || [], view: d0.view || { x: 60, y: 40, scale: 1 } };
|
|
2493
|
+
}
|
|
2494
|
+
V.x = s.graph.view.x; V.y = s.graph.view.y; V.scale = s.graph.view.scale;
|
|
2495
|
+
s.sel = new Set(); s.selEdge = null;
|
|
2496
|
+
}
|
|
2497
|
+
});
|
|
2498
|
+
for (const pid of r.deletedProjects) {
|
|
2499
|
+
try { localStorage.removeItem(LS_GRAPH + pid); } catch { /* 忽略 */ }
|
|
2500
|
+
try { localStorage.removeItem(LS_BIBLE + pid); } catch { /* 忽略 */ }
|
|
2501
|
+
}
|
|
2502
|
+
lsSet(LS_PROJECTS, S.projects);
|
|
2503
|
+
lsSet(LS_ACTIVE, S.activeId);
|
|
2504
|
+
applyViewToDomSoon();
|
|
2505
|
+
}
|
|
2506
|
+
// Agent 改动了项目文档(建节点/连线/写内容)→ 拉回合并,画布几秒内可见
|
|
2507
|
+
if (r.docChanged && r.docChanged.projectId) {
|
|
2508
|
+
const pid = r.docChanged.projectId;
|
|
2509
|
+
const stamp = Number(lsGet(LS_DOCSYNC + pid, 0)) || 0;
|
|
2510
|
+
if ((r.docChanged.at || 0) > stamp) {
|
|
2511
|
+
lsSet(LS_DOCSYNC + pid, String(r.docChanged.at || Date.now()));
|
|
2512
|
+
try {
|
|
2513
|
+
if (!S.projects.some((p) => p.id === pid)) {
|
|
2514
|
+
const hp = (r.projects || []).find((x) => x.id === pid);
|
|
2515
|
+
if (hp) await importHostProject(hp);
|
|
2516
|
+
} else {
|
|
2517
|
+
const full = await hostRpc('projectsGet', { id: pid });
|
|
2518
|
+
mergeHostDoc(pid, full);
|
|
2519
|
+
}
|
|
2520
|
+
} catch { /* 下一轮 sync 会重试不到(信号已清)——由 missing-node 路径兜底 */ }
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
for (const hp of r.projects || []) {
|
|
2524
|
+
// 逐项目容错:任何一个项目合并失败(网络抖动/文件问题)都不得中断整轮,
|
|
2525
|
+
// 否则排在它后面的项目的设定集/节点拉取会被连累跳过
|
|
2526
|
+
try {
|
|
2527
|
+
const known = S.projects.some((p) => p.id === hp.id);
|
|
2528
|
+
const doc = known ? loadGraphDoc(hp.id) : null;
|
|
2529
|
+
// 设定集:Host 侧(对话改写)更新过 → 拉回本地(设定集先行,故优先于节点合并)
|
|
2530
|
+
if (known && hp.bibleUpdatedAt && hp.bibleUpdatedAt > (Number(lsGet(LS_BIBLE_SYNC + hp.id, 0)) || 0)) {
|
|
2531
|
+
try {
|
|
2532
|
+
const full = await hostRpc('projectsGet', { id: hp.id });
|
|
2533
|
+
if (full && full.bible) {
|
|
2534
|
+
saveBibleLocal(hp.id, full.bible);
|
|
2535
|
+
lsSet(LS_BIBLE_SYNC + hp.id, hp.bibleUpdatedAt);
|
|
2536
|
+
biblePushSnap.set(hp.id, JSON.stringify(full.bible)); // 已是最新:后续保存不必回推
|
|
2537
|
+
if (hp.id === S.activeId) update((s) => { s.assetsTick++; });
|
|
2538
|
+
}
|
|
2539
|
+
} catch { /* 忽略 */ }
|
|
2540
|
+
}
|
|
2541
|
+
// 对话删过的节点(墓碑):本地同步删除,避免被推送复活
|
|
2542
|
+
const dead = (hp.tombstones || []).filter((nid) => doc && doc.nodes[nid]);
|
|
2543
|
+
// 内存画布为准:当前画布上还存在的节点不算死(撤销恢复后 Host 可能仍留旧墓碑)
|
|
2544
|
+
const liveNodes = (hp.id === S.activeId && S.graph) ? S.graph.nodes : null;
|
|
2545
|
+
const deadEff = liveNodes ? dead.filter((nid) => !liveNodes[nid]) : dead;
|
|
2546
|
+
if (known && deadEff.length) {
|
|
2547
|
+
for (const nid of deadEff) delete doc.nodes[nid];
|
|
2548
|
+
doc.edges = (doc.edges || []).filter((e) => !deadEff.includes(e.fromNode) && !deadEff.includes(e.toNode));
|
|
2549
|
+
saveGraphDoc(hp.id, doc);
|
|
2550
|
+
if (hp.id === S.activeId) {
|
|
2551
|
+
update((s) => {
|
|
2552
|
+
for (const nid of deadEff) delete s.graph.nodes[nid];
|
|
2553
|
+
s.graph.edges = s.graph.edges.filter((e) => !deadEff.includes(e.fromNode) && !deadEff.includes(e.toNode));
|
|
2554
|
+
s.sel = new Set([...s.sel].filter((x) => !deadEff.includes(x)));
|
|
2555
|
+
s.assetsTick++;
|
|
2556
|
+
});
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
// 缺失节点拉回:跳过本地已标墓碑的节点(本地删除后 Host 尚未落地时的拉取不能让节点复活)。
|
|
2560
|
+
// 墓碑以内存中的当前画布为准(防抖窗口内的删除也要生效)。
|
|
2561
|
+
const liveT = (hp.id === S.activeId && S.graph) ? (S.graph.tombstones || []) : [];
|
|
2562
|
+
const localDead = new Set([...((doc && doc.tombstones) || []), ...liveT]);
|
|
2563
|
+
// 内存画布为准:撤销恢复的节点虽然墓碑可能还没清干净,但画布上还在 → 不能当死节点拦掉拉回
|
|
2564
|
+
if (hp.id === S.activeId && S.graph) {
|
|
2565
|
+
for (const nid of [...localDead]) { if (S.graph.nodes[nid]) localDead.delete(nid); }
|
|
2566
|
+
}
|
|
2567
|
+
const missing = (hp.nodeIds || []).filter((nid) => (!doc || !doc.nodes[nid]) && !localDead.has(nid));
|
|
2568
|
+
if (!known) await importHostProject(hp);
|
|
2569
|
+
else if (missing.length && doc) {
|
|
2570
|
+
const full = await hostRpc('projectsGet', { id: hp.id });
|
|
2571
|
+
for (const nid of missing) { if (full.nodes && full.nodes[nid]) doc.nodes[nid] = full.nodes[nid]; }
|
|
2572
|
+
saveGraphDoc(hp.id, doc);
|
|
2573
|
+
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++; });
|
|
2574
|
+
}
|
|
2575
|
+
} catch (e) {
|
|
2576
|
+
if (/未知操作/.test(String((e && e.message) || e))) { hostSyncStopped = true; return; }
|
|
2577
|
+
// 其余失败记入控制台,继续下一个项目
|
|
2578
|
+
console.warn('[infinite-canvas] 项目合并失败(下轮重试):', hp.id, e);
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
// 一次性请求(Host 读取即清空,绝不能因后面出错而丢失):
|
|
2582
|
+
// 放在逐项目循环之后、独立 try —— 循环里任何异常都不会让 runRequest 被静默吞掉
|
|
2583
|
+
try {
|
|
2584
|
+
if (r.runRequest) await handleRunRequest(r.runRequest);
|
|
2585
|
+
} catch (e) {
|
|
2586
|
+
toast(T('runRequestFailed') + ':' + String((e && e.message) || e));
|
|
2587
|
+
}
|
|
2588
|
+
try {
|
|
2589
|
+
if (r.openRequest) {
|
|
2590
|
+
const pid = r.openRequest.projectId || null;
|
|
2591
|
+
if (pid && !S.projects.some((p) => p.id === pid)) {
|
|
2592
|
+
const hp = (r.projects || []).find((x) => x.id === pid);
|
|
2593
|
+
if (hp) await importHostProject(hp);
|
|
2594
|
+
}
|
|
2595
|
+
if (!pid || S.projects.some((p) => p.id === pid)) {
|
|
2596
|
+
if (pid && pid !== S.activeId) switchProject(pid);
|
|
2597
|
+
try { if (CTX && CTX.layout && CTX.layout.selectPanel) CTX.layout.selectPanel(PANEL_ID); } catch { /* 忽略 */ }
|
|
2598
|
+
}
|
|
2599
|
+
}
|
|
2600
|
+
} catch { /* 打开失败不阻断后续同步 */ }
|
|
2601
|
+
} catch (e) {
|
|
2602
|
+
if (/未知操作/.test(String((e && e.message) || e))) hostSyncStopped = true; // 旧 Host:停止轮询
|
|
2603
|
+
} finally {
|
|
2604
|
+
hostSyncBusy = false;
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
/** 处理对话触发的节点执行:切到目标项目 → 用画布内配置的模型/接口真实运行 → 结果回写 Host。 */
|
|
2608
|
+
async function handleRunRequest(runRequest) {
|
|
2609
|
+
const pid = runRequest.projectId;
|
|
2610
|
+
const nodeId = runRequest.nodeId;
|
|
2611
|
+
if (pid && pid !== S.activeId && S.projects.some((p) => p.id === pid)) {
|
|
2612
|
+
switchProject(pid);
|
|
2613
|
+
try { if (CTX && CTX.layout && CTX.layout.selectPanel) CTX.layout.selectPanel(PANEL_ID); } catch { /* 忽略 */ }
|
|
2614
|
+
}
|
|
2615
|
+
const node = S.graph.nodes[nodeId];
|
|
2616
|
+
if (!node) throw new Error('节点不存在: ' + nodeId);
|
|
2617
|
+
if (node.data && node.data.status === 'running') return; // 已在运行:不重复触发
|
|
2618
|
+
await runGenNode(node);
|
|
2619
|
+
flushSave();
|
|
2620
|
+
}
|
|
2621
|
+
/** 启动时把本地全部项目推给 Host:让对话能看到并操作画布上已有的项目(客户端为内容权威)。 */
|
|
2622
|
+
async function pushAllProjectsToHost() {
|
|
2623
|
+
if (hostSyncStopped) return;
|
|
2624
|
+
for (const p of [...S.projects]) {
|
|
2625
|
+
try {
|
|
2626
|
+
const doc = loadGraphDoc(p.id) || emptyGraph();
|
|
2627
|
+
await hostRpc('projectsPut', { id: p.id, name: p.name || p.id, doc });
|
|
2628
|
+
} catch (e) {
|
|
2629
|
+
if (/未知操作/.test(String((e && e.message) || e))) { hostSyncStopped = true; return; }
|
|
2630
|
+
}
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
1883
2633
|
|
|
1884
2634
|
// ============================================================
|
|
1885
2635
|
// 尺寸 / 比例(Toonflow 式:清晰度 × 比例 → 像素)
|
|
@@ -1896,13 +2646,15 @@ window.__ModuleLoader__.load({
|
|
|
1896
2646
|
const base = { '1K': 1024, '2K': 2048, '4K': 4096 }[(opts && opts.size) || '2K'] || 2048;
|
|
1897
2647
|
return dimsFromRatio(opts && opts.ratio, base, base);
|
|
1898
2648
|
}
|
|
1899
|
-
function videoDimsFor(opts) {
|
|
2649
|
+
function videoDimsFor(opts, limits) {
|
|
1900
2650
|
const base = { '480P': 480, '720P': 720, '1080P': 1080 }[(opts && opts.resolution) || '720P'] || 720;
|
|
1901
2651
|
const d = dimsFromRatio(opts && opts.ratio, base, base);
|
|
1902
|
-
//
|
|
2652
|
+
// 仅演示/本地合成路径限制长边(浏览器合成性能保护);真实 API 与成片按你选的分辨率
|
|
2653
|
+
const cap = limits && Number(limits.capLong);
|
|
2654
|
+
if (!cap) return d;
|
|
1903
2655
|
const long = Math.max(d.w, d.h);
|
|
1904
|
-
if (long >
|
|
1905
|
-
const k =
|
|
2656
|
+
if (long > cap) {
|
|
2657
|
+
const k = cap / long;
|
|
1906
2658
|
return { w: Math.round(d.w * k / 2) * 2, h: Math.round(d.h * k / 2) * 2 };
|
|
1907
2659
|
}
|
|
1908
2660
|
return d;
|
|
@@ -1961,7 +2713,42 @@ window.__ModuleLoader__.load({
|
|
|
1961
2713
|
}
|
|
1962
2714
|
return null;
|
|
1963
2715
|
}
|
|
1964
|
-
|
|
2716
|
+
// AI 流式进度(不落盘:只用于"生成中"时显示一行思考内容,让用户知道没卡住)
|
|
2717
|
+
const AI_STREAM = new Map(); // nodeId -> { reasoning, text }
|
|
2718
|
+
const AI_STREAM_KEEP = 600; // 只保留尾部若干字符,避免长思考占内存
|
|
2719
|
+
let aiStreamFlushTimer = null;
|
|
2720
|
+
function aiStreamAppend(nodeId, kind, d) {
|
|
2721
|
+
if (!d) return;
|
|
2722
|
+
const cur = AI_STREAM.get(nodeId) || { reasoning: '', text: '' };
|
|
2723
|
+
const next = { reasoning: cur.reasoning, text: cur.text };
|
|
2724
|
+
if (kind === 'reasoning') next.reasoning = (cur.reasoning + d).slice(-AI_STREAM_KEEP);
|
|
2725
|
+
else next.text = (cur.text + d).slice(-AI_STREAM_KEEP);
|
|
2726
|
+
AI_STREAM.set(nodeId, next);
|
|
2727
|
+
// 节流:思考内容每秒可能来几十段,重渲染节流到 300ms 一次(只展示最后一行足够了)
|
|
2728
|
+
if (aiStreamFlushTimer) return;
|
|
2729
|
+
aiStreamFlushTimer = setTimeout(() => {
|
|
2730
|
+
aiStreamFlushTimer = null;
|
|
2731
|
+
update((s) => { s.aiTick = (s.aiTick || 0) + 1; }, { quiet: true });
|
|
2732
|
+
notify();
|
|
2733
|
+
}, 300);
|
|
2734
|
+
}
|
|
2735
|
+
function aiStreamClear(nodeId) {
|
|
2736
|
+
AI_STREAM.delete(nodeId);
|
|
2737
|
+
if (aiStreamFlushTimer) { clearTimeout(aiStreamFlushTimer); aiStreamFlushTimer = null; }
|
|
2738
|
+
}
|
|
2739
|
+
/** 生成中显示的一行动态内容:优先思考,其次正文;压缩空白并只留尾部。 */
|
|
2740
|
+
function aiStreamLine(nodeId) {
|
|
2741
|
+
const s = AI_STREAM.get(nodeId);
|
|
2742
|
+
if (!s) return '';
|
|
2743
|
+
const tail = (v) => String(v || '').replace(/\s+/g, ' ').trim().slice(-180);
|
|
2744
|
+
return tail(s.reasoning) || tail(s.text);
|
|
2745
|
+
}
|
|
2746
|
+
|
|
2747
|
+
/**
|
|
2748
|
+
* 走 Host 的流式接口生成文本:实时把「思考/正文」增量喂给 onDelta。
|
|
2749
|
+
* 旧 Host 没有该路由时自动回退到一次性 RPC(功能不受影响,只是没有实时预览)。
|
|
2750
|
+
*/
|
|
2751
|
+
async function aiCompleteStream({ system, user, model, onReasoning, onText }) {
|
|
1965
2752
|
const settings = loadSettings();
|
|
1966
2753
|
let provider;
|
|
1967
2754
|
let modelId;
|
|
@@ -1971,9 +2758,69 @@ window.__ModuleLoader__.load({
|
|
|
1971
2758
|
provider = settings.textLlm.provider || undefined;
|
|
1972
2759
|
modelId = settings.textLlm.model || undefined;
|
|
1973
2760
|
}
|
|
1974
|
-
const
|
|
1975
|
-
|
|
1976
|
-
|
|
2761
|
+
const cfg = settings.textLlm || {};
|
|
2762
|
+
const args = { provider, model: modelId, system, user };
|
|
2763
|
+
const maxTokens = Number(String(cfg.maxTokens ?? '').trim());
|
|
2764
|
+
if (String(cfg.maxTokens ?? '').trim() !== '' && Number.isFinite(maxTokens) && maxTokens > 0) args.maxTokens = maxTokens;
|
|
2765
|
+
const temperature = Number(String(cfg.temperature ?? '').trim());
|
|
2766
|
+
if (String(cfg.temperature ?? '').trim() !== '' && Number.isFinite(temperature)) args.temperature = temperature;
|
|
2767
|
+
let res;
|
|
2768
|
+
try {
|
|
2769
|
+
res = await fetch('/' + NS + '/api/llm-stream', {
|
|
2770
|
+
method: 'POST',
|
|
2771
|
+
headers: { 'content-type': 'application/json' },
|
|
2772
|
+
body: JSON.stringify(args),
|
|
2773
|
+
});
|
|
2774
|
+
} catch (e) {
|
|
2775
|
+
throw new Error(T('connectFailed') + ':' + String((e && e.message) || e));
|
|
2776
|
+
}
|
|
2777
|
+
if (res.status === 404 || !res.body) return await aiComplete({ system, user, model }); // 旧 Host 回退
|
|
2778
|
+
if (!res.ok) throw new Error((await res.text()).slice(0, 300) || T('rpcFail'));
|
|
2779
|
+
const reader = res.body.getReader();
|
|
2780
|
+
const decoder = new TextDecoder();
|
|
2781
|
+
let buf = '';
|
|
2782
|
+
let final = null;
|
|
2783
|
+
for (;;) {
|
|
2784
|
+
const { done, value } = await reader.read();
|
|
2785
|
+
if (done) break;
|
|
2786
|
+
buf += decoder.decode(value, { stream: true });
|
|
2787
|
+
let idx;
|
|
2788
|
+
while ((idx = buf.indexOf('\n\n')) >= 0) {
|
|
2789
|
+
const frame = buf.slice(0, idx);
|
|
2790
|
+
buf = buf.slice(idx + 2);
|
|
2791
|
+
const line = frame.split('\n').find((l) => l.startsWith('data: '));
|
|
2792
|
+
if (!line) continue;
|
|
2793
|
+
let msg = null;
|
|
2794
|
+
try { msg = JSON.parse(line.slice(6)); } catch { continue; }
|
|
2795
|
+
if (msg.t === 'r' && onReasoning) onReasoning(String(msg.d || ''));
|
|
2796
|
+
else if (msg.t === 'x' && onText) onText(String(msg.d || ''));
|
|
2797
|
+
else if (msg.t === 'attempt' && onReasoning) onReasoning('');
|
|
2798
|
+
else if (msg.t === 'done') final = msg.d || {};
|
|
2799
|
+
else if (msg.t === 'fail') throw new Error(String(msg.d || T('rpcFail')));
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
if (!final) throw new Error(T('rpcFail'));
|
|
2803
|
+
return final.text || '';
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
async function aiComplete({ system, user, model }) {
|
|
2807
|
+
const settings = loadSettings();
|
|
2808
|
+
let provider;
|
|
2809
|
+
let modelId;
|
|
2810
|
+
if (model && String(model).includes('|')) {
|
|
2811
|
+
[provider, modelId] = String(model).split('|');
|
|
2812
|
+
} else {
|
|
2813
|
+
provider = settings.textLlm.provider || undefined;
|
|
2814
|
+
modelId = settings.textLlm.model || undefined;
|
|
2815
|
+
}
|
|
2816
|
+
// 生成参数一律来自设置;留空则**不发该字段**,由 Harness 适配器/模型用已配置的默认值
|
|
2817
|
+
const cfg = settings.textLlm || {};
|
|
2818
|
+
const args = { provider, model: modelId, system, user };
|
|
2819
|
+
const maxTokens = Number(String(cfg.maxTokens ?? '').trim());
|
|
2820
|
+
if (String(cfg.maxTokens ?? '').trim() !== '' && Number.isFinite(maxTokens) && maxTokens > 0) args.maxTokens = maxTokens;
|
|
2821
|
+
const temperature = Number(String(cfg.temperature ?? '').trim());
|
|
2822
|
+
if (String(cfg.temperature ?? '').trim() !== '' && Number.isFinite(temperature)) args.temperature = temperature;
|
|
2823
|
+
const res = await hostRpc('llmComplete', args);
|
|
1977
2824
|
return res.text;
|
|
1978
2825
|
}
|
|
1979
2826
|
|
|
@@ -1994,11 +2841,13 @@ window.__ModuleLoader__.load({
|
|
|
1994
2841
|
} catch { return null; /* 技能读取失败回退内置专家 */ }
|
|
1995
2842
|
}
|
|
1996
2843
|
|
|
1997
|
-
async function aiScriptNodes(idea, model) {
|
|
2844
|
+
async function aiScriptNodes(idea, model, onDelta) {
|
|
1998
2845
|
const settings = loadSettings();
|
|
1999
2846
|
const bible = bibleBlock(loadBible(S.activeId));
|
|
2000
2847
|
const zh = effLang() !== 'en';
|
|
2848
|
+
// 未选技能时默认注入内置创作方法论 canvas-craft(与内置专家 persona 叠加,互为补充)
|
|
2001
2849
|
const rules = await loadSkillRules(settings.skillScript, zh)
|
|
2850
|
+
|| await loadSkillRules(CRAFT_SKILL, zh)
|
|
2002
2851
|
|| (zh ? '把输入的创意或片段写成可直接拍摄的短剧剧本。只输出剧本正文,不要解释。' : 'Turn the idea or excerpt into a shootable short-drama script. Output the script only.');
|
|
2003
2852
|
const system = [
|
|
2004
2853
|
EXPERT_SCRIPT(zh),
|
|
@@ -2021,34 +2870,41 @@ window.__ModuleLoader__.load({
|
|
|
2021
2870
|
'- Visual actions only, no inner thoughts; dialogue on its own lines; mark sounds in brackets;',
|
|
2022
2871
|
'- End with "Characters:" and "Locations/Props:" lists for asset production.',
|
|
2023
2872
|
].join('\n');
|
|
2024
|
-
return await
|
|
2873
|
+
return await aiCompleteStream({
|
|
2874
|
+
system, user, model,
|
|
2875
|
+
onReasoning: (d) => onDelta && onDelta('reasoning', d),
|
|
2876
|
+
onText: (d) => onDelta && onDelta('text', d),
|
|
2877
|
+
});
|
|
2025
2878
|
}
|
|
2026
|
-
async function aiStoryboardShots(scriptText, requirement, count, model) {
|
|
2879
|
+
async function aiStoryboardShots(scriptText, requirement, count, model, onDelta) {
|
|
2027
2880
|
const settings = loadSettings();
|
|
2028
2881
|
const bible = bibleBlock(loadBible(S.activeId));
|
|
2029
2882
|
const zh = effLang() !== 'en';
|
|
2030
2883
|
const jsonContract = zh
|
|
2031
2884
|
? [
|
|
2032
|
-
'
|
|
2033
|
-
'只输出一个 JSON 对象:{"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":
|
|
2885
|
+
'把剧本拆解为「多镜头一次性视频生成」的分镜脚本:铺开后由**一个视频生成节点**把全部镜头按顺序与时长写进同一条提示词,由视频模型一次性生成整段视频(镜头间由模型直接硬切,不是一镜一视频再拼接)。',
|
|
2886
|
+
'只输出一个 JSON 对象:{"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3}]}。',
|
|
2034
2887
|
'规则:',
|
|
2035
|
-
'- visual
|
|
2036
|
-
'- motion
|
|
2037
|
-
'-
|
|
2038
|
-
'-
|
|
2888
|
+
'- visual 是本镜画面描述(景别、人物外观与站位、场景、光线、构图,60 字内);',
|
|
2889
|
+
'- **visual 与 motion 里的角色/场景/道具必须写成 @名称 引用形式**(如 `@张山`、`@褶皱巷`),插件会自动带上该实体的设定描述与其参考图资产,保证跨镜一致;',
|
|
2890
|
+
'- motion 是本镜动作与运镜(主体动作节拍、机位运动方向与速度、情绪节奏,50 字内);与 visual 不重复;',
|
|
2891
|
+
'- characters/scene/props 使用剧本与设定集中的名称(不带 @),便于匹配资产;无道具给空数组;',
|
|
2892
|
+
'- duration 为该镜时长,2-5 的整数(一个镜头拍一个连续动作段落);全部 duration 之和 = 目标视频总时长(如 15 秒 ≈ 4-7 镜);镜头顺序即叙事顺序,首镜交代场景、末镜收束情绪;',
|
|
2039
2893
|
'- 只输出 JSON,不要输出任何其他内容。',
|
|
2040
2894
|
].join('\n')
|
|
2041
2895
|
: [
|
|
2042
|
-
'Break the script into shots for a
|
|
2043
|
-
'Output only one JSON object: {"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":
|
|
2896
|
+
'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).',
|
|
2897
|
+
'Output only one JSON object: {"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3}]}.',
|
|
2044
2898
|
'Rules:',
|
|
2045
|
-
'- visual =
|
|
2046
|
-
'-
|
|
2047
|
-
'-
|
|
2048
|
-
'-
|
|
2899
|
+
'- visual = the shot\'s frame description (shot size, wardrobe, staging, light, framing, <=60 words);',
|
|
2900
|
+
'- **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;',
|
|
2901
|
+
'- motion = action & camera for the shot (action beats, camera move, emotional rhythm, <=50 words); do not repeat visual;',
|
|
2902
|
+
'- characters/scene/props arrays must use plain names from the script and series bible (no @) so assets match; empty props array if none;',
|
|
2903
|
+
'- 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;',
|
|
2049
2904
|
'- Output JSON only.',
|
|
2050
2905
|
].join('\n');
|
|
2051
|
-
const methodology = await loadSkillRules(settings.skillStoryboard, zh)
|
|
2906
|
+
const methodology = await loadSkillRules(settings.skillStoryboard, zh)
|
|
2907
|
+
|| await loadSkillRules(CRAFT_SKILL, zh); // 未选技能时默认注入内置创作方法论
|
|
2052
2908
|
const system = [
|
|
2053
2909
|
EXPERT_STORYBOARD(zh),
|
|
2054
2910
|
methodology || '',
|
|
@@ -2061,10 +2917,20 @@ window.__ModuleLoader__.load({
|
|
|
2061
2917
|
`${zh ? '额外要求' : 'Extra requirements'}:${String(requirement ?? '').trim() || (zh ? '无' : 'none')}`,
|
|
2062
2918
|
`${zh ? '目标分镜数量' : 'Shot count'}:${count}`,
|
|
2063
2919
|
].join('\n\n');
|
|
2064
|
-
const text = await
|
|
2920
|
+
const text = await aiCompleteStream({
|
|
2921
|
+
system, user, model,
|
|
2922
|
+
onReasoning: (d) => onDelta && onDelta('reasoning', d),
|
|
2923
|
+
onText: (d) => onDelta && onDelta('text', d),
|
|
2924
|
+
});
|
|
2065
2925
|
const parsed = parseJsonBalanced(text);
|
|
2066
2926
|
const shots = parsed && Array.isArray(parsed.shots) ? parsed.shots : null;
|
|
2067
|
-
if (!shots || !shots.length)
|
|
2927
|
+
if (!shots || !shots.length) {
|
|
2928
|
+
// 解析失败必须带上下文(长度 + 首尾原文),才能判断是被截断还是格式跑偏
|
|
2929
|
+
const raw = String(text || '').trim();
|
|
2930
|
+
const head = raw.slice(0, 160);
|
|
2931
|
+
const tail = raw.slice(-160);
|
|
2932
|
+
throw new Error(T('errParseStoryboard') + '(正文 ' + raw.length + ' 字;开头: ' + head + ';结尾: ' + tail + ')');
|
|
2933
|
+
}
|
|
2068
2934
|
return shots.slice(0, 24).map((s, i) => ({
|
|
2069
2935
|
title: String(s.title || (T('shotPrefix') + (i + 1))).slice(0, 24),
|
|
2070
2936
|
visual: String(s.visual || ''),
|
|
@@ -2072,7 +2938,7 @@ window.__ModuleLoader__.load({
|
|
|
2072
2938
|
characters: Array.isArray(s.characters) ? s.characters.map(String).slice(0, 6) : [],
|
|
2073
2939
|
scene: String(s.scene || ''),
|
|
2074
2940
|
props: Array.isArray(s.props) ? s.props.map(String).slice(0, 6) : [],
|
|
2075
|
-
duration: Number.isFinite(Number(s.duration)) ? clamp(Math.round(Number(s.duration)),
|
|
2941
|
+
duration: Number.isFinite(Number(s.duration)) ? clamp(Math.round(Number(s.duration)), 1, 15) : 3,
|
|
2076
2942
|
}));
|
|
2077
2943
|
}
|
|
2078
2944
|
/** 本地一致性检查:分镜里出现的角色/场景/道具名是否都能在设定集或图片资产中找到。 */
|
|
@@ -2084,7 +2950,7 @@ window.__ModuleLoader__.load({
|
|
|
2084
2950
|
(bible.scenes || []).forEach((c) => addName(c.name));
|
|
2085
2951
|
(bible.props || []).forEach((c) => addName(c.name));
|
|
2086
2952
|
try {
|
|
2087
|
-
const recs = await idbByProject(S.activeId);
|
|
2953
|
+
const recs = (await idbByProject(S.activeId)).concat(await idbByProject(GENERAL_PID));
|
|
2088
2954
|
recs.forEach((r) => addName(r.name));
|
|
2089
2955
|
} catch { /* 忽略 */ }
|
|
2090
2956
|
const warnings = [];
|
|
@@ -2104,6 +2970,36 @@ window.__ModuleLoader__.load({
|
|
|
2104
2970
|
}
|
|
2105
2971
|
return warnings.slice(0, 12);
|
|
2106
2972
|
}
|
|
2973
|
+
/** 分镜生成后:把设定集里没有的角色/场景/道具自动补进设定集(描述留空待用户补充)。 */
|
|
2974
|
+
async function autoRegisterBibleEntries(shots) {
|
|
2975
|
+
const bible = loadBible(S.activeId);
|
|
2976
|
+
const known = new Set();
|
|
2977
|
+
const addName = (n) => { const q = String(n || '').trim().toLowerCase(); if (q) known.add(q); };
|
|
2978
|
+
(bible.characters || []).forEach((c) => addName(c.name));
|
|
2979
|
+
(bible.scenes || []).forEach((c) => addName(c.name));
|
|
2980
|
+
(bible.props || []).forEach((c) => addName(c.name));
|
|
2981
|
+
try { ((await idbByProject(S.activeId)).concat(await idbByProject(GENERAL_PID))).forEach((r) => addName(r.name)); } catch { /* 忽略 */ }
|
|
2982
|
+
const next = { characters: [...bible.characters], scenes: [...bible.scenes], props: [...bible.props] };
|
|
2983
|
+
let added = 0;
|
|
2984
|
+
const push = (kind, name) => {
|
|
2985
|
+
const q = String(name || '').trim();
|
|
2986
|
+
if (!q) return;
|
|
2987
|
+
const ql = q.toLowerCase();
|
|
2988
|
+
if ([...known].some((k) => k.includes(ql) || ql.includes(k))) return;
|
|
2989
|
+
known.add(ql);
|
|
2990
|
+
next[kind].push({ name: q, desc: '', assetIds: [] });
|
|
2991
|
+
added++;
|
|
2992
|
+
};
|
|
2993
|
+
for (const sh of shots) {
|
|
2994
|
+
(sh.characters || []).forEach((n) => push('characters', n));
|
|
2995
|
+
push('scenes', sh.scene);
|
|
2996
|
+
(sh.props || []).forEach((n) => push('props', n));
|
|
2997
|
+
}
|
|
2998
|
+
if (added) {
|
|
2999
|
+
saveBible(S.activeId, { ...bible, ...next });
|
|
3000
|
+
toast(T('bibleAutoAdd').replace('{n}', String(added)));
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
2107
3003
|
async function saveTextAssetRecord(projectId, meta) {
|
|
2108
3004
|
const rec = {
|
|
2109
3005
|
id: uid('a'), projectId, kind: 'text',
|
|
@@ -2138,6 +3034,9 @@ window.__ModuleLoader__.load({
|
|
|
2138
3034
|
assetsTick: 0,
|
|
2139
3035
|
tick: 0,
|
|
2140
3036
|
craftStatus: null, // {at, data} | {at, error}
|
|
3037
|
+
dataRoot: null, // 数据根目录(Host 配置);null = 未设置(资产走 IndexedDB)
|
|
3038
|
+
dataRootExists: false,
|
|
3039
|
+
dataRootSupported: true,
|
|
2141
3040
|
updateInfo: null, // Host updateCheck 结果
|
|
2142
3041
|
updateChecking: false,
|
|
2143
3042
|
updateBusy: false,
|
|
@@ -2151,7 +3050,14 @@ window.__ModuleLoader__.load({
|
|
|
2151
3050
|
function notify() { S.tick++; for (const fn of listeners) fn(); }
|
|
2152
3051
|
function subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); }
|
|
2153
3052
|
function update(fn, opts) {
|
|
3053
|
+
const edgesBefore = S.graph ? S.graph.edges : null; // 引用比较:fn 换了数组说明发生过边增删
|
|
2154
3054
|
fn(S);
|
|
3055
|
+
// 边墓碑:画布上删除的连线自动记录,Host 合并时据此判定"真的被删了"(否则会被并回来)
|
|
3056
|
+
if (S.graph && S.graph.edges && edgesBefore && S.graph.edges !== edgesBefore) {
|
|
3057
|
+
const gone = new Set(edgesBefore.map((e) => e.id));
|
|
3058
|
+
for (const e of S.graph.edges) gone.delete(e.id);
|
|
3059
|
+
if (gone.size) S.graph.edgeTombstones = [...new Set([...(S.graph.edgeTombstones || []), ...gone])].slice(-400);
|
|
3060
|
+
}
|
|
2155
3061
|
if (!opts || !opts.quiet) notify();
|
|
2156
3062
|
scheduleSave();
|
|
2157
3063
|
}
|
|
@@ -2168,6 +3074,27 @@ window.__ModuleLoader__.load({
|
|
|
2168
3074
|
if (proj) proj.updatedAt = Date.now();
|
|
2169
3075
|
lsSet(LS_PROJECTS, S.projects);
|
|
2170
3076
|
lsSet(LS_ACTIVE, S.activeId);
|
|
3077
|
+
try {
|
|
3078
|
+
// 设定集只在本地确实变化时才带上:否则会把对话侧刚改的版本覆盖回去
|
|
3079
|
+
const bibleField = bibleFieldForPush(S.activeId);
|
|
3080
|
+
hostRpc('projectsPut', {
|
|
3081
|
+
id: S.activeId, name: (proj && proj.name) || S.activeId,
|
|
3082
|
+
doc: {
|
|
3083
|
+
ts: Date.now(), // 推送发出时刻:Host 据此区分"撤销恢复"(清墓碑)与"陈旧推送"(墓碑继续生效)
|
|
3084
|
+
nodes: S.graph.nodes, edges: S.graph.edges, view: S.graph.view,
|
|
3085
|
+
tombstones: S.graph.tombstones || [], // 本地删除标记:Host 依据它判定节点真的被删
|
|
3086
|
+
...(bibleField.__bibleChanged ? { bible: bibleField.bible } : {}),
|
|
3087
|
+
},
|
|
3088
|
+
}).then((r) => {
|
|
3089
|
+
if (bibleField.__bibleChanged && r && r.bibleUpdatedAt) {
|
|
3090
|
+
lsSet(LS_BIBLE_SYNC + S.activeId, r.bibleUpdatedAt);
|
|
3091
|
+
biblePushSnap.set(S.activeId, JSON.stringify(bibleField.bible));
|
|
3092
|
+
}
|
|
3093
|
+
}).catch((err) => {
|
|
3094
|
+
// 同步失败必须让用户知道:否则对话侧看到的是旧内容,用户以为"刚写的对话里查不到"
|
|
3095
|
+
if (!syncWarned) { syncWarned = true; toast(T('syncFailed') + ':' + String((err && err.message) || err)); }
|
|
3096
|
+
});
|
|
3097
|
+
} catch { /* 忽略 */ }
|
|
2171
3098
|
}
|
|
2172
3099
|
function initStore() {
|
|
2173
3100
|
S.projects = lsGet(LS_PROJECTS, []);
|
|
@@ -2179,8 +3106,21 @@ window.__ModuleLoader__.load({
|
|
|
2179
3106
|
S.activeId = S.projects.some((p) => p.id === active) ? active : S.projects[0].id;
|
|
2180
3107
|
const doc = loadGraphDoc(S.activeId) || emptyGraph();
|
|
2181
3108
|
S.graph = { nodes: doc.nodes || {}, edges: doc.edges || [], view: doc.view || { x: 60, y: 40, scale: 1 } };
|
|
3109
|
+
// 页面刷新/关闭会中断进行中的生成:清理残留的 running 状态,避免节点永远卡在"运行中"
|
|
3110
|
+
let stale = false;
|
|
3111
|
+
for (const n of Object.values(S.graph.nodes || {})) {
|
|
3112
|
+
if (n && n.data && (n.data.status === 'running' || n.data.aiStatus === 'running')) {
|
|
3113
|
+
n.data.status = 'idle';
|
|
3114
|
+
if (n.data.aiStatus === 'running') { n.data.aiStatus = 'error'; n.data.aiError = T('aiInterrupted'); }
|
|
3115
|
+
n.data.progress = null;
|
|
3116
|
+
stale = true;
|
|
3117
|
+
}
|
|
3118
|
+
}
|
|
3119
|
+
if (stale) { try { saveGraphDoc(S.activeId, S.graph); } catch { /* 忽略 */ } }
|
|
2182
3120
|
V.x = S.graph.view.x; V.y = S.graph.view.y; V.scale = S.graph.view.scale;
|
|
2183
3121
|
S.ready = true;
|
|
3122
|
+
// 预热设定集快照:页面加载时视本地版本为"已同步",避免把旧版本推回覆盖对话侧的新设定
|
|
3123
|
+
for (const p of S.projects) biblePushSnap.set(p.id, bibleSnapshot(p.id));
|
|
2184
3124
|
}
|
|
2185
3125
|
function switchProject(pid) {
|
|
2186
3126
|
if (pid === S.activeId) return;
|
|
@@ -2189,10 +3129,15 @@ window.__ModuleLoader__.load({
|
|
|
2189
3129
|
update((s) => {
|
|
2190
3130
|
s.activeId = pid;
|
|
2191
3131
|
s.graph = { nodes: doc.nodes || {}, edges: doc.edges || [], view: doc.view || { x: 60, y: 40, scale: 1 } };
|
|
2192
|
-
s.sel = new Set(); s.selEdge = null;
|
|
3132
|
+
s.sel = new Set(); s.selEdge = null;
|
|
3133
|
+
// 注意:不复位 s.running —— 否则运行中切项目会让"运行工作流"重新可点,并发跑同一节点
|
|
2193
3134
|
V.x = s.graph.view.x; V.y = s.graph.view.y; V.scale = s.graph.view.scale;
|
|
2194
3135
|
applyViewToDom();
|
|
2195
3136
|
});
|
|
3137
|
+
// 撤销栈必须按项目隔离:否则在新项目里按 Ctrl+Z 会把上一个项目的节点写进来(覆盖数据)
|
|
3138
|
+
history.stack = [];
|
|
3139
|
+
history.idx = -1;
|
|
3140
|
+
pushHistory();
|
|
2196
3141
|
}
|
|
2197
3142
|
|
|
2198
3143
|
// ============================================================
|
|
@@ -2214,9 +3159,15 @@ window.__ModuleLoader__.load({
|
|
|
2214
3159
|
const snap = history.stack[history.idx];
|
|
2215
3160
|
if (!snap) return;
|
|
2216
3161
|
const doc = JSON.parse(snap);
|
|
3162
|
+
const prevIds = S.graph ? Object.keys(S.graph.nodes || {}) : [];
|
|
2217
3163
|
update((s) => {
|
|
2218
3164
|
s.graph.nodes = doc.nodes;
|
|
2219
3165
|
s.graph.edges = doc.edges;
|
|
3166
|
+
// 撤销/重做必须对齐墓碑:快照里消失的节点=这次撤销动作删掉的(补墓碑,防同步复活);
|
|
3167
|
+
// 快照里出现的节点=被恢复的(撤墓碑,否则 Ctrl+Z 恢复的节点会被下一轮同步按旧墓碑再次抹掉)
|
|
3168
|
+
const live = new Set(Object.keys(s.graph.nodes || {}));
|
|
3169
|
+
s.graph.tombstones = [...new Set([...(s.graph.tombstones || []), ...prevIds])]
|
|
3170
|
+
.filter((id) => !live.has(id)).slice(-200);
|
|
2220
3171
|
s.sel = new Set();
|
|
2221
3172
|
s.selEdge = null;
|
|
2222
3173
|
});
|
|
@@ -2319,7 +3270,8 @@ window.__ModuleLoader__.load({
|
|
|
2319
3270
|
return {
|
|
2320
3271
|
id: uid('n'), type,
|
|
2321
3272
|
x: Math.round(pos.x), y: Math.round(pos.y),
|
|
2322
|
-
w:
|
|
3273
|
+
w: type === 'script' ? 340 : type === 'storyboard' ? 360 : type === 'text' ? 280 : NODE_W,
|
|
3274
|
+
h: 96 + portRows * 4,
|
|
2323
3275
|
data,
|
|
2324
3276
|
};
|
|
2325
3277
|
}
|
|
@@ -2329,14 +3281,18 @@ window.__ModuleLoader__.load({
|
|
|
2329
3281
|
pushHistory();
|
|
2330
3282
|
return n;
|
|
2331
3283
|
}
|
|
2332
|
-
function removeNodes(ids) {
|
|
3284
|
+
function removeNodes(ids, edgeId) {
|
|
2333
3285
|
update((s) => {
|
|
2334
3286
|
for (const id of ids) delete s.graph.nodes[id];
|
|
2335
|
-
s.graph.edges = s.graph.edges.filter((e) => !ids.includes(e.fromNode) && !ids.includes(e.toNode));
|
|
3287
|
+
s.graph.edges = s.graph.edges.filter((e) => !ids.includes(e.fromNode) && !ids.includes(e.toNode) && (!edgeId || e.id !== edgeId));
|
|
2336
3288
|
s.sel = new Set([...s.sel].filter((x) => ids.includes(x) === false));
|
|
2337
3289
|
if (s.selEdge && !s.graph.edges.some((e) => e.id === s.selEdge)) s.selEdge = null;
|
|
3290
|
+
// 本地删除也要留墓碑:Host 侧据此判定"真的被删了",
|
|
3291
|
+
// 否则 Host 合并(保留对话新节点)会把用户刚删的节点又留下来
|
|
3292
|
+
if (ids.length) s.graph.tombstones = [...new Set([...(s.graph.tombstones || []), ...ids])].slice(-200);
|
|
2338
3293
|
});
|
|
2339
3294
|
pushHistory();
|
|
3295
|
+
flushSave(); // 删除立即推送(不等 450ms 防抖):同步把 Host 上的节点删掉,压缩"拉回复活"的时间窗
|
|
2340
3296
|
}
|
|
2341
3297
|
function duplicateSelection() {
|
|
2342
3298
|
const clones = [];
|
|
@@ -2455,6 +3411,28 @@ window.__ModuleLoader__.load({
|
|
|
2455
3411
|
}
|
|
2456
3412
|
|
|
2457
3413
|
// 输入解析:生成节点的实际 prompt(文本节点 / 分镜节点的画面、动作输出)
|
|
3414
|
+
/** 分镜表 → 一次多镜头视频生成的提示词:所有镜头按顺序、按时长写进同一条提示词,
|
|
3415
|
+
* 由支持多镜头的视频模型**一次性生成整段视频**(而不是一镜一视频再拼接)。
|
|
3416
|
+
* 各镜内的 @实体 引用原样保留,collectRefs 会自动挂上对应参考图资产。 */
|
|
3417
|
+
function buildStoryboardVideoPrompt(sb) {
|
|
3418
|
+
const shots = ((sb && sb.data && sb.data.shots) || []).filter((s) => s && (String(s.visual || '').trim() || String(s.motion || '').trim()));
|
|
3419
|
+
if (!shots.length) return '';
|
|
3420
|
+
const zh = effLang() !== 'en';
|
|
3421
|
+
const total = shots.reduce((a, s) => a + Math.max(1, Number(s.duration) || 3), 0);
|
|
3422
|
+
const lines = [
|
|
3423
|
+
zh
|
|
3424
|
+
? `【多镜头一次性生成】共 ${shots.length} 镜、总时长 ${total} 秒。严格按下列顺序与时长切镜(每镜时长不可变),镜头之间直接硬切,不要叠加转场:`
|
|
3425
|
+
: `[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:`,
|
|
3426
|
+
];
|
|
3427
|
+
shots.forEach((s, i) => {
|
|
3428
|
+
const dur = Math.max(1, Number(s.duration) || 3);
|
|
3429
|
+
const parts = [];
|
|
3430
|
+
if (String(s.visual || '').trim()) parts.push(String(s.visual).trim());
|
|
3431
|
+
if (String(s.motion || '').trim()) parts.push((zh ? '运镜/动作:' : 'Camera/Action: ') + String(s.motion).trim());
|
|
3432
|
+
lines.push((zh ? `镜头${i + 1}(${dur}s):` : `Shot ${i + 1} (${dur}s): `) + parts.join(zh ? ';' : '; '));
|
|
3433
|
+
});
|
|
3434
|
+
return lines.join('\n');
|
|
3435
|
+
}
|
|
2458
3436
|
function resolvePrompt(n) {
|
|
2459
3437
|
const e = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'prompt');
|
|
2460
3438
|
if (e) {
|
|
@@ -2466,6 +3444,11 @@ window.__ModuleLoader__.load({
|
|
|
2466
3444
|
const v = (src.data[field] || '').trim();
|
|
2467
3445
|
if (v) return v;
|
|
2468
3446
|
}
|
|
3447
|
+
// 上游是分镜表 → 多镜头一次性生成:全部镜头组合成一条提示词(时长 = 各镜之和)
|
|
3448
|
+
if (src.type === 'storyboard') {
|
|
3449
|
+
const composed = buildStoryboardVideoPrompt(src);
|
|
3450
|
+
if (composed) return composed;
|
|
3451
|
+
}
|
|
2469
3452
|
}
|
|
2470
3453
|
}
|
|
2471
3454
|
return (n.data.prompt || '').trim();
|
|
@@ -2483,8 +3466,7 @@ window.__ModuleLoader__.load({
|
|
|
2483
3466
|
if (!src) return null;
|
|
2484
3467
|
const aid = servedAssetId(src, e.fromPort);
|
|
2485
3468
|
if (!aid) return null;
|
|
2486
|
-
|
|
2487
|
-
return rec ? rec.blob : null;
|
|
3469
|
+
return await assetBlob(aid);
|
|
2488
3470
|
}
|
|
2489
3471
|
// 多参考图:按节点上的显式顺序(refOrder,未排序时按连线先后)解析,带名称/分类供 [图N] 绑定
|
|
2490
3472
|
async function resolveRefMetas(nodeId, portId) {
|
|
@@ -2501,7 +3483,8 @@ window.__ModuleLoader__.load({
|
|
|
2501
3483
|
if (!aid) continue;
|
|
2502
3484
|
try {
|
|
2503
3485
|
const rec = await idbGet(aid);
|
|
2504
|
-
|
|
3486
|
+
const blob = await assetBlob(aid);
|
|
3487
|
+
if (blob) metas.push({ blob, name: (rec && rec.name) || '', category: (rec && rec.category) || 'general' });
|
|
2505
3488
|
} catch { /* 单个参考图读取失败不影响其余 */ }
|
|
2506
3489
|
}
|
|
2507
3490
|
return metas;
|
|
@@ -2512,49 +3495,215 @@ window.__ModuleLoader__.load({
|
|
|
2512
3495
|
// ============================================================
|
|
2513
3496
|
const cancelRef = { cancelled: false };
|
|
2514
3497
|
|
|
3498
|
+
/** 分镜节点的剧本来源:**连线上游与手动粘贴二选一**——
|
|
3499
|
+
* 连了线就只用连线(上游没生成完会明确报错,绝不偷用粘贴的文本混着来)。 */
|
|
3500
|
+
function storyboardScriptText(n) {
|
|
3501
|
+
const e = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'script');
|
|
3502
|
+
if (e) {
|
|
3503
|
+
const src = S.graph.nodes[e.fromNode];
|
|
3504
|
+
if (!src) throw new Error(T('aiNeedScript'));
|
|
3505
|
+
if (src.type === 'script') {
|
|
3506
|
+
const out = String(src.data.output || '').trim();
|
|
3507
|
+
if (!out) throw new Error(T('upstreamStillRunning'));
|
|
3508
|
+
return out;
|
|
3509
|
+
}
|
|
3510
|
+
if (src.type === 'text') {
|
|
3511
|
+
const txt = String(src.data.text || '').trim();
|
|
3512
|
+
if (!txt) throw new Error(T('upstreamStillRunning'));
|
|
3513
|
+
return txt;
|
|
3514
|
+
}
|
|
3515
|
+
throw new Error(T('aiNeedScript'));
|
|
3516
|
+
}
|
|
3517
|
+
return String(n.data.script || '').trim();
|
|
3518
|
+
}
|
|
3519
|
+
const setNodeAiError = (nodeId, message) => update((s) => {
|
|
3520
|
+
const nd = s.graph.nodes[nodeId];
|
|
3521
|
+
if (nd) { nd.data.aiStatus = 'error'; nd.data.aiError = String(message || ''); nd.data.status = 'error'; }
|
|
3522
|
+
});
|
|
3523
|
+
|
|
3524
|
+
/** 剧本节点:idea → 成稿。按钮与对话触发的 canvas_run_node 共用同一实现。 */
|
|
3525
|
+
async function genScriptNode(n) {
|
|
3526
|
+
if (!String(n.data.idea || '').trim()) throw new Error(T('needIdea'));
|
|
3527
|
+
update((s) => {
|
|
3528
|
+
const nd = s.graph.nodes[n.id];
|
|
3529
|
+
if (nd) {
|
|
3530
|
+
// 生成开始即清空结果框(旧稿备份,失败自动还原):避免"还在生成中,结果框却挂着上一版"的误解
|
|
3531
|
+
nd.data.prevOutput = nd.data.output || '';
|
|
3532
|
+
nd.data.output = '';
|
|
3533
|
+
nd.data.aiStatus = 'running';
|
|
3534
|
+
nd.data.aiError = null;
|
|
3535
|
+
}
|
|
3536
|
+
});
|
|
3537
|
+
aiStreamClear(n.id);
|
|
3538
|
+
flushSave();
|
|
3539
|
+
try {
|
|
3540
|
+
const text = await aiScriptNodes(n.data.idea, n.data.model, (kind, d) => aiStreamAppend(n.id, kind, d));
|
|
3541
|
+
if (!String(text || '').trim()) throw new Error(T('aiEmptyResult'));
|
|
3542
|
+
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++; });
|
|
3543
|
+
await saveTextAssetRecord(S.activeId, {
|
|
3544
|
+
name: T('scriptAssetPrefix') + new Date().toISOString().slice(5, 16).replace('T', ' '),
|
|
3545
|
+
text, category: 'script', prompt: n.data.idea,
|
|
3546
|
+
});
|
|
3547
|
+
update((s) => { s.assetsTick++; });
|
|
3548
|
+
flushSave();
|
|
3549
|
+
return text;
|
|
3550
|
+
} catch (e) {
|
|
3551
|
+
update((s) => {
|
|
3552
|
+
const nd = s.graph.nodes[n.id];
|
|
3553
|
+
// 失败时还原上一版成稿,不让用户白丢内容
|
|
3554
|
+
if (nd && !String(nd.data.output || '').trim() && nd.data.prevOutput) { nd.data.output = nd.data.prevOutput; nd.data.prevOutput = null; }
|
|
3555
|
+
});
|
|
3556
|
+
setNodeAiError(n.id, (e && e.message) || e);
|
|
3557
|
+
flushSave();
|
|
3558
|
+
throw e;
|
|
3559
|
+
} finally {
|
|
3560
|
+
aiStreamClear(n.id);
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
/** 分镜节点:剧本 → 镜头列表(含一致性检查)。 */
|
|
3565
|
+
async function genStoryboardNode(n) {
|
|
3566
|
+
const st = storyboardScriptText(n);
|
|
3567
|
+
if (!st) throw new Error(T('aiNeedScript'));
|
|
3568
|
+
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.aiStatus = 'running'; nd.data.aiError = null; nd.data.warnings = []; } });
|
|
3569
|
+
aiStreamClear(n.id);
|
|
3570
|
+
flushSave();
|
|
3571
|
+
try {
|
|
3572
|
+
const shots = await aiStoryboardShots(st, n.data.requirement, n.data.count || 6, n.data.model, (kind, d) => aiStreamAppend(n.id, kind, d));
|
|
3573
|
+
const warnings = await checkShotConsistency(shots);
|
|
3574
|
+
update((s) => {
|
|
3575
|
+
const nd = s.graph.nodes[n.id];
|
|
3576
|
+
if (nd) { nd.data.shots = shots; nd.data.aiStatus = 'idle'; nd.data.warnings = warnings; nd.data.status = 'done'; nd.data.error = null; }
|
|
3577
|
+
s.assetsTick++;
|
|
3578
|
+
});
|
|
3579
|
+
// 分镜里出现的新角色/场景/道具 → 自动补进设定集(描述留空待补充),保证设定集始终是唯一事实源
|
|
3580
|
+
try { await autoRegisterBibleEntries(shots); } catch { /* 补录失败不影响分镜 */ }
|
|
3581
|
+
await saveTextAssetRecord(S.activeId, {
|
|
3582
|
+
name: T('storyboardAssetPrefix') + new Date().toISOString().slice(5, 16).replace('T', ' '),
|
|
3583
|
+
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'),
|
|
3584
|
+
category: 'storyboard', prompt: st.slice(0, 200),
|
|
3585
|
+
});
|
|
3586
|
+
update((s) => { s.assetsTick++; });
|
|
3587
|
+
flushSave();
|
|
3588
|
+
return shots;
|
|
3589
|
+
} catch (e) {
|
|
3590
|
+
setNodeAiError(n.id, (e && e.message) || e);
|
|
3591
|
+
flushSave();
|
|
3592
|
+
throw e;
|
|
3593
|
+
} finally {
|
|
3594
|
+
aiStreamClear(n.id);
|
|
3595
|
+
}
|
|
3596
|
+
}
|
|
3597
|
+
|
|
3598
|
+
/** 成片节点:片段(+配音) → MP4。 */
|
|
3599
|
+
async function genFinalCutNode(n) {
|
|
3600
|
+
const segEdges = S.graph.edges.filter((x) => x.toNode === n.id && x.toPort === 'in');
|
|
3601
|
+
const segs = segEdges
|
|
3602
|
+
.map((e) => ({ e, nd: S.graph.nodes[e.fromNode] }))
|
|
3603
|
+
.filter((x) => x.nd)
|
|
3604
|
+
.sort((a, b) => (a.nd.y - b.nd.y) || (a.nd.x - b.nd.x));
|
|
3605
|
+
if (!segs.length) throw new Error(T('noSegments'));
|
|
3606
|
+
const getSub = (x) => {
|
|
3607
|
+
const defSub = x.nd.type === 'video-gen' ? (x.nd.data.prompt || '') : (x.nd.data.label || TYPE_LABEL()[x.nd.type]);
|
|
3608
|
+
const v = n.data.subs && n.data.subs[x.e.id];
|
|
3609
|
+
return v != null ? v : defSub;
|
|
3610
|
+
};
|
|
3611
|
+
const metas = [];
|
|
3612
|
+
for (const x of segs) {
|
|
3613
|
+
if (!x.nd.data.assetId) continue;
|
|
3614
|
+
try {
|
|
3615
|
+
const rec = await idbGet(x.nd.data.assetId);
|
|
3616
|
+
const blob = await assetBlob(x.nd.data.assetId);
|
|
3617
|
+
if (blob) {
|
|
3618
|
+
const ttsId = n.data.tts && n.data.tts[x.e.id] && n.data.tts[x.e.id].assetId;
|
|
3619
|
+
const ttsUrl = ttsId ? await assetUrl(ttsId) : null;
|
|
3620
|
+
// 分镜的编辑时长(1-3s 级)作为成片裁剪依据:API 生成的片段偏长,成片时裁到分镜时长
|
|
3621
|
+
const editDur = Number(x.nd.data.editDuration) || null;
|
|
3622
|
+
metas.push({ url: URL.createObjectURL(blob), name: (rec && rec.name) || 'seg', sub: getSub(x), ttsUrl, dur: editDur || undefined });
|
|
3623
|
+
}
|
|
3624
|
+
} catch { /* 跳过加载失败的片段 */ }
|
|
3625
|
+
}
|
|
3626
|
+
if (!metas.length) throw new Error(T('noSegments'));
|
|
3627
|
+
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.status = 'running'; nd.data.error = null; nd.data.progress = '0%'; } });
|
|
3628
|
+
flushSave();
|
|
3629
|
+
try {
|
|
3630
|
+
const dims = videoDimsFor({ resolution: n.data.resolution, ratio: n.data.ratio });
|
|
3631
|
+
const blob = await composeFinalCut(metas, {
|
|
3632
|
+
width: dims.w, height: dims.h, keepAudio: n.data.keepAudio !== false, transition: n.data.transition,
|
|
3633
|
+
bitrate: n.data.bitrate ? Number(n.data.bitrate) : undefined,
|
|
3634
|
+
}, (frac, name) => {
|
|
3635
|
+
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.progress = Math.round(frac * 100) + '% · ' + name; });
|
|
3636
|
+
});
|
|
3637
|
+
const rec = await saveAssetRecord(S.activeId, 'video', blob, {
|
|
3638
|
+
name: T('finalCutDone') + '-' + new Date().toISOString().slice(5, 16).replace('T', ' '),
|
|
3639
|
+
prompt: metas.length + ' segments', source: 'finalcut', category: 'finalcut',
|
|
3640
|
+
});
|
|
3641
|
+
metas.forEach((m) => URL.revokeObjectURL(m.url));
|
|
3642
|
+
update((s) => {
|
|
3643
|
+
const nd = s.graph.nodes[n.id];
|
|
3644
|
+
if (nd) { nd.data.status = 'done'; nd.data.assetId = rec.id; nd.data.progress = null; }
|
|
3645
|
+
s.assetsTick++;
|
|
3646
|
+
});
|
|
3647
|
+
flushSave();
|
|
3648
|
+
return rec.id;
|
|
3649
|
+
} catch (e) {
|
|
3650
|
+
metas.forEach((m) => URL.revokeObjectURL(m.url));
|
|
3651
|
+
update((s) => {
|
|
3652
|
+
const nd = s.graph.nodes[n.id];
|
|
3653
|
+
if (nd) { nd.data.status = 'error'; nd.data.error = String((e && e.message) || e); nd.data.progress = null; }
|
|
3654
|
+
});
|
|
3655
|
+
flushSave();
|
|
3656
|
+
throw e;
|
|
3657
|
+
}
|
|
3658
|
+
}
|
|
3659
|
+
|
|
2515
3660
|
async function runGenNode(n) {
|
|
2516
3661
|
const type = n.type;
|
|
2517
3662
|
update((s) => {
|
|
2518
3663
|
const nd = s.graph.nodes[n.id];
|
|
2519
3664
|
if (nd) { nd.data.status = 'running'; nd.data.error = null; }
|
|
2520
3665
|
});
|
|
3666
|
+
flushSave(); // 立刻落盘 + 推送 Host:切走画面/对话查询都能看到"运行中"
|
|
2521
3667
|
try {
|
|
3668
|
+
if (type === 'script') { await genScriptNode(n); return true; }
|
|
3669
|
+
if (type === 'storyboard') { await genStoryboardNode(n); return true; }
|
|
3670
|
+
if (type === 'finalcut') { await genFinalCutNode(n); return true; }
|
|
3671
|
+
if (type !== 'image-gen' && type !== 'video-gen') throw new Error(T('nodeNotRunnable'));
|
|
2522
3672
|
const prompt = resolvePrompt(n);
|
|
3673
|
+
const bible = loadBible(S.activeId);
|
|
2523
3674
|
if (type === 'image-gen') {
|
|
2524
|
-
|
|
2525
|
-
const
|
|
2526
|
-
const
|
|
2527
|
-
|
|
2528
|
-
const hit = (bible[key] || []).find((x) => (x.name || '').trim() === String(name || '').trim());
|
|
2529
|
-
return hit && hit.desc ? hit.desc.trim() : '';
|
|
2530
|
-
};
|
|
2531
|
-
const bind = metas.map((m, i) => {
|
|
2532
|
-
const d = bibleDesc(m.category, m.name);
|
|
2533
|
-
return `[图${i + 1}] ${catLabel(m.category)}:${m.name}${d ? '(' + d + ')' : ''}`;
|
|
2534
|
-
}).join(';');
|
|
2535
|
-
const p = [
|
|
2536
|
-
(bible.style || '').trim() ? '风格:' + bible.style.trim() : '',
|
|
2537
|
-
bind ? `${T('refCount')} → ${bind}` : '',
|
|
2538
|
-
prompt,
|
|
2539
|
-
].filter(Boolean).join('\n');
|
|
3675
|
+
// 参考图 = 连线(用户顺序)+ @引用命中的设定集资产(按出现顺序)
|
|
3676
|
+
const { refs, entities } = await collectRefs(n, prompt, bible);
|
|
3677
|
+
const metas = refs.map((r) => ({ blob: r.blob, name: r.name, category: r.category }));
|
|
3678
|
+
const p = buildEffectivePrompt(prompt, refs, entities, bible);
|
|
2540
3679
|
const { blob, demo } = await generateImage(p, metas, { size: n.data.size, ratio: n.data.ratio, apiId: n.data.imageApiId });
|
|
2541
|
-
const rec = await saveAssetRecord(S.activeId, 'image', blob, {
|
|
3680
|
+
const rec = await saveAssetRecord(S.activeId, 'image', blob, {
|
|
3681
|
+
name: assetNameFor(n, entities, 'image'),
|
|
3682
|
+
prompt: stripMentionMarks(prompt), effectivePrompt: p,
|
|
3683
|
+
source: demo ? 'demo' : 'api',
|
|
3684
|
+
entity: entities.length ? entities[0].name : undefined,
|
|
3685
|
+
entities: entities.map((e) => e.name),
|
|
3686
|
+
nodeLabel: (n.data && n.data.label) || '',
|
|
3687
|
+
category: entities.length ? entityCategoryOf(entities[0].kind) : 'general',
|
|
3688
|
+
});
|
|
2542
3689
|
commitGenResult(n.id, rec.id, demo, 'image');
|
|
2543
3690
|
} else {
|
|
2544
3691
|
const frameBlob = await resolveAssetBlob(n.id, 'frame');
|
|
2545
|
-
const
|
|
2546
|
-
const
|
|
2547
|
-
const bind = refMetas.map((m, i) => `[图${i + 1}] ${catLabel(m.category)}:${m.name}`).join(';');
|
|
2548
|
-
const p = [
|
|
2549
|
-
(bible.style || '').trim() ? '风格:' + bible.style.trim() : '',
|
|
2550
|
-
bind ? `${T('refCount')} → ${bind}` : '',
|
|
2551
|
-
prompt,
|
|
2552
|
-
].filter(Boolean).join('\n');
|
|
3692
|
+
const { refs, entities } = await collectRefs(n, prompt, bible);
|
|
3693
|
+
const p = buildEffectivePrompt(prompt, refs, entities, bible);
|
|
2553
3694
|
const { blob, demo } = await generateVideo(p, frameBlob, {
|
|
2554
3695
|
duration: n.data.duration, resolution: n.data.resolution, ratio: n.data.ratio, apiId: n.data.videoApiId,
|
|
2555
|
-
refs:
|
|
3696
|
+
refs: refs.map((r) => r.blob),
|
|
3697
|
+
});
|
|
3698
|
+
const rec = await saveAssetRecord(S.activeId, 'video', blob, {
|
|
3699
|
+
name: assetNameFor(n, entities, 'video'),
|
|
3700
|
+
prompt: stripMentionMarks(prompt), effectivePrompt: p,
|
|
3701
|
+
source: demo ? 'demo' : 'api',
|
|
3702
|
+
entity: entities.length ? entities[0].name : undefined,
|
|
3703
|
+
entities: entities.map((e) => e.name),
|
|
3704
|
+
nodeLabel: (n.data && n.data.label) || '',
|
|
3705
|
+
category: entities.length ? entityCategoryOf(entities[0].kind) : 'general',
|
|
2556
3706
|
});
|
|
2557
|
-
const rec = await saveAssetRecord(S.activeId, 'video', blob, { prompt, source: demo ? 'demo' : 'api' });
|
|
2558
3707
|
commitGenResult(n.id, rec.id, demo, 'video');
|
|
2559
3708
|
// 尾帧提取(通用):供 last 端口输出与链式下一镜使用
|
|
2560
3709
|
try {
|
|
@@ -2576,6 +3725,7 @@ window.__ModuleLoader__.load({
|
|
|
2576
3725
|
const nd = s.graph.nodes[n.id];
|
|
2577
3726
|
if (nd) { nd.data.status = 'error'; nd.data.error = String((err && err.message) || err); }
|
|
2578
3727
|
});
|
|
3728
|
+
flushSave(); // 让对话侧的 canvas_node_status 也能读到失败原因
|
|
2579
3729
|
return false;
|
|
2580
3730
|
}
|
|
2581
3731
|
}
|
|
@@ -2617,7 +3767,9 @@ window.__ModuleLoader__.load({
|
|
|
2617
3767
|
if (cancelRef.cancelled) break;
|
|
2618
3768
|
const n = S.graph.nodes[id];
|
|
2619
3769
|
if (!n) continue;
|
|
2620
|
-
|
|
3770
|
+
// 已在生成中的节点跳过:避免与对话触发的 canvas_run_node 并发调用同一节点(重复计费/重复资产)
|
|
3771
|
+
if (n.data && n.data.status === 'running') continue;
|
|
3772
|
+
if (n.type === 'image-gen' || n.type === 'video-gen' || n.type === 'script' || n.type === 'storyboard' || n.type === 'finalcut') {
|
|
2621
3773
|
await runGenNode(n);
|
|
2622
3774
|
}
|
|
2623
3775
|
}
|
|
@@ -2921,17 +4073,9 @@ window.__ModuleLoader__.load({
|
|
|
2921
4073
|
}
|
|
2922
4074
|
if ((e.key === 'Delete' || e.key === 'Backspace') && (S.sel.size || S.selEdge)) {
|
|
2923
4075
|
e.preventDefault();
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
if (s.selEdge) {
|
|
2928
|
-
s.graph.edges = s.graph.edges.filter((x) => x.id !== s.selEdge);
|
|
2929
|
-
s.selEdge = null;
|
|
2930
|
-
}
|
|
2931
|
-
s.graph.edges = s.graph.edges.filter((x) => !ids.includes(x.fromNode) && !ids.includes(x.toNode));
|
|
2932
|
-
s.sel = new Set();
|
|
2933
|
-
});
|
|
2934
|
-
pushHistory();
|
|
4076
|
+
// 必须走 removeNodes(记节点墓碑 + 立即推送)。此前这里是裸删:不记墓碑、不立即推送,
|
|
4077
|
+
// Host 合并把删除的节点当"Host 独有"并回来 —— 表现为 Delete 键删除后自动恢复,而 × 删除正常
|
|
4078
|
+
removeNodes([...S.sel], S.selEdge || undefined);
|
|
2935
4079
|
}
|
|
2936
4080
|
}
|
|
2937
4081
|
|
|
@@ -3047,9 +4191,13 @@ window.__ModuleLoader__.load({
|
|
|
3047
4191
|
}
|
|
3048
4192
|
}
|
|
3049
4193
|
delete s.graph.nodes[id];
|
|
4194
|
+
s.graph.edges = s.graph.edges.filter((e) => e.fromNode !== id && e.toNode !== id);
|
|
4195
|
+
// 解散分组也是删除节点:不记墓碑会被 Host 合并复活
|
|
4196
|
+
s.graph.tombstones = [...new Set([...(s.graph.tombstones || []), id])].slice(-200);
|
|
3050
4197
|
s.sel.delete(id);
|
|
3051
4198
|
});
|
|
3052
4199
|
pushHistory();
|
|
4200
|
+
flushSave();
|
|
3053
4201
|
}
|
|
3054
4202
|
function createGroupFromSelection() {
|
|
3055
4203
|
const ids = [...S.sel].filter((id) => S.graph.nodes[id] && S.graph.nodes[id].type !== 'group');
|
|
@@ -3083,25 +4231,112 @@ window.__ModuleLoader__.load({
|
|
|
3083
4231
|
nodeEls.delete(n.id);
|
|
3084
4232
|
}
|
|
3085
4233
|
}, [n.id]);
|
|
4234
|
+
// 【顺序要求】所有 hook 必须先无条件执行,再决定渲染什么:
|
|
4235
|
+
// 否则分组折叠切换 data.hidden 时 hook 数量变化,React 会抛错并卸载整个画布(白屏)
|
|
4236
|
+
const [renaming, setRenaming] = useState(false);
|
|
4237
|
+
const [nameVal, setNameVal] = useState('');
|
|
4238
|
+
// 注意:这里**不做**任何"随内容自动增高"——高度只能由用户拖节点边改变,
|
|
4239
|
+
// 文本框内容超出就在框内滚动。此前的自动增高会在流式刷新时被反复触发,
|
|
4240
|
+
// 造成"生成中节点自己不断变长"的 bug(用户实测)。
|
|
3086
4241
|
if (n.data.hidden) return null; // 分组折叠时隐藏成员
|
|
3087
4242
|
if (n.type === 'group') return h(GroupShell, { n });
|
|
4243
|
+
const commitName = () => {
|
|
4244
|
+
const v = nameVal.trim();
|
|
4245
|
+
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.label = v; });
|
|
4246
|
+
pushHistory();
|
|
4247
|
+
setRenaming(false);
|
|
4248
|
+
};
|
|
4249
|
+
const startRename = () => { setNameVal(n.data.label || ''); setRenaming(true); };
|
|
4250
|
+
// 统一的尺寸拖拽:dir = 'e' | 'w' | 's' | 'n' | 'se'
|
|
4251
|
+
// 左右改宽(左边缘同时改 x,视觉上"从左边拉"),上下改高,右下角同时改宽高。
|
|
4252
|
+
// 竖向一旦手动拖过 → 记 hManual(固定高度,内容超出则节点内滚动);双击底边恢复自适应。
|
|
4253
|
+
const startResize = (e, dir) => {
|
|
4254
|
+
e.stopPropagation();
|
|
4255
|
+
e.preventDefault();
|
|
4256
|
+
const sx = e.clientX;
|
|
4257
|
+
const sy = e.clientY;
|
|
4258
|
+
const scale = V.scale || 1;
|
|
4259
|
+
const cur = S.graph.nodes[n.id] || n;
|
|
4260
|
+
const w0 = cur.w || NODE_W;
|
|
4261
|
+
const h0 = cur.h || 120;
|
|
4262
|
+
const x0 = cur.x;
|
|
4263
|
+
const y0 = cur.y;
|
|
4264
|
+
const move = (ev) => {
|
|
4265
|
+
const dx = (ev.clientX - sx) / scale;
|
|
4266
|
+
const dy = (ev.clientY - sy) / scale;
|
|
4267
|
+
update((s) => {
|
|
4268
|
+
const nd = s.graph.nodes[n.id];
|
|
4269
|
+
if (!nd) return;
|
|
4270
|
+
if (dir.includes('e')) nd.w = Math.round(Math.max(200, Math.min(1200, w0 + dx)));
|
|
4271
|
+
if (dir.includes('w')) {
|
|
4272
|
+
const w = Math.round(Math.max(200, Math.min(1200, w0 - dx)));
|
|
4273
|
+
nd.w = w;
|
|
4274
|
+
nd.x = Math.round(x0 + (w0 - w));
|
|
4275
|
+
}
|
|
4276
|
+
if (dir.includes('s')) nd.hManual = Math.round(Math.max(96, Math.min(2400, h0 + dy)));
|
|
4277
|
+
if (dir.includes('n')) {
|
|
4278
|
+
const h = Math.round(Math.max(96, Math.min(2400, h0 - dy)));
|
|
4279
|
+
nd.hManual = h;
|
|
4280
|
+
nd.y = Math.round(y0 + (h0 - h));
|
|
4281
|
+
}
|
|
4282
|
+
});
|
|
4283
|
+
};
|
|
4284
|
+
const up = () => {
|
|
4285
|
+
window.removeEventListener('pointermove', move);
|
|
4286
|
+
window.removeEventListener('pointerup', up);
|
|
4287
|
+
pushHistory();
|
|
4288
|
+
};
|
|
4289
|
+
window.addEventListener('pointermove', move);
|
|
4290
|
+
window.addEventListener('pointerup', up);
|
|
4291
|
+
};
|
|
4292
|
+
const resetHeight = (e) => {
|
|
4293
|
+
e.stopPropagation();
|
|
4294
|
+
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.hManual = null; });
|
|
4295
|
+
pushHistory();
|
|
4296
|
+
};
|
|
3088
4297
|
return h('div', {
|
|
3089
4298
|
'data-node-id': n.id,
|
|
3090
|
-
className: cls('dshic-node', 'type-' + n.type, 'st-' + st, selected && 'is-selected'),
|
|
3091
|
-
style: { left: n.x, top: n.y, width: n.w },
|
|
4299
|
+
className: cls('dshic-node', 'type-' + n.type, 'st-' + st, selected && 'is-selected', n.hManual && 'h-manual'),
|
|
4300
|
+
style: { left: n.x, top: n.y, width: n.w, ...(n.hManual ? { height: n.hManual } : {}) },
|
|
3092
4301
|
ref: refCb,
|
|
3093
4302
|
onPointerDown: (e) => onNodePointerDown(e, n),
|
|
3094
4303
|
},
|
|
3095
4304
|
h('div', { className: 'dshic-node-head' },
|
|
3096
4305
|
h('span', { className: 'dshic-head-ic' }, Ic(TYPE_ICON[n.type] || 'text', 12)),
|
|
3097
|
-
|
|
4306
|
+
renaming
|
|
4307
|
+
? h('input', {
|
|
4308
|
+
className: 'dshic-input', style: { height: 22, flex: 1, minWidth: 0 }, autoFocus: true, value: nameVal,
|
|
4309
|
+
placeholder: TYPE_LABEL()[n.type],
|
|
4310
|
+
onChange: (e) => setNameVal(e.target.value),
|
|
4311
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
4312
|
+
onKeyDown: (e) => {
|
|
4313
|
+
if (e.key === 'Enter') commitName();
|
|
4314
|
+
if (e.key === 'Escape') setRenaming(false);
|
|
4315
|
+
},
|
|
4316
|
+
onBlur: commitName,
|
|
4317
|
+
})
|
|
4318
|
+
: h('span', {
|
|
4319
|
+
className: 'dshic-node-title', title: T('renameHint'),
|
|
4320
|
+
onDoubleClick: (e) => { e.stopPropagation(); startRename(); },
|
|
4321
|
+
}, n.data.label || TYPE_LABEL()[n.type]),
|
|
3098
4322
|
(n.data.status === 'running') ? h('span', { className: 'dshic-spin' }) : null,
|
|
4323
|
+
h('button', {
|
|
4324
|
+
type: 'button', className: 'dshic-node-x', title: T('renameHint'),
|
|
4325
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
4326
|
+
onClick: startRename,
|
|
4327
|
+
}, '✎'),
|
|
3099
4328
|
h('button', {
|
|
3100
4329
|
type: 'button', className: 'dshic-node-x', title: T('delete'),
|
|
3101
4330
|
onPointerDown: (e) => e.stopPropagation(),
|
|
3102
4331
|
onClick: () => removeNodes([n.id]),
|
|
3103
4332
|
}, '✕')),
|
|
3104
4333
|
h('div', { className: 'dshic-node-body' }, nodeBody(n)),
|
|
4334
|
+
// 尺寸把手:右/左/下/上四条边 + 右下角(双击底边恢复"随内容自适应")
|
|
4335
|
+
h('div', { className: 'dshic-rs dshic-rs-e', title: T('resizeHint'), onPointerDown: (e) => startResize(e, 'e') }),
|
|
4336
|
+
h('div', { className: 'dshic-rs dshic-rs-w', title: T('resizeHint'), onPointerDown: (e) => startResize(e, 'w') }),
|
|
4337
|
+
h('div', { className: 'dshic-rs dshic-rs-s', title: T('resizeHeightHint'), onPointerDown: (e) => startResize(e, 's'), onDoubleClick: resetHeight }),
|
|
4338
|
+
h('div', { className: 'dshic-rs dshic-rs-n', title: T('resizeHint'), onPointerDown: (e) => startResize(e, 'n') }),
|
|
4339
|
+
h('div', { className: 'dshic-rs dshic-rs-se', title: T('resizeHint'), onPointerDown: (e) => startResize(e, 'se') }),
|
|
3105
4340
|
...(PORTS[n.type].inputs || []).map((p, i) =>
|
|
3106
4341
|
h('div', { key: 'in-' + p.id, style: { position: 'absolute', left: -5.5, top: 32.5 + i * 22 }, className: 'dshic-port-wrap' },
|
|
3107
4342
|
h(PortEl, { n, port: p, dir: 'in' }),
|
|
@@ -3368,18 +4603,24 @@ window.__ModuleLoader__.load({
|
|
|
3368
4603
|
const [showMask, setShowMask] = useState(false);
|
|
3369
4604
|
const [showCraft, setShowCraft] = useState(false);
|
|
3370
4605
|
useEffect(() => { probeCraft(); }, []);
|
|
3371
|
-
const promptEdge = S.graph.edges.
|
|
4606
|
+
const promptEdge = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'prompt');
|
|
4607
|
+
const promptSrc = promptEdge && S.graph.nodes[promptEdge.fromNode];
|
|
4608
|
+
const fromStoryboard = !!(promptSrc && promptSrc.type === 'storyboard');
|
|
3372
4609
|
const refEdges = S.graph.edges.filter((x) => x.toNode === n.id && (kind === 'image' ? x.toPort === 'ref' : x.toPort === 'frame'));
|
|
3373
4610
|
const vrCount = S.graph.edges.filter((x) => x.toNode === n.id && x.toPort === 'ref').length;
|
|
3374
4611
|
const assetUrlV = useAssetUrl(n.data.assetId);
|
|
3375
4612
|
return [
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
4613
|
+
promptEdge
|
|
4614
|
+
? h('textarea', {
|
|
4615
|
+
key: 'p', className: 'dshic-textarea', value: n.data.prompt || '',
|
|
4616
|
+
placeholder: fromStoryboard ? T('multiShotHint') : T('connected'), disabled: true,
|
|
4617
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
4618
|
+
})
|
|
4619
|
+
: h(PromptArea, {
|
|
4620
|
+
key: 'p', value: n.data.prompt || '', minHeight: 72,
|
|
4621
|
+
placeholder: T('promptPlaceholder'),
|
|
4622
|
+
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.prompt = v; }),
|
|
4623
|
+
}),
|
|
3383
4624
|
h(GenSettingsRow, { key: 'set', n, kind }),
|
|
3384
4625
|
h('button', {
|
|
3385
4626
|
key: 'gen', type: 'button', className: 'dshic-gen', disabled: S.running || n.data.status === 'running',
|
|
@@ -3397,6 +4638,7 @@ window.__ModuleLoader__.load({
|
|
|
3397
4638
|
T('portFrame') + ' × ' + refEdges.length + ' · ' + T('connected'))
|
|
3398
4639
|
: null,
|
|
3399
4640
|
h(RefRows, { key: 'refrows', n, portId: 'ref' }),
|
|
4641
|
+
h(MentionInfo, { key: 'mn', n }),
|
|
3400
4642
|
n.data.error ? h('div', { key: 'err', className: 'dshic-err' }, n.data.error) : null,
|
|
3401
4643
|
assetUrlV && kind === 'image'
|
|
3402
4644
|
? h('img', { key: 'img', className: 'dshic-preview', src: assetUrlV, draggable: false, 'data-no-drag': '1' })
|
|
@@ -3488,6 +4730,36 @@ window.__ModuleLoader__.load({
|
|
|
3488
4730
|
}
|
|
3489
4731
|
|
|
3490
4732
|
/** 参考图排序列表:[图N] 顺序即提示词绑定顺序,可上移/下移/解除。 */
|
|
4733
|
+
/** @引用解析预览:提示词里 @名称 命中的设定集条目与其参考图资产(标注将成为第几张)。
|
|
4734
|
+
* 连线参考图排在前面,故 @ 资产的序号从连线数之后开始。 */
|
|
4735
|
+
function MentionInfo({ n }) {
|
|
4736
|
+
const prompt = n.data.prompt || '';
|
|
4737
|
+
const [rows, setRows] = useState([]);
|
|
4738
|
+
useEffect(() => {
|
|
4739
|
+
let ok = true;
|
|
4740
|
+
// 防抖:输入过程中不必每个按键都查一次资产库
|
|
4741
|
+
const timer = setTimeout(async () => {
|
|
4742
|
+
try {
|
|
4743
|
+
const bible = loadBible(S.activeId);
|
|
4744
|
+
const bundle = await resolveMentionBundle(prompt, bible, S.activeId);
|
|
4745
|
+
if (ok) setRows(bundle.entities.map((e) => ({ ...e, count: bundle.refs.filter((r) => r.entity === e.name).length })));
|
|
4746
|
+
} catch { if (ok) setRows([]); }
|
|
4747
|
+
}, 350);
|
|
4748
|
+
return () => { ok = false; clearTimeout(timer); };
|
|
4749
|
+
}, [prompt, S.assetsTick, S.activeId]);
|
|
4750
|
+
if (!rows.length) return null;
|
|
4751
|
+
const all = rows.reduce((a, r) => a + r.count, 0);
|
|
4752
|
+
return h('div', { className: 'dshic-shot-list' },
|
|
4753
|
+
h('div', { className: 'dshic-shot-label' }, T('mentionRefs') + '(' + rows.length + ')'),
|
|
4754
|
+
...rows.map((r, i) => h('div', { key: r.name, className: 'dshic-row', style: { gap: 5 } },
|
|
4755
|
+
h('span', { className: 'dshic-mention' }, '@' + r.name),
|
|
4756
|
+
h('span', { style: { flex: 1, minWidth: 0, fontSize: 11, color: 'var(--dsw-alias-label-secondary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
|
4757
|
+
entityLabelOf(r.kind) + ' · ' + (r.count ? T('mentionAssets').replace('{n}', String(r.count)) : T('mentionNoAsset'))),
|
|
4758
|
+
)),
|
|
4759
|
+
all === 0 ? h('div', { className: 'dshic-hint' }, T('mentionNoAssetHint')) : null,
|
|
4760
|
+
);
|
|
4761
|
+
}
|
|
4762
|
+
|
|
3491
4763
|
function RefRows({ n, portId }) {
|
|
3492
4764
|
const [rows, setRows] = useState([]);
|
|
3493
4765
|
const es = S.graph.edges.filter((x) => x.toNode === n.id && x.toPort === portId);
|
|
@@ -3518,7 +4790,7 @@ window.__ModuleLoader__.load({
|
|
|
3518
4790
|
if (ok) setRows(out);
|
|
3519
4791
|
})();
|
|
3520
4792
|
return () => { ok = false; };
|
|
3521
|
-
}, [idsKey, S.assetsTick]);
|
|
4793
|
+
}, [idsKey, S.assetsTick, (n.data.refOrder || []).join(',')]); // refOrder 变化必须重建:否则 [图N] 与实际上送顺序不一致
|
|
3522
4794
|
if (!es.length) return null;
|
|
3523
4795
|
const move = (id, dir) => {
|
|
3524
4796
|
update((s) => {
|
|
@@ -3556,7 +4828,11 @@ window.__ModuleLoader__.load({
|
|
|
3556
4828
|
const isImg = f.type.startsWith('image/');
|
|
3557
4829
|
const isVid = f.type.startsWith('video/');
|
|
3558
4830
|
if (kind === 'image' ? !isImg : !isVid) { toast(kind === 'image' ? T('pickImageFile') : T('pickVideoFile')); return; }
|
|
3559
|
-
const rec = await saveAssetRecord(S.activeId, kind, f, { name: f.name, source: 'upload', category: 'general' })
|
|
4831
|
+
const rec = await saveAssetRecord(S.activeId, kind, f, { name: f.name, source: 'upload', category: 'general' }).catch((err) => {
|
|
4832
|
+
toast(T('uploadFailed') + ':' + String((err && err.message) || err));
|
|
4833
|
+
return null;
|
|
4834
|
+
});
|
|
4835
|
+
if (!rec) return;
|
|
3560
4836
|
update((s) => {
|
|
3561
4837
|
const nd = s.graph.nodes[n.id];
|
|
3562
4838
|
if (nd) { nd.data.assetId = rec.id; nd.data.status = 'done'; nd.data.placeholderName = null; }
|
|
@@ -3637,23 +4913,44 @@ window.__ModuleLoader__.load({
|
|
|
3637
4913
|
);
|
|
3638
4914
|
}
|
|
3639
4915
|
|
|
4916
|
+
/**
|
|
4917
|
+
* 提示词输入框:@名称 蓝色加粗高亮。
|
|
4918
|
+
* 与主流工具同一做法——透明文字的 textarea 叠在同步滚动的背景层上,
|
|
4919
|
+
* 背景层渲染同一段文本并给 @名称 上色,光标/选区/中文输入法都不受影响。
|
|
4920
|
+
*/
|
|
4921
|
+
function PromptArea({ value, onChange, placeholder, minHeight, style }) {
|
|
4922
|
+
const taRef = useRef(null);
|
|
4923
|
+
const backRef = useRef(null);
|
|
4924
|
+
const sync = () => {
|
|
4925
|
+
if (backRef.current && taRef.current) {
|
|
4926
|
+
backRef.current.scrollTop = taRef.current.scrollTop;
|
|
4927
|
+
backRef.current.scrollLeft = taRef.current.scrollLeft;
|
|
4928
|
+
}
|
|
4929
|
+
};
|
|
4930
|
+
const html = highlightMentions(value || '');
|
|
4931
|
+
const ph = placeholder ? '<span class="dshic-prompt-ph">' + escapeHtml(placeholder) + '</span>' : '';
|
|
4932
|
+
return h('div', { className: 'dshic-prompt' },
|
|
4933
|
+
h('div', {
|
|
4934
|
+
ref: backRef, className: 'dshic-prompt-back', 'aria-hidden': 'true',
|
|
4935
|
+
dangerouslySetInnerHTML: { __html: html || ph },
|
|
4936
|
+
}),
|
|
4937
|
+
h('textarea', {
|
|
4938
|
+
ref: taRef, className: 'dshic-textarea dshic-prompt-input', value: value || '',
|
|
4939
|
+
placeholder: html ? undefined : placeholder,
|
|
4940
|
+
style: { minHeight: minHeight || 72, ...(style || {}) },
|
|
4941
|
+
onChange: (e) => onChange(e.target.value),
|
|
4942
|
+
onScroll: sync,
|
|
4943
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
4944
|
+
onKeyUp: sync,
|
|
4945
|
+
onClick: sync,
|
|
4946
|
+
}),
|
|
4947
|
+
);
|
|
4948
|
+
}
|
|
4949
|
+
|
|
3640
4950
|
function ScriptNodeBody({ n }) {
|
|
3641
4951
|
const running = n.data.aiStatus === 'running';
|
|
3642
4952
|
const generate = async () => {
|
|
3643
|
-
|
|
3644
|
-
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.aiStatus = 'running'; nd.data.aiError = null; } });
|
|
3645
|
-
try {
|
|
3646
|
-
const text = await aiScriptNodes(n.data.idea, n.data.model);
|
|
3647
|
-
update((s) => {
|
|
3648
|
-
const nd = s.graph.nodes[n.id];
|
|
3649
|
-
if (nd) { nd.data.output = text; nd.data.aiStatus = 'idle'; }
|
|
3650
|
-
s.assetsTick++;
|
|
3651
|
-
});
|
|
3652
|
-
await saveTextAssetRecord(S.activeId, { name: T('scriptAssetPrefix') + new Date().toISOString().slice(5, 16).replace('T', ' '), text, category: 'script', prompt: n.data.idea });
|
|
3653
|
-
update((s) => { s.assetsTick++; });
|
|
3654
|
-
} catch (e) {
|
|
3655
|
-
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.aiStatus = 'error'; nd.data.aiError = String(e.message || e); } });
|
|
3656
|
-
}
|
|
4953
|
+
try { await genScriptNode(n); } catch (e) { toast(String((e && e.message) || e)); }
|
|
3657
4954
|
};
|
|
3658
4955
|
return [
|
|
3659
4956
|
h('div', { key: 'mrow', className: 'dshic-node-row' },
|
|
@@ -3662,13 +4959,17 @@ window.__ModuleLoader__.load({
|
|
|
3662
4959
|
),
|
|
3663
4960
|
h('span', { key: 'l1', className: 'dshic-shot-label' }, T('ideaLabel')),
|
|
3664
4961
|
h('textarea', {
|
|
3665
|
-
key: 'idea', className: 'dshic-textarea', style: { minHeight:
|
|
4962
|
+
key: 'idea', className: 'dshic-textarea', style: { minHeight: 72 }, value: n.data.idea || '',
|
|
3666
4963
|
placeholder: T('ideaPlaceholder'),
|
|
3667
4964
|
onChange: (e) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.idea = e.target.value; }),
|
|
3668
4965
|
onPointerDown: (e) => e.stopPropagation(),
|
|
3669
4966
|
}),
|
|
3670
4967
|
h('button', { key: 'gen', type: 'button', className: 'dshic-gen', disabled: running, onClick: generate, onPointerDown: (e) => e.stopPropagation() },
|
|
3671
4968
|
running ? h('span', { className: 'dshic-spin' }) : Ic('spark', 12), running ? T('aiRunning') : T('aiScript')),
|
|
4969
|
+
running && aiStreamLine(n.id)
|
|
4970
|
+
? h('div', { key: 'stream', className: 'dshic-stream', title: aiStreamLine(n.id) },
|
|
4971
|
+
h('span', { className: 'dshic-stream-dot' }), aiStreamLine(n.id))
|
|
4972
|
+
: null,
|
|
3672
4973
|
h('div', { key: 'btns', className: 'dshic-node-row' },
|
|
3673
4974
|
h('div', { className: 'dshic-flex' }),
|
|
3674
4975
|
n.data.output ? h('button', {
|
|
@@ -3684,7 +4985,7 @@ window.__ModuleLoader__.load({
|
|
|
3684
4985
|
n.data.aiError ? h('div', { key: 'err', className: 'dshic-err' }, n.data.aiError) : null,
|
|
3685
4986
|
h('span', { key: 'l2', className: 'dshic-shot-label' }, T('scriptOutputLabel')),
|
|
3686
4987
|
h('textarea', {
|
|
3687
|
-
key: 'out', className: 'dshic-textarea', style: { minHeight:
|
|
4988
|
+
key: 'out', className: 'dshic-textarea', style: { minHeight: 180 }, value: n.data.output || '',
|
|
3688
4989
|
placeholder: running ? T('aiRunning') : '',
|
|
3689
4990
|
onChange: (e) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.output = e.target.value; }),
|
|
3690
4991
|
onPointerDown: (e) => e.stopPropagation(),
|
|
@@ -3707,24 +5008,7 @@ window.__ModuleLoader__.load({
|
|
|
3707
5008
|
const generate = async () => {
|
|
3708
5009
|
const st = scriptText();
|
|
3709
5010
|
if (!st) { toast(T('aiNeedScript')); return; }
|
|
3710
|
-
|
|
3711
|
-
try {
|
|
3712
|
-
const shots = await aiStoryboardShots(st, n.data.requirement, n.data.count || 6, n.data.model);
|
|
3713
|
-
const warnings = await checkShotConsistency(shots);
|
|
3714
|
-
update((s) => {
|
|
3715
|
-
const nd = s.graph.nodes[n.id];
|
|
3716
|
-
if (nd) { nd.data.shots = shots; nd.data.aiStatus = 'idle'; nd.data.warnings = warnings; }
|
|
3717
|
-
s.assetsTick++;
|
|
3718
|
-
});
|
|
3719
|
-
await saveTextAssetRecord(S.activeId, {
|
|
3720
|
-
name: T('storyboardAssetPrefix') + new Date().toISOString().slice(5, 16).replace('T', ' '),
|
|
3721
|
-
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'),
|
|
3722
|
-
category: 'storyboard', prompt: st.slice(0, 200),
|
|
3723
|
-
});
|
|
3724
|
-
update((s) => { s.assetsTick++; });
|
|
3725
|
-
} catch (e) {
|
|
3726
|
-
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.aiStatus = 'error'; nd.data.aiError = String(e.message || e); } });
|
|
3727
|
-
}
|
|
5011
|
+
try { await genStoryboardNode(n); } catch (e) { toast(String((e && e.message) || e)); }
|
|
3728
5012
|
};
|
|
3729
5013
|
const shots = n.data.shots || [];
|
|
3730
5014
|
return [
|
|
@@ -3749,13 +5033,17 @@ window.__ModuleLoader__.load({
|
|
|
3749
5033
|
h('div', { key: 'row', className: 'dshic-node-row' },
|
|
3750
5034
|
h(MiniSelect, {
|
|
3751
5035
|
value: String(n.data.count || 6), title: T('storyboardCountLabel'), width: 72,
|
|
3752
|
-
options: [3, 4,
|
|
5036
|
+
options: [3, 4, 6, 8, 10, 12, 16].map((x) => ({ value: String(x), label: x + ' ' + T('shotsUnit') })),
|
|
3753
5037
|
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.count = Number(v); }),
|
|
3754
5038
|
}),
|
|
3755
5039
|
h('div', { className: 'dshic-flex' }),
|
|
3756
5040
|
),
|
|
3757
5041
|
h('button', { key: 'gen', type: 'button', className: 'dshic-gen', disabled: running, onClick: generate, onPointerDown: (e) => e.stopPropagation() },
|
|
3758
5042
|
running ? h('span', { className: 'dshic-spin' }) : Ic('spark', 12), running ? T('aiRunning') : T('aiStoryboard')),
|
|
5043
|
+
running && aiStreamLine(n.id)
|
|
5044
|
+
? h('div', { key: 'stream', className: 'dshic-stream', title: aiStreamLine(n.id) },
|
|
5045
|
+
h('span', { className: 'dshic-stream-dot' }), aiStreamLine(n.id))
|
|
5046
|
+
: null,
|
|
3759
5047
|
n.data.aiError ? h('div', { key: 'err', className: 'dshic-err' }, n.data.aiError) : null,
|
|
3760
5048
|
(n.data.warnings || []).length
|
|
3761
5049
|
? h('div', { key: 'warn', className: 'dshic-card', style: { padding: 6, gap: 3 } },
|
|
@@ -3776,16 +5064,28 @@ window.__ModuleLoader__.load({
|
|
|
3776
5064
|
];
|
|
3777
5065
|
}
|
|
3778
5066
|
|
|
3779
|
-
// 分镜表 →
|
|
3780
|
-
//
|
|
5067
|
+
// 分镜表 → 画布(现代多镜头工作流):**一个分镜表 = 一次视频生成**。
|
|
5068
|
+
// 分镜表(镜头脚本)连到 1 个视频生成节点的 prompt 端口(运行时把全部镜头按顺序+时长组合成
|
|
5069
|
+
// 一条多镜头提示词,一次性生成整段视频);角色/场景/道具参考图按实体**跨镜去重**后连到 ref 端口;
|
|
5070
|
+
// 视频生成 → 1 个视频资产。不再一镜一视频、无需拼接。
|
|
3781
5071
|
async function expandStoryboard(n) {
|
|
3782
5072
|
const shots = (n.data.shots || []).slice();
|
|
3783
5073
|
if (!shots.length) { toast(T('expandNoShots')); return; }
|
|
3784
5074
|
// 项目资产名称索引(用于把角色/场景/道具连接到已有资产节点)
|
|
3785
5075
|
const recs = await idbByProject(S.activeId).catch(() => []);
|
|
3786
|
-
const recById = new Map(recs.map((r) => [r.id, r]));
|
|
3787
5076
|
const norm = (s) => String(s || '').trim().toLowerCase();
|
|
3788
5077
|
const nameIndex = recs.filter((r) => r.kind === 'image').map((r) => ({ id: r.id, name: norm(r.name), category: r.category }));
|
|
5078
|
+
// 跨镜去重的实体清单:所有镜头共用的角色/场景/道具各一个参考图节点
|
|
5079
|
+
const entityNames = [];
|
|
5080
|
+
const pushName = (nm) => {
|
|
5081
|
+
const q = String(nm || '').trim();
|
|
5082
|
+
if (q && !entityNames.some((x) => x.toLowerCase() === q.toLowerCase())) entityNames.push(q);
|
|
5083
|
+
};
|
|
5084
|
+
for (const sh of shots) {
|
|
5085
|
+
(sh.characters || []).forEach(pushName);
|
|
5086
|
+
if (sh.scene) pushName(sh.scene);
|
|
5087
|
+
(sh.props || []).forEach(pushName);
|
|
5088
|
+
}
|
|
3789
5089
|
update((s) => {
|
|
3790
5090
|
const findAssetNode = (name) => {
|
|
3791
5091
|
const q = norm(name);
|
|
@@ -3804,47 +5104,30 @@ window.__ModuleLoader__.load({
|
|
|
3804
5104
|
if (reaches(to.id, from.id)) return;
|
|
3805
5105
|
s.graph.edges.push({ id: uid('e'), fromNode: from.id, fromPort: fp, toNode: to.id, toPort: tp });
|
|
3806
5106
|
};
|
|
3807
|
-
const
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
const videoAsset = mk('video-asset', cx + 600, cy + 300);
|
|
3825
|
-
const wireRef = (names, port) => {
|
|
3826
|
-
let plcRow = 0;
|
|
3827
|
-
for (const nm of names) {
|
|
3828
|
-
let an = findAssetNode(nm);
|
|
3829
|
-
if (!an) {
|
|
3830
|
-
an = mk('image-asset', cx - 300, cy - 40 + plcRow * 120, { placeholderName: nm });
|
|
3831
|
-
plcRow++;
|
|
3832
|
-
}
|
|
3833
|
-
if (port) linkRaw(an, 'out', shotNode, port);
|
|
3834
|
-
linkRaw(an, 'out', imageGen, 'ref');
|
|
3835
|
-
}
|
|
3836
|
-
};
|
|
3837
|
-
wireRef(shot.characters, 'char');
|
|
3838
|
-
if (shot.scene) wireRef([shot.scene], 'scene');
|
|
3839
|
-
wireRef(shot.props, 'prop');
|
|
3840
|
-
linkRaw(shotNode, 'visual', imageGen, 'prompt');
|
|
3841
|
-
linkRaw(imageGen, 'out', videoGen, 'frame');
|
|
3842
|
-
linkRaw(shotNode, 'motion', videoGen, 'prompt');
|
|
3843
|
-
linkRaw(videoGen, 'out', videoAsset, 'in');
|
|
5107
|
+
const mk = (type, x, y, dataOver, label) => {
|
|
5108
|
+
const nd = newNode(type, { x, y });
|
|
5109
|
+
if (dataOver) nd.data = { ...nd.data, ...dataOver };
|
|
5110
|
+
if (label) nd.data.label = label;
|
|
5111
|
+
s.graph.nodes[nd.id] = nd;
|
|
5112
|
+
return nd;
|
|
5113
|
+
};
|
|
5114
|
+
const baseX = n.x + n.w + 140;
|
|
5115
|
+
const baseY = n.y - 40;
|
|
5116
|
+
// 生成时长 = 各镜时长之和(多镜头一次性生成的总时长),由视频模型内部切镜
|
|
5117
|
+
const total = shots.reduce((a, sh) => a + Math.max(1, Number(sh.duration) || 3), 0);
|
|
5118
|
+
const videoGen = mk('video-gen', baseX + 360, baseY + 20, { prompt: '', duration: Math.max(3, Math.min(20, total)) });
|
|
5119
|
+
const videoAsset = mk('video-asset', baseX + 720, baseY + 20);
|
|
5120
|
+
entityNames.forEach((nm, i) => {
|
|
5121
|
+
let an = findAssetNode(nm);
|
|
5122
|
+
if (!an) an = mk('image-asset', baseX, baseY + i * 130, { placeholderName: nm });
|
|
5123
|
+
linkRaw(an, 'out', videoGen, 'ref');
|
|
3844
5124
|
});
|
|
3845
|
-
|
|
5125
|
+
linkRaw(n, 'out', videoGen, 'prompt'); // 多镜头提示词来自上游分镜表(不复制文本)
|
|
5126
|
+
linkRaw(videoGen, 'out', videoAsset, 'in');
|
|
5127
|
+
s.sel = new Set([videoGen.id]);
|
|
3846
5128
|
});
|
|
3847
5129
|
pushHistory();
|
|
5130
|
+
flushSave(); // 批量铺开立即推送,Host 侧立即可见
|
|
3848
5131
|
toast(T('expandDone').replace('{n}', String(shots.length)));
|
|
3849
5132
|
setTimeout(() => fitView(), 60);
|
|
3850
5133
|
}
|
|
@@ -3873,11 +5156,11 @@ window.__ModuleLoader__.load({
|
|
|
3873
5156
|
if (!text) { toast(T('subPlaceholder')); return; }
|
|
3874
5157
|
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' }; } });
|
|
3875
5158
|
try {
|
|
3876
|
-
const res = await
|
|
5159
|
+
const res = await fetchT((cfg.baseUrl || '').replace(/\/+$/, '') + '/audio/speech', {
|
|
3877
5160
|
method: 'POST',
|
|
3878
5161
|
headers: { 'Content-Type': 'application/json', ...(cfg.apiKey ? { Authorization: 'Bearer ' + cfg.apiKey } : {}) },
|
|
3879
5162
|
body: JSON.stringify({ ...(cfg.model ? { model: cfg.model } : {}), input: text, voice: cfg.voice || 'alloy', response_format: 'mp3' }),
|
|
3880
|
-
});
|
|
5163
|
+
}, cfg.timeoutMs, 'TTS API');
|
|
3881
5164
|
if (!res.ok) throw new Error('TTS HTTP ' + res.status + ':' + (await res.text()).slice(0, 200));
|
|
3882
5165
|
const buf = await res.arrayBuffer();
|
|
3883
5166
|
const blob = new Blob([buf], { type: res.headers.get('content-type') || 'audio/mpeg' });
|
|
@@ -3889,40 +5172,7 @@ window.__ModuleLoader__.load({
|
|
|
3889
5172
|
};
|
|
3890
5173
|
const compose = async () => {
|
|
3891
5174
|
if (!segs.length) { toast(T('noSegments')); return; }
|
|
3892
|
-
|
|
3893
|
-
for (const x of segs) {
|
|
3894
|
-
if (!x.nd.data.assetId) continue;
|
|
3895
|
-
try {
|
|
3896
|
-
const rec = await idbGet(x.nd.data.assetId);
|
|
3897
|
-
if (rec && rec.blob) {
|
|
3898
|
-
const ttsId = n.data.tts && n.data.tts[x.e.id] && n.data.tts[x.e.id].assetId;
|
|
3899
|
-
const ttsUrl = ttsId ? await assetUrl(ttsId) : null;
|
|
3900
|
-
metas.push({ url: URL.createObjectURL(rec.blob), name: rec.name || 'seg', sub: getSub(x), ttsUrl });
|
|
3901
|
-
}
|
|
3902
|
-
} catch { /* 跳过加载失败的片段 */ }
|
|
3903
|
-
}
|
|
3904
|
-
if (!metas.length) { toast(T('noSegments')); return; }
|
|
3905
|
-
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.status = 'running'; nd.data.error = null; nd.data.progress = '0%'; } });
|
|
3906
|
-
try {
|
|
3907
|
-
const dims = videoDimsFor({ resolution: n.data.resolution, ratio: n.data.ratio });
|
|
3908
|
-
const blob = await composeFinalCut(metas, { width: dims.w, height: dims.h, keepAudio: n.data.keepAudio !== false, transition: n.data.transition }, (frac, name) => {
|
|
3909
|
-
update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.progress = Math.round(frac * 100) + '% · ' + name; });
|
|
3910
|
-
});
|
|
3911
|
-
const rec = await saveAssetRecord(S.activeId, 'video', blob, {
|
|
3912
|
-
name: T('finalCutDone') + '-' + new Date().toISOString().slice(5, 16).replace('T', ' '),
|
|
3913
|
-
prompt: metas.length + ' segments', source: 'finalcut', category: 'finalcut',
|
|
3914
|
-
});
|
|
3915
|
-
metas.forEach((m) => URL.revokeObjectURL(m.url));
|
|
3916
|
-
update((s) => {
|
|
3917
|
-
const nd = s.graph.nodes[n.id];
|
|
3918
|
-
if (nd) { nd.data.status = 'done'; nd.data.assetId = rec.id; nd.data.progress = null; }
|
|
3919
|
-
s.assetsTick++;
|
|
3920
|
-
});
|
|
3921
|
-
toast(T('composeDone'));
|
|
3922
|
-
} catch (e) {
|
|
3923
|
-
metas.forEach((m) => URL.revokeObjectURL(m.url));
|
|
3924
|
-
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; } });
|
|
3925
|
-
}
|
|
5175
|
+
try { await genFinalCutNode(n); toast(T('composeDone')); } catch (e) { toast(String((e && e.message) || e)); }
|
|
3926
5176
|
};
|
|
3927
5177
|
const filmcraftExport = async () => {
|
|
3928
5178
|
const st = await craftStatusSafe();
|
|
@@ -3933,9 +5183,12 @@ window.__ModuleLoader__.load({
|
|
|
3933
5183
|
if (!x.nd.data.assetId) continue;
|
|
3934
5184
|
try {
|
|
3935
5185
|
const rec = await idbGet(x.nd.data.assetId);
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
5186
|
+
const blob = await assetBlob(x.nd.data.assetId);
|
|
5187
|
+
if (!blob) continue;
|
|
5188
|
+
const rel = await ensureAssetOnDisk({ ...(rec || {}), id: x.nd.data.assetId, blob });
|
|
5189
|
+
const vd = await videoDurationOf(blob);
|
|
5190
|
+
const ed = Number(x.nd.data.editDuration) || 0;
|
|
5191
|
+
items.push({ rel, name: (rec && rec.name) || 'seg', sub: getSub(x), dur: ed > 0 ? Math.min(ed, vd) : vd });
|
|
3939
5192
|
} catch (e) { toast('Craft: ' + (e.message || e)); return; }
|
|
3940
5193
|
}
|
|
3941
5194
|
if (!items.length) { toast(T('noSegments')); return; }
|
|
@@ -3987,6 +5240,16 @@ window.__ModuleLoader__.load({
|
|
|
3987
5240
|
options: [{ value: 'on', label: T('keepAudio') + ' ' + T('on') }, { value: 'off', label: T('keepAudio') + ' ' + T('off') }],
|
|
3988
5241
|
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.keepAudio = v !== 'off'; }),
|
|
3989
5242
|
}),
|
|
5243
|
+
h(MiniSelect, {
|
|
5244
|
+
value: n.data.bitrate || '', title: T('bitrate'), width: 92,
|
|
5245
|
+
options: [
|
|
5246
|
+
{ value: '', label: T('bitrateAuto') },
|
|
5247
|
+
{ value: '4000000', label: '4 Mbps' },
|
|
5248
|
+
{ value: '8000000', label: '8 Mbps' },
|
|
5249
|
+
{ value: '12000000', label: '12 Mbps' },
|
|
5250
|
+
],
|
|
5251
|
+
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.bitrate = v; }),
|
|
5252
|
+
}),
|
|
3990
5253
|
h('div', { className: 'dshic-flex' }),
|
|
3991
5254
|
),
|
|
3992
5255
|
),
|
|
@@ -4118,6 +5381,7 @@ window.__ModuleLoader__.load({
|
|
|
4118
5381
|
ev.stopPropagation();
|
|
4119
5382
|
update((s) => { s.graph.edges = s.graph.edges.filter((x) => x.id !== e.id); if (s.selEdge === e.id) s.selEdge = null; });
|
|
4120
5383
|
pushHistory();
|
|
5384
|
+
flushSave(); // 边删除立即推送:压缩 450ms 防抖窗口内的"拉回复活"
|
|
4121
5385
|
},
|
|
4122
5386
|
},
|
|
4123
5387
|
h('circle', { r: 9, className: 'dshic-edge-delbg' }),
|
|
@@ -4248,6 +5512,7 @@ window.__ModuleLoader__.load({
|
|
|
4248
5512
|
const p = { id: uid('p'), name: nm, createdAt: Date.now(), updatedAt: Date.now() };
|
|
4249
5513
|
update((s) => { s.projects.push(p); });
|
|
4250
5514
|
switchProject(p.id);
|
|
5515
|
+
try { hostRpc('projectsPut', { id: p.id, name: nm, doc: emptyGraph() }).catch(() => { /* 静默 */ }); } catch { /* 忽略 */ }
|
|
4251
5516
|
setCreating(false); setName('');
|
|
4252
5517
|
};
|
|
4253
5518
|
const commitRename = () => {
|
|
@@ -4258,6 +5523,10 @@ window.__ModuleLoader__.load({
|
|
|
4258
5523
|
if (p) { p.name = v; p.updatedAt = Date.now(); }
|
|
4259
5524
|
});
|
|
4260
5525
|
lsSet(LS_PROJECTS, S.projects);
|
|
5526
|
+
try {
|
|
5527
|
+
const doc = loadGraphDoc(editId) || emptyGraph();
|
|
5528
|
+
hostRpc('projectsPut', { id: editId, name: v, doc }).catch(() => { /* 静默 */ });
|
|
5529
|
+
} catch { /* 忽略 */ }
|
|
4261
5530
|
}
|
|
4262
5531
|
setEditId(null);
|
|
4263
5532
|
};
|
|
@@ -4295,9 +5564,15 @@ window.__ModuleLoader__.load({
|
|
|
4295
5564
|
});
|
|
4296
5565
|
lsSet(LS_PROJECTS, S.projects);
|
|
4297
5566
|
lsSet(LS_ACTIVE, S.activeId);
|
|
5567
|
+
try { hostRpc('projectsDelete', { id: pid }).catch(() => { /* 静默 */ }); } catch { /* 忽略 */ }
|
|
4298
5568
|
try {
|
|
4299
5569
|
const recs = await idbByProject(pid);
|
|
4300
|
-
for (const r of recs) {
|
|
5570
|
+
for (const r of recs) {
|
|
5571
|
+
try {
|
|
5572
|
+
if (r.rel) { try { await hostRpc('assetDelete', { rel: r.rel }); } catch { /* 磁盘清理失败不阻塞 */ } }
|
|
5573
|
+
await idbDelete(r.id);
|
|
5574
|
+
} catch { /* 逐个容错 */ }
|
|
5575
|
+
}
|
|
4301
5576
|
} catch { /* 资产清理失败不影响项目删除 */ }
|
|
4302
5577
|
setDelTarget(null);
|
|
4303
5578
|
};
|
|
@@ -4404,25 +5679,46 @@ window.__ModuleLoader__.load({
|
|
|
4404
5679
|
const [assets, setAssets] = useState([]);
|
|
4405
5680
|
const [tick, setTick] = useState(0);
|
|
4406
5681
|
const [filter, setFilter] = useState('all');
|
|
4407
|
-
const [scope, setScope] = useState('
|
|
5682
|
+
const [scope, setScope] = useState(S.activeId || 'all'); // 打开资产面板默认定位到当前项目
|
|
5683
|
+
const prevActiveRef = useRef(S.activeId);
|
|
5684
|
+
useEffect(() => {
|
|
5685
|
+
// 当前项目变化时,资产筛选跟随(面板重开也会重置为当前项目)
|
|
5686
|
+
if (S.activeId && S.activeId !== prevActiveRef.current) { prevActiveRef.current = S.activeId; setScope(S.activeId); }
|
|
5687
|
+
}, [S.activeId]);
|
|
4408
5688
|
const [search, setSearch] = useState('');
|
|
4409
5689
|
const [craftFor, setCraftFor] = useState(null);
|
|
5690
|
+
const [delTarget, setDelTarget] = useState(null); // 待确认删除的资产(确认框见 DeleteAssetModal)
|
|
5691
|
+
const [assetName, setAssetName] = useState(''); // 删除确认框的输入内容
|
|
4410
5692
|
const fileRef = useRef(null);
|
|
4411
5693
|
useEffect(() => {
|
|
4412
5694
|
probeCraft();
|
|
4413
5695
|
let ok = true;
|
|
4414
5696
|
(async () => {
|
|
4415
5697
|
try {
|
|
4416
|
-
|
|
5698
|
+
let list;
|
|
5699
|
+
if (scope === 'all') list = await idbAll();
|
|
5700
|
+
else list = (await idbAll()).filter((a) => a.projectId === scope || a.projectId === GENERAL_PID);
|
|
4417
5701
|
if (ok) setAssets(list);
|
|
4418
5702
|
} catch { if (ok) setAssets([]); }
|
|
4419
5703
|
})();
|
|
4420
5704
|
return () => { ok = false; };
|
|
4421
5705
|
}, [S.activeId, tick, S.assetsTick, scope]);
|
|
4422
5706
|
const projectName = (pid) => {
|
|
5707
|
+
if (pid === GENERAL_PID) return T('generalProject');
|
|
4423
5708
|
const p = S.projects.find((x) => x.id === pid);
|
|
4424
5709
|
return p ? p.name : '';
|
|
4425
5710
|
};
|
|
5711
|
+
/** 资产改所属项目(通用/其它项目):通用资产多项目共用。 */
|
|
5712
|
+
const moveAsset = async (a, targetPid) => {
|
|
5713
|
+
if (!targetPid || targetPid === (a.projectId || '')) return;
|
|
5714
|
+
const rec = await idbGet(a.id);
|
|
5715
|
+
if (!rec) return;
|
|
5716
|
+
rec.projectId = targetPid;
|
|
5717
|
+
await idbPut(rec);
|
|
5718
|
+
update((s) => { s.assetsTick++; });
|
|
5719
|
+
setTick((x) => x + 1);
|
|
5720
|
+
toast(T('moved'));
|
|
5721
|
+
};
|
|
4426
5722
|
const addNodeFor = (a) => {
|
|
4427
5723
|
if (a.kind === 'audio') return; // 音频资产在卡片内直接试听
|
|
4428
5724
|
if (a.kind === 'text') {
|
|
@@ -4447,14 +5743,19 @@ window.__ModuleLoader__.load({
|
|
|
4447
5743
|
const onImport = async (e) => {
|
|
4448
5744
|
const files = [...((e.target.files) || [])];
|
|
4449
5745
|
e.target.value = '';
|
|
5746
|
+
let failed = 0;
|
|
4450
5747
|
for (const f of files) {
|
|
4451
5748
|
const kind = f.type.startsWith('video/') ? 'video' : f.type.startsWith('image/') ? 'image' : null;
|
|
4452
5749
|
if (!kind) continue;
|
|
4453
|
-
|
|
5750
|
+
try {
|
|
5751
|
+
await saveAssetRecord(S.activeId, kind, f, { name: f.name, source: 'upload', category: 'general' });
|
|
5752
|
+
} catch { failed++; } // 单个失败不影响其余文件
|
|
4454
5753
|
}
|
|
5754
|
+
if (failed) toast(T('uploadFailed') + '(' + failed + '/' + files.length + ')');
|
|
4455
5755
|
if (files.length) setTick((x) => x + 1);
|
|
4456
5756
|
};
|
|
4457
5757
|
const del = async (a) => {
|
|
5758
|
+
if (a.rel) { try { await hostRpc('assetDelete', { rel: a.rel }); } catch { /* 磁盘清理失败不阻塞 */ } }
|
|
4458
5759
|
await idbDelete(a.id);
|
|
4459
5760
|
update((s) => {
|
|
4460
5761
|
for (const nd of Object.values(s.graph.nodes)) {
|
|
@@ -4464,11 +5765,22 @@ window.__ModuleLoader__.load({
|
|
|
4464
5765
|
});
|
|
4465
5766
|
setTick((x) => x + 1);
|
|
4466
5767
|
};
|
|
5768
|
+
// 资产重命名:写回 IndexedDB 记录并刷新名称缓存(@引用与连线名称都吃这份缓存)
|
|
5769
|
+
const rename = async (a, name) => {
|
|
5770
|
+
const rec = await idbGet(a.id);
|
|
5771
|
+
if (!rec) return;
|
|
5772
|
+
rec.name = name;
|
|
5773
|
+
await idbPut(rec);
|
|
5774
|
+
assetNameCache.set(a.id, name);
|
|
5775
|
+
update((s) => { s.assetsTick++; });
|
|
5776
|
+
setTick((x) => x + 1);
|
|
5777
|
+
toast(T('renamed'));
|
|
5778
|
+
};
|
|
4467
5779
|
const q = search.trim().toLowerCase();
|
|
4468
5780
|
const syncAll = async () => {
|
|
4469
5781
|
const st = await craftStatusSafe();
|
|
4470
5782
|
if (!st) return;
|
|
4471
|
-
const recs = assets.filter((a) => a.blob);
|
|
5783
|
+
const recs = assets.filter((a) => a.blob || a.rel);
|
|
4472
5784
|
let okN = 0, failN = 0;
|
|
4473
5785
|
for (const a of recs) {
|
|
4474
5786
|
try { await syncAssetToDisk(a, { force: true }); okN++; } catch { failN++; }
|
|
@@ -4476,7 +5788,7 @@ window.__ModuleLoader__.load({
|
|
|
4476
5788
|
toast(T('craftSyncDone').replace('{ok}', String(okN)).replace('{fail}', String(failN)));
|
|
4477
5789
|
};
|
|
4478
5790
|
const shown = assets.filter((a) => {
|
|
4479
|
-
if (scope !== 'all' && scope !==
|
|
5791
|
+
if (scope !== 'all' && scope !== GENERAL_PID && a.projectId !== scope && a.projectId !== GENERAL_PID) return false;
|
|
4480
5792
|
if (filter !== 'all' && (a.category || 'general') !== filter) return false;
|
|
4481
5793
|
if (!q) return true;
|
|
4482
5794
|
const hay = [
|
|
@@ -4502,8 +5814,8 @@ window.__ModuleLoader__.load({
|
|
|
4502
5814
|
}),
|
|
4503
5815
|
h(MiniSelect, {
|
|
4504
5816
|
key: 'scope', value: scope, title: T('projectFilter'),
|
|
4505
|
-
options: [{ value: '
|
|
4506
|
-
.concat(S.projects.
|
|
5817
|
+
options: [{ value: 'all', label: T('allProjects') }, { value: GENERAL_PID, label: T('generalProject') }]
|
|
5818
|
+
.concat(S.projects.map((p) => ({ value: p.id, label: p.name }))),
|
|
4507
5819
|
onChange: (v) => setScope(v),
|
|
4508
5820
|
}),
|
|
4509
5821
|
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')),
|
|
@@ -4513,25 +5825,110 @@ window.__ModuleLoader__.load({
|
|
|
4513
5825
|
? h('div', { key: 'empty', className: 'dshic-hint' }, T('emptyAssets'))
|
|
4514
5826
|
: h('div', { key: 'grid', className: 'dshic-asset-grid' },
|
|
4515
5827
|
shown.map((a) => h(AssetCard, {
|
|
4516
|
-
key: a.id, a, onAdd: addNodeFor, onDel:
|
|
4517
|
-
|
|
5828
|
+
key: a.id, a, onAdd: addNodeFor, onDel: (x) => setDelTarget(x), onCat: changeCategory,
|
|
5829
|
+
onRename: rename, onMove: moveAsset,
|
|
5830
|
+
projectName: projectName(a.projectId),
|
|
4518
5831
|
})),
|
|
4519
5832
|
),
|
|
5833
|
+
delTarget ? h(DeleteAssetModal, {
|
|
5834
|
+
key: 'delasset', target: delTarget, name: assetName, setName: setAssetName,
|
|
5835
|
+
onCancel: () => { setDelTarget(null); setAssetName(''); },
|
|
5836
|
+
onConfirm: async () => { const t = delTarget; setDelTarget(null); setAssetName(''); await del(t); },
|
|
5837
|
+
}) : null,
|
|
4520
5838
|
];
|
|
4521
5839
|
}
|
|
4522
|
-
|
|
5840
|
+
/** 删除资产确认框:与项目删除同一套交互 —— 名字可选中,手打确认,不做一键填入。 */
|
|
5841
|
+
function DeleteAssetModal({ target, name = '', setName = () => {}, onCancel = () => {}, onConfirm = () => {} }) {
|
|
5842
|
+
const matched = String(name || '').trim() === (target.name || '') && String(name || '').trim() !== '';
|
|
5843
|
+
useEffect(() => {
|
|
5844
|
+
const fn = (e) => { if (e.key === 'Escape') onCancel(); };
|
|
5845
|
+
window.addEventListener('keydown', fn);
|
|
5846
|
+
return () => window.removeEventListener('keydown', fn);
|
|
5847
|
+
}, [onCancel]);
|
|
5848
|
+
return h('div', { className: 'dshic-modal-overlay', onPointerDown: (e) => { if (e.target === e.currentTarget) onCancel(); } },
|
|
5849
|
+
h('div', { className: 'dshic-modal' },
|
|
5850
|
+
h('div', { className: 'dshic-modal-title' }, T('delAssetTitle')),
|
|
5851
|
+
h('div', { className: 'dshic-modal-warn' },
|
|
5852
|
+
T('delAssetWarn'),
|
|
5853
|
+
h('div', { className: 'dshic-modal-name', style: { marginTop: 4 } }, target.name || '—')),
|
|
5854
|
+
h('div', { className: 'dshic-field' },
|
|
5855
|
+
h('span', { className: 'dshic-field-label' }, T('delAssetInput')),
|
|
5856
|
+
h('input', {
|
|
5857
|
+
className: 'dshic-input', autoFocus: true, value: name,
|
|
5858
|
+
placeholder: target.name,
|
|
5859
|
+
onChange: (e) => setName(e.target.value),
|
|
5860
|
+
onKeyDown: (e) => { if (e.key === 'Enter' && matched) onConfirm(); },
|
|
5861
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
5862
|
+
})),
|
|
5863
|
+
h('div', { className: 'dshic-modal-actions' },
|
|
5864
|
+
h('button', { type: 'button', className: 'dshic-btn dshic-btn-outline', onClick: onCancel }, T('cancel')),
|
|
5865
|
+
h('button', {
|
|
5866
|
+
type: 'button', className: 'dshic-btn is-danger dshic-btn-outline', disabled: !matched,
|
|
5867
|
+
style: matched ? { borderColor: 'var(--dsw-alias-state-error-primary)', color: 'var(--dsw-alias-state-error-primary)' } : null,
|
|
5868
|
+
onClick: () => { if (matched) onConfirm(); },
|
|
5869
|
+
}, T('doDelete'))),
|
|
5870
|
+
));
|
|
5871
|
+
}
|
|
5872
|
+
|
|
5873
|
+
function AssetCard({ a, onAdd, onDel, onCat, onRename, onMove, projectName }) {
|
|
4523
5874
|
const url = useAssetUrl(a.kind === 'text' ? null : a.id);
|
|
4524
5875
|
const [craftFor, setCraftFor] = useState(false);
|
|
5876
|
+
const [renaming, setRenaming] = useState(false);
|
|
5877
|
+
const [nameVal, setNameVal] = useState('');
|
|
5878
|
+
const startRename = (e) => { e.stopPropagation(); setNameVal(a.name || ''); setRenaming(true); };
|
|
5879
|
+
const commitRename = () => {
|
|
5880
|
+
const v = nameVal.trim();
|
|
5881
|
+
setRenaming(false);
|
|
5882
|
+
if (v && v !== (a.name || '')) onRename(a, v);
|
|
5883
|
+
};
|
|
5884
|
+
const nameInput = h('input', {
|
|
5885
|
+
className: 'dshic-input', autoFocus: true, value: nameVal,
|
|
5886
|
+
onChange: (e) => setNameVal(e.target.value),
|
|
5887
|
+
onKeyDown: (e) => { if (e.key === 'Enter') commitRename(); if (e.key === 'Escape') setRenaming(false); },
|
|
5888
|
+
onBlur: commitRename,
|
|
5889
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
5890
|
+
onClick: (e) => e.stopPropagation(),
|
|
5891
|
+
});
|
|
5892
|
+
const renameBtn = h('button', {
|
|
5893
|
+
type: 'button', className: 'dshic-iconbtn', title: T('renameAsset'),
|
|
5894
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
5895
|
+
onClick: startRename,
|
|
5896
|
+
}, Ic('pencil', 11));
|
|
4525
5897
|
const downloadName = (a.name || 'asset') + (a.kind === 'image' ? '.png' : a.kind === 'video' ? '.webm' : a.kind === 'audio' ? '.mp3' : '.md');
|
|
4526
5898
|
return h('div', { className: 'dshic-asset', onClick: a.kind === 'audio' ? undefined : () => onAdd(a), title: a.kind === 'text' ? T('textAssetHint') : a.kind === 'audio' ? '' : T('addAssetNode') },
|
|
4527
5899
|
a.kind === 'text'
|
|
4528
|
-
? h('div', { className: 'dshic-textcard' },
|
|
5900
|
+
? h('div', { className: 'dshic-textcard is-title' },
|
|
5901
|
+
renaming ? nameInput : h('div', { className: 'dshic-textcard-title', title: a.name || '' }, a.name || T('scriptAssetPrefix')),
|
|
5902
|
+
h('div', { className: 'dshic-textcard-sub' },
|
|
5903
|
+
projectName
|
|
5904
|
+
? h('span', { className: 'dshic-projchip', title: projectName }, projectName)
|
|
5905
|
+
: h('span', { className: 'dshic-projchip' }, catLabel(a.category || 'script')),
|
|
5906
|
+
h('span', { style: { flex: 1 } }),
|
|
5907
|
+
renameBtn),
|
|
5908
|
+
)
|
|
4529
5909
|
: a.kind === 'image'
|
|
4530
5910
|
? h('img', { className: 'dshic-asset-thumb', src: url || '', draggable: false })
|
|
4531
5911
|
: a.kind === 'video'
|
|
4532
5912
|
? h('video', { className: 'dshic-asset-thumb', src: url ? url + '#t=0.1' : '', muted: true, preload: 'metadata' })
|
|
4533
5913
|
: h('div', { className: 'dshic-asset-thumb', style: { display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'var(--dsw-alias-brand-primary)' } },
|
|
4534
5914
|
Ic('audio', 26)),
|
|
5915
|
+
a.kind !== 'text'
|
|
5916
|
+
? h('div', { className: 'dshic-asset-name' },
|
|
5917
|
+
renaming ? nameInput : h('span', { className: 'dshic-asset-name-text', title: a.name || '' }, a.name || '—'),
|
|
5918
|
+
renameBtn)
|
|
5919
|
+
: null,
|
|
5920
|
+
a.kind !== 'text'
|
|
5921
|
+
? h('select', {
|
|
5922
|
+
className: 'dshic-asset-project', title: T('projectFilter'),
|
|
5923
|
+
value: a.projectId || GENERAL_PID,
|
|
5924
|
+
onClick: (e) => e.stopPropagation(),
|
|
5925
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
5926
|
+
onChange: (e) => { e.stopPropagation(); onMove(a, e.target.value); },
|
|
5927
|
+
},
|
|
5928
|
+
h('option', { value: GENERAL_PID }, T('generalProject')),
|
|
5929
|
+
S.projects.map((pp) => h('option', { key: pp.id, value: pp.id }, pp.name)),
|
|
5930
|
+
)
|
|
5931
|
+
: null,
|
|
4535
5932
|
h('div', { className: 'dshic-asset-actions' },
|
|
4536
5933
|
a.kind === 'image'
|
|
4537
5934
|
? h('button', {
|
|
@@ -4570,7 +5967,6 @@ window.__ModuleLoader__.load({
|
|
|
4570
5967
|
onChange: (e) => { e.stopPropagation(); onCat(a, e.target.value); },
|
|
4571
5968
|
},
|
|
4572
5969
|
CATEGORIES.map((c) => h('option', { key: c, value: c }, catLabel(c)))),
|
|
4573
|
-
showProject && a.projectId ? h('span', { className: 'dshic-projchip', title: projectName }, projectName) : null,
|
|
4574
5970
|
h('div', { style: { flex: 1 } }),
|
|
4575
5971
|
h('span', {}, fmtTime(a.createdAt))),
|
|
4576
5972
|
craftFor ? h(PhotoCraftDialog, { key: 'pc', assetId: a.id, onClose: () => setCraftFor(false) }) : null,
|
|
@@ -4595,6 +5991,7 @@ window.__ModuleLoader__.load({
|
|
|
4595
5991
|
}
|
|
4596
5992
|
function SkillSelect({ label, value, onChange }) {
|
|
4597
5993
|
const data = useSkills();
|
|
5994
|
+
// 默认项 = 内置专家 persona + 内置创作方法论 canvas-craft(自动叠加)
|
|
4598
5995
|
const options = [{ value: '', label: T('builtinExpert') }].concat((data.skills || []).map((x) => ({ value: x.name, label: x.name })));
|
|
4599
5996
|
return h('div', { className: 'dshic-field' },
|
|
4600
5997
|
h('span', { className: 'dshic-field-label' }, label),
|
|
@@ -4602,7 +5999,8 @@ window.__ModuleLoader__.load({
|
|
|
4602
5999
|
className: 'dshic-select', value: value || '',
|
|
4603
6000
|
onChange: (e) => onChange(e.target.value),
|
|
4604
6001
|
onPointerDown: (e) => e.stopPropagation(),
|
|
4605
|
-
}, options.map((o) => h('option', { key: o.value, value: o.value }, o.label)))
|
|
6002
|
+
}, options.map((o) => h('option', { key: o.value, value: o.value }, o.label))),
|
|
6003
|
+
h('div', { className: 'dshic-hint' }, T('skillHint')));
|
|
4606
6004
|
}
|
|
4607
6005
|
|
|
4608
6006
|
function LlmModelSelect() {
|
|
@@ -4752,6 +6150,17 @@ window.__ModuleLoader__.load({
|
|
|
4752
6150
|
h(Field, { key: 'u', label: T('baseUrl'), value: cfg[kcUrlKey(kind)] || '', onChange: (v) => patch(cfg.id, urlKey, v), placeholder: urlPh }),
|
|
4753
6151
|
h(Field, { key: 'k', label: T('apiKey'), value: cfg.apiKey || '', onChange: (v) => patch(cfg.id, 'apiKey', v), placeholder: 'sk-…', password: true }),
|
|
4754
6152
|
h(Field, { key: 'm', label: T('model'), value: cfg.model || '', onChange: (v) => patch(cfg.id, 'model', v) }),
|
|
6153
|
+
h(Field, {
|
|
6154
|
+
key: 'to', label: T('apiTimeout'), value: cfg.timeoutMs || '',
|
|
6155
|
+
placeholder: T('llmParamAuto'),
|
|
6156
|
+
onChange: (v) => patch(cfg.id, 'timeoutMs', v),
|
|
6157
|
+
}),
|
|
6158
|
+
kind === 'image'
|
|
6159
|
+
? h(Field, { key: 'size', label: T('imgSizeOverride'), value: cfg.size || '', onChange: (v) => patch(cfg.id, 'size', v), placeholder: T('imgSizePlaceholder') })
|
|
6160
|
+
: null,
|
|
6161
|
+
kind === 'image'
|
|
6162
|
+
? h(Field, { key: 'rf', label: T('imgResponseFormat'), value: cfg.responseFormat || '', onChange: (v) => patch(cfg.id, 'responseFormat', v), placeholder: 'b64_json / url(留空=API 默认)' })
|
|
6163
|
+
: null,
|
|
4755
6164
|
kind === 'image'
|
|
4756
6165
|
? h('div', { key: 'mode', className: 'dshic-field' },
|
|
4757
6166
|
h('span', { className: 'dshic-field-label' }, T('imgMode')),
|
|
@@ -4777,6 +6186,24 @@ window.__ModuleLoader__.load({
|
|
|
4777
6186
|
kind === 'tts'
|
|
4778
6187
|
? h(Field, { key: 'voice', label: T('voice'), value: cfg.voice || '', onChange: (v) => patch(cfg.id, 'voice', v), placeholder: 'alloy' })
|
|
4779
6188
|
: null,
|
|
6189
|
+
(kind === 'video' && (cfg.protocol === 'seedance'))
|
|
6190
|
+
? h('div', { key: 'seedopts', className: 'dshic-field' },
|
|
6191
|
+
h('label', { className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
|
|
6192
|
+
h('input', {
|
|
6193
|
+
type: 'checkbox', checked: cfg.generateAudio !== false,
|
|
6194
|
+
onChange: (e) => patch(cfg.id, 'generateAudio', e.target.checked),
|
|
6195
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
6196
|
+
}),
|
|
6197
|
+
h('span', {}, T('seedanceAudio'))),
|
|
6198
|
+
h('label', { className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
|
|
6199
|
+
h('input', {
|
|
6200
|
+
type: 'checkbox', checked: cfg.watermark === true,
|
|
6201
|
+
onChange: (e) => patch(cfg.id, 'watermark', e.target.checked),
|
|
6202
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
6203
|
+
}),
|
|
6204
|
+
h('span', {}, T('seedanceWatermark'))),
|
|
6205
|
+
h('div', { className: 'dshic-hint' }, T('seedanceHint')))
|
|
6206
|
+
: null,
|
|
4780
6207
|
] : null,
|
|
4781
6208
|
);
|
|
4782
6209
|
}),
|
|
@@ -4838,7 +6265,7 @@ window.__ModuleLoader__.load({
|
|
|
4838
6265
|
if (!(st.bins && st.bins.photocraft && st.bins.photocraft.found)) { toast(T('craftCliMissing').replace('{name}', 'photocraft')); return; }
|
|
4839
6266
|
let paramsObj;
|
|
4840
6267
|
try { paramsObj = JSON.parse(paramsText || '{}'); } catch { toast(T('craftInvalidJson')); return; }
|
|
4841
|
-
if (!rec || !rec.blob) { toast(T('noResult')); return; }
|
|
6268
|
+
if (!rec || (!rec.blob && !rec.rel)) { toast(T('noResult')); return; }
|
|
4842
6269
|
setRunning(true); setErr(null); setProgress('…');
|
|
4843
6270
|
try {
|
|
4844
6271
|
const mediaRel = await ensureAssetOnDisk(rec);
|
|
@@ -4945,6 +6372,77 @@ window.__ModuleLoader__.load({
|
|
|
4945
6372
|
));
|
|
4946
6373
|
}
|
|
4947
6374
|
|
|
6375
|
+
/** 数据根目录卡片:设置/打开/迁移历史资产。 */
|
|
6376
|
+
function DataRootCard() {
|
|
6377
|
+
const [val, setVal] = useState('');
|
|
6378
|
+
const [busy, setBusy] = useState(false);
|
|
6379
|
+
const [picking, setPicking] = useState(false);
|
|
6380
|
+
const [mig, setMig] = useState(null); // null | {done,total} | 'done'
|
|
6381
|
+
useEffect(() => { if (S.dataRoot) setVal(S.dataRoot); }, [S.dataRoot]);
|
|
6382
|
+
const save = async () => {
|
|
6383
|
+
if (busy) return;
|
|
6384
|
+
setBusy(true);
|
|
6385
|
+
try {
|
|
6386
|
+
await hostRpc('dataRootSet', { root: val.trim() });
|
|
6387
|
+
await refreshDataRoot();
|
|
6388
|
+
toast(T('dataRootSaved') + (S.dataRoot ? ':' + S.dataRoot : ''));
|
|
6389
|
+
} catch (e) { toast('设置失败:' + ((e && e.message) || e)); }
|
|
6390
|
+
setBusy(false);
|
|
6391
|
+
};
|
|
6392
|
+
const applyRoot = async (root) => {
|
|
6393
|
+
await hostRpc('dataRootSet', { root });
|
|
6394
|
+
await refreshDataRoot();
|
|
6395
|
+
setVal(S.dataRoot || root);
|
|
6396
|
+
toast(T('dataRootSaved') + ':' + S.dataRoot);
|
|
6397
|
+
};
|
|
6398
|
+
const browse = async () => {
|
|
6399
|
+
if (busy || picking) return;
|
|
6400
|
+
setBusy(true); setPicking(true);
|
|
6401
|
+
try {
|
|
6402
|
+
const r = await hostRpc('pickFolder');
|
|
6403
|
+
if (r && r.ok && r.path) await applyRoot(r.path);
|
|
6404
|
+
else if (r && r.unsupported) toast(T('dataRootPickUnsupported'));
|
|
6405
|
+
// cancelled → 静默
|
|
6406
|
+
} catch (e) {
|
|
6407
|
+
const msg = String((e && e.message) || e);
|
|
6408
|
+
toast(/未知操作/.test(msg) ? T('dataRootUnsupported') : '目录选择失败:' + msg);
|
|
6409
|
+
}
|
|
6410
|
+
setPicking(false); setBusy(false);
|
|
6411
|
+
};
|
|
6412
|
+
const openDir = async () => { try { await hostRpc('dataRootReveal'); } catch (e) { toast((e && e.message) || e); } };
|
|
6413
|
+
const migrate = async () => {
|
|
6414
|
+
if (busy || picking || mig) return;
|
|
6415
|
+
setBusy(true); setMig({ done: 0, total: 0 });
|
|
6416
|
+
try {
|
|
6417
|
+
const r = await migrateAssetsToDisk((done, total) => setMig({ done, total }));
|
|
6418
|
+
setMig('done');
|
|
6419
|
+
toast(T('dataRootMigrated').replace('{n}', String(r.migrated)));
|
|
6420
|
+
} catch (e) { setMig(null); toast('迁移失败:' + ((e && e.message) || e)); }
|
|
6421
|
+
setBusy(false);
|
|
6422
|
+
};
|
|
6423
|
+
return h('div', { key: 'dataroot', className: 'dshic-card' },
|
|
6424
|
+
h('div', { className: 'dshic-row' }, Ic('image', 12), h('strong', { style: { fontSize: 12 } }, T('dataRootLabel'))),
|
|
6425
|
+
S.dataRootSupported === false
|
|
6426
|
+
? h('div', { className: 'dshic-hint' }, T('dataRootUnsupported'))
|
|
6427
|
+
: [
|
|
6428
|
+
h('div', { className: 'dshic-row', key: 'row1' },
|
|
6429
|
+
h('input', {
|
|
6430
|
+
className: 'dshic-input', value: val, placeholder: T('dataRootPlaceholder'),
|
|
6431
|
+
onChange: (e) => setVal(e.target.value), onPointerDown: (e) => e.stopPropagation(),
|
|
6432
|
+
}),
|
|
6433
|
+
h('button', { type: 'button', className: 'dshic-mini', title: T('dataRootBrowse'), disabled: busy || picking, onPointerDown: (e) => e.stopPropagation(), onClick: browse }, Ic('folder', 12)),
|
|
6434
|
+
h('button', { type: 'button', className: 'dshic-mini', disabled: busy || picking, onPointerDown: (e) => e.stopPropagation(), onClick: save }, T('dataRootSave')),
|
|
6435
|
+
),
|
|
6436
|
+
picking ? h('div', { className: 'dshic-hint', key: 'picking' }, T('dataRootPicking')) : null,
|
|
6437
|
+
h('div', { className: 'dshic-hint', key: 'hint' }, T('dataRootHint')),
|
|
6438
|
+
h('div', { className: 'dshic-row', key: 'row2' },
|
|
6439
|
+
S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini', onPointerDown: (e) => e.stopPropagation(), onClick: openDir }, T('dataRootOpen')) : null,
|
|
6440
|
+
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,
|
|
6441
|
+
(mig && mig !== 'done') ? h('span', { style: { fontSize: 11, opacity: 0.85 } }, T('dataRootMigrating') + ' ' + mig.done + '/' + mig.total) : null,
|
|
6442
|
+
),
|
|
6443
|
+
],
|
|
6444
|
+
);
|
|
6445
|
+
}
|
|
4948
6446
|
function SettingsTab() {
|
|
4949
6447
|
const [s, setS] = useState(loadSettings);
|
|
4950
6448
|
const apply = (fn) => {
|
|
@@ -4958,6 +6456,7 @@ window.__ModuleLoader__.load({
|
|
|
4958
6456
|
const field = (label, value, onChange, placeholder, password) => h(Field, { label, value, onChange, placeholder, password });
|
|
4959
6457
|
return [
|
|
4960
6458
|
h(CraftCard, { key: 'craft' }),
|
|
6459
|
+
h(DataRootCard, { key: 'dataroot' }),
|
|
4961
6460
|
h('div', { key: 'lang', className: 'dshic-card' },
|
|
4962
6461
|
h('div', { className: 'dshic-row' }, Ic('text', 12), h('strong', { style: { fontSize: 12 } }, T('langLabel'))),
|
|
4963
6462
|
h('select', {
|
|
@@ -4976,6 +6475,17 @@ window.__ModuleLoader__.load({
|
|
|
4976
6475
|
h('div', { key: 'llm', className: 'dshic-card' },
|
|
4977
6476
|
h('div', { className: 'dshic-row' }, Ic('spark', 12), h('strong', { style: { fontSize: 12 } }, T('llmLabel'))),
|
|
4978
6477
|
h(LlmModelSelect),
|
|
6478
|
+
h('div', { className: 'dshic-row' },
|
|
6479
|
+
h(Field, {
|
|
6480
|
+
label: T('llmMaxTokens'), value: s.textLlm.maxTokens || '', placeholder: T('llmParamAuto'),
|
|
6481
|
+
onChange: (v) => apply((d) => { d.textLlm.maxTokens = v; }),
|
|
6482
|
+
}),
|
|
6483
|
+
h(Field, {
|
|
6484
|
+
label: T('llmTemperature'), value: s.textLlm.temperature || '', placeholder: T('llmParamAuto'),
|
|
6485
|
+
onChange: (v) => apply((d) => { d.textLlm.temperature = v; }),
|
|
6486
|
+
}),
|
|
6487
|
+
),
|
|
6488
|
+
h('div', { className: 'dshic-hint' }, T('llmParamHint')),
|
|
4979
6489
|
h('div', { className: 'dshic-hint' }, T('skillEnhance')),
|
|
4980
6490
|
h(SkillSelect, { label: T('skillScript'), value: s.skillScript, onChange: (v) => apply((d) => { d.skillScript = v; }) }),
|
|
4981
6491
|
h(SkillSelect, { label: T('skillStoryboard'), value: s.skillStoryboard, onChange: (v) => apply((d) => { d.skillStoryboard = v; }) }),
|
|
@@ -4983,15 +6493,164 @@ window.__ModuleLoader__.load({
|
|
|
4983
6493
|
h(ApiListEditor, { key: 'imglist', kind: 'image' }),
|
|
4984
6494
|
h(ApiListEditor, { key: 'vidlist', kind: 'video' }),
|
|
4985
6495
|
h(ApiListEditor, { key: 'ttslist', kind: 'tts' }),
|
|
4986
|
-
h('div', { key: 'note', className: 'dshic-hint' }, 'DeepSeek Harness · dsh-infinite-canvas
|
|
6496
|
+
h('div', { key: 'note', className: 'dshic-hint' }, 'DeepSeek Harness · dsh-infinite-canvas' + ((S.updateInfo && S.updateInfo.runningVersion) ? ' v' + S.updateInfo.runningVersion : '')),
|
|
4987
6497
|
];
|
|
4988
6498
|
}
|
|
4989
6499
|
|
|
4990
|
-
/**
|
|
6500
|
+
/** 从项目现有创作意图/剧本一键起草设定集(世界观/风格/角色/场景/道具)。 */
|
|
6501
|
+
async function aiDraftBible(cur) {
|
|
6502
|
+
const settings = loadSettings();
|
|
6503
|
+
const zh = effLang() !== 'en';
|
|
6504
|
+
const ideas = Object.values(S.graph.nodes || {})
|
|
6505
|
+
.filter((n) => n && (n.type === 'script' || n.type === 'text'))
|
|
6506
|
+
.map((n) => String((n.type === 'script' ? n.data.idea : n.data.text) || '').trim())
|
|
6507
|
+
.filter(Boolean)
|
|
6508
|
+
.join('\n---\n')
|
|
6509
|
+
.slice(0, 6000);
|
|
6510
|
+
const existing = bibleBlock(cur);
|
|
6511
|
+
const system = [
|
|
6512
|
+
await loadSkillRules(settings.skillScript, zh) || await loadSkillRules(CRAFT_SKILL, zh) || '',
|
|
6513
|
+
zh
|
|
6514
|
+
? '你是短剧设定集策划:根据创作意图提炼世界观与风格,并列出角色/场景/道具设定,供后续剧本与画面生成保持一致。只输出 JSON。'
|
|
6515
|
+
: '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.',
|
|
6516
|
+
].filter(Boolean).join('\n');
|
|
6517
|
+
const user = zh ? [
|
|
6518
|
+
ideas ? '项目现有创作意图/剧本:\n' + ideas : '(项目暂无创作意图,请给出一个可用的短剧设定草案)',
|
|
6519
|
+
existing ? '已有设定集(请在其基础上补全,不要丢弃已有条目):\n' + existing : '',
|
|
6520
|
+
'输出 JSON:{"worldview":"世界观/核心设定(题材、年代、核心规则、主线暗线)","style":"风格基调(画风、色调、质感)",'
|
|
6521
|
+
+ '"characters":[{"name":"…","desc":"外观与性格关键词"}],"scenes":[{"name":"…","desc":"外观关键词"}],"props":[{"name":"…","desc":"外观关键词"}]}',
|
|
6522
|
+
'要求:名称唯一且可直接用于检索;desc 写可复现的外观关键词(年龄/发型/服装/材质/光线),不要写抽象评价。',
|
|
6523
|
+
].filter(Boolean).join('\n') : [
|
|
6524
|
+
ideas ? 'Existing intent/script:\n' + ideas : '(No intent yet — produce a usable draft)',
|
|
6525
|
+
existing ? 'Existing bible (extend it, keep existing entries):\n' + existing : '',
|
|
6526
|
+
'Output JSON: {"worldview":"…","style":"…","characters":[{"name":"…","desc":"…"}],"scenes":[…],"props":[…]}',
|
|
6527
|
+
'Names must be unique and searchable; desc must be reproducible visual keywords.',
|
|
6528
|
+
].filter(Boolean).join('\n');
|
|
6529
|
+
const text = await aiComplete({ system, user });
|
|
6530
|
+
const raw = parseJsonBalanced(text);
|
|
6531
|
+
const obj = raw && typeof raw === 'object' ? raw : {};
|
|
6532
|
+
const list = (v) => (Array.isArray(v) ? v : [])
|
|
6533
|
+
.map((x) => ({ name: String((x && x.name) || '').trim(), desc: String((x && x.desc) || '').trim() }))
|
|
6534
|
+
.filter((x) => x.name);
|
|
6535
|
+
const merge = (base, incoming) => {
|
|
6536
|
+
const out = [...(base || [])];
|
|
6537
|
+
for (const item of incoming) {
|
|
6538
|
+
const i = out.findIndex((x) => String(x.name).trim() === item.name);
|
|
6539
|
+
if (i >= 0) out[i] = { ...out[i], ...item };
|
|
6540
|
+
else out.push(item);
|
|
6541
|
+
}
|
|
6542
|
+
return out;
|
|
6543
|
+
};
|
|
6544
|
+
const next = {
|
|
6545
|
+
worldview: String(obj.worldview || cur.worldview || '').trim(),
|
|
6546
|
+
style: String(obj.style || cur.style || '').trim(),
|
|
6547
|
+
characters: merge(cur.characters, list(obj.characters)),
|
|
6548
|
+
scenes: merge(cur.scenes, list(obj.scenes)),
|
|
6549
|
+
props: merge(cur.props, list(obj.props)),
|
|
6550
|
+
};
|
|
6551
|
+
if (!next.worldview && !next.style && !next.characters.length) throw new Error(T('bibleDraftEmpty'));
|
|
6552
|
+
return next;
|
|
6553
|
+
}
|
|
6554
|
+
|
|
6555
|
+
/** 设定条目 ↔ 资产绑定对话框:从当前项目的资产库里多选图片/音频/视频。 */
|
|
6556
|
+
/** 绑定对话框的资产行:组件化以便用 useAssetUrl 显示缩略图。 */
|
|
6557
|
+
function AssetPickRow({ a, checked, onToggle }) {
|
|
6558
|
+
const url = useAssetUrl(a.kind === 'image' ? a.id : null);
|
|
6559
|
+
return h('label', { className: 'dshic-row', style: { gap: 6, fontSize: 11, cursor: 'pointer', alignItems: 'center' }, onPointerDown: (e) => e.stopPropagation(), onClick: () => onToggle() },
|
|
6560
|
+
h('input', { type: 'checkbox', checked, onChange: () => onToggle(), onPointerDown: (e) => e.stopPropagation() }),
|
|
6561
|
+
a.kind === 'image' && url
|
|
6562
|
+
? h('img', { src: url, style: { width: 26, height: 26, objectFit: 'cover', borderRadius: 4, flex: 'none' }, draggable: false })
|
|
6563
|
+
: h('span', { className: 'dshic-tag st', style: { flex: 'none' } }, a.kind === 'video' ? T('kindVideo') : T('kindAudio')),
|
|
6564
|
+
h('span', { style: { flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, a.name || a.id),
|
|
6565
|
+
a.projectId === GENERAL_PID ? h('span', { className: 'dshic-projchip' }, T('generalProject')) : null,
|
|
6566
|
+
);
|
|
6567
|
+
}
|
|
6568
|
+
|
|
6569
|
+
/** 设定条目 ↔ 资产绑定对话框:项目可切换、搜索、分页(资产会越来越多)。 */
|
|
6570
|
+
function BibleLinkDialog({ entry, onApply, onClose }) {
|
|
6571
|
+
const PAGE_SIZE = 24;
|
|
6572
|
+
const [assets, setAssets] = useState(null);
|
|
6573
|
+
const [picked, setPicked] = useState(() => new Set((entry.assetIds || []).map(String)));
|
|
6574
|
+
const [q, setQ] = useState('');
|
|
6575
|
+
const [scope, setScope] = useState(S.activeId || 'all');
|
|
6576
|
+
const [page, setPage] = useState(0);
|
|
6577
|
+
useEffect(() => {
|
|
6578
|
+
let ok = true;
|
|
6579
|
+
(async () => {
|
|
6580
|
+
try {
|
|
6581
|
+
const all = await idbAll();
|
|
6582
|
+
if (ok) setAssets(all.filter((a) => a.kind !== 'text'));
|
|
6583
|
+
} catch { if (ok) setAssets([]); }
|
|
6584
|
+
})();
|
|
6585
|
+
return () => { ok = false; };
|
|
6586
|
+
}, []);
|
|
6587
|
+
const toggle = (id) => {
|
|
6588
|
+
const next = new Set(picked);
|
|
6589
|
+
if (next.has(id)) next.delete(id);
|
|
6590
|
+
else next.add(id);
|
|
6591
|
+
setPicked(next);
|
|
6592
|
+
};
|
|
6593
|
+
const ql = q.trim().toLowerCase();
|
|
6594
|
+
const scoped = (assets || []).filter((a) => {
|
|
6595
|
+
if (scope === GENERAL_PID) return a.projectId === GENERAL_PID;
|
|
6596
|
+
if (scope === 'all') return true;
|
|
6597
|
+
return a.projectId === scope || a.projectId === GENERAL_PID; // 项目视图含通用资产(多项目共用)
|
|
6598
|
+
});
|
|
6599
|
+
const list = scoped.filter((a) => !ql || String(a.name || '').toLowerCase().includes(ql));
|
|
6600
|
+
const pages = Math.max(1, Math.ceil(list.length / PAGE_SIZE));
|
|
6601
|
+
const pageSafe = Math.min(page, pages - 1);
|
|
6602
|
+
const pageItems = list.slice(pageSafe * PAGE_SIZE, pageSafe * PAGE_SIZE + PAGE_SIZE);
|
|
6603
|
+
return h('div', { className: 'dshic-modal-overlay', onPointerDown: (e) => { if (e.target === e.currentTarget) onClose(); } },
|
|
6604
|
+
h('div', { className: 'dshic-modal', style: { width: 560 } },
|
|
6605
|
+
h('div', { className: 'dshic-modal-title' }, T('bibleLinkTitle') + ' — ' + (entry.name || '…')),
|
|
6606
|
+
h('div', { className: 'dshic-hint' }, T('bibleLinkHint')),
|
|
6607
|
+
h('div', { className: 'dshic-node-row' },
|
|
6608
|
+
h(MiniSelect, {
|
|
6609
|
+
value: scope, title: T('projectFilter'), width: 150,
|
|
6610
|
+
options: [{ value: 'all', label: T('allProjects') }, { value: GENERAL_PID, label: T('generalProject') }]
|
|
6611
|
+
.concat(S.projects.map((pp) => ({ value: pp.id, label: pp.name }))),
|
|
6612
|
+
onChange: (v) => { setScope(v); setPage(0); },
|
|
6613
|
+
}),
|
|
6614
|
+
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() }),
|
|
6615
|
+
),
|
|
6616
|
+
h('div', { className: 'dshic-shot-list', style: { maxHeight: 420 } },
|
|
6617
|
+
assets === null
|
|
6618
|
+
? h('div', { className: 'dshic-hint' }, T('aiRunning'))
|
|
6619
|
+
: pageItems.length === 0
|
|
6620
|
+
? h('div', { className: 'dshic-hint' }, T('emptyAssets'))
|
|
6621
|
+
: pageItems.map((a) => h(AssetPickRow, { key: a.id, a, checked: picked.has(a.id), onToggle: () => toggle(a.id) }))),
|
|
6622
|
+
h('div', { className: 'dshic-node-row' },
|
|
6623
|
+
h('button', { type: 'button', className: 'dshic-mini', disabled: pageSafe <= 0, onClick: () => setPage(pageSafe - 1), onPointerDown: (e) => e.stopPropagation() }, '‹ ' + T('pagePrev')),
|
|
6624
|
+
h('span', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)' } }, (pageSafe + 1) + ' / ' + pages + ' · ' + list.length),
|
|
6625
|
+
h('button', { type: 'button', className: 'dshic-mini', disabled: pageSafe >= pages - 1, onClick: () => setPage(pageSafe + 1), onPointerDown: (e) => e.stopPropagation() }, T('pageNext') + ' ›'),
|
|
6626
|
+
h('div', { className: 'dshic-flex' }),
|
|
6627
|
+
),
|
|
6628
|
+
h('div', { className: 'dshic-modal-actions' },
|
|
6629
|
+
h('button', { type: 'button', className: 'dshic-btn dshic-btn-outline', onClick: onClose }, T('cancel')),
|
|
6630
|
+
h('button', {
|
|
6631
|
+
type: 'button', className: 'dshic-btn',
|
|
6632
|
+
onClick: () => onApply([...picked]),
|
|
6633
|
+
}, T('bibleLinkApply') + (picked.size ? '(' + picked.size + ')' : ''))),
|
|
6634
|
+
));
|
|
6635
|
+
}
|
|
6636
|
+
/** 项目设定集(Series Bible):世界观 + 风格 + 角色/场景/道具卡,注入所有 AI 生成保证跨集一致。 */
|
|
4991
6637
|
function BibleTab() {
|
|
4992
6638
|
const [bible, setBible] = useState(() => loadBible(S.activeId));
|
|
4993
|
-
|
|
6639
|
+
const [busy, setBusy] = useState(false);
|
|
6640
|
+
useEffect(() => { setBible(loadBible(S.activeId)); }, [S.activeId, S.assetsTick]);
|
|
4994
6641
|
const persist = (next) => { setBible(next); saveBible(S.activeId, next); };
|
|
6642
|
+
/** 从项目里的创作意图/剧本一键起草设定集(设定集先行)。 */
|
|
6643
|
+
const draft = async () => {
|
|
6644
|
+
if (busy) return;
|
|
6645
|
+
setBusy(true);
|
|
6646
|
+
try {
|
|
6647
|
+
const next = await aiDraftBible(bible);
|
|
6648
|
+
persist(next);
|
|
6649
|
+
toast(T('bibleDrafted'));
|
|
6650
|
+
} catch (e) { toast(String((e && e.message) || e)); }
|
|
6651
|
+
setBusy(false);
|
|
6652
|
+
};
|
|
6653
|
+
const [linkFor, setLinkFor] = useState(null); // { key, index, entry } —— 正在编辑链接资产的设定条目
|
|
4995
6654
|
const listEditor = (key, labelKey) => h('div', { className: 'dshic-card' },
|
|
4996
6655
|
h('div', { className: 'dshic-row' },
|
|
4997
6656
|
h('strong', { style: { fontSize: 12 } }, T(labelKey)),
|
|
@@ -4999,16 +6658,23 @@ window.__ModuleLoader__.load({
|
|
|
4999
6658
|
h('button', {
|
|
5000
6659
|
type: 'button', className: 'dshic-mini',
|
|
5001
6660
|
onPointerDown: (e) => e.stopPropagation(),
|
|
5002
|
-
onClick: () => persist({ ...bible, [key]: [...(bible[key] || []), { name: '', desc: '' }] }),
|
|
6661
|
+
onClick: () => persist({ ...bible, [key]: [...(bible[key] || []), { name: '', desc: '', assetIds: [] }] }),
|
|
5003
6662
|
}, Ic('plus', 11), T('bibleAdd')),
|
|
5004
6663
|
),
|
|
5005
6664
|
...(bible[key] || []).map((item, i) => h('div', { key: i, className: 'dshic-row' },
|
|
5006
6665
|
h('input', {
|
|
5007
|
-
className: 'dshic-input', style: { width:
|
|
6666
|
+
className: 'dshic-input', style: { width: 76, flex: 'none' }, value: item.name || '',
|
|
5008
6667
|
placeholder: T('bibleNamePh'),
|
|
5009
6668
|
onChange: (e) => { const arr = [...bible[key]]; arr[i] = { ...arr[i], name: e.target.value }; persist({ ...bible, [key]: arr }); },
|
|
5010
6669
|
onPointerDown: (e) => e.stopPropagation(),
|
|
5011
6670
|
}),
|
|
6671
|
+
// 链接资产栏:把该条目绑定到资产库的图片/音频/视频(多选),@引用与参考图解析会优先用它
|
|
6672
|
+
h('button', {
|
|
6673
|
+
type: 'button', className: cls('dshic-mini', (item.assetIds || []).length && 'is-active'),
|
|
6674
|
+
title: T('bibleLinkAssets'),
|
|
6675
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
6676
|
+
onClick: () => setLinkFor({ key, index: i, entry: item }),
|
|
6677
|
+
}, Ic('image', 11), (item.assetIds || []).length ? ' ' + item.assetIds.length : ''),
|
|
5012
6678
|
h('input', {
|
|
5013
6679
|
className: 'dshic-input', style: { flex: 1, minWidth: 0 }, value: item.desc || '',
|
|
5014
6680
|
placeholder: T('bibleDescPh'),
|
|
@@ -5021,10 +6687,37 @@ window.__ModuleLoader__.load({
|
|
|
5021
6687
|
onClick: () => { const arr = [...bible[key]]; arr.splice(i, 1); persist({ ...bible, [key]: arr }); },
|
|
5022
6688
|
}, '✕'),
|
|
5023
6689
|
)),
|
|
6690
|
+
linkFor && linkFor.key === key
|
|
6691
|
+
? h(BibleLinkDialog, {
|
|
6692
|
+
key: 'link', entry: linkFor.entry, onClose: () => setLinkFor(null),
|
|
6693
|
+
onApply: (assetIds) => {
|
|
6694
|
+
const arr = [...bible[key]];
|
|
6695
|
+
arr[linkFor.index] = { ...arr[linkFor.index], assetIds };
|
|
6696
|
+
persist({ ...bible, [key]: arr });
|
|
6697
|
+
setLinkFor(null);
|
|
6698
|
+
},
|
|
6699
|
+
})
|
|
6700
|
+
: null,
|
|
5024
6701
|
);
|
|
5025
6702
|
return [
|
|
5026
6703
|
h('div', { key: 'proj', className: 'dshic-hint' },
|
|
5027
6704
|
`${T('bibleTab')} · ${T('currentProject')}:${(S.projects.find((p) => p.id === S.activeId) || {}).name || ''}`),
|
|
6705
|
+
h('div', { key: 'order', className: 'dshic-hint' }, T('bibleOrderHint')),
|
|
6706
|
+
h('div', { key: 'draft', className: 'dshic-row' },
|
|
6707
|
+
h('button', {
|
|
6708
|
+
type: 'button', className: 'dshic-mini', disabled: busy,
|
|
6709
|
+
onPointerDown: (e) => e.stopPropagation(), onClick: draft,
|
|
6710
|
+
}, busy ? h('span', { className: 'dshic-spin' }) : Ic('spark', 11), busy ? T('aiRunning') : T('bibleDraft')),
|
|
6711
|
+
),
|
|
6712
|
+
h('div', { key: 'world', className: 'dshic-card' },
|
|
6713
|
+
h('span', { className: 'dshic-shot-label' }, T('bibleWorldview')),
|
|
6714
|
+
h('textarea', {
|
|
6715
|
+
className: 'dshic-textarea', style: { minHeight: 110 }, value: bible.worldview || '',
|
|
6716
|
+
placeholder: T('bibleWorldviewPh'),
|
|
6717
|
+
onChange: (e) => persist({ ...bible, worldview: e.target.value }),
|
|
6718
|
+
onPointerDown: (e) => e.stopPropagation(),
|
|
6719
|
+
}),
|
|
6720
|
+
),
|
|
5028
6721
|
h('div', { key: 'style', className: 'dshic-card' },
|
|
5029
6722
|
h('span', { className: 'dshic-shot-label' }, T('bibleStyle')),
|
|
5030
6723
|
h('textarea', {
|
|
@@ -5128,6 +6821,39 @@ window.__ModuleLoader__.load({
|
|
|
5128
6821
|
useEffect(() => subscribe(() => force((x) => x + 1)), []);
|
|
5129
6822
|
return S;
|
|
5130
6823
|
}
|
|
6824
|
+
// 错误边界:任何渲染期崩溃都不再白屏——显示错误与堆栈 + 重载按钮(用户可直接把内容发回来修)
|
|
6825
|
+
const BoundaryBase = (React && React.Component) ? React.Component : class {}; // 冒烟桩 React 没有 Component,兜底
|
|
6826
|
+
class PanelErrorBoundary extends BoundaryBase {
|
|
6827
|
+
constructor(props) {
|
|
6828
|
+
super(props);
|
|
6829
|
+
this.state = { error: null };
|
|
6830
|
+
}
|
|
6831
|
+
static getDerivedStateFromError(error) {
|
|
6832
|
+
return { error: error || new Error('未知渲染错误') };
|
|
6833
|
+
}
|
|
6834
|
+
componentDidCatch(error, info) {
|
|
6835
|
+
try { console.error('[infinite-canvas] 渲染错误', error, info && info.componentStack); } catch { /* 忽略 */ }
|
|
6836
|
+
}
|
|
6837
|
+
render() {
|
|
6838
|
+
if (!this.state.error) return this.props.children;
|
|
6839
|
+
const msg = String(this.state.error && this.state.error.message || this.state.error);
|
|
6840
|
+
const stack = String(this.state.error && this.state.error.stack || '').split('\n').slice(0, 6).join('\n');
|
|
6841
|
+
return h('div', { style: { padding: 24, color: 'var(--dsw-alias-state-error-primary, #e5544b)', fontSize: 13, lineHeight: 20, whiteSpace: 'pre-wrap', userSelect: 'text' } },
|
|
6842
|
+
h('div', { style: { fontWeight: 700, marginBottom: 8 } }, '⚠ 画布界面出现了一个渲染错误(已拦截,不会丢失已保存的数据)'),
|
|
6843
|
+
h('div', { style: { marginBottom: 8 } }, msg),
|
|
6844
|
+
stack ? h('div', { style: { fontSize: 11, color: 'var(--dsw-alias-label-secondary)', marginBottom: 12, userSelect: 'text' } }, stack) : null,
|
|
6845
|
+
h('button', {
|
|
6846
|
+
type: 'button', className: 'dshic-btn',
|
|
6847
|
+
onClick: () => { this.setState({ error: null }); },
|
|
6848
|
+
}, '恢复界面'),
|
|
6849
|
+
h('button', {
|
|
6850
|
+
type: 'button', className: 'dshic-btn', style: { marginLeft: 8 },
|
|
6851
|
+
onClick: () => { try { window.location.reload(); } catch { /* 忽略 */ } },
|
|
6852
|
+
}, '重载页面'),
|
|
6853
|
+
);
|
|
6854
|
+
}
|
|
6855
|
+
}
|
|
6856
|
+
|
|
5131
6857
|
function CanvasPage() {
|
|
5132
6858
|
useApp();
|
|
5133
6859
|
const rootRef = useRef(null);
|
|
@@ -5317,13 +7043,18 @@ window.__ModuleLoader__.load({
|
|
|
5317
7043
|
probeCraft();
|
|
5318
7044
|
// 检查更新(Host RPC;Host 旧版未带此方法时静默)
|
|
5319
7045
|
checkUpdate(true);
|
|
7046
|
+
// 数据根目录(Host 旧版静默降级为 IndexedDB 模式)
|
|
7047
|
+
refreshDataRoot();
|
|
7048
|
+
// 对话 ↔ 画布同步(启动一次 + 3s 轮询;旧 Host 自动停轮询)
|
|
7049
|
+
hostSyncTick().then(() => pushAllProjectsToHost());
|
|
7050
|
+
const hostSyncTimer = setInterval(() => hostSyncTick(), 3000);
|
|
5320
7051
|
// 后台路由探活:失败时在画布顶部给出可操作的提示
|
|
5321
7052
|
hostRpc('ping').then(() => { S.hostOk = true; S.hostError = null; notify(); }).catch((e) => {
|
|
5322
7053
|
S.hostOk = false;
|
|
5323
7054
|
S.hostError = String((e && e.message) || e);
|
|
5324
7055
|
notify();
|
|
5325
7056
|
});
|
|
5326
|
-
return () => { unsub(); };
|
|
7057
|
+
return () => { unsub(); clearInterval(hostSyncTimer); };
|
|
5327
7058
|
}, 'infinite-canvas: store & locale');
|
|
5328
7059
|
ctx.slots.inject('sidebar.panellist', () => ctx.slots.register({
|
|
5329
7060
|
name: 'sidebar.panellist',
|
|
@@ -5332,11 +7063,27 @@ window.__ModuleLoader__.load({
|
|
|
5332
7063
|
label: () => T('panel'),
|
|
5333
7064
|
locale: NS,
|
|
5334
7065
|
}, PanelIcon));
|
|
7066
|
+
// 面板根:包一层错误边界——任何渲染期崩溃都不再白屏,而是显示可复制的错误卡片
|
|
7067
|
+
PanelErrorBoundary.__dshicClass = true; // 冒烟 walker 标记
|
|
7068
|
+
function PanelRoot() {
|
|
7069
|
+
return h(PanelErrorBoundary, null, h(CanvasPage));
|
|
7070
|
+
}
|
|
5335
7071
|
ctx.slots.inject('main', () => ctx.slots.register({
|
|
5336
7072
|
name: 'main',
|
|
5337
7073
|
key: PANEL_ID,
|
|
5338
7074
|
locale: NS,
|
|
5339
|
-
},
|
|
7075
|
+
}, PanelRoot));
|
|
7076
|
+
// 冒烟测试专用缝隙(仅在 smoke-render.mjs 设置了该标记时存在):
|
|
7077
|
+
// 直接暴露各页签组件与同步入口,让测试能逐个渲染/驱动,抓白屏与同步断链问题
|
|
7078
|
+
if (typeof window !== 'undefined' && window.__DSH_IC_SMOKE__) {
|
|
7079
|
+
window.__DSH_IC_TABS__ = { ProjectsTab, AssetsTab, BibleTab, SettingsTab, CanvasPage };
|
|
7080
|
+
window.__DSH_IC_SYNC__ = {
|
|
7081
|
+
hostSyncTick, mergeHostDoc, loadBible, loadGraphDoc,
|
|
7082
|
+
removeNodes, ungroupNode, undo, redo, pushHistory,
|
|
7083
|
+
graph: () => JSON.parse(JSON.stringify(S.graph)),
|
|
7084
|
+
state: () => ({ stopped: hostSyncStopped, busy: hostSyncBusy, projects: S.projects.map((p) => p.id), activeId: S.activeId, ready: S.ready }),
|
|
7085
|
+
};
|
|
7086
|
+
}
|
|
5340
7087
|
},
|
|
5341
7088
|
};
|
|
5342
7089
|
},
|