@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/README.md +20 -15
- package/assets/canvas-studio/SKILL.md +61 -14
- package/client.js +987 -163
- package/index.js +135 -13
- package/package.json +1 -1
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
|
-
|
|
1110
|
-
|
|
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
|
-
|
|
1124
|
-
|
|
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.
|
|
1137
|
-
out.
|
|
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
|
-
|
|
1231
|
+
mode: s.imageApi.mode || 'generations', models: [{ id: 'legacy-image-m', model: s.imageApi.model || '' }],
|
|
1141
1232
|
}];
|
|
1142
|
-
out.
|
|
1233
|
+
out.imageStar = 'legacy-image|legacy-image-m';
|
|
1143
1234
|
}
|
|
1144
|
-
if (!out.
|
|
1145
|
-
out.
|
|
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 || '',
|
|
1238
|
+
url: s.videoApi.url || '', apiKey: s.videoApi.apiKey || '',
|
|
1239
|
+
models: [{ id: 'legacy-video-m', model: s.videoApi.model || '' }],
|
|
1148
1240
|
}];
|
|
1149
|
-
out.
|
|
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
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
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
|
-
|
|
1356
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1871
|
-
for (const b of refBlobs) content.push({ type: 'image_url', image_url: { url: await
|
|
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
|
-
|
|
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
|
-
//
|
|
1906
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2228
|
+
// 分辨率一律小写(Seedance 系为 '720p'/'1080p';'720P' 会被部分网关判为不支持)
|
|
2229
|
+
resolution: (o.resolution || '720p').toLowerCase(),
|
|
1917
2230
|
ratio: o.ratio || '16:9',
|
|
1918
2231
|
};
|
|
1919
|
-
//
|
|
1920
|
-
if (
|
|
2232
|
+
// 首帧图生视频:构图以首帧为准——Seedance 系要求首帧模式必须 ratio=adaptive
|
|
2233
|
+
if (frameBlob) body.ratio = 'adaptive';
|
|
1921
2234
|
if (frameBlob) {
|
|
1922
|
-
try { body.image = await
|
|
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)
|
|
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
|
-
/**
|
|
1949
|
-
function
|
|
1950
|
-
if (!st || typeof st !== 'object') return
|
|
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
|
-
|
|
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
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
const
|
|
1982
|
-
const
|
|
1983
|
-
|
|
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
|
-
|
|
1986
|
-
|
|
1987
|
-
const
|
|
1988
|
-
|
|
1989
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
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
|
-
'-
|
|
2980
|
-
'-
|
|
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
|
-
'-
|
|
2987
|
-
'-
|
|
2988
|
-
'-
|
|
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
|
-
'-
|
|
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
|
-
'-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3066
|
-
const
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
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
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
4364
|
+
// 分类:@引用命中的实体品类优先;无引用时按命名规范前缀(角色-/场景-/道具-)兜底
|
|
4365
|
+
category: entities.length ? entityCategoryOf(entities[0].kind) : (categoryFromName(n.data.label) || 'general'),
|
|
3828
4366
|
});
|
|
3829
|
-
commitGenResult(n.id, rec.id,
|
|
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
|
|
3835
|
-
|
|
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:
|
|
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,
|
|
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:
|
|
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
|
|
4573
|
-
const
|
|
4574
|
-
const
|
|
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.
|
|
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
|
-
//
|
|
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:
|
|
4585
|
-
|
|
4586
|
-
|
|
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 =
|
|
4677
|
-
if (!cfg) { toast(T('
|
|
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.
|
|
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 ||
|
|
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(
|
|
6700
|
-
h(
|
|
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);
|