@ninkch/dsh-infinite-canvas 0.17.9 → 0.17.11

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
@@ -106,6 +106,22 @@ window.__ModuleLoader__.load({
106
106
  r.readAsDataURL(blob);
107
107
  });
108
108
  }
109
+ /** 参考图/首帧压缩:长边 ≤ maxEdge、JPEG 重编码。
110
+ * 参考图不需要 2K 原图——多张 2K PNG base64 后会击穿 Host 代理 16MB 上限(HTTP 413)。
111
+ * 画布/解码失败时回退原始 base64。 */
112
+ async function blobToDataUrlScaled(blob, maxEdge = 1024) {
113
+ try {
114
+ const bmp = await createImageBitmap(blob);
115
+ const scale = Math.min(1, maxEdge / Math.max(bmp.width, bmp.height));
116
+ const w = Math.max(1, Math.round(bmp.width * scale));
117
+ const h = Math.max(1, Math.round(bmp.height * scale));
118
+ const c = document.createElement('canvas');
119
+ c.width = w; c.height = h;
120
+ c.getContext('2d').drawImage(bmp, 0, 0, w, h);
121
+ bmp.close && bmp.close();
122
+ return c.toDataURL('image/jpeg', 0.85);
123
+ } catch { return await blobToDataUrl(blob); }
124
+ }
109
125
  function downloadBlob(blob, name) {
110
126
  const url = URL.createObjectURL(blob);
111
127
  const a = document.createElement('a');
@@ -299,6 +315,43 @@ window.__ModuleLoader__.load({
299
315
  'shotsUnit': '镜',
300
316
  'savedOk': '已存为资产',
301
317
  'warnUnregistered': '未在设定集/资产中登记',
318
+ 'warnScriptTooLong': '剧本较长({n} 字)≈ 超过单分镜 10-15s 成片容量:请按场景拆分为多个场景剧本节点(每个分镜只连一个场景剧本,长剧本内容会被 24 镜上限截断)',
319
+ 'warnEmptyDesc': '已登记但描述为空(待补全)',
320
+ 'catMention': '提及',
321
+ 'assetCategoriesFixed': '已按命名规范纠正 {n} 个资产的分类',
322
+ 'cfgExtra': '扩展参数(JSON,可选)',
323
+ 'cfgExtraHint': 'JSON 对象,逐键合并进请求体(留空不发送)——厂商专属参数如 watermark、prompt_extend、output_format、seed 等在此填写,换厂商无需改代码',
324
+ 'vidProtocolHint': '通用 = OpenAI 风格任务接口(JUAI 等聚合平台,URL 填完整 /v1/video/generations);Seedance 官方 = 火山方舟 contents/generations/tasks',
325
+ 'vidAudioOpt': '生成同步音频(勾选才发送 generate_audio)',
326
+ 'vidOutFormat': '输出格式 output_format(留空不发送)',
327
+ 'voiceExtractBtn': '提取声音参考',
328
+ 'voiceRole': '角色名',
329
+ 'voiceRolePh': '陈拾',
330
+ 'voiceRoleNeeded': '需要角色名',
331
+ 'voiceStart': '开始秒',
332
+ 'voiceEnd': '结束秒(留空 = 到片尾)',
333
+ 'voiceTip': '选一段"该角色独占台词"的窗口(多角色同屏的段落不适合做声音参考)。片段将存为「角色-<名> 声音」并绑定实体,之后 @<名> 自动携带音色参考。需要降噪/精细分离时可在 Craft(SoundCraft)中处理后再按同一规范导入。',
334
+ 'voiceExtractDone': '已存为声音参考:{name}',
335
+ 'voiceExtractSrc': '提取自',
336
+ 'expandScenes': '铺开到场景',
337
+ 'expandScenesDone': '已铺开 {n} 个场景(每场:文本节点 → 分镜表 已连线)',
338
+ 'expandNeedScenes': '未识别到场景分隔标记——剧本需按「【场N】场景名(地点·时间)」分场书写(重新生成或手动补分场标题)',
339
+ 'autoShots': '自动(按内容节拍)',
340
+ 'voiceAutoHint': '输入角色名 → 自动按分镜台词定位该角色的全部时间窗,并拼接为一个声音参考文件(无需手动找时间点)',
341
+ 'voiceRolesInShots': '分镜台词中的角色:',
342
+ 'voiceSegs': '命中镜头',
343
+ 'voiceNoDialogue': '未找到分镜表或分镜表无镜头——自动定位需要分镜台词标注,请先生成/铺开分镜',
344
+ 'voiceManual': '手动指定时间窗',
345
+ 'voiceRoleNotFound': '分镜台词中没有该角色。可选角色:{roles}',
346
+ 'providerLabel': '供应商',
347
+ 'addProvider': '新增供应商',
348
+ 'addModel': '添加模型',
349
+ 'modelsLabel': '模型',
350
+ 'modelNamePh': '模型 ID(如 doubao-seedance-2.0)',
351
+ 'starDefault': '设为默认模型(节点未指定时自动路由到这里)',
352
+ 'noModelsCfg': '未配置模型——节点运行将报错',
353
+ 'errNoImageModel': '未指定图片模型:请在设置里配置图片模型并星标默认模型,或在节点上选择模型',
354
+ 'errNoVideoModel': '未指定视频模型:请在设置里配置视频模型并星标默认模型,或在节点上选择模型',
302
355
  'consistencyTitle': '一致性检查',
303
356
  'restartHint': '插件后台尚未加载最新版本:请重启一次 DeepSeek Harness 后重试(仅首次更新需要)',
304
357
  'rpcFail': '请求失败',
@@ -636,6 +689,43 @@ window.__ModuleLoader__.load({
636
689
  'shotsUnit': 'shots',
637
690
  'savedOk': 'Saved as asset',
638
691
  'warnUnregistered': 'not in bible/assets',
692
+ '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)',
693
+ 'warnEmptyDesc': 'registered but description is empty (fill it in)',
694
+ 'catMention': 'Mention',
695
+ 'assetCategoriesFixed': 'Re-categorized {n} assets by naming convention',
696
+ 'cfgExtra': 'Extra params (JSON, optional)',
697
+ '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',
698
+ 'vidProtocolHint': 'Generic = OpenAI-style task API (JUAI etc., URL = full /v1/video/generations); Seedance official = Volcano Ark contents/generations/tasks',
699
+ 'vidAudioOpt': 'Generate synced audio (only sent when checked)',
700
+ 'vidOutFormat': 'Output format output_format (empty = not sent)',
701
+ 'voiceExtractBtn': 'Extract voice reference',
702
+ 'voiceRole': 'Character',
703
+ 'voiceRolePh': 'e.g. Chen Shi',
704
+ 'voiceRoleNeeded': 'Character name required',
705
+ 'voiceStart': 'Start (s)',
706
+ 'voiceEnd': 'End (s, empty = to end)',
707
+ '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.',
708
+ 'voiceExtractDone': 'Saved voice reference: {name}',
709
+ 'voiceExtractSrc': 'Extracted from',
710
+ 'expandScenes': 'Expand to scenes',
711
+ 'expandScenesDone': 'Expanded {n} scenes (each: text node → storyboard, wired)',
712
+ 'expandNeedScenes': 'No scene separators found — the script must use "【SCENE N】name (location · time)" per-scene headers (regenerate or add scene headers manually)',
713
+ 'autoShots': 'Auto (by content beats)',
714
+ 'voiceAutoHint': 'Type the character name — all dialogue windows for that role are located automatically from the storyboard and merged into ONE voice reference file',
715
+ 'voiceRolesInShots': 'Characters found in shot dialogue: ',
716
+ 'voiceSegs': 'Matched shots',
717
+ 'voiceNoDialogue': 'No storyboard with shots found — auto location needs the dialogue annotations; generate the storyboard first',
718
+ 'voiceManual': 'Manual time window',
719
+ 'voiceRoleNotFound': 'Role not found in shot dialogue. Available: {roles}',
720
+ 'providerLabel': 'Provider',
721
+ 'addProvider': 'Add provider',
722
+ 'addModel': 'Add model',
723
+ 'modelsLabel': 'Models',
724
+ 'modelNamePh': 'Model id (e.g. doubao-seedance-2.0)',
725
+ 'starDefault': 'Set as default model (nodes without a selection route here)',
726
+ 'noModelsCfg': 'No models configured — node runs will fail',
727
+ 'errNoImageModel': 'No image model selected: configure image models in Settings and star a default, or pick one on the node',
728
+ 'errNoVideoModel': 'No video model selected: configure video models in Settings and star a default, or pick one on the node',
639
729
  'consistencyTitle': 'Consistency check',
640
730
  'restartHint': 'The plugin backend is not loaded yet: restart DeepSeek Harness once and retry (first update only)',
641
731
  'rpcFail': 'Request failed',
@@ -1106,11 +1196,12 @@ window.__ModuleLoader__.load({
1106
1196
  const DEFAULT_SETTINGS = {
1107
1197
  // 文本模型全部用 Harness 的当前模型与默认参数:画布只选择模型,不单独配置生成参数
1108
1198
  textLlm: { provider: '', model: '' },
1109
- imageApis: [], // [{id, name, baseUrl, apiKey, model, mode, size?, responseFormat?}]
1110
- videoApis: [], // [{id, name, url, apiKey, model, protocol?, watermark?, generateAudio?}]
1199
+ // 供应商 + 模型两级:一个供应商(URL/Key)配一次,下面挂多个模型;★星标模型 = 节点未指定时的默认路由
1200
+ imageProviders: [], // [{id, name, baseUrl, apiKey, extra, size?, responseFormat?, mode?, models: [{id, model}]}]
1201
+ videoProviders: [], // [{id, name, url, apiKey, extra, outputFormat?, generateAudio?, protocol?, watermark?, models: [{id, model}]}]
1202
+ imageStar: '', // 'providerId|modelId'
1203
+ videoStar: '',
1111
1204
  ttsApis: [], // [{id, name, baseUrl, apiKey, model, voice}]
1112
- imageApiId: '',
1113
- videoApiId: '',
1114
1205
  ttsApiId: '',
1115
1206
  lang: '', // '' = 跟随 Harness
1116
1207
  skillScript: '', // '' = 内置编剧专家
@@ -1120,11 +1211,11 @@ window.__ModuleLoader__.load({
1120
1211
  const s = lsGet(LS_SETTINGS, {}) || {};
1121
1212
  const out = {
1122
1213
  textLlm: { provider: (s.textLlm && s.textLlm.provider) || '', model: (s.textLlm && s.textLlm.model) || '' },
1123
- imageApis: Array.isArray(s.imageApis) ? s.imageApis : [],
1124
- videoApis: Array.isArray(s.videoApis) ? s.videoApis : [],
1214
+ imageProviders: Array.isArray(s.imageProviders) ? s.imageProviders : [],
1215
+ videoProviders: Array.isArray(s.videoProviders) ? s.videoProviders : [],
1216
+ imageStar: s.imageStar || '',
1217
+ videoStar: s.videoStar || '',
1125
1218
  ttsApis: Array.isArray(s.ttsApis) ? s.ttsApis : [],
1126
- imageApiId: s.imageApiId || '',
1127
- videoApiId: s.videoApiId || '',
1128
1219
  ttsApiId: s.ttsApiId || '',
1129
1220
  lang: s.lang || '',
1130
1221
  skillScript: s.skillScript || '',
@@ -1133,25 +1224,97 @@ window.__ModuleLoader__.load({
1133
1224
  // v0.3 及以前的单配置自动迁移为列表
1134
1225
  // 注意:id 必须**确定性**——loadSettings 是高频调用(每次渲染/生成都会走),
1135
1226
  // 若这里用 uid() 每次生成新 id,节点上保存的 apiId 会永远匹配不上、默认项也会漂移
1136
- if (!out.imageApis.length && s.imageApi && s.imageApi.baseUrl) {
1137
- out.imageApis = [{
1227
+ if (!out.imageProviders.length && !out.imageApis?.length && s.imageApi && s.imageApi.baseUrl) {
1228
+ out.imageProviders = [{
1138
1229
  id: 'legacy-image', name: T('defaultImageCfg'),
1139
1230
  baseUrl: s.imageApi.baseUrl || '', apiKey: s.imageApi.apiKey || '',
1140
- model: s.imageApi.model || '', mode: s.imageApi.mode || 'generations',
1231
+ mode: s.imageApi.mode || 'generations', models: [{ id: 'legacy-image-m', model: s.imageApi.model || '' }],
1141
1232
  }];
1142
- out.imageApiId = s.imageApiId || 'legacy-image';
1233
+ out.imageStar = 'legacy-image|legacy-image-m';
1143
1234
  }
1144
- if (!out.videoApis.length && s.videoApi && s.videoApi.url) {
1145
- out.videoApis = [{
1235
+ if (!out.videoProviders.length && !out.videoApis?.length && s.videoApi && s.videoApi.url) {
1236
+ out.videoProviders = [{
1146
1237
  id: 'legacy-video', name: T('defaultVideoCfg'),
1147
- url: s.videoApi.url || '', apiKey: s.videoApi.apiKey || '', model: s.videoApi.model || '',
1238
+ url: s.videoApi.url || '', apiKey: s.videoApi.apiKey || '',
1239
+ models: [{ id: 'legacy-video-m', model: s.videoApi.model || '' }],
1148
1240
  }];
1149
- out.videoApiId = s.videoApiId || 'legacy-video';
1241
+ out.videoStar = 'legacy-video|legacy-video-m';
1242
+ }
1243
+ // v0.17.x 扁平 API(一配置一模型)→ 供应商 + 模型列表:
1244
+ // 派生 id 确定性沿用旧 cfg id,节点上的旧 apiId 可通过 legacy 映射懒迁移
1245
+ if (!out.imageProviders.length && Array.isArray(s.imageApis) && s.imageApis.length) {
1246
+ out.imageProviders = s.imageApis.map((cfg) => ({
1247
+ id: 'p_' + cfg.id, name: cfg.name || T('defaultImageCfg'),
1248
+ baseUrl: cfg.baseUrl || '', apiKey: cfg.apiKey || '', extra: cfg.extra || '',
1249
+ size: cfg.size || '', responseFormat: cfg.responseFormat || '', mode: cfg.mode || 'generations',
1250
+ models: [{ id: 'm_' + cfg.id, model: cfg.model || '' }],
1251
+ }));
1252
+ out.imageStar = (s.imageApiId && 'p_' + s.imageApiId + '|m_' + s.imageApiId) || (out.imageProviders[0] ? out.imageProviders[0].id + '|' + out.imageProviders[0].models[0].id : '');
1253
+ legacyImageApiRefMap = {};
1254
+ for (const cfg of s.imageApis) legacyImageApiRefMap[cfg.id] = 'p_' + cfg.id + '|m_' + cfg.id;
1255
+ }
1256
+ if (!out.videoProviders.length && Array.isArray(s.videoApis) && s.videoApis.length) {
1257
+ out.videoProviders = s.videoApis.map((cfg) => ({
1258
+ id: 'p_' + cfg.id, name: cfg.name || T('defaultVideoCfg'),
1259
+ url: cfg.url || '', apiKey: cfg.apiKey || '', extra: cfg.extra || '',
1260
+ outputFormat: cfg.outputFormat || '', generateAudio: cfg.generateAudio, protocol: cfg.protocol || 'generic', watermark: cfg.watermark,
1261
+ models: [{ id: 'm_' + cfg.id, model: cfg.model || '' }],
1262
+ }));
1263
+ out.videoStar = (s.videoApiId && 'p_' + s.videoApiId + '|m_' + s.videoApiId) || (out.videoProviders[0] ? out.videoProviders[0].id + '|' + out.videoProviders[0].models[0].id : '');
1264
+ legacyVideoApiRefMap = {};
1265
+ for (const cfg of s.videoApis) legacyVideoApiRefMap[cfg.id] = 'p_' + cfg.id + '|m_' + cfg.id;
1150
1266
  }
1151
1267
  return out;
1152
1268
  }
1153
1269
  function saveSettings(s) { lsSet(LS_SETTINGS, s); }
1154
1270
 
1271
+ // ---------- 供应商 + 模型(图片/视频):扁平视图与解析 ----------
1272
+ let legacyImageApiRefMap = {}; // 旧扁平配置 id → 'providerId|modelId'(节点字段懒迁移用)
1273
+ let legacyVideoApiRefMap = {};
1274
+ /** 供应商列表 → 扁平模型视图(ref = 'providerId|modelId');未填模型名的条目不进下拉。 */
1275
+ function flattenImageModels(settings) {
1276
+ const out = [];
1277
+ for (const p of settings.imageProviders || []) {
1278
+ for (const m of (p.models || [])) {
1279
+ if (!m || !m.id || !String(m.model || '').trim()) continue;
1280
+ 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' });
1281
+ }
1282
+ }
1283
+ return out;
1284
+ }
1285
+ function flattenVideoModels(settings) {
1286
+ const out = [];
1287
+ for (const p of settings.videoProviders || []) {
1288
+ for (const m of (p.models || [])) {
1289
+ if (!m || !m.id || !String(m.model || '').trim()) continue;
1290
+ 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 });
1291
+ }
1292
+ }
1293
+ return out;
1294
+ }
1295
+ /** 解析节点上的模型引用:空 = 星标默认模型;都没有 = null(运行时报错)。 */
1296
+ function resolveImageModelCfg(settings, ref) {
1297
+ const list = flattenImageModels(settings);
1298
+ const use = String(ref || '').trim() || settings.imageStar || '';
1299
+ return list.find((x) => x.ref === use) || null;
1300
+ }
1301
+ function resolveVideoModelCfg(settings, ref) {
1302
+ const list = flattenVideoModels(settings);
1303
+ const use = String(ref || '').trim() || settings.videoStar || '';
1304
+ return list.find((x) => x.ref === use) || null;
1305
+ }
1306
+ /** 节点上的模型引用(兼容旧扁平 apiId 的懒迁移)。 */
1307
+ function nodeImageModelRef(n) {
1308
+ const d = (n && n.data) || {};
1309
+ if (d.imageModel) return d.imageModel;
1310
+ return legacyImageApiRefMap[d.imageApiId] || '';
1311
+ }
1312
+ function nodeVideoModelRef(n) {
1313
+ const d = (n && n.data) || {};
1314
+ if (d.videoModel) return d.videoModel;
1315
+ return legacyVideoApiRefMap[d.videoApiId] || '';
1316
+ }
1317
+
1155
1318
  function emptyGraph() { return { nodes: {}, edges: [], view: { x: 60, y: 40, scale: 1 } }; }
1156
1319
  function loadGraphDoc(pid) { return lsGet(LS_GRAPH + pid, null); }
1157
1320
  function saveGraphDoc(pid, graph) { lsSet(LS_GRAPH + pid, graph); }
@@ -1270,6 +1433,17 @@ window.__ModuleLoader__.load({
1270
1433
  function entityCategoryOf(kind) {
1271
1434
  return kind === 'character' ? 'character' : kind === 'scene' ? 'scene' : kind === 'prop' ? 'prop' : 'general';
1272
1435
  }
1436
+ /** 按命名规范前缀推导资产品类:角色-/场景-/道具-/剧本-/分镜- → 对应分类(无前缀返回 '')。
1437
+ * 用于 @引用未命中时(如对话侧直接写角色名)的兜底分类。 */
1438
+ function categoryFromName(name) {
1439
+ const n = String(name || '').trim();
1440
+ if (/^角色[-_//]/.test(n)) return 'character';
1441
+ if (/^场景[-_//]/.test(n)) return 'scene';
1442
+ if (/^道具[-_//]/.test(n)) return 'prop';
1443
+ if (/^剧本[-_//]/.test(n)) return 'script';
1444
+ if (/^分镜(表)?[-_//]/.test(n)) return 'storyboard';
1445
+ return '';
1446
+ }
1273
1447
  /** 在设定集里按名称找实体(角色/场景/道具),携带其链接的资产 id。 */
1274
1448
  function matchBibleEntity(name, bible) {
1275
1449
  const want = String(name || '').trim().toLowerCase();
@@ -1330,36 +1504,42 @@ window.__ModuleLoader__.load({
1330
1504
  }
1331
1505
  // <<<MENTION_PURE_END>>>
1332
1506
 
1333
- /** 解析提示词里的 @引用:返回命中实体 + 其名下的参考图资产(按实体出现顺序、各实体内按生成时间)。
1334
- * 实体的参考图来源:① 设定条目里**链接的资产**(assetIds,绑定关系优先)
1335
- * ② 其次按命名/实体字段匹配的资产(角色-张山 多视图 等)。 */
1336
- async function resolveMentionBundle(prompt, bible, projectId) {
1337
- const entities = [];
1338
- for (const m of parseMentions(prompt)) {
1339
- const hit = matchBibleEntity(m.name, bible);
1340
- if (hit && !entities.some((e) => e.name === hit.name)) entities.push(hit);
1341
- }
1342
- if (!entities.length) return { entities: [], refs: [] };
1343
- let all = [];
1344
- try { all = (await idbByProject(projectId)).concat(await idbByProject(GENERAL_PID)); } catch { all = []; }
1345
- const byId = new Map(all.map((r) => [r.id, r]));
1346
- const refs = [];
1347
- for (const ent of entities) {
1348
- const owned = [];
1349
- // ① 条目链接的资产(绑定优先,任何类型都认;生成时只有图片能当参考图)
1350
- for (const aid of ent.assetIds || []) {
1351
- const rec = byId.get(aid);
1352
- if (rec && rec.kind === 'image') owned.push(rec);
1507
+ /** 解析提示词里的 @引用:返回命中实体 + 其名下的参考图资产与**声音参考资产**(按实体出现顺序、各实体内按生成时间)。
1508
+ * 实体的参考资产来源:① 设定条目里**链接的资产**(assetIds,绑定关系优先)
1509
+ * ② 其次按命名/实体字段匹配的资产(角色-张山 多视图 / 角色-张山 声音 等)。 */
1510
+ async function resolveMentionBundle(prompt, bible, projectId) {
1511
+ const entities = [];
1512
+ for (const m of parseMentions(prompt)) {
1513
+ const hit = matchBibleEntity(m.name, bible);
1514
+ if (hit && !entities.some((e) => e.name === hit.name)) entities.push(hit);
1353
1515
  }
1354
- // ② 命名/实体匹配的资产
1355
- for (const r of all) {
1356
- if (r.kind === 'image' && assetBelongsToEntity(r, ent) && !owned.some((x) => x.id === r.id)) owned.push(r);
1516
+ if (!entities.length) return { entities: [], refs: [], audioRefs: [] };
1517
+ let all = [];
1518
+ try { all = (await idbByProject(projectId)).concat(await idbByProject(GENERAL_PID)); } catch { all = []; }
1519
+ const byId = new Map(all.map((r) => [r.id, r]));
1520
+ const refs = [];
1521
+ const audioRefs = [];
1522
+ for (const ent of entities) {
1523
+ const owned = [];
1524
+ const ownedAudio = [];
1525
+ // ① 条目链接的资产(绑定优先,任何类型都认;生成时图片当参考图、音频当声音参考)
1526
+ for (const aid of ent.assetIds || []) {
1527
+ const rec = byId.get(aid);
1528
+ if (rec && rec.kind === 'image') owned.push(rec);
1529
+ if (rec && rec.kind === 'audio') ownedAudio.push(rec);
1530
+ }
1531
+ // ② 命名/实体匹配的资产
1532
+ for (const r of all) {
1533
+ if (r.kind === 'image' && assetBelongsToEntity(r, ent) && !owned.some((x) => x.id === r.id)) owned.push(r);
1534
+ if (r.kind === 'audio' && assetBelongsToEntity(r, ent) && !ownedAudio.some((x) => x.id === r.id)) ownedAudio.push(r);
1535
+ }
1536
+ owned.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
1537
+ ownedAudio.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
1538
+ 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 });
1539
+ for (const r of ownedAudio) if (!audioRefs.some((x) => x.assetId === r.id)) audioRefs.push({ assetId: r.id, entity: ent.name, name: r.name });
1357
1540
  }
1358
- owned.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
1359
- 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 });
1541
+ return { entities, refs, audioRefs };
1360
1542
  }
1361
- return { entities, refs };
1362
- }
1363
1543
  /** 合成参考图列表:连线(用户显式顺序)优先,其后追加 @引用命中的设定集资产(去重)。 */
1364
1544
  async function collectRefs(n, prompt, bible) {
1365
1545
  const out = [];
@@ -1387,7 +1567,16 @@ window.__ModuleLoader__.load({
1387
1567
  if (blob) out.push({ assetId: r.assetId, blob, name: (rec && rec.name) || r.name, category: (rec && rec.category) || 'general', source: 'mention', entity: r.entity });
1388
1568
  } catch { /* 忽略 */ }
1389
1569
  }
1390
- return { refs: out, entities: bundle.entities };
1570
+ // 声音参考:@引用命中实体名下的音频资产(角色音色一致性用),视频生成时作为 audios[] 传出
1571
+ const audioBlobs = [];
1572
+ for (const r of (bundle.audioRefs || [])) {
1573
+ try {
1574
+ const rec = await idbGet(r.assetId);
1575
+ const blob = await assetBlob(r.assetId);
1576
+ if (blob) audioBlobs.push({ blob, name: (rec && rec.name) || r.name, entity: r.entity, assetId: r.assetId });
1577
+ } catch { /* 忽略 */ }
1578
+ }
1579
+ return { refs: out, entities: bundle.entities, audioBlobs };
1391
1580
  }
1392
1581
 
1393
1582
  function outLangLine() {
@@ -1610,6 +1799,27 @@ window.__ModuleLoader__.load({
1610
1799
  }
1611
1800
  return { migrated: done, total: targets.length };
1612
1801
  }
1802
+ /** 启动自检:把分类为「通用」但命名符合规范前缀(角色-/场景-/道具-/剧本-/分镜-)的存量资产纠正分类。 */
1803
+ async function fixGeneralCategories() {
1804
+ try {
1805
+ const all = await idbAll();
1806
+ let fixed = 0;
1807
+ for (const rec of all) {
1808
+ if (!rec || !rec.id || (rec.kind !== 'image' && rec.kind !== 'video')) continue;
1809
+ if ((rec.category || 'general') !== 'general') continue;
1810
+ const c = categoryFromName(rec.name);
1811
+ if (!c) continue;
1812
+ const next = { ...rec, category: c };
1813
+ await idbPut(next);
1814
+ mirrorAssetIndex(next);
1815
+ fixed++;
1816
+ }
1817
+ if (fixed) {
1818
+ update((s) => { s.assetsTick++; });
1819
+ toast(T('assetCategoriesFixed').replace('{n}', String(fixed)));
1820
+ }
1821
+ } catch { /* 静默:失败不影响主流程,下次启动再试 */ }
1822
+ }
1613
1823
  /** 数据根目录就绪后自动把浏览器 IndexedDB 里的存量资产搬到磁盘(替代原"迁移历史资产"按钮,全自动无需手动触发)。 */
1614
1824
  async function autoMigrateAssetsToRoot() {
1615
1825
  if (!S.dataRoot) return;
@@ -1760,11 +1970,23 @@ window.__ModuleLoader__.load({
1760
1970
  if (!ct || /json|text|plain/.test(ct)) { try { text = new TextDecoder().decode(buf); } catch { text = ''; } }
1761
1971
  return { ok: res.ok, status: res.status, contentType: ct, text, blob };
1762
1972
  }
1973
+ /** 扩展参数(JSON 字符串 → 对象):厂商专属参数的通用出口(watermark/prompt_extend/output_format/seed…)。
1974
+ * 非空但不是合法 JSON 对象时抛错(配置错误应当立刻暴露,而不是被静默吞掉)。 */
1975
+ function parseExtraParams(json) {
1976
+ const t = String(json || '').trim();
1977
+ if (!t) return null;
1978
+ const v = JSON.parse(t);
1979
+ if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('扩展参数必须是 JSON 对象(如 {"watermark":false})');
1980
+ return v;
1981
+ }
1763
1982
  async function apiImage(prompt, refBlobs, s, opts) {
1764
1983
  if (!s.baseUrl) throw new Error('未配置图片 API');
1765
- const base = s.baseUrl.replace(/\/+$/, '');
1984
+ // URL 归一化:裸域 → /v1;粘贴完整端点也不会被二次拼接
1985
+ let base = s.baseUrl.replace(/\/+$/, '').replace(/\/images\/generations$/, '');
1986
+ if (!base.replace(/^https?:\/\//i, '').includes('/')) base = base + '/v1';
1766
1987
  const refs = refBlobs || [];
1767
1988
  const auth = s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {};
1989
+ const extra = parseExtraParams(s.extra); // 厂商专属参数(留空不发送)
1768
1990
  const upstreamTimeout = Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000; // 商汤等同步接口 20s+,默认 120s
1769
1991
  // 参考图编辑模式:multipart 上传到 /images/edits(OpenAI gpt-image-1 兼容)
1770
1992
  if (refs.length && s.mode === 'edits') {
@@ -1801,6 +2023,8 @@ window.__ModuleLoader__.load({
1801
2023
  const sizeOverride = String(s.size || '').trim();
1802
2024
  if (sizeOverride) body.size = sizeOverride;
1803
2025
  else { const dims = imageDimsFor(opts); body.size = `${dims.w}x${dims.h}`; }
2026
+ // 厂商专属扩展参数最后合并(可覆盖以上任何字段):换厂商不需要改代码
2027
+ if (extra) Object.assign(body, extra);
1804
2028
  const res = await fetchViaProxy(base + '/images/generations', {
1805
2029
  headers: { 'Content-Type': 'application/json', ...auth },
1806
2030
  body, timeoutMs: upstreamTimeout, label: '图片 API',
@@ -1838,6 +2062,8 @@ window.__ModuleLoader__.load({
1838
2062
  j.video && j.video.url,
1839
2063
  j.output && (j.output.url || j.output.video_url),
1840
2064
  j.result && (j.result.url || j.result.video_url),
2065
+ // OpenAI 风格视频任务(如 JUAI):完成响应把结果放在 metadata.url
2066
+ j.metadata && (j.metadata.url || j.metadata.video_url),
1841
2067
  Array.isArray(j.data) && j.data[0] && (j.data[0].url || j.data[0].video_url),
1842
2068
  ];
1843
2069
  return cands.find((u) => typeof u === 'string' && /^https?:/.test(u)) || null;
@@ -1848,9 +2074,13 @@ window.__ModuleLoader__.load({
1848
2074
  ?? (j.data && (j.data.task_id ?? j.data.id ?? j.data.taskId))
1849
2075
  ?? (j.output && (j.output.task_id ?? j.output.id));
1850
2076
  }
1851
- async function downloadVideo(url, headers) {
2077
+ async function downloadVideo(url, headers, authFallback) {
1852
2078
  // 经 Host 代理:签名下载直连同样会被 CORS 拦
1853
- const r = await fetchViaProxy(url, { method: 'GET', headers: headers || {}, timeoutMs: 300000, label: '下载视频' });
2079
+ let r = await fetchViaProxy(url, { method: 'GET', headers: headers || {}, timeoutMs: 300000, label: '下载视频' });
2080
+ // 认证内容代理(如 JUAI /v1/videos/{id}/content)返回 401/403 时:用 API 鉴权头重试一次
2081
+ if (!r.ok && (r.status === 401 || r.status === 403) && authFallback && Object.keys(authFallback).length) {
2082
+ r = await fetchViaProxy(url, { method: 'GET', headers: authFallback, timeoutMs: 300000, label: '下载视频' });
2083
+ }
1854
2084
  if (!r.ok) throw new Error('下载视频失败 HTTP ' + r.status);
1855
2085
  return r.blob;
1856
2086
  }
@@ -1867,16 +2097,18 @@ window.__ModuleLoader__.load({
1867
2097
  text += '\n(' + [...Array(refBlobs.length)].map((_, i) => '[图' + (i + 1) + ']').join('') + ' 为参考图,请保持其人物/场景/道具特征一致)';
1868
2098
  }
1869
2099
  const content = [{ type: 'text', text }];
1870
- if (frameBlob) content.push({ type: 'image_url', image_url: { url: await blobToDataUrl(frameBlob) }, role: 'first_frame' });
1871
- for (const b of refBlobs) content.push({ type: 'image_url', image_url: { url: await blobToDataUrl(b) }, role: 'reference_image' });
2100
+ if (frameBlob) content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(frameBlob) }, role: 'first_frame' });
2101
+ for (const b of refBlobs) content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(b) }, role: 'reference_image' });
1872
2102
  const body = {
1873
2103
  model: s.model,
1874
2104
  content,
1875
2105
  duration: Number(o.duration) || 5,
1876
2106
  resolution: (o.resolution || '720P').toLowerCase(),
1877
2107
  watermark: s.watermark === true,
1878
- generate_audio: s.generateAudio !== false,
2108
+ // 勾选才发送/置 true:与通用路径的显式语义一致(默认不发送,用网关默认值)
2109
+ generate_audio: s.generateAudio === true,
1879
2110
  };
2111
+ try { const extra = parseExtraParams(s.extra); if (extra) Object.assign(body, extra); } catch (e) { throw new Error('视频 API 扩展参数配置有误:' + ((e && e.message) || e)); }
1880
2112
  body.ratio = frameBlob ? 'adaptive' : (o.ratio || '16:9');
1881
2113
  const res = await fetchViaProxy(base, { headers, body, timeoutMs: Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000, label: 'Seedance 视频 API' });
1882
2114
  if (!res.ok) throw new Error('Seedance HTTP ' + res.status + ':' + res.text.slice(0, 300));
@@ -1900,33 +2132,154 @@ window.__ModuleLoader__.load({
1900
2132
  }
1901
2133
  throw new Error('Seedance 任务超时(15 分钟)');
1902
2134
  }
2135
+ /** Seedance 原生 content[] 格式兜底(RelayRouter 等:POST /v1/video/generations {model, content, metadata},
2136
+ * 轮询 GET /v1/video/query?task_id=...)。首帧/参考图角色与 Seedance 原生协议一致(图压缩内联)。 */
2137
+ async function relayVideoContentFallback(base, prompt, frameBlob, o, s, headers) {
2138
+ const content = [{ type: 'text', text: prompt }];
2139
+ if (frameBlob) content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(frameBlob) }, role: 'first_frame' });
2140
+ for (const b of (o.refs || [])) content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(b) }, role: 'reference_image' });
2141
+ const body = {
2142
+ model: s.model,
2143
+ content,
2144
+ metadata: {
2145
+ duration: Math.max(4, Math.min(15, Math.round(Number(o.duration) || 5))),
2146
+ ratio: o.ratio || '16:9',
2147
+ resolution: String(o.resolution || '720p').toLowerCase(),
2148
+ },
2149
+ };
2150
+ const r1 = await fetchViaProxy(base, { headers, body, timeoutMs: 120000, label: '视频 API(content)' });
2151
+ if (!r1.ok) throw new Error('HTTP ' + r1.status + ':' + r1.text.slice(0, 200));
2152
+ let j1 = null;
2153
+ try { j1 = JSON.parse(r1.text); } catch { /* 非 JSON */ }
2154
+ const taskId = extractTaskId(j1);
2155
+ if (!taskId) throw new Error('响应中没有任务 id:' + r1.text.slice(0, 200));
2156
+ let queryEp = '';
2157
+ try { queryEp = new URL(base).origin + '/v1/video/query'; } catch { throw new Error('无法构造轮询地址'); }
2158
+ const dlHeaders = (u) => { try { return new URL(u).origin === new URL(base).origin ? headers : {}; } catch { return {}; } };
2159
+ const deadline = Date.now() + 15 * 60 * 1000;
2160
+ while (Date.now() < deadline) {
2161
+ await sleep(6000);
2162
+ const r2 = await fetchViaProxy(queryEp + '?task_id=' + encodeURIComponent(taskId), { method: 'GET', headers, timeoutMs: 60000, label: '查询视频任务' });
2163
+ if (!r2.ok) throw new Error('查询视频任务失败 HTTP ' + r2.status);
2164
+ let st = null;
2165
+ try { st = JSON.parse(r2.text); } catch { /* 忽略 */ }
2166
+ const u = extractVideoUrl(st);
2167
+ if (u) return await downloadVideo(u, dlHeaders(u), headers);
2168
+ const failed = taskFailureOf(st);
2169
+ if (failed) throw new Error('视频任务失败:' + failed);
2170
+ const status = taskStatusOf(st);
2171
+ if (['failed', 'expired', 'canceled', 'cancelled', 'error'].includes(status)) throw new Error('视频任务失败:' + status);
2172
+ }
2173
+ throw new Error('视频生成超时(15 分钟)');
2174
+ }
2175
+ /** Sora 风格 /v1/videos 兜底(RelayRouter 等网关:/v1/video/generations 不接某些视频渠道时)。
2176
+ * 提交 POST {model, prompt, seconds, size} → 轮询 GET /v1/videos/{id} →
2177
+ * 完成后优先取响应里的地址,否则经 /content 端点(带鉴权)下载。
2178
+ * 注意:该格式暂不支持首帧注入——跨段连贯由提示词承接。 */
2179
+ async function soraVideosFallback(base, prompt, o, s, headers) {
2180
+ const root = base.replace(/\/v1\/video\/generations$/i, '').replace(/\/+$/, '');
2181
+ const ep = root + '/v1/videos';
2182
+ const secs = String(Math.max(4, Math.min(15, Math.round(Number(o.duration) || 5))));
2183
+ const vertical = (o.ratio || '16:9') === '9:16';
2184
+ const hd = String(o.resolution || '720p').toLowerCase().includes('1080');
2185
+ const size = hd ? (vertical ? '1080x1920' : '1920x1080') : (vertical ? '720x1280' : '1280x720');
2186
+ const body = { model: s.model, prompt, seconds: secs, size };
2187
+ const r1 = await fetchViaProxy(ep, { headers, body, timeoutMs: 120000, label: '视频 API(/v1/videos)' });
2188
+ if (!r1.ok) throw new Error('HTTP ' + r1.status + ' @ ' + ep + ':' + r1.text.slice(0, 200));
2189
+ let j1 = null;
2190
+ try { j1 = JSON.parse(r1.text); } catch { /* 非 JSON */ }
2191
+ const taskId = j1 && (j1.id || j1.task_id || (j1.data && j1.data[0] && j1.data[0].id));
2192
+ if (!taskId) throw new Error('/v1/videos 响应中没有任务 id:' + r1.text.slice(0, 200));
2193
+ const deadline = Date.now() + 15 * 60 * 1000;
2194
+ while (Date.now() < deadline) {
2195
+ await sleep(6000);
2196
+ const r2 = await fetchViaProxy(ep + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询视频任务' });
2197
+ if (!r2.ok) throw new Error('查询视频任务失败 HTTP ' + r2.status);
2198
+ let st = null;
2199
+ try { st = JSON.parse(r2.text); } catch { /* 忽略 */ }
2200
+ const u = extractVideoUrl(st);
2201
+ if (u) return await downloadVideo(u, {}, headers);
2202
+ const failed = taskFailureOf(st);
2203
+ if (failed) throw new Error('视频任务失败:' + failed);
2204
+ const status = taskStatusOf(st);
2205
+ if (taskDoneOf(st)) return await downloadVideo(ep + '/' + taskId + '/content', {}, headers);
2206
+ if (['failed', 'expired', 'canceled', 'cancelled', 'error'].includes(status)) throw new Error('视频任务失败:' + status);
2207
+ }
2208
+ throw new Error('视频生成超时(15 分钟)');
2209
+ }
1903
2210
  async function apiVideo(prompt, frameBlob, s, opts) {
1904
2211
  const o = opts || {};
1905
- // 火山 Seedance 官方协议适配
1906
- if (s.protocol === 'seedance' || (s.url || '').includes('contents/generations/tasks')) {
2212
+ // OpenAI 风格视频接口(JUAI 等:/v1/video/generations)优先识别——
2213
+ // 即使误把协议设成 seedance 也不走火山官方协议(两者状态/结果字段不同)
2214
+ if ((s.protocol === 'seedance' || (s.url || '').includes('contents/generations/tasks')) && !(s.url || '').includes('/video/generations')) {
1907
2215
  return await seedanceVideo(prompt, frameBlob, o.refs || [], s, o);
1908
2216
  }
1909
2217
  if (!s.url) throw new Error('未配置视频 API');
1910
- const base = s.url.replace(/\/+$/, '');
2218
+ // URL 归一化:裸域 → /v1/video/generations;/v1 → /video/generations;完整端点原样使用
2219
+ let base = s.url.replace(/\/+$/, '').replace(/\/video\/generations$/, '');
2220
+ if (!base.replace(/^https?:\/\//i, '').includes('/')) base = base + '/v1';
2221
+ else if (/\/v1$/.test(base)) base = base + '/video/generations';
2222
+ const isOpenaiVideo = /\/video\/generations$/.test(base);
1911
2223
  const headers = { 'Content-Type': 'application/json', ...(s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {}) };
1912
2224
  const body = {
1913
2225
  ...(s.model ? { model: s.model } : {}),
1914
2226
  prompt,
1915
2227
  duration: Number(o.duration) || 5,
1916
- resolution: o.resolution || '720P',
2228
+ // 分辨率一律小写(Seedance 系为 '720p'/'1080p';'720P' 会被部分网关判为不支持)
2229
+ resolution: (o.resolution || '720p').toLowerCase(),
1917
2230
  ratio: o.ratio || '16:9',
1918
2231
  };
1919
- // 多参考图(扩展字段):供支持多参考的视频网关使用
1920
- if (o.refs && o.refs.length) body.images = await Promise.all(o.refs.map((b) => blobToDataUrl(b)));
2232
+ // 首帧图生视频:构图以首帧为准——Seedance 系要求首帧模式必须 ratio=adaptive
2233
+ if (frameBlob) body.ratio = 'adaptive';
1921
2234
  if (frameBlob) {
1922
- try { body.image = await blobToDataUrl(frameBlob); } catch { /* 忽略首帧编码失败 */ }
2235
+ try { body.image = await blobToDataUrlScaled(frameBlob); } catch { /* 忽略首帧编码失败 */ }
2236
+ // OpenAI 风格接口禁止首帧与参考图混用(首帧模式只认 first_frame)→ 有首帧时不带参考图
2237
+ if (!(isOpenaiVideo && o.refs && o.refs.length)) {
2238
+ if (o.refs && o.refs.length) body.images = await Promise.all(o.refs.map((b) => blobToDataUrlScaled(b)));
2239
+ }
2240
+ } else if (o.refs && o.refs.length) {
2241
+ body.images = isOpenaiVideo
2242
+ ? await Promise.all(o.refs.map(async (b) => ({ url: await blobToDataUrlScaled(b), role: 'reference_image' })))
2243
+ : await Promise.all(o.refs.map((b) => blobToDataUrlScaled(b)));
2244
+ }
2245
+ // 可选参数:勾选才发送 generate_audio;output_format 留空不发送
2246
+ if (s.generateAudio === true) body.generate_audio = true;
2247
+ if (String(s.outputFormat || '').trim()) body.output_format = String(s.outputFormat).trim();
2248
+ // 角色声音参考(@引用实体名下的音频资产)→ audios[]:模型生成的对白/声音向参考音色对齐;
2249
+ // 带声音参考时强制开启同步音频(否则参考无意义)
2250
+ if (o.audioRefs && o.audioRefs.length) {
2251
+ body.audios = await Promise.all(o.audioRefs.map((b) => blobToDataUrl(b)));
2252
+ body.generate_audio = true;
1923
2253
  }
2254
+ // 厂商专属扩展参数最后合并(可覆盖以上任何字段):换厂商不需要改代码
2255
+ try {
2256
+ const extra = parseExtraParams(s.extra);
2257
+ if (extra) Object.assign(body, extra);
2258
+ } catch (e) { throw new Error('视频 API 扩展参数配置有误:' + ((e && e.message) || e)); }
1924
2259
  const res = await fetchViaProxy(base, { headers, body, timeoutMs: Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000, label: '视频 API' });
1925
- if (!res.ok) throw new Error('视频 API HTTP ' + res.status + ':' + res.text.slice(0, 300));
2260
+ if (!res.ok) {
2261
+ if (isOpenaiVideo) {
2262
+ // 降级链:① Seedance 原生 content[] 格式(RelayRouter 等,轮询 /v1/video/query)
2263
+ // ② Sora 风格 /v1/videos
2264
+ let e1 = null, e2 = null;
2265
+ try {
2266
+ return await relayVideoContentFallback(base, prompt, frameBlob, o, s, headers);
2267
+ } catch (e) { e1 = e; }
2268
+ try {
2269
+ return await soraVideosFallback(base, prompt, o, s, headers);
2270
+ } catch (e) { e2 = e; }
2271
+ throw new Error('视频 API HTTP ' + res.status + ' @ ' + base + ':' + res.text.slice(0, 160)
2272
+ + ';降级链失败——[content格式] ' + ((e1 && e1.message) || e1) + ';[/v1/videos] ' + ((e2 && e2.message) || e2));
2273
+ }
2274
+ throw new Error('视频 API HTTP ' + res.status + ' @ ' + base + ':' + res.text.slice(0, 300));
2275
+ }
1926
2276
  let j = null;
1927
2277
  try { j = JSON.parse(res.text); } catch { /* 非 JSON 响应 */ }
2278
+ // 同源结果地址(如认证内容代理)带上鉴权下载;外部公开地址不带
2279
+ const apiOrigin = (() => { try { return new URL(base).origin; } catch { return ''; } })();
2280
+ const dlHeaders = (u) => { try { return new URL(u).origin === apiOrigin ? headers : {}; } catch { return {}; } };
1928
2281
  const direct = extractVideoUrl(j);
1929
- if (direct) return await downloadVideo(direct, {});
2282
+ if (direct) return await downloadVideo(direct, dlHeaders(direct), headers);
1930
2283
  const taskId = extractTaskId(j);
1931
2284
  if (!taskId) throw new Error('视频 API 响应中没有任务 id 或视频地址');
1932
2285
  const deadline = Date.now() + 15 * 60 * 1000;
@@ -1937,24 +2290,31 @@ window.__ModuleLoader__.load({
1937
2290
  let st = null;
1938
2291
  try { st = JSON.parse(r2.text); } catch { /* 忽略 */ }
1939
2292
  const u = extractVideoUrl(st);
1940
- if (u) return await downloadVideo(u, {});
2293
+ if (u) return await downloadVideo(u, dlHeaders(u), headers);
1941
2294
  // 失败判定必须看状态字段,不能拿整串 JSON 做子串匹配:
1942
2295
  // 很多网关在 processing 阶段返回 {"status":"processing","error":null},子串匹配会误判失败
1943
2296
  const failed = taskFailureOf(st);
1944
2297
  if (failed) throw new Error('视频任务失败:' + failed);
2298
+ // 已完成却没有可提取的地址:明确报错并附响应原文,绝不无限轮询
2299
+ if (taskDoneOf(st)) throw new Error('任务已完成但响应中没有视频地址:' + JSON.stringify(st).slice(0, 300));
1945
2300
  }
1946
2301
  throw new Error('视频生成超时(15 分钟)');
1947
2302
  }
1948
- /** 从任务轮询响应里判断是否真的失败(白名单状态 + 显式错误信息),返回失败原因或 null。 */
1949
- function taskFailureOf(st) {
1950
- if (!st || typeof st !== 'object') return null;
2303
+ /** 从任务轮询响应里取状态字符串(小写)。 */
2304
+ function taskStatusOf(st) {
2305
+ if (!st || typeof st !== 'object') return '';
1951
2306
  const pick = (v) => {
1952
2307
  if (typeof v === 'string') return v.toLowerCase();
1953
2308
  if (v && typeof v === 'object' && typeof v.status === 'string') return v.status.toLowerCase();
1954
2309
  return '';
1955
2310
  };
1956
- const status = pick(st.status) || pick(st.state) || pick(st.task_status)
2311
+ return pick(st.status) || pick(st.state) || pick(st.task_status)
1957
2312
  || pick(st.data && st.data.status) || pick(st.output && st.output.status) || pick(st.result && st.result.status);
2313
+ }
2314
+ /** 从任务轮询响应里判断是否真的失败(白名单状态 + 显式错误信息),返回失败原因或 null。 */
2315
+ function taskFailureOf(st) {
2316
+ if (!st || typeof st !== 'object') return null;
2317
+ const status = taskStatusOf(st);
1958
2318
  const FAIL = ['failed', 'fail', 'error', 'canceled', 'cancelled', 'expired', 'rejected'];
1959
2319
  const DONE = ['succeeded', 'success', 'completed', 'complete', 'done', 'finished'];
1960
2320
  if (status && FAIL.includes(status)) {
@@ -1970,23 +2330,103 @@ window.__ModuleLoader__.load({
1970
2330
  }
1971
2331
  return null;
1972
2332
  }
2333
+ /** 任务是否已到达成功终态——轮询方据此终止,绝不无限轮询已完成的任务。 */
2334
+ function taskDoneOf(st) {
2335
+ return ['succeeded', 'success', 'completed', 'complete', 'done', 'finished'].includes(taskStatusOf(st));
2336
+ }
1973
2337
 
1974
2338
  function resolveApiCfg(list, defId, wantId) {
1975
2339
  if (!Array.isArray(list) || !list.length) return null;
1976
2340
  return list.find((x) => x.id === wantId) || list.find((x) => x.id === defId) || list[0];
1977
2341
  }
1978
- async function generateImage(prompt, refMetas, opts) {
1979
- const settings = loadSettings();
1980
- const cfg = resolveApiCfg(settings.imageApis, settings.imageApiId, opts && opts.apiId);
1981
- const blobs = (refMetas || []).map((m) => (m && m.blob) ? m.blob : m);
1982
- const blob = cfg ? await apiImage(prompt, blobs, cfg, opts || {}) : await demoImageBlob(prompt, blobs, imageDimsFor(opts));
1983
- return { blob, demo: !cfg };
2342
+
2343
+ /** PCM 采样数组 → WAV Blob(单声道 16-bit,声音参考不需要立体声)。 */
2344
+ function pcmToWavBlob(samples, sampleRate) {
2345
+ const n = samples.length;
2346
+ const data = new ArrayBuffer(44 + n * 2);
2347
+ const dv = new DataView(data);
2348
+ const wstr = (off, str) => { for (let i = 0; i < str.length; i++) dv.setUint8(off + i, str.charCodeAt(i)); };
2349
+ wstr(0, 'RIFF'); dv.setUint32(4, 36 + n * 2, true); wstr(8, 'WAVE'); wstr(12, 'fmt ');
2350
+ dv.setUint32(16, 16, true); dv.setUint16(20, 1, true); dv.setUint16(22, 1, true);
2351
+ dv.setUint32(24, sampleRate, true); dv.setUint32(28, sampleRate * 2, true); dv.setUint16(32, 2, true);
2352
+ dv.setUint16(34, 16, true); wstr(36, 'data'); dv.setUint32(40, n * 2, true);
2353
+ let off = 44;
2354
+ for (let i = 0; i < n; i++) {
2355
+ const v = Math.max(-1, Math.min(1, samples[i]));
2356
+ dv.setInt16(off, v < 0 ? v * 0x8000 : v * 0x7fff, true); off += 2;
2357
+ }
2358
+ return new Blob([data], { type: 'audio/wav' });
1984
2359
  }
1985
- async function generateVideo(prompt, frameBlob, opts) {
1986
- const settings = loadSettings();
1987
- const cfg = resolveApiCfg(settings.videoApis, settings.videoApiId, opts && opts.apiId);
1988
- const blob = cfg ? await apiVideo(prompt, frameBlob, cfg, opts || {}) : await demoVideoBlob(prompt, videoDimsFor(opts, { capLong: 1280 }), ((opts && opts.duration) || 5) * 1000);
1989
- return { blob, demo: !cfg };
2360
+ /** 解码媒体音轨并按绝对秒区间取 PCM 拼接(多段合成一条)→ WAV Blob。 */
2361
+ async function extractWavRanges(mediaBlob, rangesSec) {
2362
+ const ac = new (window.AudioContext || window.webkitAudioContext)();
2363
+ try {
2364
+ const buf = await ac.decodeAudioData(await mediaBlob.arrayBuffer());
2365
+ const chans = [];
2366
+ for (let c = 0; c < buf.numberOfChannels; c++) chans.push(buf.getChannelData(c));
2367
+ const sr = buf.sampleRate;
2368
+ const pcm = [];
2369
+ for (const [a, b] of rangesSec) {
2370
+ const s0 = Math.max(0, Math.floor(a * sr));
2371
+ const s1 = Math.min(buf.length, Math.ceil(b * sr));
2372
+ for (let i = s0; i < s1; i++) {
2373
+ let v = 0;
2374
+ for (let c = 0; c < chans.length; c++) v += chans[c][i];
2375
+ pcm.push(v / chans.length);
2376
+ }
2377
+ }
2378
+ if (!pcm.length) throw new Error('裁剪区间为空');
2379
+ return { blob: pcmToWavBlob(pcm, sr) };
2380
+ } finally { try { ac.close(); } catch { /* 忽略 */ } }
2381
+ }
2382
+ /** 台词驱动的声音参考提取:按分镜 shots 的 dialogue 找到该角色的全部镜头,
2383
+ * 以镜头时长占比把**真实视频时长**映射回各镜头时间窗(±0.15s 呼吸垫),
2384
+ * 逐窗取 PCM **拼接为一个 WAV**(一个角色一个总的声音参考文件)。 */
2385
+ async function extractVoiceAuto(videoBlob, shots, role) {
2386
+ const rl = String(role || '').trim().toLowerCase();
2387
+ const windows = [];
2388
+ let t = 0;
2389
+ for (const s of shots) {
2390
+ const d = Number(s.duration) || 0;
2391
+ const lines = (s.dialogue || []).filter((l) => String(l.role || '').trim().toLowerCase() === rl);
2392
+ if (lines.length) windows.push({ title: s.title, from: t, to: t + d, lines });
2393
+ t += d;
2394
+ }
2395
+ if (!windows.length) {
2396
+ const roles = [...new Set((shots || []).flatMap((s) => (s.dialogue || []).map((l) => String(l.role || '').trim()).filter(Boolean)))];
2397
+ throw new Error(T('voiceRoleNotFound').replace('{roles}', roles.join('、') || '—'));
2398
+ }
2399
+ const ac = new (window.AudioContext || window.webkitAudioContext)();
2400
+ try {
2401
+ const buf = await ac.decodeAudioData(await videoBlob.arrayBuffer());
2402
+ const sumDur = shots.reduce((a, s) => a + (Number(s.duration) || 0), 0) || 1;
2403
+ const k = buf.duration / sumDur; // 请求时长 → 实际时长的比例映射(模型未必精确跟时长)
2404
+ const chans = [];
2405
+ for (let c = 0; c < buf.numberOfChannels; c++) chans.push(buf.getChannelData(c));
2406
+ const sr = buf.sampleRate;
2407
+ const pcm = [];
2408
+ const pad = 0.15; // 呼吸垫:防止窗口边缘切字
2409
+ const segs = [];
2410
+ for (const w of windows) {
2411
+ const a = Math.max(0, w.from * k - (w.from > 0 ? pad : 0));
2412
+ const b = Math.min(buf.duration, w.to * k + pad);
2413
+ const s0 = Math.max(0, Math.floor(a * sr));
2414
+ const s1 = Math.min(buf.length, Math.ceil(b * sr));
2415
+ for (let i = s0; i < s1; i++) {
2416
+ let v = 0;
2417
+ for (let c = 0; c < chans.length; c++) v += chans[c][i];
2418
+ pcm.push(v / chans.length);
2419
+ }
2420
+ 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) });
2421
+ }
2422
+ if (!pcm.length) throw new Error('裁剪区间为空');
2423
+ return { blob: pcmToWavBlob(pcm, sr), segs, duration: Math.round(buf.duration * 10) / 10 };
2424
+ } finally { try { ac.close(); } catch { /* 忽略 */ } }
2425
+ }
2426
+ /** 手动模式:按用户给定的绝对秒区间提取。 */
2427
+ async function extractVoiceClip(mediaBlob, startSec, endSec) {
2428
+ const r = await extractWavRanges(mediaBlob, [[startSec || 0, endSec || 0]]);
2429
+ return { blob: r.blob };
1990
2430
  }
1991
2431
 
1992
2432
  /** 从视频 Blob 提取最后一帧为 PNG(通用:任何 API 生成的视频都能取尾帧)。 */
@@ -2032,7 +2472,7 @@ window.__ModuleLoader__.load({
2032
2472
  nd2.data.duration = n.data.duration || 5;
2033
2473
  nd2.data.resolution = n.data.resolution || '720P';
2034
2474
  nd2.data.ratio = n.data.ratio || '16:9';
2035
- nd2.data.videoApiId = n.data.videoApiId || '';
2475
+ nd2.data.videoModel = nodeVideoModelRef(n);
2036
2476
  }
2037
2477
  s.graph.edges.push({ id: uid('e'), fromNode: n.id, fromPort: 'last', toNode: nn.id, toPort: 'frame' });
2038
2478
  });
@@ -2535,6 +2975,9 @@ window.__ModuleLoader__.load({
2535
2975
  let hostSyncStopped = false;
2536
2976
  let hostSyncBusy = false; // hostSyncTick 互斥:单次 tick 可能超过 3 秒
2537
2977
  let syncWarned = false; // 同步失败只提示一次,避免刷屏
2978
+ // 用户在 UI 里删除过的节点(id → 时间戳):同步的"内存画布为准"豁免只认这里——
2979
+ // 对话侧/Host 删除的节点不在此列,必须照常本地清除(否则旧内存副本会把对话侧刚删的节点复活)
2980
+ const userDeletedNodes = new Map();
2538
2981
  async function importHostProject(hp) {
2539
2982
  const full = await hostRpc('projectsGet', { id: hp.id });
2540
2983
  update((s) => {
@@ -2658,9 +3101,14 @@ window.__ModuleLoader__.load({
2658
3101
  }
2659
3102
  // 对话删过的节点(墓碑):本地同步删除,避免被推送复活
2660
3103
  const dead = (hp.tombstones || []).filter((nid) => doc && doc.nodes[nid]);
2661
- // 内存画布为准:当前画布上还存在的节点不算死(撤销恢复后 Host 可能仍留旧墓碑)
3104
+ // "内存画布为准"的豁免**仅限用户自己的撤销恢复**:用户在 UI 删除过(userDeletedNodes 有记录)
3105
+ // 且节点当前 live = 它是被撤销恢复的,Host 墓碑已过期 → 暂不删(等本机推送清墓碑)。
3106
+ // 对话侧(Host)删除的节点不在 userDeletedNodes 里 → 照常本地删除,
3107
+ // 否则旧内存副本会把对话侧刚删的节点复活(陈旧推送 + 过宽豁免的组合 bug)。
2662
3108
  const liveNodes = (hp.id === S.activeId && S.graph) ? S.graph.nodes : null;
2663
- const deadEff = liveNodes ? dead.filter((nid) => !liveNodes[nid]) : dead;
3109
+ const deadEff = liveNodes
3110
+ ? dead.filter((nid) => !(liveNodes[nid] && userDeletedNodes.has(nid)))
3111
+ : dead;
2664
3112
  if (known && deadEff.length) {
2665
3113
  for (const nid of deadEff) delete doc.nodes[nid];
2666
3114
  doc.edges = (doc.edges || []).filter((e) => !deadEff.includes(e.fromNode) && !deadEff.includes(e.toNode));
@@ -2976,16 +3424,18 @@ window.__ModuleLoader__.load({
2976
3424
  const user = zh ? [
2977
3425
  `创意 / 原文:\n${String(idea ?? '').trim()}`,
2978
3426
  '要求:',
2979
- '- 3-8 个场次,总片长 1-3 分钟;',
2980
- '- 每场以「场X 地点(日/夜)· 出场人物」开头;',
2981
- '- 动作用可视化描述,不写心理;对白单独成行;需要的声音(环境音/旁白)标注在括号里;',
3427
+ '- 本节点输出**一集**的剧本:成片时长 1-2 分钟(60-120 秒),共 4-10 个场景;',
3428
+ '- **每个场景必须以「【场N】场景名(地点·时间)」单独一行开头**(如【场1】城市街头(夜))——这是后续自动铺开到分镜的分场标记,格式不可省略或改写;',
3429
+ '- 每场容量 10-15 秒成片(约 60-150 字,含该场全部对白);全部场景时长之和 ≈ 1-2 分钟;',
3430
+ '- 场景头之后:可视化动作描写 + 对白(角色:台词,单独成行);需要的声音(环境音/旁白)标注在括号里;',
2982
3431
  '- 结尾单独一行「人物:」与「场景/道具:」清单,便于后续资产制作。',
2983
3432
  ].join('\n') : [
2984
3433
  `Idea / source:\n${String(idea ?? '').trim()}`,
2985
3434
  'Requirements:',
2986
- '- 3-8 scenes, 1-3 minutes total;',
2987
- '- Each scene starts with "SCENE X location (day/night) · characters";',
2988
- '- Visual actions only, no inner thoughts; dialogue on its own lines; mark sounds in brackets;',
3435
+ '- This node outputs ONE episode: 1-2 minutes of screen time (60-120s), 4-10 scenes;',
3436
+ '- **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;',
3437
+ '- Each scene holds 10-15 seconds of screen time (≈60-150 words incl. dialogue); all scenes sum to ≈1-2 minutes;',
3438
+ '- After each scene header: visual action + dialogue ("Character: line" on its own lines); mark sounds in brackets;',
2989
3439
  '- End with "Characters:" and "Locations/Props:" lists for asset production.',
2990
3440
  ].join('\n');
2991
3441
  return await aiCompleteStream({
@@ -2994,6 +3444,31 @@ window.__ModuleLoader__.load({
2994
3444
  onText: (d) => onDelta && onDelta('text', d),
2995
3445
  });
2996
3446
  }
3447
+ /** 修复 JSON 字符串值内部未转义的半角引号(模型把 喷漆"拆"字 这类引用原样写进值里)。
3448
+ * 状态机扫描:跟踪是否处于字符串内;串内引号看其后第一个非空白字符——
3449
+ * 是 JSON 结构符(, } ] :)→ 这是字符串结束;否则是值内引号 → 转为全角「 / 」。 */
3450
+ function repairJsonInnerQuotes(s) {
3451
+ const src = String(s);
3452
+ let out = '';
3453
+ let inStr = false, esc = false, innerOpen = false;
3454
+ for (let i = 0; i < src.length; i++) {
3455
+ const ch = src[i];
3456
+ if (!inStr) {
3457
+ if (ch === '"') inStr = true;
3458
+ out += ch;
3459
+ continue;
3460
+ }
3461
+ if (esc) { out += ch; esc = false; continue; }
3462
+ if (ch === '\\') { out += ch; esc = true; continue; }
3463
+ if (ch !== '"') { out += ch; continue; }
3464
+ let j = i + 1;
3465
+ while (j < src.length && /\s/.test(src[j])) j++;
3466
+ const nxt = src[j] || '';
3467
+ if (nxt === ',' || nxt === '}' || nxt === ']' || nxt === ':' || nxt === '') { inStr = false; innerOpen = false; out += ch; }
3468
+ else { out += innerOpen ? '」' : '「'; innerOpen = !innerOpen; }
3469
+ }
3470
+ return out;
3471
+ }
2997
3472
  async function aiStoryboardShots(scriptText, requirement, count, model, onDelta) {
2998
3473
  const settings = loadSettings();
2999
3474
  const bible = bibleBlock(loadBible(S.activeId));
@@ -3001,24 +3476,32 @@ window.__ModuleLoader__.load({
3001
3476
  const jsonContract = zh
3002
3477
  ? [
3003
3478
  '把剧本拆解为「多镜头一次性视频生成」的分镜脚本:铺开后由**一个视频生成节点**把全部镜头按顺序与时长写进同一条提示词,由视频模型一次性生成整段视频(镜头间由模型直接硬切,不是一镜一视频再拼接)。',
3004
- '只输出一个 JSON 对象:{"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3}]}。',
3479
+ '只输出一个 JSON 对象:{"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"陈拾","line":"收件时间,1999……"}]}]}。',
3005
3480
  '规则:',
3006
3481
  '- visual 是本镜画面描述(景别、人物外观与站位、场景、光线、构图,60 字内);',
3007
3482
  '- **visual 与 motion 里的角色/场景/道具必须写成 @名称 引用形式**(如 `@张山`、`@褶皱巷`),插件会自动带上该实体的设定描述与其参考图资产,保证跨镜一致;',
3008
3483
  '- motion 是本镜动作与运镜(主体动作节拍、机位运动方向与速度、情绪节奏,50 字内);与 visual 不重复;',
3009
3484
  '- characters/scene/props 使用剧本与设定集中的名称(不带 @),便于匹配资产;无道具给空数组;',
3010
- '- duration 为该镜时长,2-5 的整数(一个镜头拍一个连续动作段落);全部 duration 之和 = 目标视频总时长(如 15 秒 ≈ 4-7 镜);镜头顺序即叙事顺序,首镜交代场景、末镜收束情绪;',
3485
+ '- **dialogue 是本镜的角色台词**:把剧本中落在这一镜的对白逐行搬进来({"role":"角色名","line":"台词原文"}),台词必须**逐字来自剧本**、不新写不改写;本镜无台词给空数组;一镜尽量只放一个角色的台词(单说话人镜头利于声音参考提取);',
3486
+ '- **【完整覆盖】分镜必须覆盖所给剧本的全部内容**:每一个动作、每一句对白都要落在某个镜头里——不概述、不省略、不跳过、不"挑选重点";镜头数量与总时长由剧本体量自然决定,宁多勿漏(容量超限由上层拆分剧本处理,不由分镜偷工减料);',
3487
+ '- **镜头按实际内容切分**:一个连续动作段落 = 一镜,数量由内容自然决定(3-10 镜都正常)——绝不凑数、绝不使用固定数量;',
3488
+ '- **JSON 字符串值内部禁止出现半角双引号 "**——引用文字(牌匾/字幕/台词原文)一律用「」;',
3489
+ '- duration 为该镜时长,2-5 的整数(一个镜头拍一个连续动作段落);全部 duration 之和 **≤ 15 秒**(一个分镜表 = 一个 10-15s 的场景视频)——超了就合并精简镜头,而不是拉长单镜;镜头顺序即叙事顺序,首镜交代场景、末镜收束情绪;',
3011
3490
  '- 只输出 JSON,不要输出任何其他内容。',
3012
3491
  ].join('\n')
3013
3492
  : [
3014
3493
  '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).',
3015
- 'Output only one JSON object: {"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3}]}.',
3494
+ 'Output only one JSON object: {"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"Chen","line":"…"}]}]}.',
3016
3495
  'Rules:',
3017
3496
  '- visual = the shot\'s frame description (shot size, wardrobe, staging, light, framing, <=60 words);',
3018
3497
  '- **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;',
3019
3498
  '- motion = action & camera for the shot (action beats, camera move, emotional rhythm, <=50 words); do not repeat visual;',
3020
3499
  '- characters/scene/props arrays must use plain names from the script and series bible (no @) so assets match; empty props array if none;',
3021
- '- 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;',
3500
+ '- **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);',
3501
+ '- **[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;',
3502
+ '- **Never use straight double quotes " inside JSON string values** — quoted text (signs/subtitles/lines) uses 「」 instead;',
3503
+ '- **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;',
3504
+ '- 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; first shot establishes the scene, last shot lands the emotion;',
3022
3505
  '- Output JSON only.',
3023
3506
  ].join('\n');
3024
3507
  const methodology = await loadSkillRules(settings.skillStoryboard, zh)
@@ -3030,17 +3513,25 @@ window.__ModuleLoader__.load({
3030
3513
  bible ? (zh ? '项目设定集(名称必须与之一致):\n' : 'Series bible (names must match):\n') + bible : '',
3031
3514
  outLangLine(),
3032
3515
  ].filter(Boolean).join('\n');
3516
+ const countLine = (Number(count) > 0)
3517
+ ? `${zh ? '参考镜头数量' : 'Reference shot count'}:约 ${count} 镜(**仅作参考**——必须按实际动作节拍切分,内容需要更多就更多,绝不凑数)`
3518
+ : `${zh ? '镜头数量' : 'Shot count'}:**不固定**——按实际动作节拍切分,由内容自然决定`;
3033
3519
  const user = [
3034
3520
  `${zh ? '剧本' : 'Script'}:\n${String(scriptText ?? '').trim()}`,
3035
3521
  `${zh ? '额外要求' : 'Extra requirements'}:${String(requirement ?? '').trim() || (zh ? '无' : 'none')}`,
3036
- `${zh ? '目标分镜数量' : 'Shot count'}:${count}`,
3522
+ countLine,
3037
3523
  ].join('\n\n');
3038
3524
  const text = await aiCompleteStream({
3039
3525
  system, user, model,
3040
3526
  onReasoning: (d) => onDelta && onDelta('reasoning', d),
3041
3527
  onText: (d) => onDelta && onDelta('text', d),
3042
3528
  });
3043
- const parsed = parseJsonBalanced(text);
3529
+ // 解析 + 修复重试:模型常把剧本里的引用文字(如 喷漆"拆"字)原样写进 JSON 字符串值,
3530
+ // 半角引号未转义 → JSON 非法。修复策略:把"闭合引号后不跟 JSON 结构符"的引号对换成「」。
3531
+ let parsed = parseJsonBalanced(text);
3532
+ if (!parsed || !Array.isArray(parsed.shots)) {
3533
+ try { parsed = parseJsonBalanced(repairJsonInnerQuotes(text)); } catch { /* 修复失败保持原错误 */ }
3534
+ }
3044
3535
  const shots = parsed && Array.isArray(parsed.shots) ? parsed.shots : null;
3045
3536
  if (!shots || !shots.length) {
3046
3537
  // 解析失败必须带上下文(长度 + 首尾原文),才能判断是被截断还是格式跑偏
@@ -3049,7 +3540,17 @@ window.__ModuleLoader__.load({
3049
3540
  const tail = raw.slice(-160);
3050
3541
  throw new Error(T('errParseStoryboard') + '(正文 ' + raw.length + ' 字;开头: ' + head + ';结尾: ' + tail + ')');
3051
3542
  }
3052
- return shots.slice(0, 24).map((s, i) => ({
3543
+ // 硬性容量保证:一个分镜表 ≤ 15s(视频模型单次上限)——超限从最长镜头开始逐秒压缩(每镜最低 1s)
3544
+ const capped = shots.map((s) => ({ ...s }));
3545
+ for (let guard = 0; guard < 300; guard++) {
3546
+ const sum = capped.reduce((a, s) => a + (Number(s.duration) || 0), 0);
3547
+ if (sum <= 15) break;
3548
+ let maxI = -1, maxV = 1;
3549
+ capped.forEach((s, i) => { const d = Number(s.duration) || 0; if (d > maxV) { maxV = d; maxI = i; } });
3550
+ if (maxI < 0) break;
3551
+ capped[maxI].duration = maxV - 1;
3552
+ }
3553
+ return capped.slice(0, 24).map((s, i) => ({
3053
3554
  title: String(s.title || (T('shotPrefix') + (i + 1))).slice(0, 24),
3054
3555
  visual: String(s.visual || ''),
3055
3556
  motion: String(s.motion || ''),
@@ -3057,36 +3558,51 @@ window.__ModuleLoader__.load({
3057
3558
  scene: String(s.scene || ''),
3058
3559
  props: Array.isArray(s.props) ? s.props.map(String).slice(0, 6) : [],
3059
3560
  duration: Number.isFinite(Number(s.duration)) ? clamp(Math.round(Number(s.duration)), 1, 15) : 3,
3561
+ dialogue: Array.isArray(s.dialogue)
3562
+ ? 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)
3563
+ : [],
3060
3564
  }));
3061
3565
  }
3062
- /** 本地一致性检查:分镜里出现的角色/场景/道具名是否都能在设定集或图片资产中找到。 */
3566
+ /** 本地一致性检查:分镜里出现的角色/场景/道具(含 visual/motion 文本里的 @提及)是否都在
3567
+ * 设定集/资产中登记;两级告警——「未登记」与「已登记但描述为空(待补全)」。 */
3063
3568
  async function checkShotConsistency(shots) {
3064
3569
  const bible = loadBible(S.activeId);
3065
- const known = new Set();
3066
- const addName = (n) => { const q = String(n || '').trim().toLowerCase(); if (q) known.add(q); };
3067
- (bible.characters || []).forEach((c) => addName(c.name));
3068
- (bible.scenes || []).forEach((c) => addName(c.name));
3069
- (bible.props || []).forEach((c) => addName(c.name));
3070
- try {
3071
- const recs = (await idbByProject(S.activeId)).concat(await idbByProject(GENERAL_PID));
3072
- recs.forEach((r) => addName(r.name));
3073
- } catch { /* 忽略 */ }
3570
+ const entries = [];
3571
+ const addEntry = (kind, name, desc) => {
3572
+ const nm = String(name || '').trim();
3573
+ if (nm) entries.push({ kind, name: nm, desc: String(desc || ''), lower: nm.toLowerCase() });
3574
+ };
3575
+ (bible.characters || []).forEach((c) => addEntry(T('catCharacter'), c && c.name, c && c.desc));
3576
+ (bible.scenes || []).forEach((c) => addEntry(T('catScene'), c && c.name, c && c.desc));
3577
+ (bible.props || []).forEach((c) => addEntry(T('catProp'), c && c.name, c && c.desc));
3578
+ const known = new Set(entries.map((e) => e.lower));
3579
+ try { ((await idbByProject(S.activeId)).concat(await idbByProject(GENERAL_PID))).forEach((r) => { const q = String(r && r.name || '').trim().toLowerCase(); if (q) known.add(q); }); } catch { /* 忽略 */ }
3074
3580
  const warnings = [];
3581
+ const seen = new Set();
3582
+ const warn = (line) => { if (!seen.has(line) && warnings.length < 12) { seen.add(line); warnings.push(line); } };
3583
+ const check = (title, kind, name) => {
3584
+ const q = String(name || '').trim().toLowerCase();
3585
+ if (!q) return;
3586
+ const exact = entries.find((e) => e.lower === q);
3587
+ if (exact) {
3588
+ if (!exact.desc.trim()) warn(`${title} · ${kind}「${exact.name}」${T('warnEmptyDesc')}`);
3589
+ return;
3590
+ }
3591
+ for (const k of known) { if (k.includes(q) || q.includes(k)) return; }
3592
+ warn(`${title} · ${kind}「${String(name).trim()}」${T('warnUnregistered')}`);
3593
+ };
3075
3594
  for (const sh of shots) {
3076
- const items = [
3077
- ...sh.characters.map((n) => [n, T('catCharacter')]),
3078
- [sh.scene, T('catScene')],
3079
- ...sh.props.map((n) => [n, T('catProp')]),
3080
- ];
3081
- for (const [nm, kind] of items) {
3082
- const q = String(nm || '').trim().toLowerCase();
3083
- if (!q) continue;
3084
- if (![...known].some((k) => k.includes(q) || q.includes(k))) {
3085
- warnings.push(`${sh.title} · ${kind}「${nm}」${T('warnUnregistered')}`);
3086
- }
3595
+ const title = String(sh.title || '').trim() || T('shotPrefix');
3596
+ for (const nm of (sh.characters || [])) check(title, T('catCharacter'), nm);
3597
+ check(title, T('catScene'), sh.scene);
3598
+ for (const nm of (sh.props || [])) check(title, T('catProp'), nm);
3599
+ // visual/motion 文本里的 @提及 同样纳入检查(数组外的实体也要登记,杜绝检查漏)
3600
+ const text = String(sh.visual || '') + ' ' + String(sh.motion || '');
3601
+ for (const m of (text.match(/@[^\s@,。;、!?::()"'))]+/g) || [])) {
3602
+ check(title, T('catMention'), m.slice(1).replace(/[))]+$/, ''));
3087
3603
  }
3088
3604
  }
3089
- return warnings.slice(0, 12);
3605
+ return warnings;
3090
3606
  }
3091
3607
  /** 分镜生成后:把设定集里没有的角色/场景/道具自动补进设定集(描述留空待用户补充)。 */
3092
3608
  async function autoRegisterBibleEntries(shots) {
@@ -3303,6 +3819,8 @@ window.__ModuleLoader__.load({
3303
3819
  const live = new Set(Object.keys(s.graph.nodes || {}));
3304
3820
  s.graph.tombstones = [...new Set([...(s.graph.tombstones || []), ...prevIds])]
3305
3821
  .filter((id) => !live.has(id)).slice(-200);
3822
+ // 撤销恢复的节点同步撤销"用户删除"记账:同步豁免随之失效,墓碑由推送清除
3823
+ for (const id of live) userDeletedNodes.delete(id);
3306
3824
  s.sel = new Set();
3307
3825
  s.selEdge = null;
3308
3826
  });
@@ -3384,7 +3902,7 @@ window.__ModuleLoader__.load({
3384
3902
  if (type === 'shot') { data.visual = ''; data.motion = ''; }
3385
3903
  if (type === 'script') { data.idea = ''; data.output = ''; data.aiStatus = 'idle'; data.aiError = null; }
3386
3904
  if (type === 'storyboard') {
3387
- data.script = ''; data.requirement = ''; data.count = 6;
3905
+ data.script = ''; data.requirement = ''; data.count = 0; // 0 = 自动:镜头数按内容节拍,不锚定固定数量
3388
3906
  data.shots = []; data.aiStatus = 'idle'; data.aiError = null;
3389
3907
  }
3390
3908
  if (type === 'image-gen' || type === 'video-gen') {
@@ -3426,6 +3944,15 @@ window.__ModuleLoader__.load({
3426
3944
  // 否则 Host 合并(保留对话新节点)会把用户刚删的节点又留下来
3427
3945
  if (ids.length) s.graph.tombstones = [...new Set([...(s.graph.tombstones || []), ...ids])].slice(-200);
3428
3946
  });
3947
+ // 用户删除记账:同步的"内存画布为准"豁免只认这里——对话侧(Host)删除的节点不在此列,
3948
+ // 会被正常本地清除(否则旧内存副本会把对话侧刚删的节点复活)
3949
+ for (const id of ids) {
3950
+ userDeletedNodes.set(id, Date.now());
3951
+ if (userDeletedNodes.size > 300) {
3952
+ const oldest = userDeletedNodes.keys().next().value;
3953
+ userDeletedNodes.delete(oldest);
3954
+ }
3955
+ }
3429
3956
  pushHistory();
3430
3957
  flushSave(); // 删除立即推送(不等 450ms 防抖):同步把 Host 上的节点删掉,压缩"拉回复活"的时间窗
3431
3958
  }
@@ -3710,14 +4237,20 @@ window.__ModuleLoader__.load({
3710
4237
  flushSave();
3711
4238
  try {
3712
4239
  const shots = await aiStoryboardShots(st, n.data.requirement, n.data.count || 6, n.data.model, (kind, d) => aiStreamAppend(n.id, kind, d));
4240
+ // 先自动补录缺失条目(空描述),再算一致性告警:否则告警标志的条目其实马上就被自动补录了,"未登记"语义会误导用户与对话侧
4241
+ try { await autoRegisterBibleEntries(shots); } catch { /* 补录失败不影响分镜 */ }
3713
4242
  const warnings = await checkShotConsistency(shots);
4243
+ // 容量告警:一个分镜 = 一个 10-15s 的完整场景。剧本过长说明应该按场景拆分成多个场景剧本节点,
4244
+ // 而不是让一个分镜表硬吃全集(shots 上限 24 会悄悄截断覆盖面)
4245
+ const stLen = st.length;
4246
+ if (stLen > 400) {
4247
+ warnings.push(T('warnScriptTooLong').replace('{n}', String(stLen)));
4248
+ }
3714
4249
  update((s) => {
3715
4250
  const nd = s.graph.nodes[n.id];
3716
4251
  if (nd) { nd.data.shots = shots; nd.data.aiStatus = 'idle'; nd.data.warnings = warnings; nd.data.status = 'done'; nd.data.error = null; }
3717
4252
  s.assetsTick++;
3718
4253
  });
3719
- // 分镜里出现的新角色/场景/道具 → 自动补进设定集(描述留空待补充),保证设定集始终是唯一事实源
3720
- try { await autoRegisterBibleEntries(shots); } catch { /* 补录失败不影响分镜 */ }
3721
4254
  await saveTextAssetRecord(S.activeId, {
3722
4255
  name: (String(n.data.label || '').trim() || T('storyboardAssetPrefix')) + ' ' + nowStamp(),
3723
4256
  text: storyboardAssetText(shots),
@@ -3799,6 +4332,7 @@ window.__ModuleLoader__.load({
3799
4332
 
3800
4333
  async function runGenNode(n) {
3801
4334
  const type = n.type;
4335
+ const settings = loadSettings(); // 模型解析(供应商+模型)用
3802
4336
  update((s) => {
3803
4337
  const nd = s.graph.nodes[n.id];
3804
4338
  if (nd) { nd.data.status = 'running'; nd.data.error = null; }
@@ -3816,41 +4350,48 @@ window.__ModuleLoader__.load({
3816
4350
  const { refs, entities } = await collectRefs(n, prompt, bible);
3817
4351
  const metas = refs.map((r) => ({ blob: r.blob, name: r.name, category: r.category }));
3818
4352
  const p = buildEffectivePrompt(prompt, refs, entities, bible);
3819
- const { blob, demo } = await generateImage(p, metas, { size: n.data.size, ratio: n.data.ratio, apiId: n.data.imageApiId });
4353
+ // 模型解析:节点选择优先,未指定路由到星标默认模型;都没有 = 明确报错(不再静默走演示模式)
4354
+ const mcfg = resolveImageModelCfg(settings, nodeImageModelRef(n));
4355
+ if (!mcfg) throw new Error(T('errNoImageModel'));
4356
+ const blob = await apiImage(p, metas, mcfg, { size: n.data.size, ratio: n.data.ratio });
3820
4357
  const rec = await saveAssetRecord(S.activeId, 'image', blob, {
3821
4358
  name: assetNameFor(n, entities, 'image'),
3822
4359
  prompt: stripMentionMarks(prompt), effectivePrompt: p,
3823
- source: demo ? 'demo' : 'api',
4360
+ source: 'api',
3824
4361
  entity: entities.length ? entities[0].name : undefined,
3825
4362
  entities: entities.map((e) => e.name),
3826
4363
  nodeLabel: (n.data && n.data.label) || '',
3827
- category: entities.length ? entityCategoryOf(entities[0].kind) : 'general',
4364
+ // 分类:@引用命中的实体品类优先;无引用时按命名规范前缀(角色-/场景-/道具-)兜底
4365
+ category: entities.length ? entityCategoryOf(entities[0].kind) : (categoryFromName(n.data.label) || 'general'),
3828
4366
  });
3829
- commitGenResult(n.id, rec.id, demo, 'image');
4367
+ commitGenResult(n.id, rec.id, false, 'image');
3830
4368
  } else {
3831
4369
  const frameBlob = await resolveAssetBlob(n.id, 'frame');
3832
- const { refs, entities } = await collectRefs(n, prompt, bible);
4370
+ const { refs, entities, audioBlobs } = await collectRefs(n, prompt, bible);
3833
4371
  const p = buildEffectivePrompt(prompt, refs, entities, bible);
3834
- const { blob, demo } = await generateVideo(p, frameBlob, {
3835
- duration: n.data.duration, resolution: n.data.resolution, ratio: n.data.ratio, apiId: n.data.videoApiId,
4372
+ const mcfg = resolveVideoModelCfg(settings, nodeVideoModelRef(n));
4373
+ if (!mcfg) throw new Error(T('errNoVideoModel'));
4374
+ const blob = await apiVideo(p, frameBlob, mcfg, {
4375
+ duration: n.data.duration, resolution: n.data.resolution, ratio: n.data.ratio,
3836
4376
  refs: refs.map((r) => r.blob),
4377
+ audioRefs: audioBlobs.map((a) => a.blob), // 角色声音参考(@引用实体名下的音频资产)
3837
4378
  });
3838
4379
  const rec = await saveAssetRecord(S.activeId, 'video', blob, {
3839
4380
  name: assetNameFor(n, entities, 'video'),
3840
4381
  prompt: stripMentionMarks(prompt), effectivePrompt: p,
3841
- source: demo ? 'demo' : 'api',
4382
+ source: 'api',
3842
4383
  entity: entities.length ? entities[0].name : undefined,
3843
4384
  entities: entities.map((e) => e.name),
3844
4385
  nodeLabel: (n.data && n.data.label) || '',
3845
- category: entities.length ? entityCategoryOf(entities[0].kind) : 'general',
4386
+ category: entities.length ? entityCategoryOf(entities[0].kind) : (categoryFromName(n.data.label) || 'general'),
3846
4387
  });
3847
- commitGenResult(n.id, rec.id, demo, 'video');
4388
+ commitGenResult(n.id, rec.id, false, 'video');
3848
4389
  // 尾帧提取(通用):供 last 端口输出与链式下一镜使用
3849
4390
  try {
3850
4391
  const fBlob = await extractLastFrame(blob);
3851
4392
  const fRec = await saveAssetRecord(S.activeId, 'image', fBlob, {
3852
4393
  name: T('lastFrame') + '-' + (n.data.label || n.id.slice(-4)),
3853
- prompt: 'last frame of ' + prompt.slice(0, 60), source: demo ? 'demo' : 'api', category: 'general',
4394
+ prompt: 'last frame of ' + prompt.slice(0, 60), source: 'api', category: 'general',
3854
4395
  });
3855
4396
  update((s) => {
3856
4397
  const nd = s.graph.nodes[n.id];
@@ -4569,21 +5110,26 @@ window.__ModuleLoader__.load({
4569
5110
 
4570
5111
  function GenSettingsRow({ n, kind }) {
4571
5112
  const settings = loadSettings();
4572
- const cfgs = kind === 'image' ? (settings.imageApis || []) : (settings.videoApis || []);
4573
- const cfgValue = (kind === 'image' ? n.data.imageApiId : n.data.videoApiId) || '';
4574
- const setCfg = (v) => update((s) => {
5113
+ const models = kind === 'image' ? flattenImageModels(settings) : flattenVideoModels(settings);
5114
+ const star = kind === 'image' ? settings.imageStar : settings.videoStar;
5115
+ const cur = kind === 'image' ? nodeImageModelRef(n) : nodeVideoModelRef(n);
5116
+ const setModel = (v) => update((s) => {
4575
5117
  const nd = s.graph.nodes[n.id];
4576
5118
  if (!nd) return;
4577
- if (kind === 'image') nd.data.imageApiId = v; else nd.data.videoApiId = v;
5119
+ if (kind === 'image') { nd.data.imageModel = v; delete nd.data.imageApiId; }
5120
+ else { nd.data.videoModel = v; delete nd.data.videoApiId; }
4578
5121
  });
4579
- // 两行布局,避免超出 240px 节点宽度
5122
+ // 模型下拉:只列配置的模型(供应商 · 模型,星标者带 ★);留空 = 路由到默认模型;没配置 = 空(运行报错)
4580
5123
  return h('div', { key: 'set', style: { display: 'flex', flexDirection: 'column', gap: 5 } },
4581
5124
  h('div', { className: 'dshic-node-row' },
4582
5125
  h('div', { style: { flex: 1, minWidth: 0 } },
4583
5126
  h(MiniSelect, {
4584
- value: cfgValue, title: T('modelLabel'), width: '100%',
4585
- options: [{ value: '', label: T('defaultCfg') }].concat(cfgs.map((c) => ({ value: c.id, label: (c.name || c.id) }))),
4586
- onChange: setCfg,
5127
+ value: cur, title: T('modelLabel'), width: '100%',
5128
+ disabled: !models.length,
5129
+ options: models.length
5130
+ ? models.map((m) => ({ value: m.ref, label: (m.ref === star ? '★ ' : '') + m.providerName + ' · ' + m.model }))
5131
+ : [{ value: '', label: T('noModelsCfg') }],
5132
+ onChange: setModel,
4587
5133
  })),
4588
5134
  h(MiniSelect, {
4589
5135
  value: n.data.ratio || '16:9', title: T('ratioLabel'), width: 64,
@@ -4615,6 +5161,112 @@ window.__ModuleLoader__.load({
4615
5161
  );
4616
5162
  }
4617
5163
 
5164
+ /** 声音参考提取:输入**角色名** → 自动按分镜表 dialogue 定位该角色的全部台词时间窗,
5165
+ * 逐窗取音轨 PCM **拼接为一个 WAV**(一个角色一个总的声音参考文件,无需手动找时间点);
5166
+ * 分镜缺少台词标注时降级为手动起止秒模式。 */
5167
+ function VoiceExtractDialog({ nodeId, onClose }) {
5168
+ const n = S.graph.nodes[nodeId];
5169
+ const srcId = (n && n.data && (n.data.assetId || n.data.fcAssetId)) || null;
5170
+ const url = useAssetUrl(srcId);
5171
+ // 分镜来源:视频节点 → 上游分镜表;成片节点 → 经片段视频节点的上游分镜表
5172
+ const shots = (() => {
5173
+ const seen = new Set();
5174
+ const out = [];
5175
+ const collect = (nid) => {
5176
+ for (const e of S.graph.edges.filter((x) => x.toNode === nid && x.toPort === 'prompt')) {
5177
+ const sbn = S.graph.nodes[e.fromNode];
5178
+ 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); }
5179
+ }
5180
+ };
5181
+ collect(nodeId);
5182
+ if (!out.length) {
5183
+ for (const e of S.graph.edges.filter((x) => x.toNode === nodeId && x.toPort === 'in')) {
5184
+ collect(e.fromNode); // 片段视频节点的上游分镜
5185
+ }
5186
+ }
5187
+ return out;
5188
+ })();
5189
+ const roles = [...new Set(shots.flatMap((s) => (s.dialogue || []).map((l) => String(l.role || '').trim()).filter(Boolean)))];
5190
+ const [role, setRole] = useState('');
5191
+ const [manual, setManual] = useState(false);
5192
+ const [startS, setStartS] = useState('0');
5193
+ const [endS, setEndS] = useState('');
5194
+ const [busy, setBusy] = useState(false);
5195
+ const [err, setErr] = useState(null);
5196
+ const [done, setDone] = useState(null); // {segs}
5197
+ const windows = (() => {
5198
+ const rl = String(role || '').trim().toLowerCase();
5199
+ if (!rl) return [];
5200
+ const out = [];
5201
+ let t = 0;
5202
+ for (const s of shots) {
5203
+ const d = Number(s.duration) || 0;
5204
+ const lines = (s.dialogue || []).filter((l) => String(l.role || '').trim().toLowerCase() === rl);
5205
+ if (lines.length) out.push({ title: s.title, from: t, to: t + d, lines: lines.map((l) => l.role + ':' + l.line) });
5206
+ t += d;
5207
+ }
5208
+ return out;
5209
+ })();
5210
+ const submit = async () => {
5211
+ const role2 = String(role || '').trim();
5212
+ if (!role2) { setErr(T('voiceRoleNeeded')); return; }
5213
+ if (!url) { setErr(T('noResult')); return; }
5214
+ setBusy(true); setErr(null);
5215
+ try {
5216
+ const blob = await (await fetch(url)).blob();
5217
+ let wav = null;
5218
+ let segs = [];
5219
+ if (!manual) {
5220
+ const r = await extractVoiceAuto(blob, shots, role2);
5221
+ wav = r.blob; segs = r.segs;
5222
+ } else {
5223
+ const r = await extractWavRanges(blob, [[Number(startS) || 0, Number(endS) || Number(startS) || 0]]);
5224
+ wav = r.blob; segs = [{ title: T('voiceManual'), from: Number(startS) || 0, to: Number(endS) || 0, lines: [] }];
5225
+ }
5226
+ const rec = await saveAssetRecord(S.activeId, 'audio', wav, {
5227
+ name: '角色-' + role2 + ' 声音', entity: role2,
5228
+ prompt: T('voiceExtractSrc') + ':' + ((n.data && n.data.label) || nodeId) + (segs.length ? '(' + segs.map((x) => x.title + ' ' + x.from + '-' + x.to + 's').join('、') + ')' : ''),
5229
+ source: 'voice-extract', category: 'audio',
5230
+ });
5231
+ setDone(segs);
5232
+ update((s2) => { s2.assetsTick++; });
5233
+ toast(T('voiceExtractDone').replace('{name}', rec.name));
5234
+ } catch (e) { setErr(String((e && e.message) || e)); }
5235
+ setBusy(false);
5236
+ };
5237
+ return h('div', { className: 'dshic-modal-overlay', onPointerDown: (e) => { if (e.target === e.currentTarget) onClose(); } },
5238
+ h('div', { className: 'dshic-modal', style: { width: 440 } },
5239
+ h('div', { className: 'dshic-modal-title' }, T('voiceExtractBtn') + ' — ' + ((n && n.data && n.data.label) || '')),
5240
+ shots.length
5241
+ ? h('div', { className: 'dshic-modal-warn' }, T('voiceAutoHint'))
5242
+ : h('div', { className: 'dshic-modal-warn' }, T('voiceNoDialogue')),
5243
+ h(Field, { key: 'role', label: T('voiceRole'), value: role, onChange: setRole, placeholder: T('voiceRolePh') }),
5244
+ roles.length && !manual
5245
+ ? h('div', { className: 'dshic-modal-warn' }, T('voiceRolesInShots') + roles.join('、'))
5246
+ : null,
5247
+ !manual && windows.length
5248
+ ? h('div', { key: 'segs', className: 'dshic-modal-warn' },
5249
+ T('voiceSegs') + '(' + windows.length + '):',
5250
+ 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(' / '))))
5251
+ : null,
5252
+ manual
5253
+ ? [
5254
+ h('div', { className: 'dshic-row' },
5255
+ h(Field, { key: 's', label: T('voiceStart'), value: startS, onChange: setStartS, placeholder: '0' }),
5256
+ h(Field, { key: 'e', label: T('voiceEnd'), value: endS, onChange: setEndS, placeholder: '—' }),
5257
+ ),
5258
+ ]
5259
+ : null,
5260
+ h('label', { className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
5261
+ h('input', { type: 'checkbox', checked: manual, onChange: (e) => setManual(e.target.checked), onPointerDown: (e) => e.stopPropagation() }),
5262
+ h('span', {}, T('voiceManual'))),
5263
+ done ? h('div', { className: 'dshic-modal-warn' }, T('voiceExtractDone').replace('{name}', '角色-' + (String(role || '').trim() || '—') + ' 声音') + '(' + done.map((x) => x.title).join('、') + ')') : null,
5264
+ err ? h('div', { className: 'dshic-err' }, err) : null,
5265
+ h('div', { className: 'dshic-modal-actions' },
5266
+ h('button', { type: 'button', className: 'dshic-btn dshic-btn-outline', onClick: onClose, onPointerDown: (e) => e.stopPropagation() }, T('cancel')),
5267
+ 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'))),
5268
+ ));
5269
+ }
4618
5270
  /** 蒙版级局部重绘:涂抹区域导出 mask PNG,走 images/edits 多部分请求。 */
4619
5271
  function MaskEditor({ nodeId, onClose }) {
4620
5272
  const n = S.graph.nodes[nodeId];
@@ -4673,8 +5325,8 @@ window.__ModuleLoader__.load({
4673
5325
  };
4674
5326
  const apply = async () => {
4675
5327
  const settings = loadSettings();
4676
- const cfg = resolveApiCfg(settings.imageApis, settings.imageApiId, n.data.imageApiId);
4677
- if (!cfg) { toast(T('errNoImageApi')); return; }
5328
+ const cfg = resolveImageModelCfg(settings, nodeImageModelRef(n));
5329
+ if (!cfg) { toast(T('errNoImageModel')); return; }
4678
5330
  setRunning(true); setErr(null);
4679
5331
  try {
4680
5332
  const mk = maskRef.current;
@@ -4742,6 +5394,7 @@ window.__ModuleLoader__.load({
4742
5394
  function GenNodeBody({ n, kind }) {
4743
5395
  const [showMask, setShowMask] = useState(false);
4744
5396
  const [showCraft, setShowCraft] = useState(false);
5397
+ const [voiceDlg, setVoiceDlg] = useState(false);
4745
5398
  useEffect(() => { probeCraft(); }, []);
4746
5399
  const promptEdge = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'prompt');
4747
5400
  const promptSrc = promptEdge && S.graph.nodes[promptEdge.fromNode];
@@ -4809,7 +5462,7 @@ window.__ModuleLoader__.load({
4809
5462
  const nd2 = s.graph.nodes[nn.id];
4810
5463
  if (nd2) {
4811
5464
  nd2.data.prompt = n.data.prompt || '';
4812
- nd2.data.imageApiId = n.data.imageApiId || '';
5465
+ nd2.data.imageModel = nodeImageModelRef(n);
4813
5466
  nd2.data.size = n.data.size || '2K';
4814
5467
  nd2.data.ratio = n.data.ratio || '16:9';
4815
5468
  }
@@ -4842,10 +5495,18 @@ window.__ModuleLoader__.load({
4842
5495
  onClick: () => chainNext(n),
4843
5496
  }, Ic('film', 11), T('chainNext'))
4844
5497
  : null,
5498
+ kind === 'video' && assetUrlV
5499
+ ? h('button', {
5500
+ type: 'button', className: 'dshic-mini is-ghost', title: T('voiceExtractBtn') + '(' + T('voiceTip') + ')',
5501
+ onPointerDown: (e) => e.stopPropagation(),
5502
+ onClick: () => setVoiceDlg(true),
5503
+ }, Ic('audio', 11), T('voiceExtractBtn'))
5504
+ : null,
4845
5505
  h('div', { className: 'dshic-flex' }),
4846
5506
  )
4847
5507
  : null,
4848
5508
  showMask && assetUrlV ? h(MaskEditor, { key: 'mask', nodeId: n.id, onClose: () => setShowMask(false) }) : null,
5509
+ voiceDlg && assetUrlV ? h(VoiceExtractDialog, { key: 'voice', nodeId: n.id, onClose: () => setVoiceDlg(false) }) : null,
4849
5510
  showCraft && n.data.assetId ? h(PhotoCraftDialog, { key: 'pccd', assetId: n.data.assetId, onClose: () => setShowCraft(false) }) : null,
4850
5511
  (n.data.history || []).length > 1
4851
5512
  ? h('div', { key: 'hist', className: 'dshic-hist' },
@@ -5087,6 +5748,55 @@ window.__ModuleLoader__.load({
5087
5748
  );
5088
5749
  }
5089
5750
 
5751
+ /** 剧本输出 → 场景数组:按分场标记(【场N】/ 场N:/ ## 场N / SCENE N)切分;
5752
+ * 尾部的「人物:/场景/道具:」清单从最后一场剥离(资产生产参考,不属于场景正文)。 */
5753
+ function parseScriptScenes(output) {
5754
+ const text = String(output || '').replace(/\r\n/g, '\n');
5755
+ const head = /^\s*(?:【\s*场\s*\d+[^】]*】|【?\s*场\s*\d+\s*】?\s*[::、..]|场\s*\d+\s*[::、..]|#{1,4}\s*场?\s*\d+|SCENE\s*\d+\b)/i;
5756
+ const scenes = [];
5757
+ let cur = null;
5758
+ for (const line of text.split('\n')) {
5759
+ if (head.test(line)) {
5760
+ if (cur && cur.text.trim()) scenes.push(cur);
5761
+ cur = { title: line.trim().replace(/^#{1,4}\s*/, '').slice(0, 60), text: line + '\n' };
5762
+ } else if (cur) {
5763
+ cur.text += line + '\n';
5764
+ }
5765
+ }
5766
+ if (cur && cur.text.trim()) scenes.push(cur);
5767
+ if (scenes.length) {
5768
+ const last = scenes[scenes.length - 1];
5769
+ const i = last.text.search(/\n(?:人物|角色)\s*[::][\s\S]*$/);
5770
+ if (i >= 0) last.text = last.text.slice(0, i) + '\n';
5771
+ }
5772
+ return scenes.filter((s) => s.text.trim());
5773
+ }
5774
+ /** 剧本节点「铺开到场景」:按【场N】分场标记把整集剧本拆成
5775
+ * 「场景文本节点 → 分镜表节点」组合(每场一组,连线一次生成场景分镜;每场 10-15s)。 */
5776
+ function expandScriptToScenes(n) {
5777
+ const scenes = parseScriptScenes(n.data.output);
5778
+ if (scenes.length < 2) { toast(T('expandNeedScenes')); return; }
5779
+ update((s) => {
5780
+ let row = 0;
5781
+ for (const sc of scenes) {
5782
+ const y = n.y + row * 320;
5783
+ const label = sc.title.slice(0, 40);
5784
+ const tx = newNode('text', { x: n.x + n.w + 160, y });
5785
+ tx.data.label = label;
5786
+ tx.data.text = sc.text.trim();
5787
+ s.graph.nodes[tx.id] = tx;
5788
+ const sb = newNode('storyboard', { x: n.x + n.w + 520, y });
5789
+ sb.data.label = label;
5790
+ s.graph.nodes[sb.id] = sb;
5791
+ s.graph.edges.push({ id: uid('e'), fromNode: tx.id, fromPort: 'out', toNode: sb.id, toPort: 'script' });
5792
+ row++;
5793
+ }
5794
+ s.sel = new Set();
5795
+ });
5796
+ pushHistory();
5797
+ flushSave();
5798
+ toast(T('expandScenesDone').replace('{n}', String(scenes.length)));
5799
+ }
5090
5800
  function ScriptNodeBody({ n }) {
5091
5801
  const running = n.data.aiStatus === 'running';
5092
5802
  const generate = async () => {
@@ -5112,6 +5822,11 @@ window.__ModuleLoader__.load({
5112
5822
  : null,
5113
5823
  h('div', { key: 'btns', className: 'dshic-node-row' },
5114
5824
  h('div', { className: 'dshic-flex' }),
5825
+ n.data.output ? h('button', {
5826
+ type: 'button', className: 'dshic-mini is-ghost', title: T('expandScenes') + '(每场 → 文本+分镜表 组合)',
5827
+ onPointerDown: (e) => e.stopPropagation(),
5828
+ onClick: () => expandScriptToScenes(n),
5829
+ }, Ic('expandAll', 11), T('expandScenes')) : null,
5115
5830
  n.data.output ? h('button', {
5116
5831
  type: 'button', className: 'dshic-mini is-ghost', title: T('saveTextAsset'),
5117
5832
  onPointerDown: (e) => e.stopPropagation(),
@@ -5172,8 +5887,8 @@ window.__ModuleLoader__.load({
5172
5887
  }),
5173
5888
  h('div', { key: 'row', className: 'dshic-node-row' },
5174
5889
  h(MiniSelect, {
5175
- value: String(n.data.count || 6), title: T('storyboardCountLabel'), width: 72,
5176
- options: [3, 4, 6, 8, 10, 12, 16].map((x) => ({ value: String(x), label: x + ' ' + T('shotsUnit') })),
5890
+ value: String(n.data.count || 0), title: T('storyboardCountLabel'), width: 88,
5891
+ options: [{ value: '0', label: T('autoShots') }].concat([3, 4, 6, 8, 10, 12, 16].map((x) => ({ value: String(x), label: x + ' ' + T('shotsUnit') }))),
5177
5892
  onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.count = Number(v); }),
5178
5893
  }),
5179
5894
  h('div', { className: 'dshic-flex' }),
@@ -5300,6 +6015,7 @@ window.__ModuleLoader__.load({
5300
6015
  .sort((a, b) => (a.nd.y - b.nd.y) || (a.nd.x - b.nd.x));
5301
6016
  const assetUrlV = useAssetUrl(n.data.assetId);
5302
6017
  const fcUrl = useAssetUrl(n.data.fcAssetId);
6018
+ const [voiceDlg, setVoiceDlg] = useState(false);
5303
6019
  useEffect(() => { probeCraft(); }, []);
5304
6020
  const getSub = (x) => {
5305
6021
  const defSub = x.nd.type === 'video-gen' ? (x.nd.data.prompt || '') : (x.nd.data.label || TYPE_LABEL()[x.nd.type]);
@@ -5483,9 +6199,15 @@ window.__ModuleLoader__.load({
5483
6199
  onPointerDown: (e) => e.stopPropagation(),
5484
6200
  onClick: () => downloadBlobFromUrl(fcUrl, 'finalcut-fc-' + n.id.slice(-5) + '.mp4'),
5485
6201
  }, Ic('download', 11), T('download')),
6202
+ h('button', {
6203
+ type: 'button', className: 'dshic-mini is-ghost', title: T('voiceExtractBtn'),
6204
+ onPointerDown: (e) => e.stopPropagation(),
6205
+ onClick: () => setVoiceDlg(true),
6206
+ }, Ic('audio', 11), T('voiceExtractBtn')),
5486
6207
  h('div', { className: 'dshic-flex' })),
5487
6208
  ]
5488
6209
  : null,
6210
+ voiceDlg && (n.data.fcAssetId || n.data.assetId) ? h(VoiceExtractDialog, { key: 'vcdlg', nodeId: n.id, onClose: () => setVoiceDlg(false) }) : null,
5489
6211
  fcDialog ? h(FilmcraftArgsDialog, { key: 'fcdlg', info: fcDialog, onSubmit: submitFc, onClose: () => setFcDialog(null) }) : null,
5490
6212
  assetUrlV
5491
6213
  ? [
@@ -6235,6 +6957,99 @@ window.__ModuleLoader__.load({
6235
6957
  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 },
6236
6958
  }, 'i');
6237
6959
  }
6960
+ /** 图片/视频模型配置(供应商 + 模型两级):一个供应商配一次 URL/Key,下面挂多个模型;
6961
+ * ★星标模型 = 节点未指定时的默认路由。 */
6962
+ function ModelProviderEditor({ kind }) {
6963
+ const [s, setS] = useState(loadSettings);
6964
+ const [openId, setOpenId] = useState(null);
6965
+ const listKey = kind === 'image' ? 'imageProviders' : 'videoProviders';
6966
+ const starKey = kind === 'image' ? 'imageStar' : 'videoStar';
6967
+ const icon = kind === 'image' ? 'image' : 'video';
6968
+ const labelKey = kind === 'image' ? 'imageApisLabel' : 'videoApisLabel';
6969
+ const nameKey = kind === 'image' ? 'defaultImageCfg' : 'defaultVideoCfg';
6970
+ const urlKey = kind === 'image' ? 'baseUrl' : 'url';
6971
+ const urlPh = kind === 'image'
6972
+ ? 'https://api.example.com/v1(或完整 /images/generations 端点)'
6973
+ : 'https://api.example.com/v1/video/generations(或裸域 / /v1)';
6974
+ const persist = (next) => { setS(next); saveSettings(next); };
6975
+ const mutate = (fn) => { const next = JSON.parse(JSON.stringify(s)); fn(next); persist(next); };
6976
+ const addProvider = () => {
6977
+ const id = uid('prov');
6978
+ mutate((d) => { d[listKey] = (d[listKey] || []).concat([{ id, name: T(nameKey) + ' ' + ((d[listKey] || []).length + 1), [urlKey]: '', apiKey: '', extra: '', models: [], ...(kind === 'image' ? { mode: 'generations' } : {}) }]); });
6979
+ setOpenId(id);
6980
+ };
6981
+ const removeProvider = (id) => {
6982
+ mutate((d) => { d[listKey] = (d[listKey] || []).filter((x) => x.id !== id); if (d[starKey] && d[starKey].startsWith(id + '|')) d[starKey] = ''; });
6983
+ if (openId === id) setOpenId(null);
6984
+ };
6985
+ const patchProvider = (id, k, v) => mutate((d) => { d[listKey] = (d[listKey] || []).map((x) => (x.id === id ? { ...x, [k]: v } : x)); });
6986
+ 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: '' }]); });
6987
+ 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)); });
6988
+ const removeModel = (pid, mid) => mutate((d) => {
6989
+ const p = (d[listKey] || []).find((x) => x.id === pid); if (!p) return;
6990
+ p.models = (p.models || []).filter((x) => x.id !== mid);
6991
+ if (d[starKey] === pid + '|' + mid) d[starKey] = '';
6992
+ });
6993
+ const toggleStar = (pid, mid) => { const ref = pid + '|' + mid; mutate((d) => { d[starKey] = d[starKey] === ref ? '' : ref; }); };
6994
+ const list = s[listKey] || [];
6995
+ const modelCount = (p) => (p.models || []).filter((m) => String(m.model || '').trim()).length;
6996
+ return h('div', { className: 'dshic-card' },
6997
+ h('div', { className: 'dshic-row' },
6998
+ Ic(icon, 12),
6999
+ h('strong', { style: { fontSize: 12, flex: 1, minWidth: 0, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' } }, T(labelKey)),
7000
+ h('span', { className: 'dshic-tag st' }, String(list.length)),
7001
+ h('button', { type: 'button', className: 'dshic-mini', onClick: addProvider, onPointerDown: (e) => e.stopPropagation() }, Ic('plus', 11), T('addProvider')),
7002
+ ),
7003
+ list.length === 0 ? h('div', { className: 'dshic-hint' }, T('noCfgHint')) : null,
7004
+ list.map((p) => {
7005
+ const open = openId === p.id;
7006
+ return h('div', { key: p.id, className: 'dshic-card', style: { padding: open ? 8 : 5, gap: 6, background: 'var(--dsw-alias-bg-layer-1)' } },
7007
+ h('div', { className: 'dshic-row' },
7008
+ h('button', { type: 'button', className: 'dshic-iconbtn', title: open ? T('collapse') : T('unfold'), onPointerDown: (e) => e.stopPropagation(), onClick: () => setOpenId(open ? null : p.id) }, open ? '▾' : '▸'),
7009
+ open
7010
+ ? 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() })
7011
+ : 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')),
7012
+ h('span', { className: 'dshic-tag st' }, T('modelsLabel') + ' ' + modelCount(p)),
7013
+ h('button', { type: 'button', className: 'dshic-iconbtn', title: T('doDelete'), onPointerDown: (e) => e.stopPropagation(), onClick: () => removeProvider(p.id) }, '×'),
7014
+ ),
7015
+ open ? [
7016
+ h(Field, { key: 'u', label: T('baseUrl'), value: p[urlKey] || '', onChange: (v) => patchProvider(p.id, urlKey, v), placeholder: urlPh }),
7017
+ h(Field, { key: 'k', label: T('apiKey'), value: p.apiKey || '', onChange: (v) => patchProvider(p.id, 'apiKey', v), placeholder: 'sk-…', password: true }),
7018
+ kind === 'image' ? [
7019
+ h(Field, { key: 'size', label: T('imgSizeOverride'), value: p.size || '', onChange: (v) => patchProvider(p.id, 'size', v), placeholder: T('imgSizePlaceholder') }),
7020
+ h(Field, { key: 'rf', label: T('imgResponseFormat'), value: p.responseFormat || '', onChange: (v) => patchProvider(p.id, 'responseFormat', v), placeholder: 'b64_json / url(留空=API 默认)' }),
7021
+ h('div', { key: 'mode', className: 'dshic-field' },
7022
+ h('span', { className: 'dshic-field-label' }, T('imgMode')),
7023
+ h('select', { className: 'dshic-select', value: p.mode || 'generations', onChange: (e) => patchProvider(p.id, 'mode', e.target.value), onPointerDown: (e) => e.stopPropagation() },
7024
+ h('option', { value: 'generations' }, T('modeGenerations')),
7025
+ h('option', { value: 'edits' }, T('modeEdits')))),
7026
+ ] : [
7027
+ h(Field, { key: 'of', label: T('vidOutFormat'), value: p.outputFormat || '', onChange: (v) => patchProvider(p.id, 'outputFormat', v), placeholder: 'mp4(留空不发送)' }),
7028
+ h('label', { key: 'ga', className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
7029
+ h('input', { type: 'checkbox', checked: p.generateAudio === true, onChange: (e) => patchProvider(p.id, 'generateAudio', e.target.checked), onPointerDown: (e) => e.stopPropagation() }),
7030
+ h('span', {}, T('vidAudioOpt'))),
7031
+ ],
7032
+ h(Field, { key: 'extra', label: T('cfgExtra'), value: p.extra || '', onChange: (v) => patchProvider(p.id, 'extra', v), placeholder: '{"watermark":false,"prompt_extend":false}' }),
7033
+ h('div', { className: 'dshic-hint' }, T('cfgExtraHint')),
7034
+ h('div', { className: 'dshic-row', style: { marginTop: 4 } },
7035
+ h('span', { className: 'dshic-field-label', style: { flex: 1, minWidth: 0 } }, T('modelsLabel')),
7036
+ h('button', { type: 'button', className: 'dshic-mini', onPointerDown: (e) => e.stopPropagation(), onClick: () => addModel(p.id) }, Ic('plus', 11), T('addModel')),
7037
+ ),
7038
+ (p.models || []).map((m) => h('div', { key: m.id, className: 'dshic-row' },
7039
+ h('button', {
7040
+ type: 'button', className: 'dshic-mini is-ghost', title: T('starDefault'),
7041
+ style: { color: s[starKey] === p.id + '|' + m.id ? 'var(--dsw-alias-state-warn-primary)' : 'inherit' },
7042
+ onPointerDown: (e) => e.stopPropagation(), onClick: () => toggleStar(p.id, m.id),
7043
+ }, s[starKey] === p.id + '|' + m.id ? '★' : '☆'),
7044
+ 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() }),
7045
+ h('button', { type: 'button', className: 'dshic-iconbtn', title: T('doDelete'), onPointerDown: (e) => e.stopPropagation(), onClick: () => removeModel(p.id, m.id) }, '×'),
7046
+ )),
7047
+ !(p.models || []).length ? h('div', { className: 'dshic-hint' }, T('noModelsCfg')) : null,
7048
+ ] : null,
7049
+ );
7050
+ }),
7051
+ );
7052
+ }
6238
7053
  function ApiListEditor({ kind }) {
6239
7054
  const [s, setS] = useState(loadSettings);
6240
7055
  const [openId, setOpenId] = useState(null);
@@ -6344,7 +7159,7 @@ window.__ModuleLoader__.load({
6344
7159
  : null,
6345
7160
  kind === 'video'
6346
7161
  ? h('div', { key: 'proto', className: 'dshic-field' },
6347
- h('span', { className: 'dshic-field-label' }, T('vidProtocol')),
7162
+ h('span', { className: 'dshic-field-label' }, T('vidProtocol'), InfoTip(T('vidProtocolHint'))),
6348
7163
  h('select', {
6349
7164
  className: 'dshic-select', value: cfg.protocol || 'generic',
6350
7165
  onChange: (e) => patch(cfg.id, 'protocol', e.target.value),
@@ -6353,27 +7168,36 @@ window.__ModuleLoader__.load({
6353
7168
  h('option', { value: 'generic' }, T('protoGeneric')),
6354
7169
  h('option', { value: 'seedance' }, T('protoSeedance'))))
6355
7170
  : null,
7171
+ kind === 'video'
7172
+ ? h('label', { key: 'ga', className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
7173
+ h('input', {
7174
+ type: 'checkbox', checked: cfg.generateAudio === true,
7175
+ onChange: (e) => patch(cfg.id, 'generateAudio', e.target.checked),
7176
+ onPointerDown: (e) => e.stopPropagation(),
7177
+ }),
7178
+ h('span', {}, T('vidAudioOpt')))
7179
+ : null,
7180
+ kind === 'video'
7181
+ ? h(Field, { key: 'of', label: T('vidOutFormat'), value: cfg.outputFormat || '', onChange: (v) => patch(cfg.id, 'outputFormat', v), placeholder: 'mp4(留空不发送)' })
7182
+ : null,
7183
+ kind === 'video' && cfg.protocol === 'seedance'
7184
+ ? h('label', { key: 'wm', className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
7185
+ h('input', {
7186
+ type: 'checkbox', checked: cfg.watermark === true,
7187
+ onChange: (e) => patch(cfg.id, 'watermark', e.target.checked),
7188
+ onPointerDown: (e) => e.stopPropagation(),
7189
+ }),
7190
+ h('span', {}, T('seedanceWatermark')))
7191
+ : null,
7192
+ (kind === 'image' || kind === 'video')
7193
+ ? h(Field, { key: 'extra', label: T('cfgExtra'), value: cfg.extra || '', onChange: (v) => patch(cfg.id, 'extra', v), placeholder: '{"watermark":false,"prompt_extend":false}' })
7194
+ : null,
7195
+ (kind === 'image' || kind === 'video')
7196
+ ? h('div', { className: 'dshic-hint' }, T('cfgExtraHint'))
7197
+ : null,
6356
7198
  kind === 'tts'
6357
7199
  ? h(Field, { key: 'voice', label: T('voice'), value: cfg.voice || '', onChange: (v) => patch(cfg.id, 'voice', v), placeholder: 'alloy' })
6358
7200
  : null,
6359
- (kind === 'video' && (cfg.protocol === 'seedance'))
6360
- ? h('div', { key: 'seedopts', className: 'dshic-field' },
6361
- h('label', { className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
6362
- h('input', {
6363
- type: 'checkbox', checked: cfg.generateAudio !== false,
6364
- onChange: (e) => patch(cfg.id, 'generateAudio', e.target.checked),
6365
- onPointerDown: (e) => e.stopPropagation(),
6366
- }),
6367
- h('span', {}, T('seedanceAudio'))),
6368
- h('label', { className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
6369
- h('input', {
6370
- type: 'checkbox', checked: cfg.watermark === true,
6371
- onChange: (e) => patch(cfg.id, 'watermark', e.target.checked),
6372
- onPointerDown: (e) => e.stopPropagation(),
6373
- }),
6374
- h('span', {}, T('seedanceWatermark'))),
6375
- h('div', { className: 'dshic-hint' }, T('seedanceHint')))
6376
- : null,
6377
7201
  ] : null,
6378
7202
  );
6379
7203
  }),
@@ -6696,8 +7520,8 @@ window.__ModuleLoader__.load({
6696
7520
  ),
6697
7521
  h(LlmModelSelect),
6698
7522
  ),
6699
- h(ApiListEditor, { key: 'imglist', kind: 'image' }),
6700
- h(ApiListEditor, { key: 'vidlist', kind: 'video' }),
7523
+ h(ModelProviderEditor, { key: 'imglist', kind: 'image' }),
7524
+ h(ModelProviderEditor, { key: 'vidlist', kind: 'video' }),
6701
7525
  h(ApiListEditor, { key: 'ttslist', kind: 'tts' }),
6702
7526
  h('div', { key: 'note', className: 'dshic-hint' }, 'DeepSeek Harness · dsh-infinite-canvas' + ((S.updateInfo && S.updateInfo.runningVersion) ? ' v' + S.updateInfo.runningVersion : '')),
6703
7527
  ];
@@ -7249,8 +8073,8 @@ window.__ModuleLoader__.load({
7249
8073
  probeCraft();
7250
8074
  // 检查更新(Host RPC;Host 旧版未带此方法时静默)
7251
8075
  checkUpdate(true);
7252
- // 数据根目录(Host 旧版静默降级为 IndexedDB 模式);就绪后自动搬存量资产到磁盘,再对齐索引
7253
- refreshDataRoot().then(() => autoMigrateAssetsToRoot()).then(() => syncAssetIndexToHost()).catch(() => { /* 静默 */ });
8076
+ // 数据根目录(Host 旧版静默降级为 IndexedDB 模式);就绪后自动搬存量资产到磁盘、纠正分类,再对齐索引
8077
+ refreshDataRoot().then(() => autoMigrateAssetsToRoot()).then(() => fixGeneralCategories()).then(() => syncAssetIndexToHost()).catch(() => { /* 静默 */ });
7254
8078
  // 对话 ↔ 画布同步(启动一次 + 3s 轮询;旧 Host 自动停轮询)
7255
8079
  hostSyncTick().then(() => pushAllProjectsToHost());
7256
8080
  const hostSyncTimer = setInterval(() => hostSyncTick(), 3000);