@ninkch/dsh-infinite-canvas 0.17.10 → 0.17.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -34,7 +34,11 @@ window.__ModuleLoader__.load({
34
34
  outputs: [{ id: 'visual', kind: 'text' }, { id: 'motion', kind: 'text' }],
35
35
  },
36
36
  'script': { inputs: [], outputs: [{ id: 'out', kind: 'text' }] },
37
- 'storyboard': { inputs: [{ id: 'script', kind: 'text' }], outputs: [{ id: 'out', kind: 'text' }] },
37
+ 'storyboard': {
38
+ // 承接端口(按数组顺序自上而下排布):顶部「衔接上一镜」← 上一个分镜表的「尾镜」输出
39
+ inputs: [{ id: 'prev', kind: 'text' }, { id: 'script', kind: 'text' }],
40
+ outputs: [{ id: 'out', kind: 'text' }, { id: 'tail', kind: 'text' }],
41
+ },
38
42
  'image-gen': {
39
43
  inputs: [{ id: 'prompt', kind: 'text' }, { id: 'ref', kind: 'image' }],
40
44
  outputs: [{ id: 'out', kind: 'image' }],
@@ -44,7 +48,7 @@ window.__ModuleLoader__.load({
44
48
  outputs: [{ id: 'out', kind: 'video' }, { id: 'last', kind: 'image' }],
45
49
  },
46
50
  'image-asset': { inputs: [{ id: 'in', kind: 'image' }], outputs: [{ id: 'out', kind: 'image' }] },
47
- 'video-asset': { inputs: [{ id: 'in', kind: 'video' }], outputs: [{ id: 'out', kind: 'video' }] },
51
+ 'video-asset': { inputs: [{ id: 'in', kind: 'video' }], outputs: [{ id: 'out', kind: 'video' }, { id: 'last', kind: 'image' }] },
48
52
  'finalcut': { inputs: [{ id: 'in', kind: 'video' }], outputs: [] },
49
53
  'group': { inputs: [], outputs: [] },
50
54
  };
@@ -52,7 +56,7 @@ window.__ModuleLoader__.load({
52
56
  const MULTI_INPUT_PORTS = new Set(['ref', 'char', 'scene', 'prop']);
53
57
  // 分镜端口期望的资产分类(仅用于连接时的非阻断提示)
54
58
  const PORT_CATEGORY = { char: 'character', scene: 'scene', prop: 'prop' };
55
- const CATEGORIES = ['character', 'scene', 'prop', 'script', 'storyboard', 'audio', 'finalcut', 'general'];
59
+ const CATEGORIES = ['character', 'scene', 'prop', 'script', 'storyboard', 'audio', 'finalcut', 'image', 'general'];
56
60
  const RATIOS = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];
57
61
  const IMAGE_SIZES = ['1K', '2K', '4K'];
58
62
  const VIDEO_DURATIONS = [4, 5, 6, 8, 10, 12, 15];
@@ -106,6 +110,22 @@ window.__ModuleLoader__.load({
106
110
  r.readAsDataURL(blob);
107
111
  });
108
112
  }
113
+ /** 参考图/首帧压缩:长边 ≤ maxEdge、JPEG 重编码。
114
+ * 参考图不需要 2K 原图——多张 2K PNG base64 后会击穿 Host 代理 16MB 上限(HTTP 413)。
115
+ * 画布/解码失败时回退原始 base64。 */
116
+ async function blobToDataUrlScaled(blob, maxEdge = 1024) {
117
+ try {
118
+ const bmp = await createImageBitmap(blob);
119
+ const scale = Math.min(1, maxEdge / Math.max(bmp.width, bmp.height));
120
+ const w = Math.max(1, Math.round(bmp.width * scale));
121
+ const h = Math.max(1, Math.round(bmp.height * scale));
122
+ const c = document.createElement('canvas');
123
+ c.width = w; c.height = h;
124
+ c.getContext('2d').drawImage(bmp, 0, 0, w, h);
125
+ bmp.close && bmp.close();
126
+ return c.toDataURL('image/jpeg', 0.85);
127
+ } catch { return await blobToDataUrl(blob); }
128
+ }
109
129
  function downloadBlob(blob, name) {
110
130
  const url = URL.createObjectURL(blob);
111
131
  const a = document.createElement('a');
@@ -217,6 +237,7 @@ window.__ModuleLoader__.load({
217
237
  'catScene': '场景',
218
238
  'catProp': '道具',
219
239
  'catGeneral': '通用',
240
+ 'catImage': '图片',
220
241
  'catLabel': '分类',
221
242
  'rename': '重命名',
222
243
  'cancel': '取消',
@@ -230,7 +251,10 @@ window.__ModuleLoader__.load({
230
251
  'catMismatch': '提示:资产分类为「{a}」,端口期望「{b}」,已保留连接',
231
252
  'nStoryboard': '分镜表',
232
253
  'nScript': '剧本',
254
+ 'vendorLabel': '厂商格式(选后只需填 Base URL + 模型 id)',
233
255
  'portScript': '剧本',
256
+ 'portPrev': '衔接上一镜(尾镜)',
257
+ 'portTail': '尾镜(供下一分镜衔接)',
234
258
  'aiScript': 'AI 生成剧本',
235
259
  'aiStoryboard': 'AI 生成分镜',
236
260
  'expand': '铺开到画布',
@@ -249,6 +273,18 @@ window.__ModuleLoader__.load({
249
273
  'llmLabel': '文本模型(DeepSeek)',
250
274
  'llmHint': '剧本与分镜提示词使用 Harness 当前配置的模型生成,无需另配 Key。',
251
275
  'saveTextAsset': '存为资产',
276
+ 'zoomView': '放大查看',
277
+ 'copyAll': '复制全部',
278
+ 'copiedOk': '已复制到剪贴板',
279
+ 'copyFail': '复制失败:请手动选择文本复制',
280
+ 'lbHint': '点击空白处或按 Esc 关闭',
281
+ 'resizeList': '拖拽调整分镜列表高度',
282
+ 'frameModeLabel': '衔接',
283
+ 'frameModeHint': '首帧口连线时的处理方式。网关禁止首帧与参考图混用:融合=尾帧降级为第1张参考图(保留实体参考图,衔接为软约束);硬首帧=用真首帧参数(画面绝对承接,但放弃参考图);忽略=不使用首帧',
284
+ 'frameModeAuto': '参考图融合',
285
+ 'frameModeHard': '硬首帧(弃参考图)',
286
+ 'frameModeOff': '忽略首帧',
287
+ 'contFrom': '承接',
252
288
  'textAssetHint': '文本资产点击可放入画布文本节点',
253
289
  'placeholderAsset': '待补充',
254
290
  'searchLabel': '搜索名称 / 提示词 / 内容 / 项目',
@@ -299,9 +335,43 @@ window.__ModuleLoader__.load({
299
335
  'shotsUnit': '镜',
300
336
  'savedOk': '已存为资产',
301
337
  'warnUnregistered': '未在设定集/资产中登记',
338
+ 'warnScriptTooLong': '剧本较长({n} 字)≈ 超过单分镜 10-15s 成片容量:请按场景拆分为多个场景剧本节点(每个分镜只连一个场景剧本,长剧本内容会被 24 镜上限截断)',
302
339
  'warnEmptyDesc': '已登记但描述为空(待补全)',
303
340
  'catMention': '提及',
304
341
  'assetCategoriesFixed': '已按命名规范纠正 {n} 个资产的分类',
342
+ 'cfgExtra': '扩展参数(JSON,可选)',
343
+ 'cfgExtraHint': 'JSON 对象,逐键合并进请求体(留空不发送)——厂商专属参数如 watermark、prompt_extend、output_format、seed 等在此填写,换厂商无需改代码',
344
+ 'vidProtocolHint': '通用 = OpenAI 风格任务接口(JUAI 等聚合平台,URL 填完整 /v1/video/generations);Seedance 官方 = 火山方舟 contents/generations/tasks',
345
+ 'vidAudioOpt': '生成同步音频(勾选才发送 generate_audio)',
346
+ 'vidOutFormat': '输出格式 output_format(留空不发送)',
347
+ 'voiceExtractBtn': '提取声音参考',
348
+ 'voiceRole': '角色名',
349
+ 'voiceRolePh': '陈拾',
350
+ 'voiceRoleNeeded': '需要角色名',
351
+ 'voiceStart': '开始秒',
352
+ 'voiceEnd': '结束秒(留空 = 到片尾)',
353
+ 'voiceTip': '选一段"该角色独占台词"的窗口(多角色同屏的段落不适合做声音参考)。片段将存为「角色-<名> 声音」并绑定实体,之后 @<名> 自动携带音色参考。需要降噪/精细分离时可在 Craft(SoundCraft)中处理后再按同一规范导入。',
354
+ 'voiceExtractDone': '已存为声音参考:{name}',
355
+ 'voiceExtractSrc': '提取自',
356
+ 'expandScenes': '铺开到场景',
357
+ 'expandScenesDone': '已铺开 {n} 个场景(每场:文本节点 → 分镜表 已连线)',
358
+ 'expandNeedScenes': '未识别到场景分隔标记——剧本需按「【场N】场景名(地点·时间)」分场书写(重新生成或手动补分场标题)',
359
+ 'autoShots': '自动(按内容节拍)',
360
+ 'voiceAutoHint': '输入角色名 → 自动按分镜台词定位该角色的全部时间窗,并拼接为一个声音参考文件(无需手动找时间点)',
361
+ 'voiceRolesInShots': '分镜台词中的角色:',
362
+ 'voiceSegs': '命中镜头',
363
+ 'voiceNoDialogue': '未找到分镜表或分镜表无镜头——自动定位需要分镜台词标注,请先生成/铺开分镜',
364
+ 'voiceManual': '手动指定时间窗',
365
+ 'voiceRoleNotFound': '分镜台词中没有该角色。可选角色:{roles}',
366
+ 'providerLabel': '供应商',
367
+ 'addProvider': '新增供应商',
368
+ 'addModel': '添加模型',
369
+ 'modelsLabel': '模型',
370
+ 'modelNamePh': '模型 ID(如 doubao-seedance-2.0)',
371
+ 'starDefault': '设为默认模型(节点未指定时自动路由到这里)',
372
+ 'noModelsCfg': '未配置模型——节点运行将报错',
373
+ 'errNoImageModel': '未指定图片模型:请在设置里配置图片模型并星标默认模型,或在节点上选择模型',
374
+ 'errNoVideoModel': '未指定视频模型:请在设置里配置视频模型并星标默认模型,或在节点上选择模型',
305
375
  'consistencyTitle': '一致性检查',
306
376
  'restartHint': '插件后台尚未加载最新版本:请重启一次 DeepSeek Harness 后重试(仅首次更新需要)',
307
377
  'rpcFail': '请求失败',
@@ -557,6 +627,7 @@ window.__ModuleLoader__.load({
557
627
  'catScene': 'Scene',
558
628
  'catProp': 'Prop',
559
629
  'catGeneral': 'General',
630
+ 'catImage': 'Image',
560
631
  'catLabel': 'Category',
561
632
  'rename': 'Rename',
562
633
  'cancel': 'Cancel',
@@ -571,6 +642,9 @@ window.__ModuleLoader__.load({
571
642
  'nStoryboard': 'Storyboard',
572
643
  'nScript': 'Script',
573
644
  'portScript': 'script',
645
+ 'vendorLabel': 'Vendor format (then just Base URL + model id)',
646
+ 'portPrev': 'Continue prev tail',
647
+ 'portTail': 'Tail shot (for next)',
574
648
  'aiScript': 'AI write script',
575
649
  'aiStoryboard': 'AI break into shots',
576
650
  'expand': 'Expand to canvas',
@@ -589,6 +663,18 @@ window.__ModuleLoader__.load({
589
663
  'llmLabel': 'Text model (DeepSeek)',
590
664
  'llmHint': 'Scripts and storyboard prompts use the model configured in Harness — no extra key needed.',
591
665
  'saveTextAsset': 'Save as asset',
666
+ 'zoomView': 'View large',
667
+ 'copyAll': 'Copy all',
668
+ 'copiedOk': 'Copied to clipboard',
669
+ 'copyFail': 'Copy failed — select the text manually',
670
+ 'lbHint': 'Click outside or press Esc to close',
671
+ 'resizeList': 'Drag to resize the shot list',
672
+ 'frameModeLabel': 'Chaining',
673
+ 'frameModeHint': 'How the first-frame input is used. The gateway forbids mixing first_frame with reference_image: Fuse = the tail frame becomes reference image #1 (keeps entity refs; continuity is a soft constraint); Hard = true first_frame param (exact continuity, drops refs); Ignore = do not use the frame',
674
+ 'frameModeAuto': 'Fuse with refs',
675
+ 'frameModeHard': 'Hard first frame',
676
+ 'frameModeOff': 'Ignore frame',
677
+ 'contFrom': 'Continues',
592
678
  'textAssetHint': 'Click a text asset to place it as a canvas text node',
593
679
  'placeholderAsset': 'missing',
594
680
  'searchLabel': 'Search name / prompt / content / project',
@@ -639,9 +725,43 @@ window.__ModuleLoader__.load({
639
725
  'shotsUnit': 'shots',
640
726
  'savedOk': 'Saved as asset',
641
727
  'warnUnregistered': 'not in bible/assets',
728
+ 'warnScriptTooLong': 'Script is long ({n} chars) ≈ beyond one storyboard\'s 10-15s capacity: split the script into multiple scene script nodes (each storyboard eats ONE scene; long scripts get truncated at the 24-shot cap)',
642
729
  'warnEmptyDesc': 'registered but description is empty (fill it in)',
643
730
  'catMention': 'Mention',
644
731
  'assetCategoriesFixed': 'Re-categorized {n} assets by naming convention',
732
+ 'cfgExtra': 'Extra params (JSON, optional)',
733
+ 'cfgExtraHint': 'A JSON object merged into the request body (empty = not sent) — vendor-specific params like watermark, prompt_extend, output_format, seed go here; switching vendors needs no code changes',
734
+ 'vidProtocolHint': 'Generic = OpenAI-style task API (JUAI etc., URL = full /v1/video/generations); Seedance official = Volcano Ark contents/generations/tasks',
735
+ 'vidAudioOpt': 'Generate synced audio (only sent when checked)',
736
+ 'vidOutFormat': 'Output format output_format (empty = not sent)',
737
+ 'voiceExtractBtn': 'Extract voice reference',
738
+ 'voiceRole': 'Character',
739
+ 'voiceRolePh': 'e.g. Chen Shi',
740
+ 'voiceRoleNeeded': 'Character name required',
741
+ 'voiceStart': 'Start (s)',
742
+ 'voiceEnd': 'End (s, empty = to end)',
743
+ 'voiceTip': 'Pick a window where this character speaks alone (overlapping-dialogue segments are bad references). The clip is saved as "Character-<name> voice" bound to the entity; @<name> then carries the voice automatically. For降噪/fine separation process in Craft (SoundCraft) and import with the same naming.',
744
+ 'voiceExtractDone': 'Saved voice reference: {name}',
745
+ 'voiceExtractSrc': 'Extracted from',
746
+ 'expandScenes': 'Expand to scenes',
747
+ 'expandScenesDone': 'Expanded {n} scenes (each: text node → storyboard, wired)',
748
+ 'expandNeedScenes': 'No scene separators found — the script must use "【SCENE N】name (location · time)" per-scene headers (regenerate or add scene headers manually)',
749
+ 'autoShots': 'Auto (by content beats)',
750
+ 'voiceAutoHint': 'Type the character name — all dialogue windows for that role are located automatically from the storyboard and merged into ONE voice reference file',
751
+ 'voiceRolesInShots': 'Characters found in shot dialogue: ',
752
+ 'voiceSegs': 'Matched shots',
753
+ 'voiceNoDialogue': 'No storyboard with shots found — auto location needs the dialogue annotations; generate the storyboard first',
754
+ 'voiceManual': 'Manual time window',
755
+ 'voiceRoleNotFound': 'Role not found in shot dialogue. Available: {roles}',
756
+ 'providerLabel': 'Provider',
757
+ 'addProvider': 'Add provider',
758
+ 'addModel': 'Add model',
759
+ 'modelsLabel': 'Models',
760
+ 'modelNamePh': 'Model id (e.g. doubao-seedance-2.0)',
761
+ 'starDefault': 'Set as default model (nodes without a selection route here)',
762
+ 'noModelsCfg': 'No models configured — node runs will fail',
763
+ 'errNoImageModel': 'No image model selected: configure image models in Settings and star a default, or pick one on the node',
764
+ 'errNoVideoModel': 'No video model selected: configure video models in Settings and star a default, or pick one on the node',
645
765
  'consistencyTitle': 'Consistency check',
646
766
  'restartHint': 'The plugin backend is not loaded yet: restart DeepSeek Harness once and retry (first update only)',
647
767
  'rpcFail': 'Request failed',
@@ -1006,8 +1126,15 @@ window.__ModuleLoader__.load({
1006
1126
  color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;padding:0 3px;outline:none;cursor:pointer;flex:none;}
1007
1127
  .dshic-shot-list{display:flex;flex-direction:column;gap:4px;max-height:180px;overflow-y:auto;}
1008
1128
  .dshic-shot-item{border:1px solid var(--dsw-alias-border-l1);border-radius:7px;padding:5px 7px;font-size:11px;line-height:15px;
1009
- color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);}
1129
+ color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);user-select:text;-webkit-user-select:text;cursor:text;}
1010
1130
  .dshic-shot-item strong{color:var(--dsw-alias-label-primary);font-size:11px;}
1131
+ .dshic-shot-grip{position:absolute;right:3px;bottom:3px;width:18px;height:14px;display:flex;align-items:center;justify-content:center;
1132
+ color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1;cursor:ns-resize;opacity:.6;user-select:none;-webkit-user-select:none;
1133
+ background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:4px;}
1134
+ .dshic-shot-grip:hover{opacity:1;color:var(--dsw-alias-label-primary);}
1135
+ .dshic-lightbox{position:fixed;inset:0;z-index:90;background:rgba(0,0,0,.82);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:24px;}
1136
+ .dshic-lightbox img,.dshic-lightbox video{max-width:92vw;max-height:80vh;border-radius:8px;background:#000;box-shadow:0 12px 48px rgba(0,0,0,.5);}
1137
+ .dshic-lightbox-cap{font-size:12px;color:#eee;user-select:text;-webkit-user-select:text;max-width:80vw;word-break:break-all;text-align:center;}
1011
1138
  .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);
1012
1139
  font:inherit;font-size:12px;line-height:18px;padding:7px 9px;overflow:hidden;text-align:left;display:block;white-space:pre-wrap;}
1013
1140
  /* 文本(剧本等)资产卡:只展示标题与项目标签,不展开正文 */
@@ -1054,7 +1181,7 @@ window.__ModuleLoader__.load({
1054
1181
  .dshic-hist{display:flex;gap:4px;overflow-x:auto;}
1055
1182
  .dshic-hist-thumb{width:44px;height:44px;border-radius:6px;border:2px solid var(--dsw-alias-border-l2);overflow:hidden;cursor:pointer;flex:none;
1056
1183
  background:var(--dsw-alias-bg-layer-2);display:flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-secondary);}
1057
- .dshic-hist-thumb img{width:100%;height:100%;object-fit:cover;display:block;}
1184
+ .dshic-hist-thumb img,.dshic-hist-thumb video{width:100%;height:100%;object-fit:cover;display:block;}
1058
1185
  .dshic-hist-thumb.is-active{border-color:var(--dsw-alias-brand-primary);}
1059
1186
  /* 分组框 */
1060
1187
  .dshic-group{position:absolute;box-sizing:border-box;border:1.5px dashed var(--dsw-alias-brand-primary);border-radius:14px;
@@ -1112,11 +1239,12 @@ window.__ModuleLoader__.load({
1112
1239
  const DEFAULT_SETTINGS = {
1113
1240
  // 文本模型全部用 Harness 的当前模型与默认参数:画布只选择模型,不单独配置生成参数
1114
1241
  textLlm: { provider: '', model: '' },
1115
- imageApis: [], // [{id, name, baseUrl, apiKey, model, mode, size?, responseFormat?}]
1116
- videoApis: [], // [{id, name, url, apiKey, model, protocol?, watermark?, generateAudio?}]
1242
+ // 供应商 + 模型两级:一个供应商(URL/Key)配一次,下面挂多个模型;★星标模型 = 节点未指定时的默认路由
1243
+ imageProviders: [], // [{id, name, baseUrl, apiKey, extra, size?, responseFormat?, mode?, models: [{id, model}]}]
1244
+ videoProviders: [], // [{id, name, url, apiKey, extra, outputFormat?, generateAudio?, protocol?, watermark?, models: [{id, model}]}]
1245
+ imageStar: '', // 'providerId|modelId'
1246
+ videoStar: '',
1117
1247
  ttsApis: [], // [{id, name, baseUrl, apiKey, model, voice}]
1118
- imageApiId: '',
1119
- videoApiId: '',
1120
1248
  ttsApiId: '',
1121
1249
  lang: '', // '' = 跟随 Harness
1122
1250
  skillScript: '', // '' = 内置编剧专家
@@ -1126,11 +1254,11 @@ window.__ModuleLoader__.load({
1126
1254
  const s = lsGet(LS_SETTINGS, {}) || {};
1127
1255
  const out = {
1128
1256
  textLlm: { provider: (s.textLlm && s.textLlm.provider) || '', model: (s.textLlm && s.textLlm.model) || '' },
1129
- imageApis: Array.isArray(s.imageApis) ? s.imageApis : [],
1130
- videoApis: Array.isArray(s.videoApis) ? s.videoApis : [],
1257
+ imageProviders: Array.isArray(s.imageProviders) ? s.imageProviders : [],
1258
+ videoProviders: Array.isArray(s.videoProviders) ? s.videoProviders : [],
1259
+ imageStar: s.imageStar || '',
1260
+ videoStar: s.videoStar || '',
1131
1261
  ttsApis: Array.isArray(s.ttsApis) ? s.ttsApis : [],
1132
- imageApiId: s.imageApiId || '',
1133
- videoApiId: s.videoApiId || '',
1134
1262
  ttsApiId: s.ttsApiId || '',
1135
1263
  lang: s.lang || '',
1136
1264
  skillScript: s.skillScript || '',
@@ -1139,25 +1267,97 @@ window.__ModuleLoader__.load({
1139
1267
  // v0.3 及以前的单配置自动迁移为列表
1140
1268
  // 注意:id 必须**确定性**——loadSettings 是高频调用(每次渲染/生成都会走),
1141
1269
  // 若这里用 uid() 每次生成新 id,节点上保存的 apiId 会永远匹配不上、默认项也会漂移
1142
- if (!out.imageApis.length && s.imageApi && s.imageApi.baseUrl) {
1143
- out.imageApis = [{
1270
+ if (!out.imageProviders.length && !out.imageApis?.length && s.imageApi && s.imageApi.baseUrl) {
1271
+ out.imageProviders = [{
1144
1272
  id: 'legacy-image', name: T('defaultImageCfg'),
1145
1273
  baseUrl: s.imageApi.baseUrl || '', apiKey: s.imageApi.apiKey || '',
1146
- model: s.imageApi.model || '', mode: s.imageApi.mode || 'generations',
1274
+ mode: s.imageApi.mode || 'generations', models: [{ id: 'legacy-image-m', model: s.imageApi.model || '' }],
1147
1275
  }];
1148
- out.imageApiId = s.imageApiId || 'legacy-image';
1276
+ out.imageStar = 'legacy-image|legacy-image-m';
1149
1277
  }
1150
- if (!out.videoApis.length && s.videoApi && s.videoApi.url) {
1151
- out.videoApis = [{
1278
+ if (!out.videoProviders.length && !out.videoApis?.length && s.videoApi && s.videoApi.url) {
1279
+ out.videoProviders = [{
1152
1280
  id: 'legacy-video', name: T('defaultVideoCfg'),
1153
- url: s.videoApi.url || '', apiKey: s.videoApi.apiKey || '', model: s.videoApi.model || '',
1281
+ url: s.videoApi.url || '', apiKey: s.videoApi.apiKey || '',
1282
+ models: [{ id: 'legacy-video-m', model: s.videoApi.model || '' }],
1154
1283
  }];
1155
- out.videoApiId = s.videoApiId || 'legacy-video';
1284
+ out.videoStar = 'legacy-video|legacy-video-m';
1285
+ }
1286
+ // v0.17.x 扁平 API(一配置一模型)→ 供应商 + 模型列表:
1287
+ // 派生 id 确定性沿用旧 cfg id,节点上的旧 apiId 可通过 legacy 映射懒迁移
1288
+ if (!out.imageProviders.length && Array.isArray(s.imageApis) && s.imageApis.length) {
1289
+ out.imageProviders = s.imageApis.map((cfg) => ({
1290
+ id: 'p_' + cfg.id, name: cfg.name || T('defaultImageCfg'),
1291
+ baseUrl: cfg.baseUrl || '', apiKey: cfg.apiKey || '', extra: cfg.extra || '',
1292
+ size: cfg.size || '', responseFormat: cfg.responseFormat || '', mode: cfg.mode || 'generations',
1293
+ models: [{ id: 'm_' + cfg.id, model: cfg.model || '' }],
1294
+ }));
1295
+ out.imageStar = (s.imageApiId && 'p_' + s.imageApiId + '|m_' + s.imageApiId) || (out.imageProviders[0] ? out.imageProviders[0].id + '|' + out.imageProviders[0].models[0].id : '');
1296
+ legacyImageApiRefMap = {};
1297
+ for (const cfg of s.imageApis) legacyImageApiRefMap[cfg.id] = 'p_' + cfg.id + '|m_' + cfg.id;
1298
+ }
1299
+ if (!out.videoProviders.length && Array.isArray(s.videoApis) && s.videoApis.length) {
1300
+ out.videoProviders = s.videoApis.map((cfg) => ({
1301
+ id: 'p_' + cfg.id, name: cfg.name || T('defaultVideoCfg'),
1302
+ url: cfg.url || '', apiKey: cfg.apiKey || '', extra: cfg.extra || '',
1303
+ outputFormat: cfg.outputFormat || '', generateAudio: cfg.generateAudio, protocol: cfg.protocol || 'generic', watermark: cfg.watermark,
1304
+ models: [{ id: 'm_' + cfg.id, model: cfg.model || '' }],
1305
+ }));
1306
+ out.videoStar = (s.videoApiId && 'p_' + s.videoApiId + '|m_' + s.videoApiId) || (out.videoProviders[0] ? out.videoProviders[0].id + '|' + out.videoProviders[0].models[0].id : '');
1307
+ legacyVideoApiRefMap = {};
1308
+ for (const cfg of s.videoApis) legacyVideoApiRefMap[cfg.id] = 'p_' + cfg.id + '|m_' + cfg.id;
1156
1309
  }
1157
1310
  return out;
1158
1311
  }
1159
1312
  function saveSettings(s) { lsSet(LS_SETTINGS, s); }
1160
1313
 
1314
+ // ---------- 供应商 + 模型(图片/视频):扁平视图与解析 ----------
1315
+ let legacyImageApiRefMap = {}; // 旧扁平配置 id → 'providerId|modelId'(节点字段懒迁移用)
1316
+ let legacyVideoApiRefMap = {};
1317
+ /** 供应商列表 → 扁平模型视图(ref = 'providerId|modelId');未填模型名的条目不进下拉。 */
1318
+ function flattenImageModels(settings) {
1319
+ const out = [];
1320
+ for (const p of settings.imageProviders || []) {
1321
+ for (const m of (p.models || [])) {
1322
+ if (!m || !m.id || !String(m.model || '').trim()) continue;
1323
+ out.push({ ref: p.id + '|' + m.id, providerId: p.id, providerName: p.name || p.id, modelId: m.id, model: String(m.model).trim(), baseUrl: p.baseUrl || '', apiKey: p.apiKey || '', extra: p.extra || '', size: p.size || '', responseFormat: p.responseFormat || '', mode: p.mode || 'generations' });
1324
+ }
1325
+ }
1326
+ return out;
1327
+ }
1328
+ function flattenVideoModels(settings) {
1329
+ const out = [];
1330
+ for (const p of settings.videoProviders || []) {
1331
+ for (const m of (p.models || [])) {
1332
+ if (!m || !m.id || !String(m.model || '').trim()) continue;
1333
+ out.push({ ref: p.id + '|' + m.id, providerId: p.id, providerName: p.name || p.id, modelId: m.id, model: String(m.model).trim(), url: p.url || '', apiKey: p.apiKey || '', extra: p.extra || '', outputFormat: p.outputFormat || '', generateAudio: p.generateAudio, protocol: p.protocol || 'generic', watermark: p.watermark });
1334
+ }
1335
+ }
1336
+ return out;
1337
+ }
1338
+ /** 解析节点上的模型引用:空 = 星标默认模型;都没有 = null(运行时报错)。 */
1339
+ function resolveImageModelCfg(settings, ref) {
1340
+ const list = flattenImageModels(settings);
1341
+ const use = String(ref || '').trim() || settings.imageStar || '';
1342
+ return list.find((x) => x.ref === use) || null;
1343
+ }
1344
+ function resolveVideoModelCfg(settings, ref) {
1345
+ const list = flattenVideoModels(settings);
1346
+ const use = String(ref || '').trim() || settings.videoStar || '';
1347
+ return list.find((x) => x.ref === use) || null;
1348
+ }
1349
+ /** 节点上的模型引用(兼容旧扁平 apiId 的懒迁移)。 */
1350
+ function nodeImageModelRef(n) {
1351
+ const d = (n && n.data) || {};
1352
+ if (d.imageModel) return d.imageModel;
1353
+ return legacyImageApiRefMap[d.imageApiId] || '';
1354
+ }
1355
+ function nodeVideoModelRef(n) {
1356
+ const d = (n && n.data) || {};
1357
+ if (d.videoModel) return d.videoModel;
1358
+ return legacyVideoApiRefMap[d.videoApiId] || '';
1359
+ }
1360
+
1161
1361
  function emptyGraph() { return { nodes: {}, edges: [], view: { x: 60, y: 40, scale: 1 } }; }
1162
1362
  function loadGraphDoc(pid) { return lsGet(LS_GRAPH + pid, null); }
1163
1363
  function saveGraphDoc(pid, graph) { lsSet(LS_GRAPH + pid, graph); }
@@ -1347,36 +1547,42 @@ window.__ModuleLoader__.load({
1347
1547
  }
1348
1548
  // <<<MENTION_PURE_END>>>
1349
1549
 
1350
- /** 解析提示词里的 @引用:返回命中实体 + 其名下的参考图资产(按实体出现顺序、各实体内按生成时间)。
1351
- * 实体的参考图来源:① 设定条目里**链接的资产**(assetIds,绑定关系优先)
1352
- * ② 其次按命名/实体字段匹配的资产(角色-张山 多视图 等)。 */
1353
- async function resolveMentionBundle(prompt, bible, projectId) {
1354
- const entities = [];
1355
- for (const m of parseMentions(prompt)) {
1356
- const hit = matchBibleEntity(m.name, bible);
1357
- if (hit && !entities.some((e) => e.name === hit.name)) entities.push(hit);
1358
- }
1359
- if (!entities.length) return { entities: [], refs: [] };
1360
- let all = [];
1361
- try { all = (await idbByProject(projectId)).concat(await idbByProject(GENERAL_PID)); } catch { all = []; }
1362
- const byId = new Map(all.map((r) => [r.id, r]));
1363
- const refs = [];
1364
- for (const ent of entities) {
1365
- const owned = [];
1366
- // ① 条目链接的资产(绑定优先,任何类型都认;生成时只有图片能当参考图)
1367
- for (const aid of ent.assetIds || []) {
1368
- const rec = byId.get(aid);
1369
- if (rec && rec.kind === 'image') owned.push(rec);
1550
+ /** 解析提示词里的 @引用:返回命中实体 + 其名下的参考图资产与**声音参考资产**(按实体出现顺序、各实体内按生成时间)。
1551
+ * 实体的参考资产来源:① 设定条目里**链接的资产**(assetIds,绑定关系优先)
1552
+ * ② 其次按命名/实体字段匹配的资产(角色-张山 多视图 / 角色-张山 声音 等)。 */
1553
+ async function resolveMentionBundle(prompt, bible, projectId) {
1554
+ const entities = [];
1555
+ for (const m of parseMentions(prompt)) {
1556
+ const hit = matchBibleEntity(m.name, bible);
1557
+ if (hit && !entities.some((e) => e.name === hit.name)) entities.push(hit);
1370
1558
  }
1371
- // ② 命名/实体匹配的资产
1372
- for (const r of all) {
1373
- if (r.kind === 'image' && assetBelongsToEntity(r, ent) && !owned.some((x) => x.id === r.id)) owned.push(r);
1559
+ if (!entities.length) return { entities: [], refs: [], audioRefs: [] };
1560
+ let all = [];
1561
+ try { all = (await idbByProject(projectId)).concat(await idbByProject(GENERAL_PID)); } catch { all = []; }
1562
+ const byId = new Map(all.map((r) => [r.id, r]));
1563
+ const refs = [];
1564
+ const audioRefs = [];
1565
+ for (const ent of entities) {
1566
+ const owned = [];
1567
+ const ownedAudio = [];
1568
+ // ① 条目链接的资产(绑定优先,任何类型都认;生成时图片当参考图、音频当声音参考)
1569
+ for (const aid of ent.assetIds || []) {
1570
+ const rec = byId.get(aid);
1571
+ if (rec && rec.kind === 'image') owned.push(rec);
1572
+ if (rec && rec.kind === 'audio') ownedAudio.push(rec);
1573
+ }
1574
+ // ② 命名/实体匹配的资产
1575
+ for (const r of all) {
1576
+ if (r.kind === 'image' && assetBelongsToEntity(r, ent) && !owned.some((x) => x.id === r.id)) owned.push(r);
1577
+ if (r.kind === 'audio' && assetBelongsToEntity(r, ent) && !ownedAudio.some((x) => x.id === r.id)) ownedAudio.push(r);
1578
+ }
1579
+ owned.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
1580
+ ownedAudio.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
1581
+ 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 });
1582
+ for (const r of ownedAudio) if (!audioRefs.some((x) => x.assetId === r.id)) audioRefs.push({ assetId: r.id, entity: ent.name, name: r.name });
1374
1583
  }
1375
- owned.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
1376
- 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 });
1584
+ return { entities, refs, audioRefs };
1377
1585
  }
1378
- return { entities, refs };
1379
- }
1380
1586
  /** 合成参考图列表:连线(用户显式顺序)优先,其后追加 @引用命中的设定集资产(去重)。 */
1381
1587
  async function collectRefs(n, prompt, bible) {
1382
1588
  const out = [];
@@ -1404,7 +1610,16 @@ window.__ModuleLoader__.load({
1404
1610
  if (blob) out.push({ assetId: r.assetId, blob, name: (rec && rec.name) || r.name, category: (rec && rec.category) || 'general', source: 'mention', entity: r.entity });
1405
1611
  } catch { /* 忽略 */ }
1406
1612
  }
1407
- return { refs: out, entities: bundle.entities };
1613
+ // 声音参考:@引用命中实体名下的音频资产(角色音色一致性用),视频生成时作为 audios[] 传出
1614
+ const audioBlobs = [];
1615
+ for (const r of (bundle.audioRefs || [])) {
1616
+ try {
1617
+ const rec = await idbGet(r.assetId);
1618
+ const blob = await assetBlob(r.assetId);
1619
+ if (blob) audioBlobs.push({ blob, name: (rec && rec.name) || r.name, entity: r.entity, assetId: r.assetId });
1620
+ } catch { /* 忽略 */ }
1621
+ }
1622
+ return { refs: out, entities: bundle.entities, audioBlobs };
1408
1623
  }
1409
1624
 
1410
1625
  function outLangLine() {
@@ -1798,11 +2013,23 @@ window.__ModuleLoader__.load({
1798
2013
  if (!ct || /json|text|plain/.test(ct)) { try { text = new TextDecoder().decode(buf); } catch { text = ''; } }
1799
2014
  return { ok: res.ok, status: res.status, contentType: ct, text, blob };
1800
2015
  }
2016
+ /** 扩展参数(JSON 字符串 → 对象):厂商专属参数的通用出口(watermark/prompt_extend/output_format/seed…)。
2017
+ * 非空但不是合法 JSON 对象时抛错(配置错误应当立刻暴露,而不是被静默吞掉)。 */
2018
+ function parseExtraParams(json) {
2019
+ const t = String(json || '').trim();
2020
+ if (!t) return null;
2021
+ const v = JSON.parse(t);
2022
+ if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('扩展参数必须是 JSON 对象(如 {"watermark":false})');
2023
+ return v;
2024
+ }
1801
2025
  async function apiImage(prompt, refBlobs, s, opts) {
1802
2026
  if (!s.baseUrl) throw new Error('未配置图片 API');
1803
- const base = s.baseUrl.replace(/\/+$/, '');
2027
+ // URL 归一化:裸域 → /v1;粘贴完整端点也不会被二次拼接
2028
+ let base = s.baseUrl.replace(/\/+$/, '').replace(/\/images\/generations$/, '');
2029
+ if (!base.replace(/^https?:\/\//i, '').includes('/')) base = base + '/v1';
1804
2030
  const refs = refBlobs || [];
1805
2031
  const auth = s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {};
2032
+ const extra = parseExtraParams(s.extra); // 厂商专属参数(留空不发送)
1806
2033
  const upstreamTimeout = Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000; // 商汤等同步接口 20s+,默认 120s
1807
2034
  // 参考图编辑模式:multipart 上传到 /images/edits(OpenAI gpt-image-1 兼容)
1808
2035
  if (refs.length && s.mode === 'edits') {
@@ -1839,6 +2066,8 @@ window.__ModuleLoader__.load({
1839
2066
  const sizeOverride = String(s.size || '').trim();
1840
2067
  if (sizeOverride) body.size = sizeOverride;
1841
2068
  else { const dims = imageDimsFor(opts); body.size = `${dims.w}x${dims.h}`; }
2069
+ // 厂商专属扩展参数最后合并(可覆盖以上任何字段):换厂商不需要改代码
2070
+ if (extra) Object.assign(body, extra);
1842
2071
  const res = await fetchViaProxy(base + '/images/generations', {
1843
2072
  headers: { 'Content-Type': 'application/json', ...auth },
1844
2073
  body, timeoutMs: upstreamTimeout, label: '图片 API',
@@ -1876,6 +2105,8 @@ window.__ModuleLoader__.load({
1876
2105
  j.video && j.video.url,
1877
2106
  j.output && (j.output.url || j.output.video_url),
1878
2107
  j.result && (j.result.url || j.result.video_url),
2108
+ // OpenAI 风格视频任务(如 JUAI):完成响应把结果放在 metadata.url
2109
+ j.metadata && (j.metadata.url || j.metadata.video_url),
1879
2110
  Array.isArray(j.data) && j.data[0] && (j.data[0].url || j.data[0].video_url),
1880
2111
  ];
1881
2112
  return cands.find((u) => typeof u === 'string' && /^https?:/.test(u)) || null;
@@ -1886,12 +2117,26 @@ window.__ModuleLoader__.load({
1886
2117
  ?? (j.data && (j.data.task_id ?? j.data.id ?? j.data.taskId))
1887
2118
  ?? (j.output && (j.output.task_id ?? j.output.id));
1888
2119
  }
1889
- async function downloadVideo(url, headers) {
2120
+ async function downloadVideo(url, headers, authFallback) {
1890
2121
  // 经 Host 代理:签名下载直连同样会被 CORS 拦
1891
- const r = await fetchViaProxy(url, { method: 'GET', headers: headers || {}, timeoutMs: 300000, label: '下载视频' });
2122
+ let r = await fetchViaProxy(url, { method: 'GET', headers: headers || {}, timeoutMs: 300000, label: '下载视频' });
2123
+ // 认证内容代理(如 JUAI /v1/videos/{id}/content)返回 401/403 时:用 API 鉴权头重试一次
2124
+ if (!r.ok && (r.status === 401 || r.status === 403) && authFallback && Object.keys(authFallback).length) {
2125
+ r = await fetchViaProxy(url, { method: 'GET', headers: authFallback, timeoutMs: 300000, label: '下载视频' });
2126
+ }
1892
2127
  if (!r.ok) throw new Error('下载视频失败 HTTP ' + r.status);
1893
2128
  return r.blob;
1894
2129
  }
2130
+ /** 跨镜衔接标注(短剧分镜链式生成的行业标准做法):图1=上一镜尾帧(仅用于衔接),
2131
+ * 图2..N=实体参考图(名称与 o.refNames 对齐)。Seedance 的 first_frame 与 reference_image 互斥,
2132
+ * 尾帧+参考图同连时尾帧降级为第一张衔接参考图,靠顺序+标注控制用途。 */
2133
+ function videoRefNote(refNames) {
2134
+ const names = (refNames || []).map((x) => String(x || '').trim()).filter(Boolean);
2135
+ const parts = ['【衔接硬约束】[图1] 是上一镜的尾帧:本镜视频的第 1 帧必须与 [图1] 画面一致,并从该画面自然延续——不得跳回远景、不得重新建立场景、不得切换机位'];
2136
+ names.forEach((nm, i) => { parts.push('[图' + (i + 2) + '] 为「' + nm + '」的参考图'); });
2137
+ parts.push('除 [图1] 用于衔接外,各实体外观须与对应参考图一致');
2138
+ return '(' + parts.join(';') + ')';
2139
+ }
1895
2140
  /** 火山方舟 Seedance 官方协议:创建任务 → 轮询 → 下载(支持首帧 + 最多 4 张参考图)。 */
1896
2141
  async function seedanceVideo(prompt, frameBlob, refs, s, opts) {
1897
2142
  if (!s.url) throw new Error('未配置视频 API');
@@ -1899,34 +2144,45 @@ window.__ModuleLoader__.load({
1899
2144
  const base = s.url.replace(/\/+$/, '');
1900
2145
  const headers = { 'Content-Type': 'application/json', ...(s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {}) };
1901
2146
  const o = opts || {};
1902
- const refBlobs = (refs || []).slice(0, 4);
2147
+ // first_frame 与 reference_image 互斥(网关 400)。跨镜衔接(尾帧+参考图同连)→
2148
+ // 首帧降级为第一张衔接参考图:全部按 reference_image 传、提示词按序标注、比例不锁 adaptive
2149
+ const rawRefs = o.hardFrame ? [] : (refs || []).slice(0, 8);
2150
+ const downgrade = Boolean(frameBlob && rawRefs.length);
2151
+ const refBlobs = downgrade ? [frameBlob].concat(rawRefs) : rawRefs;
1903
2152
  let text = prompt;
1904
- if (refBlobs.length && !/\[图\d\]/.test(prompt)) {
2153
+ if (downgrade) {
2154
+ text = videoRefNote(o.refNames) + '\n' + text; // 前置硬约束:衔接要求放在最前,否则模型容易忽略
2155
+ } else if (refBlobs.length && !/\[图\d\]/.test(prompt)) {
1905
2156
  text += '\n(' + [...Array(refBlobs.length)].map((_, i) => '[图' + (i + 1) + ']').join('') + ' 为参考图,请保持其人物/场景/道具特征一致)';
1906
2157
  }
1907
2158
  const content = [{ type: 'text', text }];
1908
- if (frameBlob) content.push({ type: 'image_url', image_url: { url: await blobToDataUrl(frameBlob) }, role: 'first_frame' });
1909
- for (const b of refBlobs) content.push({ type: 'image_url', image_url: { url: await blobToDataUrl(b) }, role: 'reference_image' });
2159
+ if (frameBlob && !downgrade) content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(frameBlob) }, role: 'first_frame' });
2160
+ for (const b of refBlobs) content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(b) }, role: 'reference_image' });
1910
2161
  const body = {
1911
2162
  model: s.model,
1912
2163
  content,
1913
2164
  duration: Number(o.duration) || 5,
1914
2165
  resolution: (o.resolution || '720P').toLowerCase(),
1915
2166
  watermark: s.watermark === true,
1916
- generate_audio: s.generateAudio !== false,
2167
+ // 勾选才发送/置 true:与通用路径的显式语义一致(默认不发送,用网关默认值)
2168
+ generate_audio: s.generateAudio === true,
1917
2169
  };
1918
- body.ratio = frameBlob ? 'adaptive' : (o.ratio || '16:9');
2170
+ try { const extra = parseExtraParams(s.extra); if (extra) Object.assign(body, extra); } catch (e) { throw new Error('视频 API 扩展参数配置有误:' + ((e && e.message) || e)); }
2171
+ body.ratio = (frameBlob && !downgrade) ? 'adaptive' : (o.ratio || '16:9');
1919
2172
  const res = await fetchViaProxy(base, { headers, body, timeoutMs: Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000, label: 'Seedance 视频 API' });
1920
2173
  if (!res.ok) throw new Error('Seedance HTTP ' + res.status + ':' + res.text.slice(0, 300));
1921
2174
  const j = JSON.parse(res.text || '{}');
1922
2175
  const taskId = j && j.id;
1923
2176
  if (!taskId) throw new Error('Seedance 响应中没有任务 id');
1924
- const deadline = Date.now() + 15 * 60 * 1000;
2177
+ if (o.onTask) { try { o.onTask(taskId, base + '/' + taskId); } catch { /* 持久化失败不影响生成 */ } }
2178
+ const deadline = Date.now() + 120 * 60 * 1000; // 2 小时:视频任务含排队可能很久,查询层绝不先放弃
1925
2179
  while (Date.now() < deadline) {
1926
2180
  await sleep(10000);
1927
- const r2 = await fetchViaProxy(base + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询 Seedance 任务' });
1928
- if (!r2.ok) throw new Error('查询 Seedance 任务失败 HTTP ' + r2.status);
1929
- const st = JSON.parse(r2.text || '{}');
2181
+ let r2 = null;
2182
+ try { r2 = await fetchViaProxy(base + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询 Seedance 任务' }); } catch { /* 网络抖动重试 */ }
2183
+ if (!r2 || !r2.ok) continue; // 查询层任何失败(网关 5xx/429/断连)都不算任务失败——只等生成任务自身的终态
2184
+ let st = null;
2185
+ try { st = JSON.parse(r2.text || '{}'); } catch { continue; }
1930
2186
  if (st.status === 'succeeded') {
1931
2187
  const u = st.content && st.content.video_url;
1932
2188
  if (!u) throw new Error('Seedance 任务成功但未返回视频地址');
@@ -1936,63 +2192,517 @@ window.__ModuleLoader__.load({
1936
2192
  throw new Error('Seedance 任务失败:' + ((st.error && st.error.message) || st.status));
1937
2193
  }
1938
2194
  }
1939
- throw new Error('Seedance 任务超时(15 分钟)');
2195
+ throw new Error('Seedance 任务超过 2 小时未返回(任务 id 已保存在节点上,可凭 id 回查结果)');
2196
+ }
2197
+ /** Seedance 原生 content[] 格式兜底(RelayRouter 等:POST /v1/video/generations {model, content, metadata},
2198
+ * 轮询 GET /v1/video/query?task_id=...)。首帧/参考图角色与 Seedance 原生协议一致(图压缩内联)。 */
2199
+ async function relayVideoContentFallback(base, prompt, frameBlob, o, s, headers) {
2200
+ // 尾帧+参考图同连(跨镜衔接)→ 尾帧降级为第一张衔接参考图(first_frame/reference_image 互斥)
2201
+ const rawRefs = o.hardFrame ? [] : (o.refs || []).slice(0, 8);
2202
+ const downgrade = Boolean(frameBlob && rawRefs.length);
2203
+ const refBlobs = downgrade ? [frameBlob].concat(rawRefs) : rawRefs;
2204
+ // 注意:prompt 由调用方(apiVideo 通用路径)传入,downgrade 时的 videoRefNote 已在通用路径前置,这里不重复
2205
+ const text = prompt;
2206
+ const content = [{ type: 'text', text }];
2207
+ if (frameBlob && !downgrade) content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(frameBlob) }, role: 'first_frame' });
2208
+ for (const b of refBlobs) content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(b) }, role: 'reference_image' });
2209
+ const body = {
2210
+ model: s.model,
2211
+ content,
2212
+ metadata: {
2213
+ duration: Math.max(4, Math.min(15, Math.round(Number(o.duration) || 5))),
2214
+ ratio: o.ratio || '16:9',
2215
+ resolution: String(o.resolution || '720p').toLowerCase(),
2216
+ },
2217
+ };
2218
+ const r1 = await fetchViaProxy(base, { headers, body, timeoutMs: 120000, label: '视频 API(content)' });
2219
+ if (!r1.ok) throw new Error('HTTP ' + r1.status + ':' + r1.text.slice(0, 200));
2220
+ let j1 = null;
2221
+ try { j1 = JSON.parse(r1.text); } catch { /* 非 JSON */ }
2222
+ const taskId = extractTaskId(j1);
2223
+ if (!taskId) throw new Error('响应中没有任务 id:' + r1.text.slice(0, 200));
2224
+ let queryEp = '';
2225
+ try { queryEp = new URL(base).origin + '/v1/video/query'; } catch { throw new Error('无法构造轮询地址'); }
2226
+ if (o.onTask) { try { o.onTask(taskId, queryEp + '?task_id=' + encodeURIComponent(taskId)); } catch { /* 持久化失败不影响生成 */ } }
2227
+ const dlHeaders = (u) => { try { return new URL(u).origin === new URL(base).origin ? headers : {}; } catch { return {}; } };
2228
+ const deadline = Date.now() + 120 * 60 * 1000; // 2 小时:查询层绝不先放弃
2229
+ while (Date.now() < deadline) {
2230
+ await sleep(6000);
2231
+ let r2 = null;
2232
+ try { r2 = await fetchViaProxy(queryEp + '?task_id=' + encodeURIComponent(taskId), { method: 'GET', headers, timeoutMs: 60000, label: '查询视频任务' }); } catch { /* 网络抖动重试 */ }
2233
+ if (!r2 || !r2.ok) continue; // 查询层失败 ≠ 任务失败,只等生成任务自身的终态
2234
+ let st = null;
2235
+ try { st = JSON.parse(r2.text); } catch { /* 忽略 */ }
2236
+ const u = extractVideoUrl(st);
2237
+ if (u) return await downloadVideo(u, dlHeaders(u), headers);
2238
+ const failed = taskFailureOf(st);
2239
+ if (failed) throw new Error('视频任务失败:' + failed);
2240
+ const status = taskStatusOf(st);
2241
+ if (['failed', 'expired', 'canceled', 'cancelled', 'error'].includes(status)) throw new Error('视频任务失败:' + status);
2242
+ }
2243
+ throw new Error('视频生成超过 2 小时未返回(任务 id 已保存在节点上,可凭 id 回查结果)');
2244
+ }
2245
+ /** Sora 风格 /v1/videos 兜底(RelayRouter 等网关:/v1/video/generations 不接某些视频渠道时)。
2246
+ * 提交 POST {model, prompt, seconds, size} → 轮询 GET /v1/videos/{id} →
2247
+ * 完成后优先取响应里的地址,否则经 /content 端点(带鉴权)下载。
2248
+ * 注意:该格式暂不支持首帧注入——跨段连贯由提示词承接。 */
2249
+ async function soraVideosFallback(base, prompt, o, s, headers) {
2250
+ const root = base.replace(/\/v1\/video\/generations$/i, '').replace(/\/+$/, '');
2251
+ const ep = root + '/v1/videos';
2252
+ const secs = String(Math.max(4, Math.min(15, Math.round(Number(o.duration) || 5))));
2253
+ const vertical = (o.ratio || '16:9') === '9:16';
2254
+ const hd = String(o.resolution || '720p').toLowerCase().includes('1080');
2255
+ const size = hd ? (vertical ? '1080x1920' : '1920x1080') : (vertical ? '720x1280' : '1280x720');
2256
+ const body = { model: s.model, prompt, seconds: secs, size };
2257
+ const r1 = await fetchViaProxy(ep, { headers, body, timeoutMs: 120000, label: '视频 API(/v1/videos)' });
2258
+ if (!r1.ok) throw new Error('HTTP ' + r1.status + ' @ ' + ep + ':' + r1.text.slice(0, 200));
2259
+ let j1 = null;
2260
+ try { j1 = JSON.parse(r1.text); } catch { /* 非 JSON */ }
2261
+ const taskId = j1 && (j1.id || j1.task_id || (j1.data && j1.data[0] && j1.data[0].id));
2262
+ if (!taskId) throw new Error('/v1/videos 响应中没有任务 id:' + r1.text.slice(0, 200));
2263
+ if (o.onTask) { try { o.onTask(taskId, ep + '/' + taskId); } catch { /* 持久化失败不影响生成 */ } }
2264
+ const deadline = Date.now() + 120 * 60 * 1000; // 2 小时:查询层绝不先放弃
2265
+ while (Date.now() < deadline) {
2266
+ await sleep(6000);
2267
+ let r2 = null;
2268
+ try { r2 = await fetchViaProxy(ep + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询视频任务' }); } catch { /* 网络抖动重试 */ }
2269
+ if (!r2 || !r2.ok) continue; // 查询层失败 ≠ 任务失败,只等生成任务自身的终态
2270
+ let st = null;
2271
+ try { st = JSON.parse(r2.text); } catch { /* 忽略 */ }
2272
+ const u = extractVideoUrl(st);
2273
+ if (u) return await downloadVideo(u, {}, headers);
2274
+ const failed = taskFailureOf(st);
2275
+ if (failed) throw new Error('视频任务失败:' + failed);
2276
+ const status = taskStatusOf(st);
2277
+ if (taskDoneOf(st)) return await downloadVideo(ep + '/' + taskId + '/content', {}, headers);
2278
+ if (['failed', 'expired', 'canceled', 'cancelled', 'error'].includes(status)) throw new Error('视频任务失败:' + status);
2279
+ }
2280
+ throw new Error('视频生成超过 2 小时未返回(任务 id 已保存在节点上,可凭 id 回查结果)');
2281
+ }
2282
+ // ============================================================
2283
+ // 视频厂商预设层:用户只配「厂商格式 + Base URL + 模型 id」,
2284
+ // 端点路径/鉴权方式/参数格式/轮询语义全部由代码按各家官方 API 适配。
2285
+ // ============================================================
2286
+ const VIDEO_VENDORS = {
2287
+ openai: { label: 'OpenAI 兼容格式(通用/中转站)', note: '适配 /v1/video/generations、Seedance content[]、/v1/videos 三段自动降级' },
2288
+ seedance: { label: '火山方舟 · Seedance(豆包视频)', base: 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks', keyPh: 'ARK API Key', note: 'Base URL 填方舟提交端点或 /api/v3 均可;模型 id 如 doubao-seedance-2-0-260128' },
2289
+ kling: { label: '快手可灵 Kling', base: 'https://api.klingai.com', keyPh: 'AK:SK(冒号分隔)', note: 'Key 填 AK:SK,代码自动签 JWT;国际站把 Base URL 改为 api-singapore.klingai.com;时长按官方 5/10s 取整' },
2290
+ wan: { label: '阿里通义万相 Wan(DashScope)', base: 'https://dashscope.aliyuncs.com', note: '异步任务制;国际站把 Base URL 改为 dashscope-intl.aliyuncs.com;图生视频走 img_url' },
2291
+ minimax: { label: 'MiniMax 海螺视频', base: 'https://api.minimaxi.com', note: '异步任务制;时长按官方 6/10s 取整;首帧图支持 base64' },
2292
+ vidu: { label: '生数科技 Vidu', base: 'https://api.vidu.com', note: '图片支持 base64;时长按官方 4/8s 取整' },
2293
+ runway: { label: 'Runway Gen-4', base: 'https://api.dev.runwayml.com', note: '代码自动带 X-Runway-Version 头;时长 5/10s;首帧图支持 dataURL' },
2294
+ veo: { label: 'Google Veo(Gemini API)', base: 'https://generativelanguage.googleapis.com', keyPh: 'API Key', note: '模型 id 如 veo-3.0-fast-generate-preview;图生视频走 inlineData base64' },
2295
+ luma: { label: 'Luma Dream Machine', base: 'https://api.lumalabs.ai/dream-machine/v1', note: '图生视频需要公网图片 URL(本地图片仅支持文生视频)' },
2296
+ pixverse: { label: 'PixVerse', base: 'https://app-api.pixverse.ai/openapi/v2', keyPh: 'API-KEY', note: '图片支持 base64;时长 5/8s;清晰度映射 540p/720p/1080p' },
2297
+ stability: { label: 'Stability AI 图生视频', base: 'https://api.stability.ai', note: 'SVD 纯图生视频(无提示词/无音频);必须有首帧图' },
2298
+ };
2299
+ function detectVideoVendor(s) {
2300
+ if (s && s.vendor) return s.vendor;
2301
+ if (s && (s.protocol === 'seedance' || String(s.url || '').includes('contents/generations/tasks'))) return 'seedance';
2302
+ return 'openai';
1940
2303
  }
2304
+ function b64Pure(blob) {
2305
+ return new Promise((res, rej) => {
2306
+ const r = new FileReader();
2307
+ r.onload = () => res(String(r.result).split(',')[1] || '');
2308
+ r.onerror = () => rej(new Error('图片编码失败'));
2309
+ r.readAsDataURL(blob);
2310
+ });
2311
+ }
2312
+ /** 厂商任务轮询:查询层任何失败(网络/5xx)都不判死——只认任务自身的终态;2 小时兜底。 */
2313
+ async function vendorPoll(label, step, query) {
2314
+ const deadline = Date.now() + 120 * 60 * 1000;
2315
+ while (Date.now() < deadline) {
2316
+ await sleep(step);
2317
+ let r = null;
2318
+ try { r = await query(); } catch { continue; }
2319
+ if (!r) continue;
2320
+ if (r.state === 'done') return r.url;
2321
+ if (r.state === 'fail') throw new Error(label + '任务失败:' + (r.error || '未知原因'));
2322
+ }
2323
+ throw new Error(label + '超过 2 小时未返回(任务 id 已保存在节点上,可凭 id 回查结果)');
2324
+ }
2325
+ const VIDEO_VENDOR_RUNNERS = {
2326
+ // —— 快手可灵 Kling:JWT(AK:SK) 鉴权,i2v/t2v,轮询 task_id ——
2327
+ async kling(prompt, frameBlob, s, o) {
2328
+ const [ak, sk] = String(s.apiKey || '').split(':');
2329
+ if (!ak || !sk) throw new Error('可灵的 Key 请填 AK:SK(冒号分隔,在可灵开放平台获取)');
2330
+ const enc = new TextEncoder();
2331
+ const b64u = (u8) => btoa(String.fromCharCode.apply(null, u8)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
2332
+ const hj = b64u(enc.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
2333
+ const now = Math.floor(Date.now() / 1000);
2334
+ const pj = b64u(enc.encode(JSON.stringify({ iss: ak, exp: now + 1800, nbf: now - 5 })));
2335
+ const key = await crypto.subtle.importKey('raw', enc.encode(sk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
2336
+ const sg = b64u(new Uint8Array(await crypto.subtle.sign('HMAC', key, enc.encode(hj + '.' + pj))));
2337
+ const jwt = hj + '.' + pj + '.' + sg;
2338
+ const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt };
2339
+ const base = String(s.url || VIDEO_VENDORS.kling.base).replace(/\/+$/, '');
2340
+ const path = frameBlob ? 'image2video' : 'text2video';
2341
+ const body = {
2342
+ model_name: s.model || 'kling-v1', prompt,
2343
+ cfg_scale: 0.5, mode: 'std',
2344
+ aspect_ratio: (o.ratio || '16:9') === '9:16' ? '9:16' : (o.ratio || '16:9') === '1:1' ? '1:1' : '16:9',
2345
+ duration: String(Math.max(5, Math.min(10, Number(o.duration) || 5)) <= 5 ? 5 : 10),
2346
+ };
2347
+ if (frameBlob) body.image = await blobToDataUrlScaled(frameBlob);
2348
+ const r1 = await fetchViaProxy(base + '/v1/video/' + path, { headers, body, label: 'Kling 提交' });
2349
+ if (!r1.ok) throw new Error('Kling 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2350
+ const j1 = JSON.parse(r1.text || '{}');
2351
+ const tid = j1 && j1.data && j1.data.task_id;
2352
+ if (!tid) throw new Error('Kling 响应中没有 task_id:' + r1.text.slice(0, 200));
2353
+ const url = await vendorPoll('Kling', 8000, async () => {
2354
+ const r = await fetchViaProxy(base + '/v1/video/' + path + '/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Kling 查询' });
2355
+ if (!r || !r.ok) return { state: 'wait' };
2356
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2357
+ const st = j && j.data && j.data.task_status;
2358
+ if (st === 'succeed') {
2359
+ const v = j.data.result && j.data.result.videos && j.data.result.videos[0];
2360
+ return v && v.url ? { state: 'done', url: v.url } : { state: 'fail', error: '成功但无视频地址' };
2361
+ }
2362
+ if (st === 'failed') return { state: 'fail', error: (j.data && j.data.task_status_msg) || st };
2363
+ return { state: 'wait' };
2364
+ });
2365
+ return await downloadVideo(url, {});
2366
+ },
2367
+ // —— 阿里通义万相 Wan:DashScope 异步,X-DashScope-Async ——
2368
+ async wan(prompt, frameBlob, s, o) {
2369
+ const base = String(s.url || VIDEO_VENDORS.wan.base).replace(/\/+$/, '');
2370
+ const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || ''), 'X-DashScope-Async': 'enable' };
2371
+ const body = {
2372
+ model: s.model || 'wan2.2-t2v-plus',
2373
+ input: { prompt, ...(frameBlob ? { img_url: await blobToDataUrlScaled(frameBlob) } : {}) },
2374
+ parameters: { prompt_extend: true },
2375
+ };
2376
+ const r1 = await fetchViaProxy(base + '/api/v1/services/aigc/video-generation/video-synthesis', { headers, body, label: 'Wan 提交' });
2377
+ if (!r1.ok) throw new Error('Wan 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2378
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Wan 响应非 JSON:' + r1.text.slice(0, 200)); }
2379
+ const tid = j1 && j1.output && j1.output.task_id;
2380
+ if (!tid) throw new Error('Wan 响应中没有 task_id:' + r1.text.slice(0, 200));
2381
+ const url = await vendorPoll('Wan', 8000, async () => {
2382
+ const r = await fetchViaProxy(base + '/api/v1/tasks/' + tid, { method: 'GET', headers: { Authorization: 'Bearer ' + (s.apiKey || '') }, timeoutMs: 60000, label: 'Wan 查询' });
2383
+ if (!r || !r.ok) return { state: 'wait' };
2384
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2385
+ const st = j && j.output && j.output.task_status;
2386
+ if (st === 'SUCCEEDED') return j.output.video_url ? { state: 'done', url: j.output.video_url } : { state: 'fail', error: '成功但无视频地址' };
2387
+ if (st === 'FAILED' || st === 'CANCELED') return { state: 'fail', error: (j.output && j.output.message) || st };
2388
+ return { state: 'wait' };
2389
+ });
2390
+ return await downloadVideo(url, {});
2391
+ },
2392
+ // —— MiniMax 海螺:异步 task_id + query 接口 ——
2393
+ async minimax(prompt, frameBlob, s, o) {
2394
+ const base = String(s.url || VIDEO_VENDORS.minimax.base).replace(/\/+$/, '');
2395
+ const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || '') };
2396
+ const body = {
2397
+ model: s.model || 'MiniMax-Hailuo-02', prompt, prompt_optimizer: true,
2398
+ duration: String(Math.max(6, Math.min(10, Number(o.duration) || 6)) <= 6 ? 6 : 10),
2399
+ resolution: String(o.resolution || '').toLowerCase().includes('1080') ? '1080P' : '768P',
2400
+ };
2401
+ if (frameBlob) body.first_frame_image = await blobToDataUrlScaled(frameBlob);
2402
+ const r1 = await fetchViaProxy(base + '/v1/video_generation', { headers, body, label: 'MiniMax 提交' });
2403
+ if (!r1.ok) throw new Error('MiniMax 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2404
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('MiniMax 响应非 JSON'); }
2405
+ const tid = j1 && (j1.task_id || (j1.data && j1.data.task_id));
2406
+ if (!tid) throw new Error('MiniMax 响应中没有 task_id:' + r1.text.slice(0, 200));
2407
+ const url = await vendorPoll('MiniMax', 8000, async () => {
2408
+ const r = await fetchViaProxy(base + '/v1/query/video_generation?task_id=' + encodeURIComponent(tid), { method: 'GET', headers, timeoutMs: 60000, label: 'MiniMax 查询' });
2409
+ if (!r || !r.ok) return { state: 'wait' };
2410
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2411
+ const st = String(j.status || '').toLowerCase();
2412
+ if (st === 'success' || st === 'file_downloaded') {
2413
+ const u = j.file && (j.file.download_url || j.file.url);
2414
+ return u ? { state: 'done', url: u } : { state: 'fail', error: '成功但无视频地址' };
2415
+ }
2416
+ if (st === 'fail') return { state: 'fail', error: (j.base_resp && j.base_resp.status_msg) || '失败' };
2417
+ return { state: 'wait' };
2418
+ });
2419
+ return await downloadVideo(url, {});
2420
+ },
2421
+ // —— 生数 Vidu:img2video/text2video,图片 base64 直传 ——
2422
+ async vidu(prompt, frameBlob, s, o) {
2423
+ const base = String(s.url || VIDEO_VENDORS.vidu.base).replace(/\/+$/, '');
2424
+ const headers = { 'Content-Type': 'application/json', Authorization: 'Token ' + (s.apiKey || '') };
2425
+ const path = frameBlob ? 'img2video' : 'text2video';
2426
+ const body = {
2427
+ model: s.model || 'vidu2.0', prompt,
2428
+ duration: Math.max(4, Math.min(8, Number(o.duration) || 4)) <= 4 ? 4 : 8,
2429
+ resolution: String(o.resolution || '').toLowerCase().includes('1080') ? '1080p' : '720p',
2430
+ };
2431
+ if (frameBlob) body.images = [await blobToDataUrlScaled(frameBlob)];
2432
+ const r1 = await fetchViaProxy(base + '/ent/v2/' + path, { headers, body, label: 'Vidu 提交' });
2433
+ if (!r1.ok) throw new Error('Vidu 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2434
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Vidu 响应非 JSON'); }
2435
+ const tid = j1 && (j1.task_id || j1.id);
2436
+ if (!tid) throw new Error('Vidu 响应中没有 task_id:' + r1.text.slice(0, 200));
2437
+ const url = await vendorPoll('Vidu', 8000, async () => {
2438
+ const r = await fetchViaProxy(base + '/ent/v2/tasks/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Vidu 查询' });
2439
+ if (!r || !r.ok) return { state: 'wait' };
2440
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2441
+ const st = String(j.state || '').toLowerCase();
2442
+ if (st === 'success') {
2443
+ const vids = (j.resp && (j.resp.videos || j.resp.creations)) || j.videos || [];
2444
+ const v = vids[0] || {};
2445
+ const u = v.url || v.video_url;
2446
+ return u ? { state: 'done', url: u } : { state: 'fail', error: '成功但无视频地址' };
2447
+ }
2448
+ if (st === 'failed') return { state: 'fail', error: j.err_msg || '失败' };
2449
+ return { state: 'wait' };
2450
+ });
2451
+ return await downloadVideo(url, {});
2452
+ },
2453
+ // —— Runway Gen-4:X-Runway-Version 头,tasks 轮询 ——
2454
+ async runway(prompt, frameBlob, s, o) {
2455
+ const base = String(s.url || VIDEO_VENDORS.runway.base).replace(/\/+$/, '');
2456
+ const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || ''), 'X-Runway-Version': '2024-11-06' };
2457
+ const ratio = (o.ratio || '16:9') === '9:16' ? '720:1280' : '1280:720';
2458
+ const dur = Math.max(5, Math.min(10, Number(o.duration) || 5)) <= 5 ? 5 : 10;
2459
+ let ep = '/v1/text_to_video', body = { model: 'gen4_turbo', promptText: prompt, ratio, duration: dur };
2460
+ if (frameBlob) { ep = '/v1/image_to_video'; body = { model: 'gen4_turbo', promptImage: await blobToDataUrlScaled(frameBlob), promptText: prompt, ratio, duration: dur }; }
2461
+ const r1 = await fetchViaProxy(base + ep, { headers, body, label: 'Runway 提交' });
2462
+ if (!r1.ok) throw new Error('Runway 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2463
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Runway 响应非 JSON'); }
2464
+ const tid = j1 && j1.id;
2465
+ if (!tid) throw new Error('Runway 响应中没有任务 id:' + r1.text.slice(0, 200));
2466
+ const url = await vendorPoll('Runway', 8000, async () => {
2467
+ const r = await fetchViaProxy(base + '/v1/tasks/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Runway 查询' });
2468
+ if (!r || !r.ok) return { state: 'wait' };
2469
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2470
+ if (j.status === 'SUCCEEDED') return (j.output && j.output[0]) ? { state: 'done', url: j.output[0] } : { state: 'fail', error: '成功但无视频地址' };
2471
+ if (j.status === 'FAILED') return { state: 'fail', error: j.failure || j.status };
2472
+ return { state: 'wait' };
2473
+ });
2474
+ return await downloadVideo(url, {});
2475
+ },
2476
+ // —— Google Veo(Gemini API):predictLongRunning + operation 轮询 ——
2477
+ async veo(prompt, frameBlob, s, o) {
2478
+ const base = String(s.url || VIDEO_VENDORS.veo.base).replace(/\/+$/, '');
2479
+ const model = s.model || 'veo-3.0-fast-generate-preview';
2480
+ const ep = base + '/v1beta/models/' + encodeURIComponent(model) + ':predictLongRunning?key=' + encodeURIComponent(s.apiKey || '');
2481
+ const inst = { prompt };
2482
+ if (frameBlob) inst.image = { bytesBase64Encoded: await b64Pure(await blobToDataUrlScaled(frameBlob)), mimeType: 'image/png' };
2483
+ const body = { instances: [inst], parameters: { aspectRatio: (o.ratio || '16:9') === '9:16' ? '9:16' : '16:9' } };
2484
+ const r1 = await fetchViaProxy(ep, { headers: { 'Content-Type': 'application/json' }, body, label: 'Veo 提交' });
2485
+ if (!r1.ok) throw new Error('Veo 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2486
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Veo 响应非 JSON'); }
2487
+ const op = j1 && j1.name;
2488
+ if (!op) throw new Error('Veo 响应中没有 operation name:' + r1.text.slice(0, 200));
2489
+ const url = await vendorPoll('Veo', 10000, async () => {
2490
+ const r = await fetchViaProxy(base + '/v1beta/' + op + '?key=' + encodeURIComponent(s.apiKey || ''), { method: 'GET', timeoutMs: 60000, label: 'Veo 查询' });
2491
+ if (!r || !r.ok) return { state: 'wait' };
2492
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2493
+ if (!j.done) return { state: 'wait' };
2494
+ if (j.error) return { state: 'fail', error: j.error.message || '失败' };
2495
+ const g = j.response && (j.response.generateVideoResponse || j.response);
2496
+ const sample = g && ((g.generatedSamples && g.generatedSamples[0]) || (g.videos && g.videos[0]));
2497
+ const u = sample && (sample.video ? sample.video.uri : sample.uri);
2498
+ return u ? { state: 'done', url: u + (u.includes('?') ? '&' : '?') + 'key=' + encodeURIComponent(s.apiKey || '') } : { state: 'fail', error: '成功但无视频地址' };
2499
+ });
2500
+ return await downloadVideo(url, {});
2501
+ },
2502
+ // —— Luma Dream Machine:图片需公网 URL,本地图片仅文生视频 ——
2503
+ async luma(prompt, frameBlob, s, o) {
2504
+ const base = String(s.url || VIDEO_VENDORS.luma.base).replace(/\/+$/, '');
2505
+ const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || '') };
2506
+ const body = { prompt, model: 'ray-2', resolution: String(o.resolution || '').toLowerCase().includes('1080') ? '1080p' : '720p', duration: (Math.max(5, Math.min(9, Number(o.duration) || 5)) <= 5 ? '5s' : '9s') };
2507
+ if (frameBlob) throw new Error('Luma 图生视频需要公网图片 URL——本地图片请改用文生视频,或换其他厂商');
2508
+ const r1 = await fetchViaProxy(base + '/generations', { headers, body, label: 'Luma 提交' });
2509
+ if (!r1.ok) throw new Error('Luma 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2510
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Luma 响应非 JSON'); }
2511
+ const tid = j1 && j1.id;
2512
+ if (!tid) throw new Error('Luma 响应中没有任务 id:' + r1.text.slice(0, 200));
2513
+ const url = await vendorPoll('Luma', 8000, async () => {
2514
+ const r = await fetchViaProxy(base + '/generations/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Luma 查询' });
2515
+ if (!r || !r.ok) return { state: 'wait' };
2516
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2517
+ if (j.state === 'completed') return (j.assets && j.assets.video) ? { state: 'done', url: j.assets.video } : { state: 'fail', error: '成功但无视频地址' };
2518
+ if (j.state === 'failed') return { state: 'fail', error: j.failure_reason || '失败' };
2519
+ return { state: 'wait' };
2520
+ });
2521
+ return await downloadVideo(url, {});
2522
+ },
2523
+ // —— PixVerse:API-KEY 头 + Ai-trace-id,base64 图片 ——
2524
+ async pixverse(prompt, frameBlob, s, o) {
2525
+ const base = String(s.url || VIDEO_VENDORS.pixverse.base).replace(/\/+$/, '');
2526
+ const headers = { 'Content-Type': 'application/json', 'API-KEY': s.apiKey || '', 'Ai-trace-id': 'dshic-' + Date.now().toString(36) };
2527
+ const path = frameBlob ? 'img2video' : 'text2video';
2528
+ const body = {
2529
+ model: 'v4.5', prompt,
2530
+ duration: Math.max(5, Math.min(8, Number(o.duration) || 5)) <= 5 ? 5 : 8,
2531
+ quality: String(o.resolution || '').toLowerCase().includes('1080') ? '1080p' : '720p',
2532
+ aspect_ratio: (o.ratio || '16:9') === '9:16' ? '9:16' : '16:9',
2533
+ };
2534
+ if (frameBlob) body.image = await b64Pure(await blobToDataUrlScaled(frameBlob));
2535
+ const r1 = await fetchViaProxy(base + '/video/' + path, { headers, body, label: 'PixVerse 提交' });
2536
+ if (!r1.ok) throw new Error('PixVerse 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2537
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('PixVerse 响应非 JSON'); }
2538
+ const vid = j1 && j1.Resp && j1.Resp.video_id;
2539
+ if (!vid) throw new Error('PixVerse 响应中没有 video_id:' + r1.text.slice(0, 200));
2540
+ const url = await vendorPoll('PixVerse', 8000, async () => {
2541
+ const r = await fetchViaProxy(base + '/video/result/' + vid, { method: 'GET', headers, timeoutMs: 60000, label: 'PixVerse 查询' });
2542
+ if (!r || !r.ok) return { state: 'wait' };
2543
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2544
+ const resp = j && j.Resp;
2545
+ if (!resp) return { state: 'wait' };
2546
+ if (resp.code === 0 && resp.url) return { state: 'done', url: resp.url };
2547
+ if (resp.code !== 0 && resp.code !== 6 && resp.code !== 7 && resp.code !== 8) return { state: 'fail', error: resp.msg || ('code ' + resp.code) };
2548
+ return { state: 'wait' }; // 6/7/8 = 生成中/排队中/处理中
2549
+ });
2550
+ return await downloadVideo(url, {});
2551
+ },
2552
+ // —— Stability AI:SVD 纯图生视频(multipart,无提示词),结果为 base64 JSON ——
2553
+ async stability(prompt, frameBlob, s, o) {
2554
+ const base = String(s.url || VIDEO_VENDORS.stability.base).replace(/\/+$/, '');
2555
+ if (!frameBlob) throw new Error('Stability 图生视频必须有首帧图(该服务不支持纯文生视频、无提示词输入)');
2556
+ const fd = new FormData();
2557
+ fd.append('image', frameBlob, 'frame.png');
2558
+ fd.append('cfg_scale', '1.8');
2559
+ fd.append('motion_bucket_id', '127');
2560
+ const serialized = new Response(fd);
2561
+ const bodyRaw = arrayBufferToBase64(await serialized.arrayBuffer());
2562
+ const r1 = await fetchViaProxy(base + '/v2beta/image-to-video', { method: 'POST', headers: { Authorization: 'Bearer ' + (s.apiKey || '') }, bodyRaw, bodyContentType: serialized.headers.get('content-type'), label: 'Stability 提交' });
2563
+ if (!r1.ok) throw new Error('Stability 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2564
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Stability 响应非 JSON'); }
2565
+ const tid = j1 && j1.id;
2566
+ if (!tid) throw new Error('Stability 响应中没有任务 id:' + r1.text.slice(0, 200));
2567
+ const url = await vendorPoll('Stability', 10000, async () => {
2568
+ const r = await fetchViaProxy(base + '/v2beta/image-to-video/result/' + tid, { method: 'GET', headers: { Authorization: 'Bearer ' + (s.apiKey || ''), Accept: 'video/*' }, timeoutMs: 120000, label: 'Stability 查询' });
2569
+ if (!r) return { state: 'wait' };
2570
+ if (r.status === 202) return { state: 'wait' };
2571
+ if (r.status === 200) {
2572
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'fail', error: '结果非 JSON' }; }
2573
+ if (j.video) {
2574
+ const u8 = Uint8Array.from(atob(j.video), (c) => c.charCodeAt(0));
2575
+ const rec = await saveAssetRecord(S.activeId, 'video', new Blob([u8], { type: 'video/mp4' }), { name: 'stability-' + tid.slice(-6), source: 'api', category: 'finalcut' });
2576
+ return { state: 'done', url: fileUrlFor(rec.rel) };
2577
+ }
2578
+ }
2579
+ return { state: 'fail', error: 'HTTP ' + r.status + ':' + r.text.slice(0, 200) };
2580
+ });
2581
+ return await downloadVideo(url, {});
2582
+ },
2583
+ };
1941
2584
  async function apiVideo(prompt, frameBlob, s, opts) {
1942
2585
  const o = opts || {};
1943
- // 火山 Seedance 官方协议适配
1944
- if (s.protocol === 'seedance' || (s.url || '').includes('contents/generations/tasks')) {
1945
- return await seedanceVideo(prompt, frameBlob, o.refs || [], s, o);
2586
+ // 衔接模式:auto=尾帧降级为参考图(保留实体参考图);hard=硬首帧(放弃参考图,画面绝对承接);
2587
+ // off=忽略首帧。网关禁止 first_frame 与 reference_image 混用,故 hard 模式必须放弃参考图。
2588
+ if (o.frameMode === 'off') frameBlob = null;
2589
+ if (o.frameMode === 'hard' && frameBlob) o.hardFrame = true;
2590
+ // 厂商预设层:显式选择了厂商格式(或旧配置自动识别)→ 走该厂商的官方协议适配器
2591
+ const vendor = detectVideoVendor(s);
2592
+ if (vendor !== 'openai' && vendor !== 'seedance') {
2593
+ const run = VIDEO_VENDOR_RUNNERS[vendor];
2594
+ if (!run) throw new Error('暂未适配的视频厂商格式:' + vendor);
2595
+ return await run(prompt, frameBlob, s, o);
2596
+ }
2597
+ // OpenAI 风格视频接口(JUAI 等:/v1/video/generations)优先识别——
2598
+ // 即使误把协议设成 seedance 也不走火山官方协议(两者状态/结果字段不同)
2599
+ if (vendor === 'seedance' && ((s.protocol === 'seedance' || (s.url || '').includes('contents/generations/tasks')) && !(s.url || '').includes('/video/generations'))) {
2600
+ return await seedanceVideo(prompt, frameBlob, o.hardFrame ? [] : (o.refs || []), s, o);
1946
2601
  }
1947
2602
  if (!s.url) throw new Error('未配置视频 API');
1948
- const base = s.url.replace(/\/+$/, '');
2603
+ // URL 归一化:裸域 → /v1/video/generations;/v1 → /video/generations;完整端点原样使用
2604
+ let base = s.url.replace(/\/+$/, '').replace(/\/video\/generations$/, '');
2605
+ if (!base.replace(/^https?:\/\//i, '').includes('/')) base = base + '/v1';
2606
+ else if (/\/v1$/.test(base)) base = base + '/video/generations';
2607
+ const isOpenaiVideo = /\/video\/generations$/.test(base);
1949
2608
  const headers = { 'Content-Type': 'application/json', ...(s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {}) };
2609
+ // 尾帧+参考图同连(跨镜衔接)→ 尾帧降级为第一张衔接参考图(first_frame/reference_image 互斥),标注写在提示词里
2610
+ const vRefs = o.hardFrame ? [] : (o.refs || []).slice(0, 8);
2611
+ const downgrade = Boolean(frameBlob && vRefs.length);
2612
+ if (downgrade) prompt = videoRefNote(o.refNames) + '\n' + prompt; // 前置硬约束
1950
2613
  const body = {
1951
2614
  ...(s.model ? { model: s.model } : {}),
1952
2615
  prompt,
1953
2616
  duration: Number(o.duration) || 5,
1954
- resolution: o.resolution || '720P',
2617
+ // 分辨率一律小写(Seedance 系为 '720p'/'1080p';'720P' 会被部分网关判为不支持)
2618
+ resolution: (o.resolution || '720p').toLowerCase(),
1955
2619
  ratio: o.ratio || '16:9',
1956
2620
  };
1957
- // 多参考图(扩展字段):供支持多参考的视频网关使用
1958
- if (o.refs && o.refs.length) body.images = await Promise.all(o.refs.map((b) => blobToDataUrl(b)));
1959
- if (frameBlob) {
1960
- try { body.image = await blobToDataUrl(frameBlob); } catch { /* 忽略首帧编码失败 */ }
2621
+ // 首帧图生视频:构图以首帧为准——Seedance 系要求首帧模式必须 ratio=adaptive(仅纯首帧模式)
2622
+ if (frameBlob && !downgrade) {
2623
+ body.ratio = 'adaptive';
2624
+ try { body.image = await blobToDataUrlScaled(frameBlob); } catch { /* 忽略首帧编码失败 */ }
2625
+ } else if (vRefs.length) {
2626
+ const imgs = downgrade ? [frameBlob].concat(vRefs) : vRefs;
2627
+ body.images = isOpenaiVideo
2628
+ ? await Promise.all(imgs.map(async (b) => ({ url: await blobToDataUrlScaled(b), role: 'reference_image' })))
2629
+ : await Promise.all(imgs.map((b) => blobToDataUrlScaled(b)));
1961
2630
  }
2631
+ // 可选参数:勾选才发送 generate_audio;output_format 留空不发送
2632
+ if (s.generateAudio === true) body.generate_audio = true;
2633
+ if (String(s.outputFormat || '').trim()) body.output_format = String(s.outputFormat).trim();
2634
+ // 角色声音参考(@引用实体名下的音频资产)→ audios[]:模型生成的对白/声音向参考音色对齐;
2635
+ // 带声音参考时强制开启同步音频(否则参考无意义)
2636
+ if (o.audioRefs && o.audioRefs.length) {
2637
+ body.audios = await Promise.all(o.audioRefs.map((b) => blobToDataUrl(b)));
2638
+ body.generate_audio = true;
2639
+ }
2640
+ // 厂商专属扩展参数最后合并(可覆盖以上任何字段):换厂商不需要改代码
2641
+ try {
2642
+ const extra = parseExtraParams(s.extra);
2643
+ if (extra) Object.assign(body, extra);
2644
+ } catch (e) { throw new Error('视频 API 扩展参数配置有误:' + ((e && e.message) || e)); }
1962
2645
  const res = await fetchViaProxy(base, { headers, body, timeoutMs: Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000, label: '视频 API' });
1963
- if (!res.ok) throw new Error('视频 API HTTP ' + res.status + ':' + res.text.slice(0, 300));
2646
+ if (!res.ok) {
2647
+ if (isOpenaiVideo) {
2648
+ // 降级链:① Seedance 原生 content[] 格式(RelayRouter 等,轮询 /v1/video/query)
2649
+ // ② Sora 风格 /v1/videos
2650
+ let e1 = null, e2 = null;
2651
+ try {
2652
+ return await relayVideoContentFallback(base, prompt, frameBlob, o, s, headers);
2653
+ } catch (e) { e1 = e; }
2654
+ try {
2655
+ return await soraVideosFallback(base, prompt, o, s, headers);
2656
+ } catch (e) { e2 = e; }
2657
+ throw new Error('视频 API HTTP ' + res.status + ' @ ' + base + ':' + res.text.slice(0, 160)
2658
+ + ';降级链失败——[content格式] ' + ((e1 && e1.message) || e1) + ';[/v1/videos] ' + ((e2 && e2.message) || e2));
2659
+ }
2660
+ throw new Error('视频 API HTTP ' + res.status + ' @ ' + base + ':' + res.text.slice(0, 300));
2661
+ }
1964
2662
  let j = null;
1965
2663
  try { j = JSON.parse(res.text); } catch { /* 非 JSON 响应 */ }
2664
+ // 同源结果地址(如认证内容代理)带上鉴权下载;外部公开地址不带
2665
+ const apiOrigin = (() => { try { return new URL(base).origin; } catch { return ''; } })();
2666
+ const dlHeaders = (u) => { try { return new URL(u).origin === apiOrigin ? headers : {}; } catch { return {}; } };
1966
2667
  const direct = extractVideoUrl(j);
1967
- if (direct) return await downloadVideo(direct, {});
2668
+ if (direct) return await downloadVideo(direct, dlHeaders(direct), headers);
1968
2669
  const taskId = extractTaskId(j);
1969
2670
  if (!taskId) throw new Error('视频 API 响应中没有任务 id 或视频地址');
1970
- const deadline = Date.now() + 15 * 60 * 1000;
2671
+ if (o.onTask) { try { o.onTask(taskId, base + '/' + taskId); } catch { /* 持久化失败不影响生成 */ } }
2672
+ const deadline = Date.now() + 120 * 60 * 1000; // 2 小时:查询层绝不先放弃
1971
2673
  while (Date.now() < deadline) {
1972
2674
  await sleep(3000);
1973
- const r2 = await fetchViaProxy(base + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询视频任务' });
1974
- if (!r2.ok) throw new Error('查询视频任务失败 HTTP ' + r2.status);
2675
+ let r2 = null;
2676
+ try { r2 = await fetchViaProxy(base + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询视频任务' }); } catch { /* 网络抖动重试 */ }
2677
+ if (!r2 || !r2.ok) continue; // 查询层失败 ≠ 任务失败,只等生成任务自身的终态
1975
2678
  let st = null;
1976
2679
  try { st = JSON.parse(r2.text); } catch { /* 忽略 */ }
1977
2680
  const u = extractVideoUrl(st);
1978
- if (u) return await downloadVideo(u, {});
2681
+ if (u) return await downloadVideo(u, dlHeaders(u), headers);
1979
2682
  // 失败判定必须看状态字段,不能拿整串 JSON 做子串匹配:
1980
2683
  // 很多网关在 processing 阶段返回 {"status":"processing","error":null},子串匹配会误判失败
1981
2684
  const failed = taskFailureOf(st);
1982
2685
  if (failed) throw new Error('视频任务失败:' + failed);
2686
+ // 已完成却没有可提取的地址:明确报错并附响应原文,绝不无限轮询
2687
+ if (taskDoneOf(st)) throw new Error('任务已完成但响应中没有视频地址:' + JSON.stringify(st).slice(0, 300));
1983
2688
  }
1984
- throw new Error('视频生成超时(15 分钟)');
2689
+ throw new Error('视频生成超过 2 小时未返回(任务 id 已保存在节点上,可凭 id 回查结果)');
1985
2690
  }
1986
- /** 从任务轮询响应里判断是否真的失败(白名单状态 + 显式错误信息),返回失败原因或 null。 */
1987
- function taskFailureOf(st) {
1988
- if (!st || typeof st !== 'object') return null;
2691
+ /** 从任务轮询响应里取状态字符串(小写)。 */
2692
+ function taskStatusOf(st) {
2693
+ if (!st || typeof st !== 'object') return '';
1989
2694
  const pick = (v) => {
1990
2695
  if (typeof v === 'string') return v.toLowerCase();
1991
2696
  if (v && typeof v === 'object' && typeof v.status === 'string') return v.status.toLowerCase();
1992
2697
  return '';
1993
2698
  };
1994
- const status = pick(st.status) || pick(st.state) || pick(st.task_status)
2699
+ return pick(st.status) || pick(st.state) || pick(st.task_status)
1995
2700
  || pick(st.data && st.data.status) || pick(st.output && st.output.status) || pick(st.result && st.result.status);
2701
+ }
2702
+ /** 从任务轮询响应里判断是否真的失败(白名单状态 + 显式错误信息),返回失败原因或 null。 */
2703
+ function taskFailureOf(st) {
2704
+ if (!st || typeof st !== 'object') return null;
2705
+ const status = taskStatusOf(st);
1996
2706
  const FAIL = ['failed', 'fail', 'error', 'canceled', 'cancelled', 'expired', 'rejected'];
1997
2707
  const DONE = ['succeeded', 'success', 'completed', 'complete', 'done', 'finished'];
1998
2708
  if (status && FAIL.includes(status)) {
@@ -2008,23 +2718,103 @@ window.__ModuleLoader__.load({
2008
2718
  }
2009
2719
  return null;
2010
2720
  }
2721
+ /** 任务是否已到达成功终态——轮询方据此终止,绝不无限轮询已完成的任务。 */
2722
+ function taskDoneOf(st) {
2723
+ return ['succeeded', 'success', 'completed', 'complete', 'done', 'finished'].includes(taskStatusOf(st));
2724
+ }
2011
2725
 
2012
2726
  function resolveApiCfg(list, defId, wantId) {
2013
2727
  if (!Array.isArray(list) || !list.length) return null;
2014
2728
  return list.find((x) => x.id === wantId) || list.find((x) => x.id === defId) || list[0];
2015
2729
  }
2016
- async function generateImage(prompt, refMetas, opts) {
2017
- const settings = loadSettings();
2018
- const cfg = resolveApiCfg(settings.imageApis, settings.imageApiId, opts && opts.apiId);
2019
- const blobs = (refMetas || []).map((m) => (m && m.blob) ? m.blob : m);
2020
- const blob = cfg ? await apiImage(prompt, blobs, cfg, opts || {}) : await demoImageBlob(prompt, blobs, imageDimsFor(opts));
2021
- return { blob, demo: !cfg };
2730
+
2731
+ /** PCM 采样数组 → WAV Blob(单声道 16-bit,声音参考不需要立体声)。 */
2732
+ function pcmToWavBlob(samples, sampleRate) {
2733
+ const n = samples.length;
2734
+ const data = new ArrayBuffer(44 + n * 2);
2735
+ const dv = new DataView(data);
2736
+ const wstr = (off, str) => { for (let i = 0; i < str.length; i++) dv.setUint8(off + i, str.charCodeAt(i)); };
2737
+ wstr(0, 'RIFF'); dv.setUint32(4, 36 + n * 2, true); wstr(8, 'WAVE'); wstr(12, 'fmt ');
2738
+ dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
2739
+ dv.setUint32(24, sampleRate, true); dv.setUint32(28, sampleRate * 2, true); dv.setUint16(32, 2, true);
2740
+ dv.setUint16(34, 16, true); wstr(36, 'data'); dv.setUint32(40, n * 2, true);
2741
+ let off = 44;
2742
+ for (let i = 0; i < n; i++) {
2743
+ const v = Math.max(-1, Math.min(1, samples[i]));
2744
+ dv.setInt16(off, v < 0 ? v * 0x8000 : v * 0x7fff, true); off += 2;
2745
+ }
2746
+ return new Blob([data], { type: 'audio/wav' });
2022
2747
  }
2023
- async function generateVideo(prompt, frameBlob, opts) {
2024
- const settings = loadSettings();
2025
- const cfg = resolveApiCfg(settings.videoApis, settings.videoApiId, opts && opts.apiId);
2026
- const blob = cfg ? await apiVideo(prompt, frameBlob, cfg, opts || {}) : await demoVideoBlob(prompt, videoDimsFor(opts, { capLong: 1280 }), ((opts && opts.duration) || 5) * 1000);
2027
- return { blob, demo: !cfg };
2748
+ /** 解码媒体音轨并按绝对秒区间取 PCM 拼接(多段合成一条)→ WAV Blob。 */
2749
+ async function extractWavRanges(mediaBlob, rangesSec) {
2750
+ const ac = new (window.AudioContext || window.webkitAudioContext)();
2751
+ try {
2752
+ const buf = await ac.decodeAudioData(await mediaBlob.arrayBuffer());
2753
+ const chans = [];
2754
+ for (let c = 0; c < buf.numberOfChannels; c++) chans.push(buf.getChannelData(c));
2755
+ const sr = buf.sampleRate;
2756
+ const pcm = [];
2757
+ for (const [a, b] of rangesSec) {
2758
+ const s0 = Math.max(0, Math.floor(a * sr));
2759
+ const s1 = Math.min(buf.length, Math.ceil(b * sr));
2760
+ for (let i = s0; i < s1; i++) {
2761
+ let v = 0;
2762
+ for (let c = 0; c < chans.length; c++) v += chans[c][i];
2763
+ pcm.push(v / chans.length);
2764
+ }
2765
+ }
2766
+ if (!pcm.length) throw new Error('裁剪区间为空');
2767
+ return { blob: pcmToWavBlob(pcm, sr) };
2768
+ } finally { try { ac.close(); } catch { /* 忽略 */ } }
2769
+ }
2770
+ /** 台词驱动的声音参考提取:按分镜 shots 的 dialogue 找到该角色的全部镜头,
2771
+ * 以镜头时长占比把**真实视频时长**映射回各镜头时间窗(±0.15s 呼吸垫),
2772
+ * 逐窗取 PCM **拼接为一个 WAV**(一个角色一个总的声音参考文件)。 */
2773
+ async function extractVoiceAuto(videoBlob, shots, role) {
2774
+ const rl = String(role || '').trim().toLowerCase();
2775
+ const windows = [];
2776
+ let t = 0;
2777
+ for (const s of shots) {
2778
+ const d = Number(s.duration) || 0;
2779
+ const lines = (s.dialogue || []).filter((l) => String(l.role || '').trim().toLowerCase() === rl);
2780
+ if (lines.length) windows.push({ title: s.title, from: t, to: t + d, lines });
2781
+ t += d;
2782
+ }
2783
+ if (!windows.length) {
2784
+ const roles = [...new Set((shots || []).flatMap((s) => (s.dialogue || []).map((l) => String(l.role || '').trim()).filter(Boolean)))];
2785
+ throw new Error(T('voiceRoleNotFound').replace('{roles}', roles.join('、') || '—'));
2786
+ }
2787
+ const ac = new (window.AudioContext || window.webkitAudioContext)();
2788
+ try {
2789
+ const buf = await ac.decodeAudioData(await videoBlob.arrayBuffer());
2790
+ const sumDur = shots.reduce((a, s) => a + (Number(s.duration) || 0), 0) || 1;
2791
+ const k = buf.duration / sumDur; // 请求时长 → 实际时长的比例映射(模型未必精确跟时长)
2792
+ const chans = [];
2793
+ for (let c = 0; c < buf.numberOfChannels; c++) chans.push(buf.getChannelData(c));
2794
+ const sr = buf.sampleRate;
2795
+ const pcm = [];
2796
+ const pad = 0.15; // 呼吸垫:防止窗口边缘切字
2797
+ const segs = [];
2798
+ for (const w of windows) {
2799
+ const a = Math.max(0, w.from * k - (w.from > 0 ? pad : 0));
2800
+ const b = Math.min(buf.duration, w.to * k + pad);
2801
+ const s0 = Math.max(0, Math.floor(a * sr));
2802
+ const s1 = Math.min(buf.length, Math.ceil(b * sr));
2803
+ for (let i = s0; i < s1; i++) {
2804
+ let v = 0;
2805
+ for (let c = 0; c < chans.length; c++) v += chans[c][i];
2806
+ pcm.push(v / chans.length);
2807
+ }
2808
+ segs.push({ title: w.title, from: Math.round(a * 10) / 10, to: Math.round(b * 10) / 10, lines: w.lines.map((l) => l.role + ':' + l.line) });
2809
+ }
2810
+ if (!pcm.length) throw new Error('裁剪区间为空');
2811
+ return { blob: pcmToWavBlob(pcm, sr), segs, duration: Math.round(buf.duration * 10) / 10 };
2812
+ } finally { try { ac.close(); } catch { /* 忽略 */ } }
2813
+ }
2814
+ /** 手动模式:按用户给定的绝对秒区间提取。 */
2815
+ async function extractVoiceClip(mediaBlob, startSec, endSec) {
2816
+ const r = await extractWavRanges(mediaBlob, [[startSec || 0, endSec || 0]]);
2817
+ return { blob: r.blob };
2028
2818
  }
2029
2819
 
2030
2820
  /** 从视频 Blob 提取最后一帧为 PNG(通用:任何 API 生成的视频都能取尾帧)。 */
@@ -2070,7 +2860,7 @@ window.__ModuleLoader__.load({
2070
2860
  nd2.data.duration = n.data.duration || 5;
2071
2861
  nd2.data.resolution = n.data.resolution || '720P';
2072
2862
  nd2.data.ratio = n.data.ratio || '16:9';
2073
- nd2.data.videoApiId = n.data.videoApiId || '';
2863
+ nd2.data.videoModel = nodeVideoModelRef(n);
2074
2864
  }
2075
2865
  s.graph.edges.push({ id: uid('e'), fromNode: n.id, fromPort: 'last', toNode: nn.id, toPort: 'frame' });
2076
2866
  });
@@ -2573,6 +3363,9 @@ window.__ModuleLoader__.load({
2573
3363
  let hostSyncStopped = false;
2574
3364
  let hostSyncBusy = false; // hostSyncTick 互斥:单次 tick 可能超过 3 秒
2575
3365
  let syncWarned = false; // 同步失败只提示一次,避免刷屏
3366
+ // 用户在 UI 里删除过的节点(id → 时间戳):同步的"内存画布为准"豁免只认这里——
3367
+ // 对话侧/Host 删除的节点不在此列,必须照常本地清除(否则旧内存副本会把对话侧刚删的节点复活)
3368
+ const userDeletedNodes = new Map();
2576
3369
  async function importHostProject(hp) {
2577
3370
  const full = await hostRpc('projectsGet', { id: hp.id });
2578
3371
  update((s) => {
@@ -2696,9 +3489,14 @@ window.__ModuleLoader__.load({
2696
3489
  }
2697
3490
  // 对话删过的节点(墓碑):本地同步删除,避免被推送复活
2698
3491
  const dead = (hp.tombstones || []).filter((nid) => doc && doc.nodes[nid]);
2699
- // 内存画布为准:当前画布上还存在的节点不算死(撤销恢复后 Host 可能仍留旧墓碑)
3492
+ // "内存画布为准"的豁免**仅限用户自己的撤销恢复**:用户在 UI 删除过(userDeletedNodes 有记录)
3493
+ // 且节点当前 live = 它是被撤销恢复的,Host 墓碑已过期 → 暂不删(等本机推送清墓碑)。
3494
+ // 对话侧(Host)删除的节点不在 userDeletedNodes 里 → 照常本地删除,
3495
+ // 否则旧内存副本会把对话侧刚删的节点复活(陈旧推送 + 过宽豁免的组合 bug)。
2700
3496
  const liveNodes = (hp.id === S.activeId && S.graph) ? S.graph.nodes : null;
2701
- const deadEff = liveNodes ? dead.filter((nid) => !liveNodes[nid]) : dead;
3497
+ const deadEff = liveNodes
3498
+ ? dead.filter((nid) => !(liveNodes[nid] && userDeletedNodes.has(nid)))
3499
+ : dead;
2702
3500
  if (known && deadEff.length) {
2703
3501
  for (const nid of deadEff) delete doc.nodes[nid];
2704
3502
  doc.edges = (doc.edges || []).filter((e) => !deadEff.includes(e.fromNode) && !deadEff.includes(e.toNode));
@@ -3014,16 +3812,18 @@ window.__ModuleLoader__.load({
3014
3812
  const user = zh ? [
3015
3813
  `创意 / 原文:\n${String(idea ?? '').trim()}`,
3016
3814
  '要求:',
3017
- '- 3-8 个场次,总片长 1-3 分钟;',
3018
- '- 每场以「场X 地点(日/夜)· 出场人物」开头;',
3019
- '- 动作用可视化描述,不写心理;对白单独成行;需要的声音(环境音/旁白)标注在括号里;',
3815
+ '- 本节点输出**一集**的剧本:成片时长 1-2 分钟(60-120 秒),共 4-10 个场景;',
3816
+ '- **每个场景必须以「【场N】场景名(地点·时间)」单独一行开头**(如【场1】城市街头(夜))——这是后续自动铺开到分镜的分场标记,格式不可省略或改写;',
3817
+ '- 每场容量 10-15 秒成片(约 60-150 字,含该场全部对白);全部场景时长之和 ≈ 1-2 分钟;',
3818
+ '- 场景头之后:可视化动作描写 + 对白(角色:台词,单独成行);需要的声音(环境音/旁白)标注在括号里;',
3020
3819
  '- 结尾单独一行「人物:」与「场景/道具:」清单,便于后续资产制作。',
3021
3820
  ].join('\n') : [
3022
3821
  `Idea / source:\n${String(idea ?? '').trim()}`,
3023
3822
  'Requirements:',
3024
- '- 3-8 scenes, 1-3 minutes total;',
3025
- '- Each scene starts with "SCENE X location (day/night) · characters";',
3026
- '- Visual actions only, no inner thoughts; dialogue on its own lines; mark sounds in brackets;',
3823
+ '- This node outputs ONE episode: 1-2 minutes of screen time (60-120s), 4-10 scenes;',
3824
+ '- **Every scene MUST start with its own line "【SCENE N】name (location · time)"** — this marker drives the automatic per-scene expansion and must not be omitted;',
3825
+ '- Each scene holds 10-15 seconds of screen time (≈60-150 words incl. dialogue); all scenes sum to ≈1-2 minutes;',
3826
+ '- After each scene header: visual action + dialogue ("Character: line" on its own lines); mark sounds in brackets;',
3027
3827
  '- End with "Characters:" and "Locations/Props:" lists for asset production.',
3028
3828
  ].join('\n');
3029
3829
  return await aiCompleteStream({
@@ -3032,31 +3832,68 @@ window.__ModuleLoader__.load({
3032
3832
  onText: (d) => onDelta && onDelta('text', d),
3033
3833
  });
3034
3834
  }
3035
- async function aiStoryboardShots(scriptText, requirement, count, model, onDelta) {
3835
+ /** 修复 JSON 字符串值内部未转义的半角引号(模型把 喷漆"拆"字 这类引用原样写进值里)。
3836
+ * 状态机扫描:跟踪是否处于字符串内;串内引号看其后第一个非空白字符——
3837
+ * 是 JSON 结构符(, } ] :)→ 这是字符串结束;否则是值内引号 → 转为全角「 / 」。 */
3838
+ function repairJsonInnerQuotes(s) {
3839
+ const src = String(s);
3840
+ let out = '';
3841
+ let inStr = false, esc = false, innerOpen = false;
3842
+ for (let i = 0; i < src.length; i++) {
3843
+ const ch = src[i];
3844
+ if (!inStr) {
3845
+ if (ch === '"') inStr = true;
3846
+ out += ch;
3847
+ continue;
3848
+ }
3849
+ if (esc) { out += ch; esc = false; continue; }
3850
+ if (ch === '\\') { out += ch; esc = true; continue; }
3851
+ if (ch !== '"') { out += ch; continue; }
3852
+ let j = i + 1;
3853
+ while (j < src.length && /\s/.test(src[j])) j++;
3854
+ const nxt = src[j] || '';
3855
+ if (nxt === ',' || nxt === '}' || nxt === ']' || nxt === ':' || nxt === '') { inStr = false; innerOpen = false; out += ch; }
3856
+ else { out += innerOpen ? '」' : '「'; innerOpen = !innerOpen; }
3857
+ }
3858
+ return out;
3859
+ }
3860
+ async function aiStoryboardShots(scriptText, requirement, count, model, onDelta, prevTail) {
3036
3861
  const settings = loadSettings();
3037
3862
  const bible = bibleBlock(loadBible(S.activeId));
3038
3863
  const zh = effLang() !== 'en';
3039
3864
  const jsonContract = zh
3040
3865
  ? [
3041
3866
  '把剧本拆解为「多镜头一次性视频生成」的分镜脚本:铺开后由**一个视频生成节点**把全部镜头按顺序与时长写进同一条提示词,由视频模型一次性生成整段视频(镜头间由模型直接硬切,不是一镜一视频再拼接)。',
3042
- '只输出一个 JSON 对象:{"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3}]}。',
3867
+ '只输出一个 JSON 对象:{"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"陈拾","line":"收件时间,1999……"}],"sfx":"邮包搭扣声、撕纸声"}]}。',
3043
3868
  '规则:',
3044
3869
  '- visual 是本镜画面描述(景别、人物外观与站位、场景、光线、构图,60 字内);',
3045
3870
  '- **visual 与 motion 里的角色/场景/道具必须写成 @名称 引用形式**(如 `@张山`、`@褶皱巷`),插件会自动带上该实体的设定描述与其参考图资产,保证跨镜一致;',
3046
3871
  '- motion 是本镜动作与运镜(主体动作节拍、机位运动方向与速度、情绪节奏,50 字内);与 visual 不重复;',
3047
3872
  '- characters/scene/props 使用剧本与设定集中的名称(不带 @),便于匹配资产;无道具给空数组;',
3048
- '- duration 为该镜时长,2-5 的整数(一个镜头拍一个连续动作段落);全部 duration 之和 = 目标视频总时长(如 15 秒 ≈ 4-7 镜);镜头顺序即叙事顺序,首镜交代场景、末镜收束情绪;',
3873
+ '- **dialogue 是本镜的角色台词**:把剧本中落在这一镜的对白逐行搬进来({"role":"角色名","line":"台词原文"}),台词必须**逐字来自剧本**、不新写不改写;本镜无台词给空数组;一镜尽量只放一个角色的台词(单说话人镜头利于声音参考提取);',
3874
+ '- **【音话同步·硬性】有台词的镜头必须让说话角色在画面内开口说话**:景别用近景/中近景,脸部朝向可见(正面或 3/4 侧脸),禁止背对镜头、脸部被遮挡、人物出画;visual 或 motion 里必须写明「开口说话、口型清晰可见」;台词**绝不能**处理成画外音/旁白(否则成片里人嘴是闭的、声音飘在背景里);若某句台词在剧本里说话人无法入画,就把这句台词安排到能看见他说话的那一镜;',
3875
+ '- **sfx 是本镜的音效/环境音**:剧本里括号标注的「(音效:…)(环境音:…)」按内容对齐到最贴近的镜头**逐字搬入**(多个用顿号连接);无音效的镜头给空字符串——音效是成片声音设计的一部分,绝不丢弃;',
3876
+ '- **【完整覆盖】分镜必须覆盖所给剧本的全部内容**:每一个动作、每一句对白都要落在某个镜头里——不概述、不省略、不跳过、不"挑选重点";镜头数量与总时长由剧本体量自然决定,宁多勿漏(容量超限由上层拆分剧本处理,不由分镜偷工减料);',
3877
+ '- **镜头按实际内容切分**:一个连续动作段落 = 一镜,数量由内容自然决定(3-10 镜都正常)——绝不凑数、绝不使用固定数量;',
3878
+ '- **JSON 字符串值内部禁止出现半角双引号 "**——引用文字(牌匾/字幕/台词原文)一律用「」;',
3879
+ '- duration 为该镜时长,2-5 的整数(一个镜头拍一个连续动作段落);全部 duration 之和 **≤ 15 秒**(一个分镜表 = 一个 10-15s 的场景视频)——超了就合并精简镜头,而不是拉长单镜;镜头顺序即叙事顺序,末镜收束情绪;**若下方给了「承接约束」,第 1 镜必须承接上一场尾镜的画面与人物状态(不得重新建立场景/跳回远景),无承接约束时首镜才交代场景**;',
3049
3880
  '- 只输出 JSON,不要输出任何其他内容。',
3050
3881
  ].join('\n')
3051
3882
  : [
3052
3883
  '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).',
3053
- 'Output only one JSON object: {"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3}]}.',
3884
+ 'Output only one JSON object: {"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"Chen","line":"…"}],"sfx":"zipper snap, paper tear"}]}.',
3054
3885
  'Rules:',
3055
3886
  '- visual = the shot\'s frame description (shot size, wardrobe, staging, light, framing, <=60 words);',
3056
3887
  '- **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;',
3057
3888
  '- motion = action & camera for the shot (action beats, camera move, emotional rhythm, <=50 words); do not repeat visual;',
3058
3889
  '- characters/scene/props arrays must use plain names from the script and series bible (no @) so assets match; empty props array if none;',
3059
- '- 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;',
3890
+ '- **dialogue = the character lines that fall in this shot**, moved verbatim from the script ({"role":"Name","line":"exact line"}); never rewrite lines; empty array if none; keep one speaker per shot when possible (helps voice-reference extraction);',
3891
+ '- **[LIP-SYNC - HARD] any shot carrying dialogue must show the speaker TALKING ON SCREEN**: use a close-up / medium close-up with the face visible (frontal or 3/4), never back-to-camera, never obscured, never off-frame; state "speaks on screen, mouth clearly visible" in visual or motion; dialogue must NEVER become voice-over/narration (otherwise the final cut has a closed mouth with the voice floating in the background); if a line cannot be on camera in the script, move that line to the shot where the speaker is visible;',
3892
+ '- **sfx = sound cues / ambience for this shot**: bracketed notes like (SFX: ...) (Ambience: ...) from the script are moved verbatim into the closest matching shot (join multiples with commas); empty string if none — sound design is part of the film, never drop it;',
3893
+ '- **[FULL COVERAGE] the shots must cover EVERYTHING in the given script**: every action and every line must land in some shot — no summarizing, no omitting, no cherry-picking; shot count and total duration follow naturally from the script size (more is better than missing); capacity overflow is handled upstream by splitting the script, never by thinning the shots;',
3894
+ '- **Never use straight double quotes " inside JSON string values** — quoted text (signs/subtitles/lines) uses 「」 instead;',
3895
+ '- **Split shots by actual content**: one continuous action beat = one shot; the count is decided by the content (3-10 shots are all normal) — never pad, never use a fixed number;',
3896
+ '- duration is the shot length in seconds, integer 2-5 (one continuous action beat per shot); the sum of durations must be **≤ 15 seconds** (one storyboard = one 10-15s scene video) — if over, merge and tighten shots instead of stretching; shot order = narrative order; last shot lands the emotion; **if a CONTINUITY CONSTRAINT is given below, shot 1 must continue the previous scene\'s last frame and character state (no re-establishing, no wide reset); only without a constraint does shot 1 establish the scene**;',
3060
3897
  '- Output JSON only.',
3061
3898
  ].join('\n');
3062
3899
  const methodology = await loadSkillRules(settings.skillStoryboard, zh)
@@ -3068,17 +3905,64 @@ window.__ModuleLoader__.load({
3068
3905
  bible ? (zh ? '项目设定集(名称必须与之一致):\n' : 'Series bible (names must match):\n') + bible : '',
3069
3906
  outLangLine(),
3070
3907
  ].filter(Boolean).join('\n');
3908
+ const countLine = (Number(count) > 0)
3909
+ ? `${zh ? '参考镜头数量' : 'Reference shot count'}:约 ${count} 镜(**仅作参考**——必须按实际动作节拍切分,内容需要更多就更多,绝不凑数)`
3910
+ : `${zh ? '镜头数量' : 'Shot count'}:**不固定**——按实际动作节拍切分,由内容自然决定`;
3911
+ // 承接约束:由连线指定(分镜表「衔接上一镜」输入口 ← 上游分镜表「尾镜」输出口)。
3912
+ // 只承接尾镜的**结尾瞬间**(≤0.2s 重叠),不重演上一镜——视频模型按秒计费,重演等于白花钱。
3913
+ const cont = (() => {
3914
+ if (!prevTail) return null;
3915
+ const label = prevTail.label || (zh ? '上一分镜' : 'previous storyboard');
3916
+ if (prevTail.text) {
3917
+ return zh
3918
+ ? `【承接约束·硬性】本分镜承接上游提供的衔接说明:\n${prevTail.text}\n`
3919
+ + `要求:第 1 镜从该衔接点**继续**(不重演),与上一镜画面重叠不超过 0.2 秒,人物位置/朝向/情绪/光线连续,背景环境声连续。`
3920
+ : `[CONTINUITY CONSTRAINT - HARD] Continue from the upstream hand-off note:\n${prevTail.text}\n`
3921
+ + `Shot 1 must continue from that point (no replay); overlap with the previous shot <= 0.2s; keep character position/facing/emotion/light and the ambient background sound continuous.`;
3922
+ }
3923
+ const sh = prevTail.shot;
3924
+ if (!sh) return null;
3925
+ const dl = (sh.dialogue || []).map((d) => d.role + (zh ? ':' : ': ') + d.line).join(zh ? ';' : '; ');
3926
+ return zh
3927
+ ? `【承接约束·硬性】本分镜承接「${label}」的最后一镜——**只承接它的结尾瞬间,不要重演它**:\n`
3928
+ + ` 尾镜画面:${String(sh.visual || '').trim()}\n`
3929
+ + ` 尾镜最后动作:${String(sh.motion || '').trim()}\n`
3930
+ + (dl ? ` 尾镜台词:${dl}\n` : '')
3931
+ + (String(sh.sfx || '').trim() ? ` 尾镜音效/环境声:${String(sh.sfx).trim()}\n` : '')
3932
+ + `要求:\n`
3933
+ + ` 1) 第 1 镜从上一镜的**最后一个动作**继续(人物位置、朝向、情绪、光线保持一致),不得重新建立场景、不得跳回远景交代、不得切换地点;\n`
3934
+ + ` 2) 与上一镜的画面重叠**不超过 0.2 秒**(不要重演上一镜的内容);\n`
3935
+ + ` 3) **背景声连续**:环境音/音效自然延续(风声、电流声、街道底噪等),不得突然静音或更换环境声;\n`
3936
+ + ` 4) 第 1 镜的画面描述里明确写出承接关系(如「承接上一镜,<角色>保持<姿态>,继续…」)。`
3937
+ : `[CONTINUITY CONSTRAINT - HARD] This storyboard continues the LAST SHOT of "${label}" - continue only its final instant, do NOT replay it:\n`
3938
+ + ` Tail frame: ${String(sh.visual || '').trim()}\n`
3939
+ + ` Tail last action: ${String(sh.motion || '').trim()}\n`
3940
+ + (dl ? ` Tail dialogue: ${dl}\n` : '')
3941
+ + (String(sh.sfx || '').trim() ? ` Tail SFX/ambience: ${String(sh.sfx).trim()}\n` : '')
3942
+ + `Requirements:\n`
3943
+ + ` 1) Shot 1 continues from the LAST ACTION of the previous shot (same position, facing, emotion, light) - no re-establishing, no wide reset, no location change;\n`
3944
+ + ` 2) overlap with the previous shot must be <= 0.2s (do not replay the previous shot);\n`
3945
+ + ` 3) ambient background sound continues naturally (wind, electrical hum, street noise) - never cut to silence or swap ambience;\n`
3946
+ + ` 4) state the continuity explicitly in shot 1's frame description.`;
3947
+ })();
3948
+ const prevLine = cont || '';
3071
3949
  const user = [
3072
3950
  `${zh ? '剧本' : 'Script'}:\n${String(scriptText ?? '').trim()}`,
3073
3951
  `${zh ? '额外要求' : 'Extra requirements'}:${String(requirement ?? '').trim() || (zh ? '无' : 'none')}`,
3074
- `${zh ? '目标分镜数量' : 'Shot count'}:${count}`,
3075
- ].join('\n\n');
3952
+ countLine,
3953
+ prevLine,
3954
+ ].filter(Boolean).join('\n\n');
3076
3955
  const text = await aiCompleteStream({
3077
3956
  system, user, model,
3078
3957
  onReasoning: (d) => onDelta && onDelta('reasoning', d),
3079
3958
  onText: (d) => onDelta && onDelta('text', d),
3080
3959
  });
3081
- const parsed = parseJsonBalanced(text);
3960
+ // 解析 + 修复重试:模型常把剧本里的引用文字(如 喷漆"拆"字)原样写进 JSON 字符串值,
3961
+ // 半角引号未转义 → JSON 非法。修复策略:把"闭合引号后不跟 JSON 结构符"的引号对换成「」。
3962
+ let parsed = parseJsonBalanced(text);
3963
+ if (!parsed || !Array.isArray(parsed.shots)) {
3964
+ try { parsed = parseJsonBalanced(repairJsonInnerQuotes(text)); } catch { /* 修复失败保持原错误 */ }
3965
+ }
3082
3966
  const shots = parsed && Array.isArray(parsed.shots) ? parsed.shots : null;
3083
3967
  if (!shots || !shots.length) {
3084
3968
  // 解析失败必须带上下文(长度 + 首尾原文),才能判断是被截断还是格式跑偏
@@ -3087,7 +3971,17 @@ window.__ModuleLoader__.load({
3087
3971
  const tail = raw.slice(-160);
3088
3972
  throw new Error(T('errParseStoryboard') + '(正文 ' + raw.length + ' 字;开头: ' + head + ';结尾: ' + tail + ')');
3089
3973
  }
3090
- return shots.slice(0, 24).map((s, i) => ({
3974
+ // 硬性容量保证:一个分镜表 ≤ 15s(视频模型单次上限)——超限从最长镜头开始逐秒压缩(每镜最低 1s)
3975
+ const capped = shots.map((s) => ({ ...s }));
3976
+ for (let guard = 0; guard < 300; guard++) {
3977
+ const sum = capped.reduce((a, s) => a + (Number(s.duration) || 0), 0);
3978
+ if (sum <= 15) break;
3979
+ let maxI = -1, maxV = 1;
3980
+ capped.forEach((s, i) => { const d = Number(s.duration) || 0; if (d > maxV) { maxV = d; maxI = i; } });
3981
+ if (maxI < 0) break;
3982
+ capped[maxI].duration = maxV - 1;
3983
+ }
3984
+ return capped.slice(0, 24).map((s, i) => ({
3091
3985
  title: String(s.title || (T('shotPrefix') + (i + 1))).slice(0, 24),
3092
3986
  visual: String(s.visual || ''),
3093
3987
  motion: String(s.motion || ''),
@@ -3095,6 +3989,10 @@ window.__ModuleLoader__.load({
3095
3989
  scene: String(s.scene || ''),
3096
3990
  props: Array.isArray(s.props) ? s.props.map(String).slice(0, 6) : [],
3097
3991
  duration: Number.isFinite(Number(s.duration)) ? clamp(Math.round(Number(s.duration)), 1, 15) : 3,
3992
+ dialogue: Array.isArray(s.dialogue)
3993
+ ? s.dialogue.slice(0, 6).map((d) => ({ role: String(d && d.role || '').trim().slice(0, 24), line: String(d && d.line || '').trim().slice(0, 200) })).filter((d) => d.role && d.line)
3994
+ : [],
3995
+ sfx: String(s.sfx || '').trim().slice(0, 120),
3098
3996
  }));
3099
3997
  }
3100
3998
  /** 本地一致性检查:分镜里出现的角色/场景/道具(含 visual/motion 文本里的 @提及)是否都在
@@ -3353,6 +4251,8 @@ window.__ModuleLoader__.load({
3353
4251
  const live = new Set(Object.keys(s.graph.nodes || {}));
3354
4252
  s.graph.tombstones = [...new Set([...(s.graph.tombstones || []), ...prevIds])]
3355
4253
  .filter((id) => !live.has(id)).slice(-200);
4254
+ // 撤销恢复的节点同步撤销"用户删除"记账:同步豁免随之失效,墓碑由推送清除
4255
+ for (const id of live) userDeletedNodes.delete(id);
3356
4256
  s.sel = new Set();
3357
4257
  s.selEdge = null;
3358
4258
  });
@@ -3434,7 +4334,7 @@ window.__ModuleLoader__.load({
3434
4334
  if (type === 'shot') { data.visual = ''; data.motion = ''; }
3435
4335
  if (type === 'script') { data.idea = ''; data.output = ''; data.aiStatus = 'idle'; data.aiError = null; }
3436
4336
  if (type === 'storyboard') {
3437
- data.script = ''; data.requirement = ''; data.count = 6;
4337
+ data.script = ''; data.requirement = ''; data.count = 0; // 0 = 自动:镜头数按内容节拍,不锚定固定数量
3438
4338
  data.shots = []; data.aiStatus = 'idle'; data.aiError = null;
3439
4339
  }
3440
4340
  if (type === 'image-gen' || type === 'video-gen') {
@@ -3476,6 +4376,15 @@ window.__ModuleLoader__.load({
3476
4376
  // 否则 Host 合并(保留对话新节点)会把用户刚删的节点又留下来
3477
4377
  if (ids.length) s.graph.tombstones = [...new Set([...(s.graph.tombstones || []), ...ids])].slice(-200);
3478
4378
  });
4379
+ // 用户删除记账:同步的"内存画布为准"豁免只认这里——对话侧(Host)删除的节点不在此列,
4380
+ // 会被正常本地清除(否则旧内存副本会把对话侧刚删的节点复活)
4381
+ for (const id of ids) {
4382
+ userDeletedNodes.set(id, Date.now());
4383
+ if (userDeletedNodes.size > 300) {
4384
+ const oldest = userDeletedNodes.keys().next().value;
4385
+ userDeletedNodes.delete(oldest);
4386
+ }
4387
+ }
3479
4388
  pushHistory();
3480
4389
  flushSave(); // 删除立即推送(不等 450ms 防抖):同步把 Host 上的节点删掉,压缩"拉回复活"的时间窗
3481
4390
  }
@@ -3541,7 +4450,7 @@ window.__ModuleLoader__.load({
3541
4450
  const map = {
3542
4451
  character: T('catCharacter'), scene: T('catScene'), prop: T('catProp'),
3543
4452
  script: T('nScript'), storyboard: T('nStoryboard'),
3544
- audio: T('ttsCategory'), finalcut: T('finalcutCategory'), general: T('catGeneral'),
4453
+ audio: T('ttsCategory'), finalcut: T('finalcutCategory'), image: T('catImage'), general: T('catGeneral'),
3545
4454
  };
3546
4455
  return map[c] || c;
3547
4456
  }
@@ -3604,16 +4513,29 @@ window.__ModuleLoader__.load({
3604
4513
  if (!shots.length) return '';
3605
4514
  const zh = effLang() !== 'en';
3606
4515
  const total = shots.reduce((a, s) => a + Math.max(1, Number(s.duration) || 3), 0);
4516
+ const hasDialogue = shots.some((s) => (s.dialogue || []).length);
3607
4517
  const lines = [
3608
4518
  zh
3609
4519
  ? `【多镜头一次性生成】共 ${shots.length} 镜、总时长 ${total} 秒。严格按下列顺序与时长切镜(每镜时长不可变),镜头之间直接硬切,不要叠加转场:`
3610
4520
  : `[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:`,
3611
4521
  ];
4522
+ if (hasDialogue) {
4523
+ lines.push(zh
4524
+ ? '标注「台词」的对白必须由**画面内可见的角色当场开口说出**(口型与台词同步、语气贴合情绪、面部朝镜头可见);严禁处理成画外音/旁白;同步音频开启时按台词生成人声。'
4525
+ : 'Dialogue must be SPOKEN ON SCREEN by the visible character (lip-synced to the line, matching emotion, face toward camera); never render it as voice-over/narration; generate speech when synced audio is on.');
4526
+ }
3612
4527
  shots.forEach((s, i) => {
3613
4528
  const dur = Math.max(1, Number(s.duration) || 3);
3614
4529
  const parts = [];
3615
4530
  if (String(s.visual || '').trim()) parts.push(String(s.visual).trim());
3616
4531
  if (String(s.motion || '').trim()) parts.push((zh ? '运镜/动作:' : 'Camera/Action: ') + String(s.motion).trim());
4532
+ const dls = (s.dialogue || []).map((d) => String(d && d.role || '').trim() + ':' + String(d && d.line || '').trim()).filter((x) => x.replace(':', '').trim());
4533
+ if (dls.length) {
4534
+ parts.push(zh
4535
+ ? '台词(由' + (s.dialogue || []).map((d) => String(d && d.role || '').trim()).filter(Boolean).join('、') + '在画面内开口说出,口型与台词同步):' + dls.join(';')
4536
+ : 'Dialogue (spoken on screen by ' + (s.dialogue || []).map((d) => String(d && d.role || '').trim()).filter(Boolean).join(', ') + ', lip-synced): ' + dls.join('; '));
4537
+ }
4538
+ if (String(s.sfx || '').trim()) parts.push((zh ? '音效:' : 'SFX: ') + String(s.sfx).trim());
3617
4539
  lines.push((zh ? `镜头${i + 1}(${dur}s):` : `Shot ${i + 1} (${dur}s): `) + parts.join(zh ? ';' : '; '));
3618
4540
  });
3619
4541
  return lines.join('\n');
@@ -3675,6 +4597,27 @@ window.__ModuleLoader__.load({
3675
4597
  return metas;
3676
4598
  }
3677
4599
 
4600
+ /** 承接来源(**只认连线,不靠名字**):本分镜「衔接上一镜」输入口(prev) 连到的上游分镜表,
4601
+ * 取它的**最后一镜**作为本场第 1 镜的承接依据;未连线返回 null(不加约束)。
4602
+ * 也允许连文本节点:直接把文本作为承接描述使用。 */
4603
+ function prevSceneTail(n) {
4604
+ const e = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'prev');
4605
+ if (!e) return null;
4606
+ const src = S.graph.nodes[e.fromNode];
4607
+ if (!src || !src.data) return null;
4608
+ if (src.type === 'storyboard') {
4609
+ const shots = src.data.shots || [];
4610
+ if (!shots.length) return { label: String(src.data.label || ''), shot: null };
4611
+ return { label: String(src.data.label || ''), shot: shots[shots.length - 1] };
4612
+ }
4613
+ if (src.type === 'text' || src.type === 'script') {
4614
+ const txt = String(src.data.text || src.data.output || '').trim();
4615
+ if (!txt) return null;
4616
+ return { label: String(src.data.label || ''), shot: null, text: txt };
4617
+ }
4618
+ return null;
4619
+ }
4620
+
3678
4621
  // ============================================================
3679
4622
  // 工作流执行
3680
4623
  // ============================================================
@@ -3748,7 +4691,14 @@ window.__ModuleLoader__.load({
3748
4691
 
3749
4692
  /** 分镜节点的资产文本(自动入库与手动"存为资产"共用,避免两处格式漂移)。 */
3750
4693
  function storyboardAssetText(shots) {
3751
- return (shots || []).map((x) => `【${x.title}】${x.duration}s\n画面:${x.visual}\n动作:${x.motion}\n角色:${(x.characters || []).join('、') || '-'}|场景:${x.scene || '-'}|道具:${(x.props || []).join('、') || '-'}`).join('\n\n');
4694
+ return (shots || []).map((x) => {
4695
+ const dls = (x.dialogue || []).map((d) => `${d.role}:${d.line}`).join('\n');
4696
+ const sfx = String(x.sfx || '').trim();
4697
+ return `【${x.title}】${x.duration}s\n画面:${x.visual}\n动作:${x.motion}`
4698
+ + (dls ? `\n${dls}` : '')
4699
+ + (sfx ? `\n音效:${sfx}` : '')
4700
+ + `\n角色:${(x.characters || []).join('、') || '-'}|场景:${x.scene || '-'}|道具:${(x.props || []).join('、') || '-'}`;
4701
+ }).join('\n\n');
3752
4702
  }
3753
4703
 
3754
4704
  /** 分镜节点:剧本 → 镜头列表(含一致性检查)。 */
@@ -3759,13 +4709,20 @@ window.__ModuleLoader__.load({
3759
4709
  aiStreamClear(n.id);
3760
4710
  flushSave();
3761
4711
  try {
3762
- const shots = await aiStoryboardShots(st, n.data.requirement, n.data.count || 6, n.data.model, (kind, d) => aiStreamAppend(n.id, kind, d));
4712
+ const prev = prevSceneTail(n); // 场 N-1 的尾镜 → 作为本场第 1 镜的硬性承接约束
4713
+ const shots = await aiStoryboardShots(st, n.data.requirement, n.data.count || 6, n.data.model, (kind, d) => aiStreamAppend(n.id, kind, d), prev);
3763
4714
  // 先自动补录缺失条目(空描述),再算一致性告警:否则告警标志的条目其实马上就被自动补录了,"未登记"语义会误导用户与对话侧
3764
4715
  try { await autoRegisterBibleEntries(shots); } catch { /* 补录失败不影响分镜 */ }
3765
4716
  const warnings = await checkShotConsistency(shots);
4717
+ // 容量告警:一个分镜 = 一个 10-15s 的完整场景。剧本过长说明应该按场景拆分成多个场景剧本节点,
4718
+ // 而不是让一个分镜表硬吃全集(shots 上限 24 会悄悄截断覆盖面)
4719
+ const stLen = st.length;
4720
+ if (stLen > 400) {
4721
+ warnings.push(T('warnScriptTooLong').replace('{n}', String(stLen)));
4722
+ }
3766
4723
  update((s) => {
3767
4724
  const nd = s.graph.nodes[n.id];
3768
- if (nd) { nd.data.shots = shots; nd.data.aiStatus = 'idle'; nd.data.warnings = warnings; nd.data.status = 'done'; nd.data.error = null; }
4725
+ if (nd) { nd.data.shots = shots; nd.data.aiStatus = 'idle'; nd.data.warnings = warnings; nd.data.status = 'done'; nd.data.error = null; nd.data.contFrom = prev ? prev.label : null; }
3769
4726
  s.assetsTick++;
3770
4727
  });
3771
4728
  await saveTextAssetRecord(S.activeId, {
@@ -3849,6 +4806,7 @@ window.__ModuleLoader__.load({
3849
4806
 
3850
4807
  async function runGenNode(n) {
3851
4808
  const type = n.type;
4809
+ const settings = loadSettings(); // 模型解析(供应商+模型)用
3852
4810
  update((s) => {
3853
4811
  const nd = s.graph.nodes[n.id];
3854
4812
  if (nd) { nd.data.status = 'running'; nd.data.error = null; }
@@ -3866,46 +4824,59 @@ window.__ModuleLoader__.load({
3866
4824
  const { refs, entities } = await collectRefs(n, prompt, bible);
3867
4825
  const metas = refs.map((r) => ({ blob: r.blob, name: r.name, category: r.category }));
3868
4826
  const p = buildEffectivePrompt(prompt, refs, entities, bible);
3869
- const { blob, demo } = await generateImage(p, metas, { size: n.data.size, ratio: n.data.ratio, apiId: n.data.imageApiId });
4827
+ // 模型解析:节点选择优先,未指定路由到星标默认模型;都没有 = 明确报错(不再静默走演示模式)
4828
+ const mcfg = resolveImageModelCfg(settings, nodeImageModelRef(n));
4829
+ if (!mcfg) throw new Error(T('errNoImageModel'));
4830
+ const blob = await apiImage(p, metas, mcfg, { size: n.data.size, ratio: n.data.ratio });
3870
4831
  const rec = await saveAssetRecord(S.activeId, 'image', blob, {
3871
4832
  name: assetNameFor(n, entities, 'image'),
3872
4833
  prompt: stripMentionMarks(prompt), effectivePrompt: p,
3873
- source: demo ? 'demo' : 'api',
4834
+ source: 'api',
3874
4835
  entity: entities.length ? entities[0].name : undefined,
3875
4836
  entities: entities.map((e) => e.name),
3876
4837
  nodeLabel: (n.data && n.data.label) || '',
3877
4838
  // 分类:@引用命中的实体品类优先;无引用时按命名规范前缀(角色-/场景-/道具-)兜底
3878
4839
  category: entities.length ? entityCategoryOf(entities[0].kind) : (categoryFromName(n.data.label) || 'general'),
3879
4840
  });
3880
- commitGenResult(n.id, rec.id, demo, 'image');
4841
+ commitGenResult(n.id, rec.id, false, 'image');
3881
4842
  } else {
3882
4843
  const frameBlob = await resolveAssetBlob(n.id, 'frame');
3883
- const { refs, entities } = await collectRefs(n, prompt, bible);
4844
+ const { refs, entities, audioBlobs } = await collectRefs(n, prompt, bible);
3884
4845
  const p = buildEffectivePrompt(prompt, refs, entities, bible);
3885
- const { blob, demo } = await generateVideo(p, frameBlob, {
3886
- duration: n.data.duration, resolution: n.data.resolution, ratio: n.data.ratio, apiId: n.data.videoApiId,
4846
+ const mcfg = resolveVideoModelCfg(settings, nodeVideoModelRef(n));
4847
+ if (!mcfg) throw new Error(T('errNoVideoModel'));
4848
+ const blob = await apiVideo(p, frameBlob, mcfg, {
4849
+ duration: n.data.duration, resolution: n.data.resolution, ratio: n.data.ratio,
4850
+ frameMode: n.data.frameMode || 'auto', // auto=尾帧降级为参考图 / hard=硬首帧(放弃参考图) / off=忽略首帧
3887
4851
  refs: refs.map((r) => r.blob),
4852
+ refNames: refs.map((r) => r.entity || r.name || ''), // 跨镜衔接标注用(图2..N 的实体名)
4853
+ // 任务提交成功即持久化 task id:轮询中断/页面关闭后仍可凭 id 回查结果(钱花了就必须能找回)
4854
+ onTask: (tid, qurl) => {
4855
+ update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.taskId = String(tid || ''); nd.data.taskQueryUrl = String(qurl || ''); } });
4856
+ flushSave();
4857
+ },
4858
+ audioRefs: audioBlobs.map((a) => a.blob), // 角色声音参考(@引用实体名下的音频资产)
3888
4859
  });
3889
4860
  const rec = await saveAssetRecord(S.activeId, 'video', blob, {
3890
4861
  name: assetNameFor(n, entities, 'video'),
3891
4862
  prompt: stripMentionMarks(prompt), effectivePrompt: p,
3892
- source: demo ? 'demo' : 'api',
4863
+ source: 'api',
3893
4864
  entity: entities.length ? entities[0].name : undefined,
3894
4865
  entities: entities.map((e) => e.name),
3895
4866
  nodeLabel: (n.data && n.data.label) || '',
3896
- category: entities.length ? entityCategoryOf(entities[0].kind) : (categoryFromName(n.data.label) || 'general'),
4867
+ category: 'finalcut', // 节点生成的视频 = 成片(实体归属由 entities 字段承担,不占用分类)
3897
4868
  });
3898
- commitGenResult(n.id, rec.id, demo, 'video');
4869
+ commitGenResult(n.id, rec.id, false, 'video');
3899
4870
  // 尾帧提取(通用):供 last 端口输出与链式下一镜使用
3900
4871
  try {
3901
4872
  const fBlob = await extractLastFrame(blob);
3902
4873
  const fRec = await saveAssetRecord(S.activeId, 'image', fBlob, {
3903
4874
  name: T('lastFrame') + '-' + (n.data.label || n.id.slice(-4)),
3904
- prompt: 'last frame of ' + prompt.slice(0, 60), source: demo ? 'demo' : 'api', category: 'general',
4875
+ prompt: 'last frame of ' + prompt.slice(0, 60), source: 'api', category: 'image',
3905
4876
  });
3906
4877
  update((s) => {
3907
4878
  const nd = s.graph.nodes[n.id];
3908
- if (nd) nd.data.lastAssetId = fRec.id;
4879
+ if (nd) { nd.data.lastAssetId = fRec.id; nd.data.lastAssetVideo = rec.id; }
3909
4880
  s.assetsTick++;
3910
4881
  });
3911
4882
  } catch { /* 尾帧提取失败不影响主流程 */ }
@@ -4577,6 +5548,7 @@ window.__ModuleLoader__.load({
4577
5548
  in: T('portIn'), out: T('portOut'), last: T('lastFrame'),
4578
5549
  char: T('portChar'), scene: T('portScene'), prop: T('portProp'),
4579
5550
  visual: T('portVisual'), motion: T('portMotion'), script: T('portScript'),
5551
+ prev: T('portPrev'), tail: T('portTail'),
4580
5552
  };
4581
5553
  return map[pid] || pid;
4582
5554
  }
@@ -4620,21 +5592,26 @@ window.__ModuleLoader__.load({
4620
5592
 
4621
5593
  function GenSettingsRow({ n, kind }) {
4622
5594
  const settings = loadSettings();
4623
- const cfgs = kind === 'image' ? (settings.imageApis || []) : (settings.videoApis || []);
4624
- const cfgValue = (kind === 'image' ? n.data.imageApiId : n.data.videoApiId) || '';
4625
- const setCfg = (v) => update((s) => {
5595
+ const models = kind === 'image' ? flattenImageModels(settings) : flattenVideoModels(settings);
5596
+ const star = kind === 'image' ? settings.imageStar : settings.videoStar;
5597
+ const cur = kind === 'image' ? nodeImageModelRef(n) : nodeVideoModelRef(n);
5598
+ const setModel = (v) => update((s) => {
4626
5599
  const nd = s.graph.nodes[n.id];
4627
5600
  if (!nd) return;
4628
- if (kind === 'image') nd.data.imageApiId = v; else nd.data.videoApiId = v;
5601
+ if (kind === 'image') { nd.data.imageModel = v; delete nd.data.imageApiId; }
5602
+ else { nd.data.videoModel = v; delete nd.data.videoApiId; }
4629
5603
  });
4630
- // 两行布局,避免超出 240px 节点宽度
5604
+ // 模型下拉:只列配置的模型(供应商 · 模型,星标者带 ★);留空 = 路由到默认模型;没配置 = 空(运行报错)
4631
5605
  return h('div', { key: 'set', style: { display: 'flex', flexDirection: 'column', gap: 5 } },
4632
5606
  h('div', { className: 'dshic-node-row' },
4633
5607
  h('div', { style: { flex: 1, minWidth: 0 } },
4634
5608
  h(MiniSelect, {
4635
- value: cfgValue, title: T('modelLabel'), width: '100%',
4636
- options: [{ value: '', label: T('defaultCfg') }].concat(cfgs.map((c) => ({ value: c.id, label: (c.name || c.id) }))),
4637
- onChange: setCfg,
5609
+ value: cur, title: T('modelLabel'), width: '100%',
5610
+ disabled: !models.length,
5611
+ options: models.length
5612
+ ? models.map((m) => ({ value: m.ref, label: (m.ref === star ? '★ ' : '') + m.providerName + ' · ' + m.model }))
5613
+ : [{ value: '', label: T('noModelsCfg') }],
5614
+ onChange: setModel,
4638
5615
  })),
4639
5616
  h(MiniSelect, {
4640
5617
  value: n.data.ratio || '16:9', title: T('ratioLabel'), width: 64,
@@ -4666,6 +5643,112 @@ window.__ModuleLoader__.load({
4666
5643
  );
4667
5644
  }
4668
5645
 
5646
+ /** 声音参考提取:输入**角色名** → 自动按分镜表 dialogue 定位该角色的全部台词时间窗,
5647
+ * 逐窗取音轨 PCM **拼接为一个 WAV**(一个角色一个总的声音参考文件,无需手动找时间点);
5648
+ * 分镜缺少台词标注时降级为手动起止秒模式。 */
5649
+ function VoiceExtractDialog({ nodeId, onClose }) {
5650
+ const n = S.graph.nodes[nodeId];
5651
+ const srcId = (n && n.data && (n.data.assetId || n.data.fcAssetId)) || null;
5652
+ const url = useAssetUrl(srcId);
5653
+ // 分镜来源:视频节点 → 上游分镜表;成片节点 → 经片段视频节点的上游分镜表
5654
+ const shots = (() => {
5655
+ const seen = new Set();
5656
+ const out = [];
5657
+ const collect = (nid) => {
5658
+ for (const e of S.graph.edges.filter((x) => x.toNode === nid && x.toPort === 'prompt')) {
5659
+ const sbn = S.graph.nodes[e.fromNode];
5660
+ if (sbn && sbn.type === 'storyboard' && Array.isArray(sbn.data.shots) && sbn.data.shots.length && !seen.has(sbn.id)) { seen.add(sbn.id); out.push(...sbn.data.shots); }
5661
+ }
5662
+ };
5663
+ collect(nodeId);
5664
+ if (!out.length) {
5665
+ for (const e of S.graph.edges.filter((x) => x.toNode === nodeId && x.toPort === 'in')) {
5666
+ collect(e.fromNode); // 片段视频节点的上游分镜
5667
+ }
5668
+ }
5669
+ return out;
5670
+ })();
5671
+ const roles = [...new Set(shots.flatMap((s) => (s.dialogue || []).map((l) => String(l.role || '').trim()).filter(Boolean)))];
5672
+ const [role, setRole] = useState('');
5673
+ const [manual, setManual] = useState(false);
5674
+ const [startS, setStartS] = useState('0');
5675
+ const [endS, setEndS] = useState('');
5676
+ const [busy, setBusy] = useState(false);
5677
+ const [err, setErr] = useState(null);
5678
+ const [done, setDone] = useState(null); // {segs}
5679
+ const windows = (() => {
5680
+ const rl = String(role || '').trim().toLowerCase();
5681
+ if (!rl) return [];
5682
+ const out = [];
5683
+ let t = 0;
5684
+ for (const s of shots) {
5685
+ const d = Number(s.duration) || 0;
5686
+ const lines = (s.dialogue || []).filter((l) => String(l.role || '').trim().toLowerCase() === rl);
5687
+ if (lines.length) out.push({ title: s.title, from: t, to: t + d, lines: lines.map((l) => l.role + ':' + l.line) });
5688
+ t += d;
5689
+ }
5690
+ return out;
5691
+ })();
5692
+ const submit = async () => {
5693
+ const role2 = String(role || '').trim();
5694
+ if (!role2) { setErr(T('voiceRoleNeeded')); return; }
5695
+ if (!url) { setErr(T('noResult')); return; }
5696
+ setBusy(true); setErr(null);
5697
+ try {
5698
+ const blob = await (await fetch(url)).blob();
5699
+ let wav = null;
5700
+ let segs = [];
5701
+ if (!manual) {
5702
+ const r = await extractVoiceAuto(blob, shots, role2);
5703
+ wav = r.blob; segs = r.segs;
5704
+ } else {
5705
+ const r = await extractWavRanges(blob, [[Number(startS) || 0, Number(endS) || Number(startS) || 0]]);
5706
+ wav = r.blob; segs = [{ title: T('voiceManual'), from: Number(startS) || 0, to: Number(endS) || 0, lines: [] }];
5707
+ }
5708
+ const rec = await saveAssetRecord(S.activeId, 'audio', wav, {
5709
+ name: '角色-' + role2 + ' 声音', entity: role2,
5710
+ prompt: T('voiceExtractSrc') + ':' + ((n.data && n.data.label) || nodeId) + (segs.length ? '(' + segs.map((x) => x.title + ' ' + x.from + '-' + x.to + 's').join('、') + ')' : ''),
5711
+ source: 'voice-extract', category: 'audio',
5712
+ });
5713
+ setDone(segs);
5714
+ update((s2) => { s2.assetsTick++; });
5715
+ toast(T('voiceExtractDone').replace('{name}', rec.name));
5716
+ } catch (e) { setErr(String((e && e.message) || e)); }
5717
+ setBusy(false);
5718
+ };
5719
+ return h('div', { className: 'dshic-modal-overlay', onPointerDown: (e) => { if (e.target === e.currentTarget) onClose(); } },
5720
+ h('div', { className: 'dshic-modal', style: { width: 440 } },
5721
+ h('div', { className: 'dshic-modal-title' }, T('voiceExtractBtn') + ' — ' + ((n && n.data && n.data.label) || '')),
5722
+ shots.length
5723
+ ? h('div', { className: 'dshic-modal-warn' }, T('voiceAutoHint'))
5724
+ : h('div', { className: 'dshic-modal-warn' }, T('voiceNoDialogue')),
5725
+ h(Field, { key: 'role', label: T('voiceRole'), value: role, onChange: setRole, placeholder: T('voiceRolePh') }),
5726
+ roles.length && !manual
5727
+ ? h('div', { className: 'dshic-modal-warn' }, T('voiceRolesInShots') + roles.join('、'))
5728
+ : null,
5729
+ !manual && windows.length
5730
+ ? h('div', { key: 'segs', className: 'dshic-modal-warn' },
5731
+ T('voiceSegs') + '(' + windows.length + '):',
5732
+ windows.map((w) => h('div', { key: w.title + w.from, style: { marginTop: 2 } }, '· ' + w.title + ' ' + Math.round(w.from * 10) / 10 + '-' + Math.round(w.to * 10) / 10 + 's — ' + w.lines.join(' / '))))
5733
+ : null,
5734
+ manual
5735
+ ? [
5736
+ h('div', { className: 'dshic-row' },
5737
+ h(Field, { key: 's', label: T('voiceStart'), value: startS, onChange: setStartS, placeholder: '0' }),
5738
+ h(Field, { key: 'e', label: T('voiceEnd'), value: endS, onChange: setEndS, placeholder: '—' }),
5739
+ ),
5740
+ ]
5741
+ : null,
5742
+ h('label', { className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
5743
+ h('input', { type: 'checkbox', checked: manual, onChange: (e) => setManual(e.target.checked), onPointerDown: (e) => e.stopPropagation() }),
5744
+ h('span', {}, T('voiceManual'))),
5745
+ done ? h('div', { className: 'dshic-modal-warn' }, T('voiceExtractDone').replace('{name}', '角色-' + (String(role || '').trim() || '—') + ' 声音') + '(' + done.map((x) => x.title).join('、') + ')') : null,
5746
+ err ? h('div', { className: 'dshic-err' }, err) : null,
5747
+ h('div', { className: 'dshic-modal-actions' },
5748
+ h('button', { type: 'button', className: 'dshic-btn dshic-btn-outline', onClick: onClose, onPointerDown: (e) => e.stopPropagation() }, T('cancel')),
5749
+ h('button', { type: 'button', className: 'dshic-gen', style: { width: 'auto', padding: '0 14px' }, disabled: busy || (!manual && !windows.length), onClick: submit, onPointerDown: (e) => e.stopPropagation() }, busy ? h('span', { className: 'dshic-spin' }) : Ic('audio', 12), T('voiceExtractBtn'))),
5750
+ ));
5751
+ }
4669
5752
  /** 蒙版级局部重绘:涂抹区域导出 mask PNG,走 images/edits 多部分请求。 */
4670
5753
  function MaskEditor({ nodeId, onClose }) {
4671
5754
  const n = S.graph.nodes[nodeId];
@@ -4724,8 +5807,8 @@ window.__ModuleLoader__.load({
4724
5807
  };
4725
5808
  const apply = async () => {
4726
5809
  const settings = loadSettings();
4727
- const cfg = resolveApiCfg(settings.imageApis, settings.imageApiId, n.data.imageApiId);
4728
- if (!cfg) { toast(T('errNoImageApi')); return; }
5810
+ const cfg = resolveImageModelCfg(settings, nodeImageModelRef(n));
5811
+ if (!cfg) { toast(T('errNoImageModel')); return; }
4729
5812
  setRunning(true); setErr(null);
4730
5813
  try {
4731
5814
  const mk = maskRef.current;
@@ -4793,7 +5876,32 @@ window.__ModuleLoader__.load({
4793
5876
  function GenNodeBody({ n, kind }) {
4794
5877
  const [showMask, setShowMask] = useState(false);
4795
5878
  const [showCraft, setShowCraft] = useState(false);
5879
+ const [voiceDlg, setVoiceDlg] = useState(false);
4796
5880
  useEffect(() => { probeCraft(); }, []);
5881
+ // 视频产物自动提取尾帧(含外部注入/导入的视频):last 口与跨镜衔接不依赖"必须由本节点生成"
5882
+ useEffect(() => {
5883
+ if (kind !== 'video' || !n.data.assetId) return;
5884
+ if (n.data.lastAssetId && n.data.lastAssetVideo === n.data.assetId) return;
5885
+ let cancelled = false;
5886
+ (async () => {
5887
+ try {
5888
+ const blob = await assetBlob(n.data.assetId);
5889
+ if (!blob || cancelled) return;
5890
+ const fBlob = await extractLastFrame(blob);
5891
+ if (!fBlob || cancelled) return;
5892
+ const rec = await saveAssetRecord(S.activeId, 'image', fBlob, {
5893
+ name: T('lastFrame') + '-' + (n.data.label || n.id.slice(-4)),
5894
+ prompt: 'last frame of ' + String(n.data.label || ''), source: 'api', category: 'image',
5895
+ });
5896
+ update((s) => {
5897
+ const nd = s.graph.nodes[n.id];
5898
+ if (nd && nd.data.assetId === n.data.assetId) { nd.data.lastAssetId = rec.id; nd.data.lastAssetVideo = n.data.assetId; }
5899
+ s.assetsTick++;
5900
+ });
5901
+ } catch { /* 提取失败静默:不影响视频预览与 out 口 */ }
5902
+ })();
5903
+ return () => { cancelled = true; };
5904
+ }, [kind, n.data.assetId, n.data.label, S.assetsTick]);
4797
5905
  const promptEdge = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'prompt');
4798
5906
  const promptSrc = promptEdge && S.graph.nodes[promptEdge.fromNode];
4799
5907
  const fromStoryboard = !!(promptSrc && promptSrc.type === 'storyboard');
@@ -4828,11 +5936,26 @@ window.__ModuleLoader__.load({
4828
5936
  ? h('div', { key: 'ref', className: 'dshic-hint' },
4829
5937
  T('portFrame') + ' × ' + refEdges.length + ' · ' + T('connected'))
4830
5938
  : null,
5939
+ kind === 'video' && S.graph.edges.some((x) => x.toNode === n.id && x.toPort === 'frame')
5940
+ ? h('div', { key: 'fmode', className: 'dshic-node-row' },
5941
+ h('span', { className: 'dshic-shot-label', style: { flex: 'none' } }, T('frameModeLabel')),
5942
+ h('select', {
5943
+ className: 'dshic-select', style: { flex: 1, minWidth: 0 }, title: T('frameModeHint'),
5944
+ value: n.data.frameMode || 'auto',
5945
+ onPointerDown: (e) => e.stopPropagation(),
5946
+ onChange: (e) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.frameMode = e.target.value; }),
5947
+ },
5948
+ h('option', { value: 'auto' }, T('frameModeAuto')),
5949
+ h('option', { value: 'hard' }, T('frameModeHard')),
5950
+ h('option', { value: 'off' }, T('frameModeOff')),
5951
+ ),
5952
+ )
5953
+ : null,
4831
5954
  h(RefRows, { key: 'refrows', n, portId: 'ref' }),
4832
5955
  h(MentionInfo, { key: 'mn', n }),
4833
5956
  n.data.error ? h('div', { key: 'err', className: 'dshic-err' }, n.data.error) : null,
4834
5957
  assetUrlV && kind === 'image'
4835
- ? h('img', { key: 'img', className: 'dshic-preview', src: assetUrlV, draggable: false, 'data-no-drag': '1' })
5958
+ ? h('img', { key: 'img', className: 'dshic-preview', src: assetUrlV, draggable: false, 'data-no-drag': '1', style: { cursor: 'zoom-in' }, onPointerDown: (e) => e.stopPropagation(), onClick: (e) => { e.stopPropagation(); openLightbox('image', assetUrlV, n.data.label); } })
4836
5959
  : null,
4837
5960
  assetUrlV && kind === 'video'
4838
5961
  ? h('video', { key: 'vid', className: 'dshic-video', src: assetUrlV, controls: true, preload: 'metadata', 'data-no-drag': '1', onPointerDown: (e) => e.stopPropagation() })
@@ -4860,7 +5983,7 @@ window.__ModuleLoader__.load({
4860
5983
  const nd2 = s.graph.nodes[nn.id];
4861
5984
  if (nd2) {
4862
5985
  nd2.data.prompt = n.data.prompt || '';
4863
- nd2.data.imageApiId = n.data.imageApiId || '';
5986
+ nd2.data.imageModel = nodeImageModelRef(n);
4864
5987
  nd2.data.size = n.data.size || '2K';
4865
5988
  nd2.data.ratio = n.data.ratio || '16:9';
4866
5989
  }
@@ -4893,10 +6016,18 @@ window.__ModuleLoader__.load({
4893
6016
  onClick: () => chainNext(n),
4894
6017
  }, Ic('film', 11), T('chainNext'))
4895
6018
  : null,
6019
+ kind === 'video' && assetUrlV
6020
+ ? h('button', {
6021
+ type: 'button', className: 'dshic-mini is-ghost', title: T('voiceExtractBtn') + '(' + T('voiceTip') + ')',
6022
+ onPointerDown: (e) => e.stopPropagation(),
6023
+ onClick: () => setVoiceDlg(true),
6024
+ }, Ic('audio', 11), T('voiceExtractBtn'))
6025
+ : null,
4896
6026
  h('div', { className: 'dshic-flex' }),
4897
6027
  )
4898
6028
  : null,
4899
6029
  showMask && assetUrlV ? h(MaskEditor, { key: 'mask', nodeId: n.id, onClose: () => setShowMask(false) }) : null,
6030
+ voiceDlg && assetUrlV ? h(VoiceExtractDialog, { key: 'voice', nodeId: n.id, onClose: () => setVoiceDlg(false) }) : null,
4900
6031
  showCraft && n.data.assetId ? h(PhotoCraftDialog, { key: 'pccd', assetId: n.data.assetId, onClose: () => setShowCraft(false) }) : null,
4901
6032
  (n.data.history || []).length > 1
4902
6033
  ? h('div', { key: 'hist', className: 'dshic-hist' },
@@ -4912,12 +6043,22 @@ window.__ModuleLoader__.load({
4912
6043
 
4913
6044
  function HistThumb({ assetId, active, onClick }) {
4914
6045
  const url = useAssetUrl(assetId);
6046
+ const [kind, setKind] = useState('');
6047
+ useEffect(() => {
6048
+ let ok = true;
6049
+ idbGet(assetId).then((r) => { if (ok) setKind((r && r.kind) || ''); }).catch(() => { /* 忽略 */ });
6050
+ return () => { ok = false; };
6051
+ }, [assetId]);
4915
6052
  return h('div', {
4916
6053
  className: cls('dshic-hist-thumb', active && 'is-active'),
4917
6054
  title: T('history'),
4918
6055
  onClick: (e) => { e.stopPropagation(); onClick(); },
4919
6056
  onPointerDown: (e) => e.stopPropagation(),
4920
- }, url ? h('img', { src: url, draggable: false }) : h('span', {}, '…'));
6057
+ }, url
6058
+ ? (kind === 'video'
6059
+ ? h('video', { src: url + '#t=0.1', muted: true, preload: 'metadata', draggable: false })
6060
+ : h('img', { src: url, draggable: false }))
6061
+ : h('span', {}, '…'));
4921
6062
  }
4922
6063
 
4923
6064
  /** 参考图排序列表:[图N] 顺序即提示词绑定顺序,可上移/下移/解除。 */
@@ -5012,6 +6153,30 @@ window.__ModuleLoader__.load({
5012
6153
  function AssetNodeBody({ n, kind }) {
5013
6154
  const assetUrlV = useAssetUrl(n.data.assetId);
5014
6155
  const fileRef = useRef(null);
6156
+ // 视频资产自动提取尾帧:外部导入的视频也能走 last → frame 跨镜衔接(生成后/换视频后触发一次)
6157
+ useEffect(() => {
6158
+ if (kind !== 'video' || !n.data.assetId) return;
6159
+ if (n.data.lastAssetId && n.data.lastAssetVideo === n.data.assetId) return; // 已提取过当前视频
6160
+ let cancelled = false;
6161
+ (async () => {
6162
+ try {
6163
+ const blob = await assetBlob(n.data.assetId);
6164
+ if (!blob || cancelled) return;
6165
+ const fBlob = await extractLastFrame(blob);
6166
+ if (!fBlob || cancelled) return;
6167
+ const rec = await saveAssetRecord(S.activeId, 'image', fBlob, {
6168
+ name: T('lastFrame') + '-' + (n.data.label || n.data.placeholderName || n.id.slice(-4)),
6169
+ prompt: 'last frame of imported video', source: 'upload', category: 'image',
6170
+ });
6171
+ update((s) => {
6172
+ const nd = s.graph.nodes[n.id];
6173
+ if (nd && nd.data.assetId === n.data.assetId) { nd.data.lastAssetId = rec.id; nd.data.lastAssetVideo = n.data.assetId; }
6174
+ s.assetsTick++;
6175
+ });
6176
+ } catch { /* 尾帧提取失败静默:不影响视频预览与 out 口输出 */ }
6177
+ })();
6178
+ return () => { cancelled = true; };
6179
+ }, [kind, n.data.assetId, n.data.label, S.assetsTick]);
5015
6180
  const onFiles = async (e) => {
5016
6181
  const f = e.target.files && e.target.files[0];
5017
6182
  e.target.value = '';
@@ -5026,7 +6191,7 @@ window.__ModuleLoader__.load({
5026
6191
  if (!rec) return;
5027
6192
  update((s) => {
5028
6193
  const nd = s.graph.nodes[n.id];
5029
- if (nd) { nd.data.assetId = rec.id; nd.data.status = 'done'; nd.data.placeholderName = null; }
6194
+ if (nd) { nd.data.assetId = rec.id; nd.data.status = 'done'; nd.data.placeholderName = null; if (kind === 'video') { nd.data.lastAssetId = null; nd.data.lastAssetVideo = null; } }
5030
6195
  s.assetsTick++;
5031
6196
  });
5032
6197
  };
@@ -5047,7 +6212,7 @@ window.__ModuleLoader__.load({
5047
6212
  ),
5048
6213
  h('input', { key: 'file', ref: fileRef, type: 'file', accept: kind === 'image' ? 'image/*' : 'video/*', style: { display: 'none' }, onChange: onFiles }),
5049
6214
  assetUrlV && kind === 'image'
5050
- ? h('img', { key: 'img', className: 'dshic-preview', src: assetUrlV, draggable: false, 'data-no-drag': '1' })
6215
+ ? h('img', { key: 'img', className: 'dshic-preview', src: assetUrlV, draggable: false, 'data-no-drag': '1', style: { cursor: 'zoom-in' }, onPointerDown: (e) => e.stopPropagation(), onClick: (e) => { e.stopPropagation(); openLightbox('image', assetUrlV, n.data.label || n.data.placeholderName); } })
5051
6216
  : null,
5052
6217
  assetUrlV && kind === 'video'
5053
6218
  ? h('video', { key: 'vid', className: 'dshic-video', src: assetUrlV, controls: true, preload: 'metadata', 'data-no-drag': '1', onPointerDown: (e) => e.stopPropagation() })
@@ -5138,6 +6303,55 @@ window.__ModuleLoader__.load({
5138
6303
  );
5139
6304
  }
5140
6305
 
6306
+ /** 剧本输出 → 场景数组:按分场标记(【场N】/ 场N:/ ## 场N / SCENE N)切分;
6307
+ * 尾部的「人物:/场景/道具:」清单从最后一场剥离(资产生产参考,不属于场景正文)。 */
6308
+ function parseScriptScenes(output) {
6309
+ const text = String(output || '').replace(/\r\n/g, '\n');
6310
+ const head = /^\s*(?:【\s*场\s*\d+[^】]*】|【?\s*场\s*\d+\s*】?\s*[::、..]|场\s*\d+\s*[::、..]|#{1,4}\s*场?\s*\d+|SCENE\s*\d+\b)/i;
6311
+ const scenes = [];
6312
+ let cur = null;
6313
+ for (const line of text.split('\n')) {
6314
+ if (head.test(line)) {
6315
+ if (cur && cur.text.trim()) scenes.push(cur);
6316
+ cur = { title: line.trim().replace(/^#{1,4}\s*/, '').slice(0, 60), text: line + '\n' };
6317
+ } else if (cur) {
6318
+ cur.text += line + '\n';
6319
+ }
6320
+ }
6321
+ if (cur && cur.text.trim()) scenes.push(cur);
6322
+ if (scenes.length) {
6323
+ const last = scenes[scenes.length - 1];
6324
+ const i = last.text.search(/\n(?:人物|角色)\s*[::][\s\S]*$/);
6325
+ if (i >= 0) last.text = last.text.slice(0, i) + '\n';
6326
+ }
6327
+ return scenes.filter((s) => s.text.trim());
6328
+ }
6329
+ /** 剧本节点「铺开到场景」:按【场N】分场标记把整集剧本拆成
6330
+ * 「场景文本节点 → 分镜表节点」组合(每场一组,连线一次生成场景分镜;每场 10-15s)。 */
6331
+ function expandScriptToScenes(n) {
6332
+ const scenes = parseScriptScenes(n.data.output);
6333
+ if (scenes.length < 2) { toast(T('expandNeedScenes')); return; }
6334
+ update((s) => {
6335
+ let row = 0;
6336
+ for (const sc of scenes) {
6337
+ const y = n.y + row * 320;
6338
+ const label = sc.title.slice(0, 40);
6339
+ const tx = newNode('text', { x: n.x + n.w + 160, y });
6340
+ tx.data.label = label;
6341
+ tx.data.text = sc.text.trim();
6342
+ s.graph.nodes[tx.id] = tx;
6343
+ const sb = newNode('storyboard', { x: n.x + n.w + 520, y });
6344
+ sb.data.label = label;
6345
+ s.graph.nodes[sb.id] = sb;
6346
+ s.graph.edges.push({ id: uid('e'), fromNode: tx.id, fromPort: 'out', toNode: sb.id, toPort: 'script' });
6347
+ row++;
6348
+ }
6349
+ s.sel = new Set();
6350
+ });
6351
+ pushHistory();
6352
+ flushSave();
6353
+ toast(T('expandScenesDone').replace('{n}', String(scenes.length)));
6354
+ }
5141
6355
  function ScriptNodeBody({ n }) {
5142
6356
  const running = n.data.aiStatus === 'running';
5143
6357
  const generate = async () => {
@@ -5163,6 +6377,11 @@ window.__ModuleLoader__.load({
5163
6377
  : null,
5164
6378
  h('div', { key: 'btns', className: 'dshic-node-row' },
5165
6379
  h('div', { className: 'dshic-flex' }),
6380
+ n.data.output ? h('button', {
6381
+ type: 'button', className: 'dshic-mini is-ghost', title: T('expandScenes') + '(每场 → 文本+分镜表 组合)',
6382
+ onPointerDown: (e) => e.stopPropagation(),
6383
+ onClick: () => expandScriptToScenes(n),
6384
+ }, Ic('expandAll', 11), T('expandScenes')) : null,
5166
6385
  n.data.output ? h('button', {
5167
6386
  type: 'button', className: 'dshic-mini is-ghost', title: T('saveTextAsset'),
5168
6387
  onPointerDown: (e) => e.stopPropagation(),
@@ -5186,6 +6405,19 @@ window.__ModuleLoader__.load({
5186
6405
 
5187
6406
  function StoryboardNodeBody({ n }) {
5188
6407
  const running = n.data.aiStatus === 'running';
6408
+ // 分镜列表高度:右下角手柄拖拽调整(存进节点数据,随画布持久化)
6409
+ const listH = Math.max(80, Math.min(1200, Number(n.data.shotsListH) || 180));
6410
+ const startResize = (e) => {
6411
+ e.stopPropagation(); e.preventDefault();
6412
+ const startY = e.clientY; const startH = listH;
6413
+ const onMove = (ev) => {
6414
+ const nh = Math.max(80, Math.min(1200, startH + (ev.clientY - startY)));
6415
+ update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.shotsListH = nh; });
6416
+ };
6417
+ const onUp = () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); flushSave(); };
6418
+ window.addEventListener('pointermove', onMove);
6419
+ window.addEventListener('pointerup', onUp);
6420
+ };
5189
6421
  const scriptEdge = S.graph.edges.some((x) => x.toNode === n.id && x.toPort === 'script');
5190
6422
  const scriptText = () => {
5191
6423
  if (scriptEdge) {
@@ -5223,8 +6455,8 @@ window.__ModuleLoader__.load({
5223
6455
  }),
5224
6456
  h('div', { key: 'row', className: 'dshic-node-row' },
5225
6457
  h(MiniSelect, {
5226
- value: String(n.data.count || 6), title: T('storyboardCountLabel'), width: 72,
5227
- options: [3, 4, 6, 8, 10, 12, 16].map((x) => ({ value: String(x), label: x + ' ' + T('shotsUnit') })),
6458
+ value: String(n.data.count || 0), title: T('storyboardCountLabel'), width: 88,
6459
+ options: [{ value: '0', label: T('autoShots') }].concat([3, 4, 6, 8, 10, 12, 16].map((x) => ({ value: String(x), label: x + ' ' + T('shotsUnit') }))),
5228
6460
  onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.count = Number(v); }),
5229
6461
  }),
5230
6462
  h('div', { className: 'dshic-flex' }),
@@ -5235,6 +6467,9 @@ window.__ModuleLoader__.load({
5235
6467
  ? h('div', { key: 'stream', className: 'dshic-stream', title: aiStreamLine(n.id) },
5236
6468
  h('span', { className: 'dshic-stream-dot' }), aiStreamLine(n.id))
5237
6469
  : null,
6470
+ n.data.contFrom
6471
+ ? h('div', { key: 'cont', className: 'dshic-hint' }, '↩ ' + T('contFrom') + ':' + n.data.contFrom + ' ' + T('lastFrame'))
6472
+ : null,
5238
6473
  n.data.aiError ? h('div', { key: 'err', className: 'dshic-err' }, n.data.aiError) : null,
5239
6474
  (n.data.warnings || []).length
5240
6475
  ? h('div', { key: 'warn', className: 'dshic-card', style: { padding: 6, gap: 3 } },
@@ -5243,13 +6478,31 @@ window.__ModuleLoader__.load({
5243
6478
  )
5244
6479
  : null,
5245
6480
  shots.length
5246
- ? h('div', { key: 'list', className: 'dshic-shot-list' },
5247
- shots.map((sh, i) => h('div', { key: i, className: 'dshic-shot-item' },
5248
- h('strong', {}, `${sh.title} · ${sh.duration}s `), sh.visual)),
6481
+ ? h('div', { key: 'listwrap', style: { position: 'relative', alignSelf: 'stretch' } },
6482
+ h('div', { key: 'list', className: 'dshic-shot-list', style: { maxHeight: listH + 'px' } },
6483
+ shots.map((sh, i) => h('div', { key: i, className: 'dshic-shot-item', onPointerDown: (e) => e.stopPropagation() },
6484
+ h('strong', {}, `${sh.title} · ${sh.duration}s `), sh.visual,
6485
+ (sh.dialogue || []).length
6486
+ ? h('div', { className: 'dshic-shot-label', style: { marginTop: 3 } }, '台词 ' + sh.dialogue.map((d) => `${d.role}:${d.line}`).join(';'))
6487
+ : null,
6488
+ String(sh.sfx || '').trim()
6489
+ ? h('div', { className: 'dshic-shot-label', style: { marginTop: 2, opacity: 0.85 } }, '音效 ' + String(sh.sfx).trim())
6490
+ : null,
6491
+ )),
6492
+ ),
6493
+ h('div', { key: 'grip', className: 'dshic-shot-grip', title: T('resizeList'), onPointerDown: startResize }, '⌟'),
5249
6494
  )
5250
6495
  : null,
5251
6496
  shots.length
5252
6497
  ? h('div', { key: 'acts', className: 'dshic-node-row', style: { alignSelf: 'flex-start' } },
6498
+ h('button', {
6499
+ key: 'copy', type: 'button', className: 'dshic-mini is-ghost', title: T('copyAll'),
6500
+ onPointerDown: (e) => e.stopPropagation(),
6501
+ onClick: async () => {
6502
+ try { await navigator.clipboard.writeText(storyboardAssetText(shots)); toast(T('copiedOk')); }
6503
+ catch { toast(T('copyFail')); }
6504
+ },
6505
+ }, T('copyAll')),
5253
6506
  // 手动"存为资产":AI 生成已自动入库(category=storyboard),这里用于留存当前分镜的一版快照
5254
6507
  h('button', {
5255
6508
  key: 'save', type: 'button', className: 'dshic-mini is-ghost', title: T('saveTextAsset'),
@@ -5351,6 +6604,7 @@ window.__ModuleLoader__.load({
5351
6604
  .sort((a, b) => (a.nd.y - b.nd.y) || (a.nd.x - b.nd.x));
5352
6605
  const assetUrlV = useAssetUrl(n.data.assetId);
5353
6606
  const fcUrl = useAssetUrl(n.data.fcAssetId);
6607
+ const [voiceDlg, setVoiceDlg] = useState(false);
5354
6608
  useEffect(() => { probeCraft(); }, []);
5355
6609
  const getSub = (x) => {
5356
6610
  const defSub = x.nd.type === 'video-gen' ? (x.nd.data.prompt || '') : (x.nd.data.label || TYPE_LABEL()[x.nd.type]);
@@ -5534,9 +6788,15 @@ window.__ModuleLoader__.load({
5534
6788
  onPointerDown: (e) => e.stopPropagation(),
5535
6789
  onClick: () => downloadBlobFromUrl(fcUrl, 'finalcut-fc-' + n.id.slice(-5) + '.mp4'),
5536
6790
  }, Ic('download', 11), T('download')),
6791
+ h('button', {
6792
+ type: 'button', className: 'dshic-mini is-ghost', title: T('voiceExtractBtn'),
6793
+ onPointerDown: (e) => e.stopPropagation(),
6794
+ onClick: () => setVoiceDlg(true),
6795
+ }, Ic('audio', 11), T('voiceExtractBtn')),
5537
6796
  h('div', { className: 'dshic-flex' })),
5538
6797
  ]
5539
6798
  : null,
6799
+ voiceDlg && (n.data.fcAssetId || n.data.assetId) ? h(VoiceExtractDialog, { key: 'vcdlg', nodeId: n.id, onClose: () => setVoiceDlg(false) }) : null,
5540
6800
  fcDialog ? h(FilmcraftArgsDialog, { key: 'fcdlg', info: fcDialog, onSubmit: submitFc, onClose: () => setFcDialog(null) }) : null,
5541
6801
  assetUrlV
5542
6802
  ? [
@@ -6146,6 +7406,13 @@ window.__ModuleLoader__.load({
6146
7406
  )
6147
7407
  : null,
6148
7408
  h('div', { className: 'dshic-asset-actions' },
7409
+ (a.kind === 'image' || a.kind === 'video')
7410
+ ? h('button', {
7411
+ type: 'button', className: 'dshic-iconbtn', title: T('zoomView'),
7412
+ onClick: (e) => { e.stopPropagation(); openLightbox(a.kind, url, a.name); },
7413
+ onPointerDown: (e) => e.stopPropagation(),
7414
+ }, Ic('expandAll', 11))
7415
+ : null,
6149
7416
  a.kind === 'image'
6150
7417
  ? h('button', {
6151
7418
  type: 'button', className: 'dshic-iconbtn', title: T('photoCraft'),
@@ -6286,6 +7553,111 @@ window.__ModuleLoader__.load({
6286
7553
  style: { flex: 'none', width: 14, height: 14, lineHeight: '13px', textAlign: 'center', fontSize: 10, borderRadius: '50%', border: '1px solid var(--dsw-alias-border-secondary, #777)', color: 'var(--dsw-alias-label-secondary, #999)', cursor: 'help', userSelect: 'none', opacity: 0.85 },
6287
7554
  }, 'i');
6288
7555
  }
7556
+ /** 图片/视频模型配置(供应商 + 模型两级):一个供应商配一次 URL/Key,下面挂多个模型;
7557
+ * ★星标模型 = 节点未指定时的默认路由。 */
7558
+ function ModelProviderEditor({ kind }) {
7559
+ const [s, setS] = useState(loadSettings);
7560
+ const [openId, setOpenId] = useState(null);
7561
+ const listKey = kind === 'image' ? 'imageProviders' : 'videoProviders';
7562
+ const starKey = kind === 'image' ? 'imageStar' : 'videoStar';
7563
+ const icon = kind === 'image' ? 'image' : 'video';
7564
+ const labelKey = kind === 'image' ? 'imageApisLabel' : 'videoApisLabel';
7565
+ const nameKey = kind === 'image' ? 'defaultImageCfg' : 'defaultVideoCfg';
7566
+ const urlKey = kind === 'image' ? 'baseUrl' : 'url';
7567
+ const urlPh = kind === 'image'
7568
+ ? 'https://api.example.com/v1(或完整 /images/generations 端点)'
7569
+ : 'https://api.example.com/v1/video/generations(或裸域 / /v1)';
7570
+ const persist = (next) => { setS(next); saveSettings(next); };
7571
+ const mutate = (fn) => { const next = JSON.parse(JSON.stringify(s)); fn(next); persist(next); };
7572
+ const addProvider = () => {
7573
+ const id = uid('prov');
7574
+ mutate((d) => { d[listKey] = (d[listKey] || []).concat([{ id, name: T(nameKey) + ' ' + ((d[listKey] || []).length + 1), [urlKey]: '', apiKey: '', extra: '', models: [], ...(kind === 'image' ? { mode: 'generations' } : {}) }]); });
7575
+ setOpenId(id);
7576
+ };
7577
+ const removeProvider = (id) => {
7578
+ mutate((d) => { d[listKey] = (d[listKey] || []).filter((x) => x.id !== id); if (d[starKey] && d[starKey].startsWith(id + '|')) d[starKey] = ''; });
7579
+ if (openId === id) setOpenId(null);
7580
+ };
7581
+ const patchProvider = (id, k, v) => mutate((d) => { d[listKey] = (d[listKey] || []).map((x) => (x.id === id ? { ...x, [k]: v } : x)); });
7582
+ const addModel = (pid) => mutate((d) => { const p = (d[listKey] || []).find((x) => x.id === pid); if (p) p.models = (p.models || []).concat([{ id: uid('m'), model: '' }]); });
7583
+ const patchModel = (pid, mid, k, v) => mutate((d) => { const p = (d[listKey] || []).find((x) => x.id === pid); if (!p) return; p.models = (p.models || []).map((x) => (x.id === mid ? { ...x, [k]: v } : x)); });
7584
+ const removeModel = (pid, mid) => mutate((d) => {
7585
+ const p = (d[listKey] || []).find((x) => x.id === pid); if (!p) return;
7586
+ p.models = (p.models || []).filter((x) => x.id !== mid);
7587
+ if (d[starKey] === pid + '|' + mid) d[starKey] = '';
7588
+ });
7589
+ const toggleStar = (pid, mid) => { const ref = pid + '|' + mid; mutate((d) => { d[starKey] = d[starKey] === ref ? '' : ref; }); };
7590
+ const list = s[listKey] || [];
7591
+ const modelCount = (p) => (p.models || []).filter((m) => String(m.model || '').trim()).length;
7592
+ return h('div', { className: 'dshic-card' },
7593
+ h('div', { className: 'dshic-row' },
7594
+ Ic(icon, 12),
7595
+ h('strong', { style: { fontSize: 12, flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, T(labelKey)),
7596
+ h('span', { className: 'dshic-tag st' }, String(list.length)),
7597
+ h('button', { type: 'button', className: 'dshic-mini', onClick: addProvider, onPointerDown: (e) => e.stopPropagation() }, Ic('plus', 11), T('addProvider')),
7598
+ ),
7599
+ list.length === 0 ? h('div', { className: 'dshic-hint' }, T('noCfgHint')) : null,
7600
+ list.map((p) => {
7601
+ const open = openId === p.id;
7602
+ return h('div', { key: p.id, className: 'dshic-card', style: { padding: open ? 8 : 5, gap: 6, background: 'var(--dsw-alias-bg-layer-1)' } },
7603
+ h('div', { className: 'dshic-row' },
7604
+ h('button', { type: 'button', className: 'dshic-iconbtn', title: open ? T('collapse') : T('unfold'), onPointerDown: (e) => e.stopPropagation(), onClick: () => setOpenId(open ? null : p.id) }, open ? '▾' : '▸'),
7605
+ open
7606
+ ? h('input', { className: 'dshic-input', style: { flex: 1, minWidth: 0, fontWeight: 600 }, value: p.name || '', placeholder: T('cfgName'), autoFocus: true, onChange: (e) => patchProvider(p.id, 'name', e.target.value), onPointerDown: (e) => e.stopPropagation() })
7607
+ : h('span', { style: { flex: 1, minWidth: 0, fontSize: 12, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis', cursor: 'pointer', color: 'var(--dsw-alias-label-primary)' }, onClick: () => setOpenId(p.id), onPointerDown: (e) => e.stopPropagation(), title: (p[urlKey] || '—') }, p.name || T('cfgName')),
7608
+ h('span', { className: 'dshic-tag st' }, T('modelsLabel') + ' ' + modelCount(p)),
7609
+ h('button', { type: 'button', className: 'dshic-iconbtn', title: T('doDelete'), onPointerDown: (e) => e.stopPropagation(), onClick: () => removeProvider(p.id) }, '×'),
7610
+ ),
7611
+ open ? [
7612
+ kind === 'video' ? h('div', { key: 'vendor', className: 'dshic-field' },
7613
+ h('span', { className: 'dshic-field-label' }, T('vendorLabel')),
7614
+ h('select', {
7615
+ className: 'dshic-select', value: detectVideoVendor(p),
7616
+ onPointerDown: (e) => e.stopPropagation(),
7617
+ onChange: (e) => { const v = e.target.value; const vd = VIDEO_VENDORS[v]; patchProvider(p.id, 'vendor', v); if (vd && vd.base && !p[urlKey]) patchProvider(p.id, urlKey, vd.base); },
7618
+ },
7619
+ Object.entries(VIDEO_VENDORS).map(([id, vd]) => h('option', { key: id, value: id }, vd.label))),
7620
+ h('div', { className: 'dshic-hint' }, (VIDEO_VENDORS[detectVideoVendor(p)] || {}).note || ''),
7621
+ ) : null,
7622
+ h(Field, { key: 'u', label: T('baseUrl'), value: p[urlKey] || '', onChange: (v) => patchProvider(p.id, urlKey, v), placeholder: kind === 'video' ? ((VIDEO_VENDORS[detectVideoVendor(p)] || {}).base || urlPh) : urlPh }),
7623
+ h(Field, { key: 'k', label: T('apiKey'), value: p.apiKey || '', onChange: (v) => patchProvider(p.id, 'apiKey', v), placeholder: kind === 'video' ? ((VIDEO_VENDORS[detectVideoVendor(p)] || {}).keyPh || 'sk-…') : 'sk-…', password: true }),
7624
+ kind === 'image' ? [
7625
+ h(Field, { key: 'size', label: T('imgSizeOverride'), value: p.size || '', onChange: (v) => patchProvider(p.id, 'size', v), placeholder: T('imgSizePlaceholder') }),
7626
+ h(Field, { key: 'rf', label: T('imgResponseFormat'), value: p.responseFormat || '', onChange: (v) => patchProvider(p.id, 'responseFormat', v), placeholder: 'b64_json / url(留空=API 默认)' }),
7627
+ h('div', { key: 'mode', className: 'dshic-field' },
7628
+ h('span', { className: 'dshic-field-label' }, T('imgMode')),
7629
+ h('select', { className: 'dshic-select', value: p.mode || 'generations', onChange: (e) => patchProvider(p.id, 'mode', e.target.value), onPointerDown: (e) => e.stopPropagation() },
7630
+ h('option', { value: 'generations' }, T('modeGenerations')),
7631
+ h('option', { value: 'edits' }, T('modeEdits')))),
7632
+ ] : [
7633
+ (detectVideoVendor(p) === 'openai' || detectVideoVendor(p) === 'seedance') ? h(Field, { key: 'of', label: T('vidOutFormat'), value: p.outputFormat || '', onChange: (v) => patchProvider(p.id, 'outputFormat', v), placeholder: 'mp4(留空不发送)' }) : null,
7634
+ (detectVideoVendor(p) === 'openai' || detectVideoVendor(p) === 'seedance') ? h('label', { key: 'ga', className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
7635
+ h('input', { type: 'checkbox', checked: p.generateAudio === true, onChange: (e) => patchProvider(p.id, 'generateAudio', e.target.checked), onPointerDown: (e) => e.stopPropagation() }),
7636
+ h('span', {}, T('vidAudioOpt'))) : null,
7637
+ ],
7638
+ (kind === 'image' || detectVideoVendor(p) === 'openai' || detectVideoVendor(p) === 'seedance') ? [
7639
+ h(Field, { key: 'extra', label: T('cfgExtra'), value: p.extra || '', onChange: (v) => patchProvider(p.id, 'extra', v), placeholder: '{"watermark":false,"prompt_extend":false}' }),
7640
+ h('div', { className: 'dshic-hint' }, T('cfgExtraHint')),
7641
+ ] : null,
7642
+ h('div', { className: 'dshic-row', style: { marginTop: 4 } },
7643
+ h('span', { className: 'dshic-field-label', style: { flex: 1, minWidth: 0 } }, T('modelsLabel')),
7644
+ h('button', { type: 'button', className: 'dshic-mini', onPointerDown: (e) => e.stopPropagation(), onClick: () => addModel(p.id) }, Ic('plus', 11), T('addModel')),
7645
+ ),
7646
+ (p.models || []).map((m) => h('div', { key: m.id, className: 'dshic-row' },
7647
+ h('button', {
7648
+ type: 'button', className: 'dshic-mini is-ghost', title: T('starDefault'),
7649
+ style: { color: s[starKey] === p.id + '|' + m.id ? 'var(--dsw-alias-state-warn-primary)' : 'inherit' },
7650
+ onPointerDown: (e) => e.stopPropagation(), onClick: () => toggleStar(p.id, m.id),
7651
+ }, s[starKey] === p.id + '|' + m.id ? '★' : '☆'),
7652
+ h('input', { className: 'dshic-input', style: { flex: 1, minWidth: 0 }, value: m.model || '', placeholder: T('modelNamePh'), onChange: (e) => patchModel(p.id, m.id, 'model', e.target.value), onPointerDown: (e) => e.stopPropagation() }),
7653
+ h('button', { type: 'button', className: 'dshic-iconbtn', title: T('doDelete'), onPointerDown: (e) => e.stopPropagation(), onClick: () => removeModel(p.id, m.id) }, '×'),
7654
+ )),
7655
+ !(p.models || []).length ? h('div', { className: 'dshic-hint' }, T('noModelsCfg')) : null,
7656
+ ] : null,
7657
+ );
7658
+ }),
7659
+ );
7660
+ }
6289
7661
  function ApiListEditor({ kind }) {
6290
7662
  const [s, setS] = useState(loadSettings);
6291
7663
  const [openId, setOpenId] = useState(null);
@@ -6395,7 +7767,7 @@ window.__ModuleLoader__.load({
6395
7767
  : null,
6396
7768
  kind === 'video'
6397
7769
  ? h('div', { key: 'proto', className: 'dshic-field' },
6398
- h('span', { className: 'dshic-field-label' }, T('vidProtocol')),
7770
+ h('span', { className: 'dshic-field-label' }, T('vidProtocol'), InfoTip(T('vidProtocolHint'))),
6399
7771
  h('select', {
6400
7772
  className: 'dshic-select', value: cfg.protocol || 'generic',
6401
7773
  onChange: (e) => patch(cfg.id, 'protocol', e.target.value),
@@ -6404,27 +7776,36 @@ window.__ModuleLoader__.load({
6404
7776
  h('option', { value: 'generic' }, T('protoGeneric')),
6405
7777
  h('option', { value: 'seedance' }, T('protoSeedance'))))
6406
7778
  : null,
7779
+ kind === 'video'
7780
+ ? h('label', { key: 'ga', className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
7781
+ h('input', {
7782
+ type: 'checkbox', checked: cfg.generateAudio === true,
7783
+ onChange: (e) => patch(cfg.id, 'generateAudio', e.target.checked),
7784
+ onPointerDown: (e) => e.stopPropagation(),
7785
+ }),
7786
+ h('span', {}, T('vidAudioOpt')))
7787
+ : null,
7788
+ kind === 'video'
7789
+ ? h(Field, { key: 'of', label: T('vidOutFormat'), value: cfg.outputFormat || '', onChange: (v) => patch(cfg.id, 'outputFormat', v), placeholder: 'mp4(留空不发送)' })
7790
+ : null,
7791
+ kind === 'video' && cfg.protocol === 'seedance'
7792
+ ? h('label', { key: 'wm', className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
7793
+ h('input', {
7794
+ type: 'checkbox', checked: cfg.watermark === true,
7795
+ onChange: (e) => patch(cfg.id, 'watermark', e.target.checked),
7796
+ onPointerDown: (e) => e.stopPropagation(),
7797
+ }),
7798
+ h('span', {}, T('seedanceWatermark')))
7799
+ : null,
7800
+ (kind === 'image' || kind === 'video')
7801
+ ? h(Field, { key: 'extra', label: T('cfgExtra'), value: cfg.extra || '', onChange: (v) => patch(cfg.id, 'extra', v), placeholder: '{"watermark":false,"prompt_extend":false}' })
7802
+ : null,
7803
+ (kind === 'image' || kind === 'video')
7804
+ ? h('div', { className: 'dshic-hint' }, T('cfgExtraHint'))
7805
+ : null,
6407
7806
  kind === 'tts'
6408
7807
  ? h(Field, { key: 'voice', label: T('voice'), value: cfg.voice || '', onChange: (v) => patch(cfg.id, 'voice', v), placeholder: 'alloy' })
6409
7808
  : null,
6410
- (kind === 'video' && (cfg.protocol === 'seedance'))
6411
- ? h('div', { key: 'seedopts', className: 'dshic-field' },
6412
- h('label', { className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
6413
- h('input', {
6414
- type: 'checkbox', checked: cfg.generateAudio !== false,
6415
- onChange: (e) => patch(cfg.id, 'generateAudio', e.target.checked),
6416
- onPointerDown: (e) => e.stopPropagation(),
6417
- }),
6418
- h('span', {}, T('seedanceAudio'))),
6419
- h('label', { className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
6420
- h('input', {
6421
- type: 'checkbox', checked: cfg.watermark === true,
6422
- onChange: (e) => patch(cfg.id, 'watermark', e.target.checked),
6423
- onPointerDown: (e) => e.stopPropagation(),
6424
- }),
6425
- h('span', {}, T('seedanceWatermark'))),
6426
- h('div', { className: 'dshic-hint' }, T('seedanceHint')))
6427
- : null,
6428
7809
  ] : null,
6429
7810
  );
6430
7811
  }),
@@ -6616,19 +7997,23 @@ window.__ModuleLoader__.load({
6616
7997
  if (busy) return;
6617
7998
  setBusy(true);
6618
7999
  try {
8000
+ const same = S.dataRoot && val.trim() && S.dataRoot === val.trim().replace(/[\\/]+$/, '');
6619
8001
  await hostRpc('dataRootSet', { root: val.trim() });
6620
8002
  await refreshDataRoot();
6621
8003
  toast(T('dataRootSaved') + (S.dataRoot ? ':' + S.dataRoot : ''));
8004
+ if (same) { setBusy(false); return; } // 同目录重复保存:不重建索引(避免误清数据目录里手工补充的资产记录)
6622
8005
  assetIndexSynced = false; // 换了目录:索引要往新目录重建
6623
8006
  autoMigrateAssetsToRoot().then(() => syncAssetIndexToHost()); // 先搬存量资产,再对齐索引
6624
8007
  } catch (e) { toast('设置失败:' + ((e && e.message) || e)); }
6625
8008
  setBusy(false);
6626
8009
  };
6627
8010
  const applyRoot = async (root) => {
8011
+ const same = S.dataRoot && root && S.dataRoot === String(root).replace(/[\\/]+$/, '');
6628
8012
  await hostRpc('dataRootSet', { root });
6629
8013
  await refreshDataRoot();
6630
8014
  setVal(S.dataRoot || root);
6631
8015
  toast(T('dataRootSaved') + ':' + S.dataRoot);
8016
+ if (same) return; // 同目录:不重建索引
6632
8017
  assetIndexSynced = false; // 换了目录:索引要往新目录重建
6633
8018
  autoMigrateAssetsToRoot().then(() => syncAssetIndexToHost());
6634
8019
  };
@@ -6651,6 +8036,8 @@ window.__ModuleLoader__.load({
6651
8036
  if (busy || picking) return;
6652
8037
  setBusy(true);
6653
8038
  try {
8039
+ if (!S.dataRoot) { await refreshDataRoot(); } // 未就绪先拉一次;仍无目录才提示(不必先点"设置此目录")
8040
+ if (!S.dataRoot) { toast(T('dataRootHint')); setBusy(false); return; }
6654
8041
  const r = await hostRpc('dataRootImport');
6655
8042
  // 资产元数据 → IndexedDB(本机已存在的同 id 资产跳过,绝不覆盖)
6656
8043
  let assets = 0;
@@ -6692,8 +8079,7 @@ window.__ModuleLoader__.load({
6692
8079
  picking ? h('div', { className: 'dshic-hint', key: 'picking' }, T('dataRootPicking')) : null,
6693
8080
  h('div', { className: 'dshic-row', key: 'row2' },
6694
8081
  S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini', onPointerDown: (e) => e.stopPropagation(), onClick: openDir }, T('dataRootOpen')) : null,
6695
- S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini is-ghost', disabled: busy || picking, onPointerDown: (e) => e.stopPropagation(), onClick: importFromRoot }, Ic('folder', 11), T('dataRootImportBtn')) : null,
6696
- S.dataRoot ? InfoTip(T('dataRootImportHint')) : null,
8082
+ h('button', { type: 'button', className: 'dshic-mini is-ghost', disabled: busy || picking, title: T('dataRootImportHint'), onPointerDown: (e) => e.stopPropagation(), onClick: importFromRoot }, Ic('folder', 11), T('dataRootImportBtn')),
6697
8083
  h('div', { className: 'dshic-flex' }),
6698
8084
  ),
6699
8085
  ],
@@ -6747,8 +8133,8 @@ window.__ModuleLoader__.load({
6747
8133
  ),
6748
8134
  h(LlmModelSelect),
6749
8135
  ),
6750
- h(ApiListEditor, { key: 'imglist', kind: 'image' }),
6751
- h(ApiListEditor, { key: 'vidlist', kind: 'video' }),
8136
+ h(ModelProviderEditor, { key: 'imglist', kind: 'image' }),
8137
+ h(ModelProviderEditor, { key: 'vidlist', kind: 'video' }),
6752
8138
  h(ApiListEditor, { key: 'ttslist', kind: 'tts' }),
6753
8139
  h('div', { key: 'note', className: 'dshic-hint' }, 'DeepSeek Harness · dsh-infinite-canvas' + ((S.updateInfo && S.updateInfo.runningVersion) ? ' v' + S.updateInfo.runningVersion : '')),
6754
8140
  ];
@@ -7247,6 +8633,7 @@ window.__ModuleLoader__.load({
7247
8633
  S.rightOpen ? h(Rightbar) : null,
7248
8634
  ),
7249
8635
  S.menu ? h(ContextMenu, { menu: S.menu }) : null,
8636
+ S.lightbox ? h(Lightbox) : null,
7250
8637
  S._toast ? h('div', {
7251
8638
  style: {
7252
8639
  position: 'fixed', bottom: 22, left: '50%', transform: 'translateX(-50%)', zIndex: 70,
@@ -7258,6 +8645,32 @@ window.__ModuleLoader__.load({
7258
8645
  );
7259
8646
  }
7260
8647
 
8648
+ // ============================================================
8649
+ // 灯箱:图片/视频放大查看(节点预览与资产面板共用)
8650
+ // ============================================================
8651
+ function openLightbox(kind, url, name) {
8652
+ if (!url) return;
8653
+ update((s) => { s.lightbox = { kind, url, name: String(name || '') }; });
8654
+ }
8655
+ function closeLightbox() {
8656
+ update((s) => { s.lightbox = null; });
8657
+ }
8658
+ function Lightbox() {
8659
+ const lb = S.lightbox;
8660
+ useEffect(() => {
8661
+ const onKey = (e) => { if (e.key === 'Escape') closeLightbox(); };
8662
+ window.addEventListener('keydown', onKey);
8663
+ return () => window.removeEventListener('keydown', onKey);
8664
+ }, []);
8665
+ return h('div', { className: 'dshic-lightbox', onPointerDown: (e) => { if (e.target === e.currentTarget) closeLightbox(); } },
8666
+ h('div', { className: 'dshic-lightbox-cap' }, (lb.name ? lb.name + ' · ' : '') + T('lbHint')),
8667
+ lb.kind === 'video'
8668
+ ? h('video', { src: lb.url, controls: true, preload: 'metadata', style: { maxWidth: '92vw', maxHeight: '80vh' } })
8669
+ : h('img', { src: lb.url, draggable: false, style: { maxWidth: '92vw', maxHeight: '80vh', objectFit: 'contain' } }),
8670
+ h('button', { type: 'button', className: 'dshic-mini is-ghost', style: { color: '#eee', borderColor: 'rgba(255,255,255,.35)' }, onClick: closeLightbox }, '✕'),
8671
+ );
8672
+ }
8673
+
7261
8674
  // ============================================================
7262
8675
  // 侧栏图标
7263
8676
  // ============================================================