@ninkch/dsh-infinite-canvas 0.17.11 → 0.17.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client.js CHANGED
@@ -34,7 +34,11 @@ window.__ModuleLoader__.load({
34
34
  outputs: [{ id: 'visual', kind: 'text' }, { id: 'motion', kind: 'text' }],
35
35
  },
36
36
  'script': { inputs: [], outputs: [{ id: 'out', kind: 'text' }] },
37
- 'storyboard': { inputs: [{ id: 'script', kind: 'text' }], outputs: [{ id: 'out', kind: 'text' }] },
37
+ 'storyboard': {
38
+ // 承接端口(按数组顺序自上而下排布):顶部「衔接上一镜」← 上一个分镜表的「尾镜」输出
39
+ inputs: [{ id: 'prev', kind: 'text' }, { id: 'script', kind: 'text' }],
40
+ outputs: [{ id: 'out', kind: 'text' }, { id: 'tail', kind: 'text' }],
41
+ },
38
42
  'image-gen': {
39
43
  inputs: [{ id: 'prompt', kind: 'text' }, { id: 'ref', kind: 'image' }],
40
44
  outputs: [{ id: 'out', kind: 'image' }],
@@ -44,7 +48,7 @@ window.__ModuleLoader__.load({
44
48
  outputs: [{ id: 'out', kind: 'video' }, { id: 'last', kind: 'image' }],
45
49
  },
46
50
  'image-asset': { inputs: [{ id: 'in', kind: 'image' }], outputs: [{ id: 'out', kind: 'image' }] },
47
- 'video-asset': { inputs: [{ id: 'in', kind: 'video' }], outputs: [{ id: 'out', kind: 'video' }] },
51
+ 'video-asset': { inputs: [{ id: 'in', kind: 'video' }], outputs: [{ id: 'out', kind: 'video' }, { id: 'last', kind: 'image' }] },
48
52
  'finalcut': { inputs: [{ id: 'in', kind: 'video' }], outputs: [] },
49
53
  'group': { inputs: [], outputs: [] },
50
54
  };
@@ -52,7 +56,7 @@ window.__ModuleLoader__.load({
52
56
  const MULTI_INPUT_PORTS = new Set(['ref', 'char', 'scene', 'prop']);
53
57
  // 分镜端口期望的资产分类(仅用于连接时的非阻断提示)
54
58
  const PORT_CATEGORY = { char: 'character', scene: 'scene', prop: 'prop' };
55
- const CATEGORIES = ['character', 'scene', 'prop', 'script', 'storyboard', 'audio', 'finalcut', 'general'];
59
+ const CATEGORIES = ['character', 'scene', 'prop', 'script', 'storyboard', 'audio', 'finalcut', 'image', 'general'];
56
60
  const RATIOS = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];
57
61
  const IMAGE_SIZES = ['1K', '2K', '4K'];
58
62
  const VIDEO_DURATIONS = [4, 5, 6, 8, 10, 12, 15];
@@ -233,6 +237,7 @@ window.__ModuleLoader__.load({
233
237
  'catScene': '场景',
234
238
  'catProp': '道具',
235
239
  'catGeneral': '通用',
240
+ 'catImage': '图片',
236
241
  'catLabel': '分类',
237
242
  'rename': '重命名',
238
243
  'cancel': '取消',
@@ -246,7 +251,10 @@ window.__ModuleLoader__.load({
246
251
  'catMismatch': '提示:资产分类为「{a}」,端口期望「{b}」,已保留连接',
247
252
  'nStoryboard': '分镜表',
248
253
  'nScript': '剧本',
254
+ 'vendorLabel': '厂商格式(选后只需填 Base URL + 模型 id)',
249
255
  'portScript': '剧本',
256
+ 'portPrev': '衔接上一镜(尾镜)',
257
+ 'portTail': '尾镜(供下一分镜衔接)',
250
258
  'aiScript': 'AI 生成剧本',
251
259
  'aiStoryboard': 'AI 生成分镜',
252
260
  'expand': '铺开到画布',
@@ -265,6 +273,18 @@ window.__ModuleLoader__.load({
265
273
  'llmLabel': '文本模型(DeepSeek)',
266
274
  'llmHint': '剧本与分镜提示词使用 Harness 当前配置的模型生成,无需另配 Key。',
267
275
  'saveTextAsset': '存为资产',
276
+ 'zoomView': '放大查看',
277
+ 'copyAll': '复制全部',
278
+ 'copiedOk': '已复制到剪贴板',
279
+ 'copyFail': '复制失败:请手动选择文本复制',
280
+ 'lbHint': '点击空白处或按 Esc 关闭',
281
+ 'resizeList': '拖拽调整分镜列表高度',
282
+ 'frameModeLabel': '衔接',
283
+ 'frameModeHint': '首帧口连线时的处理方式。网关禁止首帧与参考图混用:融合=尾帧降级为第1张参考图(保留实体参考图,衔接为软约束);硬首帧=用真首帧参数(画面绝对承接,但放弃参考图);忽略=不使用首帧',
284
+ 'frameModeAuto': '参考图融合',
285
+ 'frameModeHard': '硬首帧(弃参考图)',
286
+ 'frameModeOff': '忽略首帧',
287
+ 'contFrom': '承接',
268
288
  'textAssetHint': '文本资产点击可放入画布文本节点',
269
289
  'placeholderAsset': '待补充',
270
290
  'searchLabel': '搜索名称 / 提示词 / 内容 / 项目',
@@ -607,6 +627,7 @@ window.__ModuleLoader__.load({
607
627
  'catScene': 'Scene',
608
628
  'catProp': 'Prop',
609
629
  'catGeneral': 'General',
630
+ 'catImage': 'Image',
610
631
  'catLabel': 'Category',
611
632
  'rename': 'Rename',
612
633
  'cancel': 'Cancel',
@@ -621,6 +642,9 @@ window.__ModuleLoader__.load({
621
642
  'nStoryboard': 'Storyboard',
622
643
  'nScript': 'Script',
623
644
  'portScript': 'script',
645
+ 'vendorLabel': 'Vendor format (then just Base URL + model id)',
646
+ 'portPrev': 'Continue prev tail',
647
+ 'portTail': 'Tail shot (for next)',
624
648
  'aiScript': 'AI write script',
625
649
  'aiStoryboard': 'AI break into shots',
626
650
  'expand': 'Expand to canvas',
@@ -639,6 +663,18 @@ window.__ModuleLoader__.load({
639
663
  'llmLabel': 'Text model (DeepSeek)',
640
664
  'llmHint': 'Scripts and storyboard prompts use the model configured in Harness — no extra key needed.',
641
665
  'saveTextAsset': 'Save as asset',
666
+ 'zoomView': 'View large',
667
+ 'copyAll': 'Copy all',
668
+ 'copiedOk': 'Copied to clipboard',
669
+ 'copyFail': 'Copy failed — select the text manually',
670
+ 'lbHint': 'Click outside or press Esc to close',
671
+ 'resizeList': 'Drag to resize the shot list',
672
+ 'frameModeLabel': 'Chaining',
673
+ 'frameModeHint': 'How the first-frame input is used. The gateway forbids mixing first_frame with reference_image: Fuse = the tail frame becomes reference image #1 (keeps entity refs; continuity is a soft constraint); Hard = true first_frame param (exact continuity, drops refs); Ignore = do not use the frame',
674
+ 'frameModeAuto': 'Fuse with refs',
675
+ 'frameModeHard': 'Hard first frame',
676
+ 'frameModeOff': 'Ignore frame',
677
+ 'contFrom': 'Continues',
642
678
  'textAssetHint': 'Click a text asset to place it as a canvas text node',
643
679
  'placeholderAsset': 'missing',
644
680
  'searchLabel': 'Search name / prompt / content / project',
@@ -1090,8 +1126,15 @@ window.__ModuleLoader__.load({
1090
1126
  color:var(--dsw-alias-label-secondary);font:inherit;font-size:11px;padding:0 3px;outline:none;cursor:pointer;flex:none;}
1091
1127
  .dshic-shot-list{display:flex;flex-direction:column;gap:4px;max-height:180px;overflow-y:auto;}
1092
1128
  .dshic-shot-item{border:1px solid var(--dsw-alias-border-l1);border-radius:7px;padding:5px 7px;font-size:11px;line-height:15px;
1093
- color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);}
1129
+ color:var(--dsw-alias-label-secondary);background:var(--dsw-alias-bg-layer-2);user-select:text;-webkit-user-select:text;cursor:text;}
1094
1130
  .dshic-shot-item strong{color:var(--dsw-alias-label-primary);font-size:11px;}
1131
+ .dshic-shot-grip{position:absolute;right:3px;bottom:3px;width:18px;height:14px;display:flex;align-items:center;justify-content:center;
1132
+ color:var(--dsw-alias-label-secondary);font-size:13px;line-height:1;cursor:ns-resize;opacity:.6;user-select:none;-webkit-user-select:none;
1133
+ background:var(--dsw-alias-bg-layer-2);border:1px solid var(--dsw-alias-border-l1);border-radius:4px;}
1134
+ .dshic-shot-grip:hover{opacity:1;color:var(--dsw-alias-label-primary);}
1135
+ .dshic-lightbox{position:fixed;inset:0;z-index:90;background:rgba(0,0,0,.82);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;padding:24px;}
1136
+ .dshic-lightbox img,.dshic-lightbox video{max-width:92vw;max-height:80vh;border-radius:8px;background:#000;box-shadow:0 12px 48px rgba(0,0,0,.5);}
1137
+ .dshic-lightbox-cap{font-size:12px;color:#eee;user-select:text;-webkit-user-select:text;max-width:80vw;word-break:break-all;text-align:center;}
1095
1138
  .dshic-textcard{width:100%;height:86px;box-sizing:border-box;border:none;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);
1096
1139
  font:inherit;font-size:12px;line-height:18px;padding:7px 9px;overflow:hidden;text-align:left;display:block;white-space:pre-wrap;}
1097
1140
  /* 文本(剧本等)资产卡:只展示标题与项目标签,不展开正文 */
@@ -1138,7 +1181,7 @@ window.__ModuleLoader__.load({
1138
1181
  .dshic-hist{display:flex;gap:4px;overflow-x:auto;}
1139
1182
  .dshic-hist-thumb{width:44px;height:44px;border-radius:6px;border:2px solid var(--dsw-alias-border-l2);overflow:hidden;cursor:pointer;flex:none;
1140
1183
  background:var(--dsw-alias-bg-layer-2);display:flex;align-items:center;justify-content:center;color:var(--dsw-alias-label-secondary);}
1141
- .dshic-hist-thumb img{width:100%;height:100%;object-fit:cover;display:block;}
1184
+ .dshic-hist-thumb img,.dshic-hist-thumb video{width:100%;height:100%;object-fit:cover;display:block;}
1142
1185
  .dshic-hist-thumb.is-active{border-color:var(--dsw-alias-brand-primary);}
1143
1186
  /* 分组框 */
1144
1187
  .dshic-group{position:absolute;box-sizing:border-box;border:1.5px dashed var(--dsw-alias-brand-primary);border-radius:14px;
@@ -2084,6 +2127,16 @@ window.__ModuleLoader__.load({
2084
2127
  if (!r.ok) throw new Error('下载视频失败 HTTP ' + r.status);
2085
2128
  return r.blob;
2086
2129
  }
2130
+ /** 跨镜衔接标注(短剧分镜链式生成的行业标准做法):图1=上一镜尾帧(仅用于衔接),
2131
+ * 图2..N=实体参考图(名称与 o.refNames 对齐)。Seedance 的 first_frame 与 reference_image 互斥,
2132
+ * 尾帧+参考图同连时尾帧降级为第一张衔接参考图,靠顺序+标注控制用途。 */
2133
+ function videoRefNote(refNames) {
2134
+ const names = (refNames || []).map((x) => String(x || '').trim()).filter(Boolean);
2135
+ const parts = ['【衔接硬约束】[图1] 是上一镜的尾帧:本镜视频的第 1 帧必须与 [图1] 画面一致,并从该画面自然延续——不得跳回远景、不得重新建立场景、不得切换机位'];
2136
+ names.forEach((nm, i) => { parts.push('[图' + (i + 2) + '] 为「' + nm + '」的参考图'); });
2137
+ parts.push('除 [图1] 用于衔接外,各实体外观须与对应参考图一致');
2138
+ return '(' + parts.join(';') + ')';
2139
+ }
2087
2140
  /** 火山方舟 Seedance 官方协议:创建任务 → 轮询 → 下载(支持首帧 + 最多 4 张参考图)。 */
2088
2141
  async function seedanceVideo(prompt, frameBlob, refs, s, opts) {
2089
2142
  if (!s.url) throw new Error('未配置视频 API');
@@ -2091,13 +2144,19 @@ window.__ModuleLoader__.load({
2091
2144
  const base = s.url.replace(/\/+$/, '');
2092
2145
  const headers = { 'Content-Type': 'application/json', ...(s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {}) };
2093
2146
  const o = opts || {};
2094
- const refBlobs = (refs || []).slice(0, 4);
2147
+ // first_frame 与 reference_image 互斥(网关 400)。跨镜衔接(尾帧+参考图同连)→
2148
+ // 首帧降级为第一张衔接参考图:全部按 reference_image 传、提示词按序标注、比例不锁 adaptive
2149
+ const rawRefs = o.hardFrame ? [] : (refs || []).slice(0, 8);
2150
+ const downgrade = Boolean(frameBlob && rawRefs.length);
2151
+ const refBlobs = downgrade ? [frameBlob].concat(rawRefs) : rawRefs;
2095
2152
  let text = prompt;
2096
- if (refBlobs.length && !/\[图\d\]/.test(prompt)) {
2153
+ if (downgrade) {
2154
+ text = videoRefNote(o.refNames) + '\n' + text; // 前置硬约束:衔接要求放在最前,否则模型容易忽略
2155
+ } else if (refBlobs.length && !/\[图\d\]/.test(prompt)) {
2097
2156
  text += '\n(' + [...Array(refBlobs.length)].map((_, i) => '[图' + (i + 1) + ']').join('') + ' 为参考图,请保持其人物/场景/道具特征一致)';
2098
2157
  }
2099
2158
  const content = [{ type: 'text', text }];
2100
- if (frameBlob) content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(frameBlob) }, role: 'first_frame' });
2159
+ if (frameBlob && !downgrade) content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(frameBlob) }, role: 'first_frame' });
2101
2160
  for (const b of refBlobs) content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(b) }, role: 'reference_image' });
2102
2161
  const body = {
2103
2162
  model: s.model,
@@ -2109,18 +2168,21 @@ window.__ModuleLoader__.load({
2109
2168
  generate_audio: s.generateAudio === true,
2110
2169
  };
2111
2170
  try { const extra = parseExtraParams(s.extra); if (extra) Object.assign(body, extra); } catch (e) { throw new Error('视频 API 扩展参数配置有误:' + ((e && e.message) || e)); }
2112
- body.ratio = frameBlob ? 'adaptive' : (o.ratio || '16:9');
2171
+ body.ratio = (frameBlob && !downgrade) ? 'adaptive' : (o.ratio || '16:9');
2113
2172
  const res = await fetchViaProxy(base, { headers, body, timeoutMs: Number(s.timeoutMs) > 0 ? Number(s.timeoutMs) : 120000, label: 'Seedance 视频 API' });
2114
2173
  if (!res.ok) throw new Error('Seedance HTTP ' + res.status + ':' + res.text.slice(0, 300));
2115
2174
  const j = JSON.parse(res.text || '{}');
2116
2175
  const taskId = j && j.id;
2117
2176
  if (!taskId) throw new Error('Seedance 响应中没有任务 id');
2118
- const deadline = Date.now() + 15 * 60 * 1000;
2177
+ if (o.onTask) { try { o.onTask(taskId, base + '/' + taskId); } catch { /* 持久化失败不影响生成 */ } }
2178
+ const deadline = Date.now() + 120 * 60 * 1000; // 2 小时:视频任务含排队可能很久,查询层绝不先放弃
2119
2179
  while (Date.now() < deadline) {
2120
2180
  await sleep(10000);
2121
- const r2 = await fetchViaProxy(base + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询 Seedance 任务' });
2122
- if (!r2.ok) throw new Error('查询 Seedance 任务失败 HTTP ' + r2.status);
2123
- const st = JSON.parse(r2.text || '{}');
2181
+ let r2 = null;
2182
+ try { r2 = await fetchViaProxy(base + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询 Seedance 任务' }); } catch { /* 网络抖动重试 */ }
2183
+ if (!r2 || !r2.ok) continue; // 查询层任何失败(网关 5xx/429/断连)都不算任务失败——只等生成任务自身的终态
2184
+ let st = null;
2185
+ try { st = JSON.parse(r2.text || '{}'); } catch { continue; }
2124
2186
  if (st.status === 'succeeded') {
2125
2187
  const u = st.content && st.content.video_url;
2126
2188
  if (!u) throw new Error('Seedance 任务成功但未返回视频地址');
@@ -2130,14 +2192,20 @@ window.__ModuleLoader__.load({
2130
2192
  throw new Error('Seedance 任务失败:' + ((st.error && st.error.message) || st.status));
2131
2193
  }
2132
2194
  }
2133
- throw new Error('Seedance 任务超时(15 分钟)');
2195
+ throw new Error('Seedance 任务超过 2 小时未返回(任务 id 已保存在节点上,可凭 id 回查结果)');
2134
2196
  }
2135
2197
  /** Seedance 原生 content[] 格式兜底(RelayRouter 等:POST /v1/video/generations {model, content, metadata},
2136
2198
  * 轮询 GET /v1/video/query?task_id=...)。首帧/参考图角色与 Seedance 原生协议一致(图压缩内联)。 */
2137
2199
  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' });
2200
+ // 尾帧+参考图同连(跨镜衔接)→ 尾帧降级为第一张衔接参考图(first_frame/reference_image 互斥)
2201
+ const rawRefs = o.hardFrame ? [] : (o.refs || []).slice(0, 8);
2202
+ const downgrade = Boolean(frameBlob && rawRefs.length);
2203
+ const refBlobs = downgrade ? [frameBlob].concat(rawRefs) : rawRefs;
2204
+ // 注意:prompt 由调用方(apiVideo 通用路径)传入,downgrade 时的 videoRefNote 已在通用路径前置,这里不重复
2205
+ const text = prompt;
2206
+ const content = [{ type: 'text', text }];
2207
+ if (frameBlob && !downgrade) content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(frameBlob) }, role: 'first_frame' });
2208
+ for (const b of refBlobs) content.push({ type: 'image_url', image_url: { url: await blobToDataUrlScaled(b) }, role: 'reference_image' });
2141
2209
  const body = {
2142
2210
  model: s.model,
2143
2211
  content,
@@ -2155,12 +2223,14 @@ window.__ModuleLoader__.load({
2155
2223
  if (!taskId) throw new Error('响应中没有任务 id:' + r1.text.slice(0, 200));
2156
2224
  let queryEp = '';
2157
2225
  try { queryEp = new URL(base).origin + '/v1/video/query'; } catch { throw new Error('无法构造轮询地址'); }
2226
+ if (o.onTask) { try { o.onTask(taskId, queryEp + '?task_id=' + encodeURIComponent(taskId)); } catch { /* 持久化失败不影响生成 */ } }
2158
2227
  const dlHeaders = (u) => { try { return new URL(u).origin === new URL(base).origin ? headers : {}; } catch { return {}; } };
2159
- const deadline = Date.now() + 15 * 60 * 1000;
2228
+ const deadline = Date.now() + 120 * 60 * 1000; // 2 小时:查询层绝不先放弃
2160
2229
  while (Date.now() < deadline) {
2161
2230
  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);
2231
+ let r2 = null;
2232
+ try { r2 = await fetchViaProxy(queryEp + '?task_id=' + encodeURIComponent(taskId), { method: 'GET', headers, timeoutMs: 60000, label: '查询视频任务' }); } catch { /* 网络抖动重试 */ }
2233
+ if (!r2 || !r2.ok) continue; // 查询层失败 ≠ 任务失败,只等生成任务自身的终态
2164
2234
  let st = null;
2165
2235
  try { st = JSON.parse(r2.text); } catch { /* 忽略 */ }
2166
2236
  const u = extractVideoUrl(st);
@@ -2170,7 +2240,7 @@ window.__ModuleLoader__.load({
2170
2240
  const status = taskStatusOf(st);
2171
2241
  if (['failed', 'expired', 'canceled', 'cancelled', 'error'].includes(status)) throw new Error('视频任务失败:' + status);
2172
2242
  }
2173
- throw new Error('视频生成超时(15 分钟)');
2243
+ throw new Error('视频生成超过 2 小时未返回(任务 id 已保存在节点上,可凭 id 回查结果)');
2174
2244
  }
2175
2245
  /** Sora 风格 /v1/videos 兜底(RelayRouter 等网关:/v1/video/generations 不接某些视频渠道时)。
2176
2246
  * 提交 POST {model, prompt, seconds, size} → 轮询 GET /v1/videos/{id} →
@@ -2190,11 +2260,13 @@ window.__ModuleLoader__.load({
2190
2260
  try { j1 = JSON.parse(r1.text); } catch { /* 非 JSON */ }
2191
2261
  const taskId = j1 && (j1.id || j1.task_id || (j1.data && j1.data[0] && j1.data[0].id));
2192
2262
  if (!taskId) throw new Error('/v1/videos 响应中没有任务 id:' + r1.text.slice(0, 200));
2193
- const deadline = Date.now() + 15 * 60 * 1000;
2263
+ if (o.onTask) { try { o.onTask(taskId, ep + '/' + taskId); } catch { /* 持久化失败不影响生成 */ } }
2264
+ const deadline = Date.now() + 120 * 60 * 1000; // 2 小时:查询层绝不先放弃
2194
2265
  while (Date.now() < deadline) {
2195
2266
  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);
2267
+ let r2 = null;
2268
+ try { r2 = await fetchViaProxy(ep + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询视频任务' }); } catch { /* 网络抖动重试 */ }
2269
+ if (!r2 || !r2.ok) continue; // 查询层失败 ≠ 任务失败,只等生成任务自身的终态
2198
2270
  let st = null;
2199
2271
  try { st = JSON.parse(r2.text); } catch { /* 忽略 */ }
2200
2272
  const u = extractVideoUrl(st);
@@ -2205,14 +2277,327 @@ window.__ModuleLoader__.load({
2205
2277
  if (taskDoneOf(st)) return await downloadVideo(ep + '/' + taskId + '/content', {}, headers);
2206
2278
  if (['failed', 'expired', 'canceled', 'cancelled', 'error'].includes(status)) throw new Error('视频任务失败:' + status);
2207
2279
  }
2208
- throw new Error('视频生成超时(15 分钟)');
2280
+ throw new Error('视频生成超过 2 小时未返回(任务 id 已保存在节点上,可凭 id 回查结果)');
2209
2281
  }
2282
+ // ============================================================
2283
+ // 视频厂商预设层:用户只配「厂商格式 + Base URL + 模型 id」,
2284
+ // 端点路径/鉴权方式/参数格式/轮询语义全部由代码按各家官方 API 适配。
2285
+ // ============================================================
2286
+ const VIDEO_VENDORS = {
2287
+ openai: { label: 'OpenAI 兼容格式(通用/中转站)', note: '适配 /v1/video/generations、Seedance content[]、/v1/videos 三段自动降级' },
2288
+ seedance: { label: '火山方舟 · Seedance(豆包视频)', base: 'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks', keyPh: 'ARK API Key', note: 'Base URL 填方舟提交端点或 /api/v3 均可;模型 id 如 doubao-seedance-2-0-260128' },
2289
+ kling: { label: '快手可灵 Kling', base: 'https://api.klingai.com', keyPh: 'AK:SK(冒号分隔)', note: 'Key 填 AK:SK,代码自动签 JWT;国际站把 Base URL 改为 api-singapore.klingai.com;时长按官方 5/10s 取整' },
2290
+ wan: { label: '阿里通义万相 Wan(DashScope)', base: 'https://dashscope.aliyuncs.com', note: '异步任务制;国际站把 Base URL 改为 dashscope-intl.aliyuncs.com;图生视频走 img_url' },
2291
+ minimax: { label: 'MiniMax 海螺视频', base: 'https://api.minimaxi.com', note: '异步任务制;时长按官方 6/10s 取整;首帧图支持 base64' },
2292
+ vidu: { label: '生数科技 Vidu', base: 'https://api.vidu.com', note: '图片支持 base64;时长按官方 4/8s 取整' },
2293
+ runway: { label: 'Runway Gen-4', base: 'https://api.dev.runwayml.com', note: '代码自动带 X-Runway-Version 头;时长 5/10s;首帧图支持 dataURL' },
2294
+ veo: { label: 'Google Veo(Gemini API)', base: 'https://generativelanguage.googleapis.com', keyPh: 'API Key', note: '模型 id 如 veo-3.0-fast-generate-preview;图生视频走 inlineData base64' },
2295
+ luma: { label: 'Luma Dream Machine', base: 'https://api.lumalabs.ai/dream-machine/v1', note: '图生视频需要公网图片 URL(本地图片仅支持文生视频)' },
2296
+ pixverse: { label: 'PixVerse', base: 'https://app-api.pixverse.ai/openapi/v2', keyPh: 'API-KEY', note: '图片支持 base64;时长 5/8s;清晰度映射 540p/720p/1080p' },
2297
+ stability: { label: 'Stability AI 图生视频', base: 'https://api.stability.ai', note: 'SVD 纯图生视频(无提示词/无音频);必须有首帧图' },
2298
+ };
2299
+ function detectVideoVendor(s) {
2300
+ if (s && s.vendor) return s.vendor;
2301
+ if (s && (s.protocol === 'seedance' || String(s.url || '').includes('contents/generations/tasks'))) return 'seedance';
2302
+ return 'openai';
2303
+ }
2304
+ function b64Pure(blob) {
2305
+ return new Promise((res, rej) => {
2306
+ const r = new FileReader();
2307
+ r.onload = () => res(String(r.result).split(',')[1] || '');
2308
+ r.onerror = () => rej(new Error('图片编码失败'));
2309
+ r.readAsDataURL(blob);
2310
+ });
2311
+ }
2312
+ /** 厂商任务轮询:查询层任何失败(网络/5xx)都不判死——只认任务自身的终态;2 小时兜底。 */
2313
+ async function vendorPoll(label, step, query) {
2314
+ const deadline = Date.now() + 120 * 60 * 1000;
2315
+ while (Date.now() < deadline) {
2316
+ await sleep(step);
2317
+ let r = null;
2318
+ try { r = await query(); } catch { continue; }
2319
+ if (!r) continue;
2320
+ if (r.state === 'done') return r.url;
2321
+ if (r.state === 'fail') throw new Error(label + '任务失败:' + (r.error || '未知原因'));
2322
+ }
2323
+ throw new Error(label + '超过 2 小时未返回(任务 id 已保存在节点上,可凭 id 回查结果)');
2324
+ }
2325
+ const VIDEO_VENDOR_RUNNERS = {
2326
+ // —— 快手可灵 Kling:JWT(AK:SK) 鉴权,i2v/t2v,轮询 task_id ——
2327
+ async kling(prompt, frameBlob, s, o) {
2328
+ const [ak, sk] = String(s.apiKey || '').split(':');
2329
+ if (!ak || !sk) throw new Error('可灵的 Key 请填 AK:SK(冒号分隔,在可灵开放平台获取)');
2330
+ const enc = new TextEncoder();
2331
+ const b64u = (u8) => btoa(String.fromCharCode.apply(null, u8)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
2332
+ const hj = b64u(enc.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
2333
+ const now = Math.floor(Date.now() / 1000);
2334
+ const pj = b64u(enc.encode(JSON.stringify({ iss: ak, exp: now + 1800, nbf: now - 5 })));
2335
+ const key = await crypto.subtle.importKey('raw', enc.encode(sk), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
2336
+ const sg = b64u(new Uint8Array(await crypto.subtle.sign('HMAC', key, enc.encode(hj + '.' + pj))));
2337
+ const jwt = hj + '.' + pj + '.' + sg;
2338
+ const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt };
2339
+ const base = String(s.url || VIDEO_VENDORS.kling.base).replace(/\/+$/, '');
2340
+ const path = frameBlob ? 'image2video' : 'text2video';
2341
+ const body = {
2342
+ model_name: s.model || 'kling-v1', prompt,
2343
+ cfg_scale: 0.5, mode: 'std',
2344
+ aspect_ratio: (o.ratio || '16:9') === '9:16' ? '9:16' : (o.ratio || '16:9') === '1:1' ? '1:1' : '16:9',
2345
+ duration: String(Math.max(5, Math.min(10, Number(o.duration) || 5)) <= 5 ? 5 : 10),
2346
+ };
2347
+ if (frameBlob) body.image = await blobToDataUrlScaled(frameBlob);
2348
+ const r1 = await fetchViaProxy(base + '/v1/video/' + path, { headers, body, label: 'Kling 提交' });
2349
+ if (!r1.ok) throw new Error('Kling 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2350
+ const j1 = JSON.parse(r1.text || '{}');
2351
+ const tid = j1 && j1.data && j1.data.task_id;
2352
+ if (!tid) throw new Error('Kling 响应中没有 task_id:' + r1.text.slice(0, 200));
2353
+ const url = await vendorPoll('Kling', 8000, async () => {
2354
+ const r = await fetchViaProxy(base + '/v1/video/' + path + '/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Kling 查询' });
2355
+ if (!r || !r.ok) return { state: 'wait' };
2356
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2357
+ const st = j && j.data && j.data.task_status;
2358
+ if (st === 'succeed') {
2359
+ const v = j.data.result && j.data.result.videos && j.data.result.videos[0];
2360
+ return v && v.url ? { state: 'done', url: v.url } : { state: 'fail', error: '成功但无视频地址' };
2361
+ }
2362
+ if (st === 'failed') return { state: 'fail', error: (j.data && j.data.task_status_msg) || st };
2363
+ return { state: 'wait' };
2364
+ });
2365
+ return await downloadVideo(url, {});
2366
+ },
2367
+ // —— 阿里通义万相 Wan:DashScope 异步,X-DashScope-Async ——
2368
+ async wan(prompt, frameBlob, s, o) {
2369
+ const base = String(s.url || VIDEO_VENDORS.wan.base).replace(/\/+$/, '');
2370
+ const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || ''), 'X-DashScope-Async': 'enable' };
2371
+ const body = {
2372
+ model: s.model || 'wan2.2-t2v-plus',
2373
+ input: { prompt, ...(frameBlob ? { img_url: await blobToDataUrlScaled(frameBlob) } : {}) },
2374
+ parameters: { prompt_extend: true },
2375
+ };
2376
+ const r1 = await fetchViaProxy(base + '/api/v1/services/aigc/video-generation/video-synthesis', { headers, body, label: 'Wan 提交' });
2377
+ if (!r1.ok) throw new Error('Wan 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2378
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Wan 响应非 JSON:' + r1.text.slice(0, 200)); }
2379
+ const tid = j1 && j1.output && j1.output.task_id;
2380
+ if (!tid) throw new Error('Wan 响应中没有 task_id:' + r1.text.slice(0, 200));
2381
+ const url = await vendorPoll('Wan', 8000, async () => {
2382
+ const r = await fetchViaProxy(base + '/api/v1/tasks/' + tid, { method: 'GET', headers: { Authorization: 'Bearer ' + (s.apiKey || '') }, timeoutMs: 60000, label: 'Wan 查询' });
2383
+ if (!r || !r.ok) return { state: 'wait' };
2384
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2385
+ const st = j && j.output && j.output.task_status;
2386
+ if (st === 'SUCCEEDED') return j.output.video_url ? { state: 'done', url: j.output.video_url } : { state: 'fail', error: '成功但无视频地址' };
2387
+ if (st === 'FAILED' || st === 'CANCELED') return { state: 'fail', error: (j.output && j.output.message) || st };
2388
+ return { state: 'wait' };
2389
+ });
2390
+ return await downloadVideo(url, {});
2391
+ },
2392
+ // —— MiniMax 海螺:异步 task_id + query 接口 ——
2393
+ async minimax(prompt, frameBlob, s, o) {
2394
+ const base = String(s.url || VIDEO_VENDORS.minimax.base).replace(/\/+$/, '');
2395
+ const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || '') };
2396
+ const body = {
2397
+ model: s.model || 'MiniMax-Hailuo-02', prompt, prompt_optimizer: true,
2398
+ duration: String(Math.max(6, Math.min(10, Number(o.duration) || 6)) <= 6 ? 6 : 10),
2399
+ resolution: String(o.resolution || '').toLowerCase().includes('1080') ? '1080P' : '768P',
2400
+ };
2401
+ if (frameBlob) body.first_frame_image = await blobToDataUrlScaled(frameBlob);
2402
+ const r1 = await fetchViaProxy(base + '/v1/video_generation', { headers, body, label: 'MiniMax 提交' });
2403
+ if (!r1.ok) throw new Error('MiniMax 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2404
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('MiniMax 响应非 JSON'); }
2405
+ const tid = j1 && (j1.task_id || (j1.data && j1.data.task_id));
2406
+ if (!tid) throw new Error('MiniMax 响应中没有 task_id:' + r1.text.slice(0, 200));
2407
+ const url = await vendorPoll('MiniMax', 8000, async () => {
2408
+ const r = await fetchViaProxy(base + '/v1/query/video_generation?task_id=' + encodeURIComponent(tid), { method: 'GET', headers, timeoutMs: 60000, label: 'MiniMax 查询' });
2409
+ if (!r || !r.ok) return { state: 'wait' };
2410
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2411
+ const st = String(j.status || '').toLowerCase();
2412
+ if (st === 'success' || st === 'file_downloaded') {
2413
+ const u = j.file && (j.file.download_url || j.file.url);
2414
+ return u ? { state: 'done', url: u } : { state: 'fail', error: '成功但无视频地址' };
2415
+ }
2416
+ if (st === 'fail') return { state: 'fail', error: (j.base_resp && j.base_resp.status_msg) || '失败' };
2417
+ return { state: 'wait' };
2418
+ });
2419
+ return await downloadVideo(url, {});
2420
+ },
2421
+ // —— 生数 Vidu:img2video/text2video,图片 base64 直传 ——
2422
+ async vidu(prompt, frameBlob, s, o) {
2423
+ const base = String(s.url || VIDEO_VENDORS.vidu.base).replace(/\/+$/, '');
2424
+ const headers = { 'Content-Type': 'application/json', Authorization: 'Token ' + (s.apiKey || '') };
2425
+ const path = frameBlob ? 'img2video' : 'text2video';
2426
+ const body = {
2427
+ model: s.model || 'vidu2.0', prompt,
2428
+ duration: Math.max(4, Math.min(8, Number(o.duration) || 4)) <= 4 ? 4 : 8,
2429
+ resolution: String(o.resolution || '').toLowerCase().includes('1080') ? '1080p' : '720p',
2430
+ };
2431
+ if (frameBlob) body.images = [await blobToDataUrlScaled(frameBlob)];
2432
+ const r1 = await fetchViaProxy(base + '/ent/v2/' + path, { headers, body, label: 'Vidu 提交' });
2433
+ if (!r1.ok) throw new Error('Vidu 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2434
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Vidu 响应非 JSON'); }
2435
+ const tid = j1 && (j1.task_id || j1.id);
2436
+ if (!tid) throw new Error('Vidu 响应中没有 task_id:' + r1.text.slice(0, 200));
2437
+ const url = await vendorPoll('Vidu', 8000, async () => {
2438
+ const r = await fetchViaProxy(base + '/ent/v2/tasks/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Vidu 查询' });
2439
+ if (!r || !r.ok) return { state: 'wait' };
2440
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2441
+ const st = String(j.state || '').toLowerCase();
2442
+ if (st === 'success') {
2443
+ const vids = (j.resp && (j.resp.videos || j.resp.creations)) || j.videos || [];
2444
+ const v = vids[0] || {};
2445
+ const u = v.url || v.video_url;
2446
+ return u ? { state: 'done', url: u } : { state: 'fail', error: '成功但无视频地址' };
2447
+ }
2448
+ if (st === 'failed') return { state: 'fail', error: j.err_msg || '失败' };
2449
+ return { state: 'wait' };
2450
+ });
2451
+ return await downloadVideo(url, {});
2452
+ },
2453
+ // —— Runway Gen-4:X-Runway-Version 头,tasks 轮询 ——
2454
+ async runway(prompt, frameBlob, s, o) {
2455
+ const base = String(s.url || VIDEO_VENDORS.runway.base).replace(/\/+$/, '');
2456
+ const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || ''), 'X-Runway-Version': '2024-11-06' };
2457
+ const ratio = (o.ratio || '16:9') === '9:16' ? '720:1280' : '1280:720';
2458
+ const dur = Math.max(5, Math.min(10, Number(o.duration) || 5)) <= 5 ? 5 : 10;
2459
+ let ep = '/v1/text_to_video', body = { model: 'gen4_turbo', promptText: prompt, ratio, duration: dur };
2460
+ if (frameBlob) { ep = '/v1/image_to_video'; body = { model: 'gen4_turbo', promptImage: await blobToDataUrlScaled(frameBlob), promptText: prompt, ratio, duration: dur }; }
2461
+ const r1 = await fetchViaProxy(base + ep, { headers, body, label: 'Runway 提交' });
2462
+ if (!r1.ok) throw new Error('Runway 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2463
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Runway 响应非 JSON'); }
2464
+ const tid = j1 && j1.id;
2465
+ if (!tid) throw new Error('Runway 响应中没有任务 id:' + r1.text.slice(0, 200));
2466
+ const url = await vendorPoll('Runway', 8000, async () => {
2467
+ const r = await fetchViaProxy(base + '/v1/tasks/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Runway 查询' });
2468
+ if (!r || !r.ok) return { state: 'wait' };
2469
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2470
+ if (j.status === 'SUCCEEDED') return (j.output && j.output[0]) ? { state: 'done', url: j.output[0] } : { state: 'fail', error: '成功但无视频地址' };
2471
+ if (j.status === 'FAILED') return { state: 'fail', error: j.failure || j.status };
2472
+ return { state: 'wait' };
2473
+ });
2474
+ return await downloadVideo(url, {});
2475
+ },
2476
+ // —— Google Veo(Gemini API):predictLongRunning + operation 轮询 ——
2477
+ async veo(prompt, frameBlob, s, o) {
2478
+ const base = String(s.url || VIDEO_VENDORS.veo.base).replace(/\/+$/, '');
2479
+ const model = s.model || 'veo-3.0-fast-generate-preview';
2480
+ const ep = base + '/v1beta/models/' + encodeURIComponent(model) + ':predictLongRunning?key=' + encodeURIComponent(s.apiKey || '');
2481
+ const inst = { prompt };
2482
+ if (frameBlob) inst.image = { bytesBase64Encoded: await b64Pure(await blobToDataUrlScaled(frameBlob)), mimeType: 'image/png' };
2483
+ const body = { instances: [inst], parameters: { aspectRatio: (o.ratio || '16:9') === '9:16' ? '9:16' : '16:9' } };
2484
+ const r1 = await fetchViaProxy(ep, { headers: { 'Content-Type': 'application/json' }, body, label: 'Veo 提交' });
2485
+ if (!r1.ok) throw new Error('Veo 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2486
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Veo 响应非 JSON'); }
2487
+ const op = j1 && j1.name;
2488
+ if (!op) throw new Error('Veo 响应中没有 operation name:' + r1.text.slice(0, 200));
2489
+ const url = await vendorPoll('Veo', 10000, async () => {
2490
+ const r = await fetchViaProxy(base + '/v1beta/' + op + '?key=' + encodeURIComponent(s.apiKey || ''), { method: 'GET', timeoutMs: 60000, label: 'Veo 查询' });
2491
+ if (!r || !r.ok) return { state: 'wait' };
2492
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2493
+ if (!j.done) return { state: 'wait' };
2494
+ if (j.error) return { state: 'fail', error: j.error.message || '失败' };
2495
+ const g = j.response && (j.response.generateVideoResponse || j.response);
2496
+ const sample = g && ((g.generatedSamples && g.generatedSamples[0]) || (g.videos && g.videos[0]));
2497
+ const u = sample && (sample.video ? sample.video.uri : sample.uri);
2498
+ return u ? { state: 'done', url: u + (u.includes('?') ? '&' : '?') + 'key=' + encodeURIComponent(s.apiKey || '') } : { state: 'fail', error: '成功但无视频地址' };
2499
+ });
2500
+ return await downloadVideo(url, {});
2501
+ },
2502
+ // —— Luma Dream Machine:图片需公网 URL,本地图片仅文生视频 ——
2503
+ async luma(prompt, frameBlob, s, o) {
2504
+ const base = String(s.url || VIDEO_VENDORS.luma.base).replace(/\/+$/, '');
2505
+ const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || '') };
2506
+ const body = { prompt, model: 'ray-2', resolution: String(o.resolution || '').toLowerCase().includes('1080') ? '1080p' : '720p', duration: (Math.max(5, Math.min(9, Number(o.duration) || 5)) <= 5 ? '5s' : '9s') };
2507
+ if (frameBlob) throw new Error('Luma 图生视频需要公网图片 URL——本地图片请改用文生视频,或换其他厂商');
2508
+ const r1 = await fetchViaProxy(base + '/generations', { headers, body, label: 'Luma 提交' });
2509
+ if (!r1.ok) throw new Error('Luma 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2510
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Luma 响应非 JSON'); }
2511
+ const tid = j1 && j1.id;
2512
+ if (!tid) throw new Error('Luma 响应中没有任务 id:' + r1.text.slice(0, 200));
2513
+ const url = await vendorPoll('Luma', 8000, async () => {
2514
+ const r = await fetchViaProxy(base + '/generations/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Luma 查询' });
2515
+ if (!r || !r.ok) return { state: 'wait' };
2516
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2517
+ if (j.state === 'completed') return (j.assets && j.assets.video) ? { state: 'done', url: j.assets.video } : { state: 'fail', error: '成功但无视频地址' };
2518
+ if (j.state === 'failed') return { state: 'fail', error: j.failure_reason || '失败' };
2519
+ return { state: 'wait' };
2520
+ });
2521
+ return await downloadVideo(url, {});
2522
+ },
2523
+ // —— PixVerse:API-KEY 头 + Ai-trace-id,base64 图片 ——
2524
+ async pixverse(prompt, frameBlob, s, o) {
2525
+ const base = String(s.url || VIDEO_VENDORS.pixverse.base).replace(/\/+$/, '');
2526
+ const headers = { 'Content-Type': 'application/json', 'API-KEY': s.apiKey || '', 'Ai-trace-id': 'dshic-' + Date.now().toString(36) };
2527
+ const path = frameBlob ? 'img2video' : 'text2video';
2528
+ const body = {
2529
+ model: 'v4.5', prompt,
2530
+ duration: Math.max(5, Math.min(8, Number(o.duration) || 5)) <= 5 ? 5 : 8,
2531
+ quality: String(o.resolution || '').toLowerCase().includes('1080') ? '1080p' : '720p',
2532
+ aspect_ratio: (o.ratio || '16:9') === '9:16' ? '9:16' : '16:9',
2533
+ };
2534
+ if (frameBlob) body.image = await b64Pure(await blobToDataUrlScaled(frameBlob));
2535
+ const r1 = await fetchViaProxy(base + '/video/' + path, { headers, body, label: 'PixVerse 提交' });
2536
+ if (!r1.ok) throw new Error('PixVerse 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2537
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('PixVerse 响应非 JSON'); }
2538
+ const vid = j1 && j1.Resp && j1.Resp.video_id;
2539
+ if (!vid) throw new Error('PixVerse 响应中没有 video_id:' + r1.text.slice(0, 200));
2540
+ const url = await vendorPoll('PixVerse', 8000, async () => {
2541
+ const r = await fetchViaProxy(base + '/video/result/' + vid, { method: 'GET', headers, timeoutMs: 60000, label: 'PixVerse 查询' });
2542
+ if (!r || !r.ok) return { state: 'wait' };
2543
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2544
+ const resp = j && j.Resp;
2545
+ if (!resp) return { state: 'wait' };
2546
+ if (resp.code === 0 && resp.url) return { state: 'done', url: resp.url };
2547
+ if (resp.code !== 0 && resp.code !== 6 && resp.code !== 7 && resp.code !== 8) return { state: 'fail', error: resp.msg || ('code ' + resp.code) };
2548
+ return { state: 'wait' }; // 6/7/8 = 生成中/排队中/处理中
2549
+ });
2550
+ return await downloadVideo(url, {});
2551
+ },
2552
+ // —— Stability AI:SVD 纯图生视频(multipart,无提示词),结果为 base64 JSON ——
2553
+ async stability(prompt, frameBlob, s, o) {
2554
+ const base = String(s.url || VIDEO_VENDORS.stability.base).replace(/\/+$/, '');
2555
+ if (!frameBlob) throw new Error('Stability 图生视频必须有首帧图(该服务不支持纯文生视频、无提示词输入)');
2556
+ const fd = new FormData();
2557
+ fd.append('image', frameBlob, 'frame.png');
2558
+ fd.append('cfg_scale', '1.8');
2559
+ fd.append('motion_bucket_id', '127');
2560
+ const serialized = new Response(fd);
2561
+ const bodyRaw = arrayBufferToBase64(await serialized.arrayBuffer());
2562
+ const r1 = await fetchViaProxy(base + '/v2beta/image-to-video', { method: 'POST', headers: { Authorization: 'Bearer ' + (s.apiKey || '') }, bodyRaw, bodyContentType: serialized.headers.get('content-type'), label: 'Stability 提交' });
2563
+ if (!r1.ok) throw new Error('Stability 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2564
+ let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Stability 响应非 JSON'); }
2565
+ const tid = j1 && j1.id;
2566
+ if (!tid) throw new Error('Stability 响应中没有任务 id:' + r1.text.slice(0, 200));
2567
+ const url = await vendorPoll('Stability', 10000, async () => {
2568
+ const r = await fetchViaProxy(base + '/v2beta/image-to-video/result/' + tid, { method: 'GET', headers: { Authorization: 'Bearer ' + (s.apiKey || ''), Accept: 'video/*' }, timeoutMs: 120000, label: 'Stability 查询' });
2569
+ if (!r) return { state: 'wait' };
2570
+ if (r.status === 202) return { state: 'wait' };
2571
+ if (r.status === 200) {
2572
+ let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'fail', error: '结果非 JSON' }; }
2573
+ if (j.video) {
2574
+ const u8 = Uint8Array.from(atob(j.video), (c) => c.charCodeAt(0));
2575
+ const rec = await saveAssetRecord(S.activeId, 'video', new Blob([u8], { type: 'video/mp4' }), { name: 'stability-' + tid.slice(-6), source: 'api', category: 'finalcut' });
2576
+ return { state: 'done', url: fileUrlFor(rec.rel) };
2577
+ }
2578
+ }
2579
+ return { state: 'fail', error: 'HTTP ' + r.status + ':' + r.text.slice(0, 200) };
2580
+ });
2581
+ return await downloadVideo(url, {});
2582
+ },
2583
+ };
2210
2584
  async function apiVideo(prompt, frameBlob, s, opts) {
2211
2585
  const o = opts || {};
2586
+ // 衔接模式:auto=尾帧降级为参考图(保留实体参考图);hard=硬首帧(放弃参考图,画面绝对承接);
2587
+ // off=忽略首帧。网关禁止 first_frame 与 reference_image 混用,故 hard 模式必须放弃参考图。
2588
+ if (o.frameMode === 'off') frameBlob = null;
2589
+ if (o.frameMode === 'hard' && frameBlob) o.hardFrame = true;
2590
+ // 厂商预设层:显式选择了厂商格式(或旧配置自动识别)→ 走该厂商的官方协议适配器
2591
+ const vendor = detectVideoVendor(s);
2592
+ if (vendor !== 'openai' && vendor !== 'seedance') {
2593
+ const run = VIDEO_VENDOR_RUNNERS[vendor];
2594
+ if (!run) throw new Error('暂未适配的视频厂商格式:' + vendor);
2595
+ return await run(prompt, frameBlob, s, o);
2596
+ }
2212
2597
  // OpenAI 风格视频接口(JUAI 等:/v1/video/generations)优先识别——
2213
2598
  // 即使误把协议设成 seedance 也不走火山官方协议(两者状态/结果字段不同)
2214
- if ((s.protocol === 'seedance' || (s.url || '').includes('contents/generations/tasks')) && !(s.url || '').includes('/video/generations')) {
2215
- return await seedanceVideo(prompt, frameBlob, o.refs || [], s, o);
2599
+ if (vendor === 'seedance' && ((s.protocol === 'seedance' || (s.url || '').includes('contents/generations/tasks')) && !(s.url || '').includes('/video/generations'))) {
2600
+ return await seedanceVideo(prompt, frameBlob, o.hardFrame ? [] : (o.refs || []), s, o);
2216
2601
  }
2217
2602
  if (!s.url) throw new Error('未配置视频 API');
2218
2603
  // URL 归一化:裸域 → /v1/video/generations;/v1 → /video/generations;完整端点原样使用
@@ -2221,6 +2606,10 @@ window.__ModuleLoader__.load({
2221
2606
  else if (/\/v1$/.test(base)) base = base + '/video/generations';
2222
2607
  const isOpenaiVideo = /\/video\/generations$/.test(base);
2223
2608
  const headers = { 'Content-Type': 'application/json', ...(s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {}) };
2609
+ // 尾帧+参考图同连(跨镜衔接)→ 尾帧降级为第一张衔接参考图(first_frame/reference_image 互斥),标注写在提示词里
2610
+ const vRefs = o.hardFrame ? [] : (o.refs || []).slice(0, 8);
2611
+ const downgrade = Boolean(frameBlob && vRefs.length);
2612
+ if (downgrade) prompt = videoRefNote(o.refNames) + '\n' + prompt; // 前置硬约束
2224
2613
  const body = {
2225
2614
  ...(s.model ? { model: s.model } : {}),
2226
2615
  prompt,
@@ -2229,18 +2618,15 @@ window.__ModuleLoader__.load({
2229
2618
  resolution: (o.resolution || '720p').toLowerCase(),
2230
2619
  ratio: o.ratio || '16:9',
2231
2620
  };
2232
- // 首帧图生视频:构图以首帧为准——Seedance 系要求首帧模式必须 ratio=adaptive
2233
- if (frameBlob) body.ratio = 'adaptive';
2234
- if (frameBlob) {
2621
+ // 首帧图生视频:构图以首帧为准——Seedance 系要求首帧模式必须 ratio=adaptive(仅纯首帧模式)
2622
+ if (frameBlob && !downgrade) {
2623
+ body.ratio = 'adaptive';
2235
2624
  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) {
2625
+ } else if (vRefs.length) {
2626
+ const imgs = downgrade ? [frameBlob].concat(vRefs) : vRefs;
2241
2627
  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)));
2628
+ ? await Promise.all(imgs.map(async (b) => ({ url: await blobToDataUrlScaled(b), role: 'reference_image' })))
2629
+ : await Promise.all(imgs.map((b) => blobToDataUrlScaled(b)));
2244
2630
  }
2245
2631
  // 可选参数:勾选才发送 generate_audio;output_format 留空不发送
2246
2632
  if (s.generateAudio === true) body.generate_audio = true;
@@ -2282,11 +2668,13 @@ window.__ModuleLoader__.load({
2282
2668
  if (direct) return await downloadVideo(direct, dlHeaders(direct), headers);
2283
2669
  const taskId = extractTaskId(j);
2284
2670
  if (!taskId) throw new Error('视频 API 响应中没有任务 id 或视频地址');
2285
- const deadline = Date.now() + 15 * 60 * 1000;
2671
+ if (o.onTask) { try { o.onTask(taskId, base + '/' + taskId); } catch { /* 持久化失败不影响生成 */ } }
2672
+ const deadline = Date.now() + 120 * 60 * 1000; // 2 小时:查询层绝不先放弃
2286
2673
  while (Date.now() < deadline) {
2287
2674
  await sleep(3000);
2288
- const r2 = await fetchViaProxy(base + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询视频任务' });
2289
- if (!r2.ok) throw new Error('查询视频任务失败 HTTP ' + r2.status);
2675
+ let r2 = null;
2676
+ try { r2 = await fetchViaProxy(base + '/' + taskId, { method: 'GET', headers, timeoutMs: 60000, label: '查询视频任务' }); } catch { /* 网络抖动重试 */ }
2677
+ if (!r2 || !r2.ok) continue; // 查询层失败 ≠ 任务失败,只等生成任务自身的终态
2290
2678
  let st = null;
2291
2679
  try { st = JSON.parse(r2.text); } catch { /* 忽略 */ }
2292
2680
  const u = extractVideoUrl(st);
@@ -2298,7 +2686,7 @@ window.__ModuleLoader__.load({
2298
2686
  // 已完成却没有可提取的地址:明确报错并附响应原文,绝不无限轮询
2299
2687
  if (taskDoneOf(st)) throw new Error('任务已完成但响应中没有视频地址:' + JSON.stringify(st).slice(0, 300));
2300
2688
  }
2301
- throw new Error('视频生成超时(15 分钟)');
2689
+ throw new Error('视频生成超过 2 小时未返回(任务 id 已保存在节点上,可凭 id 回查结果)');
2302
2690
  }
2303
2691
  /** 从任务轮询响应里取状态字符串(小写)。 */
2304
2692
  function taskStatusOf(st) {
@@ -3469,39 +3857,43 @@ window.__ModuleLoader__.load({
3469
3857
  }
3470
3858
  return out;
3471
3859
  }
3472
- async function aiStoryboardShots(scriptText, requirement, count, model, onDelta) {
3860
+ async function aiStoryboardShots(scriptText, requirement, count, model, onDelta, prevTail) {
3473
3861
  const settings = loadSettings();
3474
3862
  const bible = bibleBlock(loadBible(S.activeId));
3475
3863
  const zh = effLang() !== 'en';
3476
3864
  const jsonContract = zh
3477
3865
  ? [
3478
3866
  '把剧本拆解为「多镜头一次性视频生成」的分镜脚本:铺开后由**一个视频生成节点**把全部镜头按顺序与时长写进同一条提示词,由视频模型一次性生成整段视频(镜头间由模型直接硬切,不是一镜一视频再拼接)。',
3479
- '只输出一个 JSON 对象:{"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"陈拾","line":"收件时间,1999……"}]}]}。',
3867
+ '只输出一个 JSON 对象:{"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"陈拾","line":"收件时间,1999……"}],"sfx":"邮包搭扣声、撕纸声"}]}。',
3480
3868
  '规则:',
3481
3869
  '- visual 是本镜画面描述(景别、人物外观与站位、场景、光线、构图,60 字内);',
3482
3870
  '- **visual 与 motion 里的角色/场景/道具必须写成 @名称 引用形式**(如 `@张山`、`@褶皱巷`),插件会自动带上该实体的设定描述与其参考图资产,保证跨镜一致;',
3483
3871
  '- motion 是本镜动作与运镜(主体动作节拍、机位运动方向与速度、情绪节奏,50 字内);与 visual 不重复;',
3484
3872
  '- characters/scene/props 使用剧本与设定集中的名称(不带 @),便于匹配资产;无道具给空数组;',
3485
3873
  '- **dialogue 是本镜的角色台词**:把剧本中落在这一镜的对白逐行搬进来({"role":"角色名","line":"台词原文"}),台词必须**逐字来自剧本**、不新写不改写;本镜无台词给空数组;一镜尽量只放一个角色的台词(单说话人镜头利于声音参考提取);',
3874
+ '- **【音话同步·硬性】有台词的镜头必须让说话角色在画面内开口说话**:景别用近景/中近景,脸部朝向可见(正面或 3/4 侧脸),禁止背对镜头、脸部被遮挡、人物出画;visual 或 motion 里必须写明「开口说话、口型清晰可见」;台词**绝不能**处理成画外音/旁白(否则成片里人嘴是闭的、声音飘在背景里);若某句台词在剧本里说话人无法入画,就把这句台词安排到能看见他说话的那一镜;',
3875
+ '- **sfx 是本镜的音效/环境音**:剧本里括号标注的「(音效:…)(环境音:…)」按内容对齐到最贴近的镜头**逐字搬入**(多个用顿号连接);无音效的镜头给空字符串——音效是成片声音设计的一部分,绝不丢弃;',
3486
3876
  '- **【完整覆盖】分镜必须覆盖所给剧本的全部内容**:每一个动作、每一句对白都要落在某个镜头里——不概述、不省略、不跳过、不"挑选重点";镜头数量与总时长由剧本体量自然决定,宁多勿漏(容量超限由上层拆分剧本处理,不由分镜偷工减料);',
3487
3877
  '- **镜头按实际内容切分**:一个连续动作段落 = 一镜,数量由内容自然决定(3-10 镜都正常)——绝不凑数、绝不使用固定数量;',
3488
3878
  '- **JSON 字符串值内部禁止出现半角双引号 "**——引用文字(牌匾/字幕/台词原文)一律用「」;',
3489
- '- duration 为该镜时长,2-5 的整数(一个镜头拍一个连续动作段落);全部 duration 之和 **≤ 15 秒**(一个分镜表 = 一个 10-15s 的场景视频)——超了就合并精简镜头,而不是拉长单镜;镜头顺序即叙事顺序,首镜交代场景、末镜收束情绪;',
3879
+ '- duration 为该镜时长,2-5 的整数(一个镜头拍一个连续动作段落);全部 duration 之和 **≤ 15 秒**(一个分镜表 = 一个 10-15s 的场景视频)——超了就合并精简镜头,而不是拉长单镜;镜头顺序即叙事顺序,末镜收束情绪;**若下方给了「承接约束」,第 1 镜必须承接上一场尾镜的画面与人物状态(不得重新建立场景/跳回远景),无承接约束时首镜才交代场景**;',
3490
3880
  '- 只输出 JSON,不要输出任何其他内容。',
3491
3881
  ].join('\n')
3492
3882
  : [
3493
3883
  'Break the script into shots for ONE multi-shot video generation: after expansion, a single video-gen node composes ALL shots (in order, with durations) into one prompt, and the video model generates the whole clip in one call (hard cuts inside the model — not one video per shot stitched together).',
3494
- 'Output only one JSON object: {"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"Chen","line":"…"}]}]}.',
3884
+ 'Output only one JSON object: {"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"Chen","line":"…"}],"sfx":"zipper snap, paper tear"}]}.',
3495
3885
  'Rules:',
3496
3886
  '- visual = the shot\'s frame description (shot size, wardrobe, staging, light, framing, <=60 words);',
3497
3887
  '- **characters/scenes/props inside visual and motion must be written as @name references** (e.g. `@ZhangSan`); the plugin then attaches that entity\'s bible description and reference images automatically;',
3498
3888
  '- motion = action & camera for the shot (action beats, camera move, emotional rhythm, <=50 words); do not repeat visual;',
3499
3889
  '- characters/scene/props arrays must use plain names from the script and series bible (no @) so assets match; empty props array if none;',
3500
3890
  '- **dialogue = the character lines that fall in this shot**, moved verbatim from the script ({"role":"Name","line":"exact line"}); never rewrite lines; empty array if none; keep one speaker per shot when possible (helps voice-reference extraction);',
3891
+ '- **[LIP-SYNC - HARD] any shot carrying dialogue must show the speaker TALKING ON SCREEN**: use a close-up / medium close-up with the face visible (frontal or 3/4), never back-to-camera, never obscured, never off-frame; state "speaks on screen, mouth clearly visible" in visual or motion; dialogue must NEVER become voice-over/narration (otherwise the final cut has a closed mouth with the voice floating in the background); if a line cannot be on camera in the script, move that line to the shot where the speaker is visible;',
3892
+ '- **sfx = sound cues / ambience for this shot**: bracketed notes like (SFX: ...) (Ambience: ...) from the script are moved verbatim into the closest matching shot (join multiples with commas); empty string if none — sound design is part of the film, never drop it;',
3501
3893
  '- **[FULL COVERAGE] the shots must cover EVERYTHING in the given script**: every action and every line must land in some shot — no summarizing, no omitting, no cherry-picking; shot count and total duration follow naturally from the script size (more is better than missing); capacity overflow is handled upstream by splitting the script, never by thinning the shots;',
3502
3894
  '- **Never use straight double quotes " inside JSON string values** — quoted text (signs/subtitles/lines) uses 「」 instead;',
3503
3895
  '- **Split shots by actual content**: one continuous action beat = one shot; the count is decided by the content (3-10 shots are all normal) — never pad, never use a fixed number;',
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;',
3896
+ '- duration is the shot length in seconds, integer 2-5 (one continuous action beat per shot); the sum of durations must be **≤ 15 seconds** (one storyboard = one 10-15s scene video) — if over, merge and tighten shots instead of stretching; shot order = narrative order; last shot lands the emotion; **if a CONTINUITY CONSTRAINT is given below, shot 1 must continue the previous scene\'s last frame and character state (no re-establishing, no wide reset); only without a constraint does shot 1 establish the scene**;',
3505
3897
  '- Output JSON only.',
3506
3898
  ].join('\n');
3507
3899
  const methodology = await loadSkillRules(settings.skillStoryboard, zh)
@@ -3516,11 +3908,50 @@ window.__ModuleLoader__.load({
3516
3908
  const countLine = (Number(count) > 0)
3517
3909
  ? `${zh ? '参考镜头数量' : 'Reference shot count'}:约 ${count} 镜(**仅作参考**——必须按实际动作节拍切分,内容需要更多就更多,绝不凑数)`
3518
3910
  : `${zh ? '镜头数量' : 'Shot count'}:**不固定**——按实际动作节拍切分,由内容自然决定`;
3911
+ // 承接约束:由连线指定(分镜表「衔接上一镜」输入口 ← 上游分镜表「尾镜」输出口)。
3912
+ // 只承接尾镜的**结尾瞬间**(≤0.2s 重叠),不重演上一镜——视频模型按秒计费,重演等于白花钱。
3913
+ const cont = (() => {
3914
+ if (!prevTail) return null;
3915
+ const label = prevTail.label || (zh ? '上一分镜' : 'previous storyboard');
3916
+ if (prevTail.text) {
3917
+ return zh
3918
+ ? `【承接约束·硬性】本分镜承接上游提供的衔接说明:\n${prevTail.text}\n`
3919
+ + `要求:第 1 镜从该衔接点**继续**(不重演),与上一镜画面重叠不超过 0.2 秒,人物位置/朝向/情绪/光线连续,背景环境声连续。`
3920
+ : `[CONTINUITY CONSTRAINT - HARD] Continue from the upstream hand-off note:\n${prevTail.text}\n`
3921
+ + `Shot 1 must continue from that point (no replay); overlap with the previous shot <= 0.2s; keep character position/facing/emotion/light and the ambient background sound continuous.`;
3922
+ }
3923
+ const sh = prevTail.shot;
3924
+ if (!sh) return null;
3925
+ const dl = (sh.dialogue || []).map((d) => d.role + (zh ? ':' : ': ') + d.line).join(zh ? ';' : '; ');
3926
+ return zh
3927
+ ? `【承接约束·硬性】本分镜承接「${label}」的最后一镜——**只承接它的结尾瞬间,不要重演它**:\n`
3928
+ + ` 尾镜画面:${String(sh.visual || '').trim()}\n`
3929
+ + ` 尾镜最后动作:${String(sh.motion || '').trim()}\n`
3930
+ + (dl ? ` 尾镜台词:${dl}\n` : '')
3931
+ + (String(sh.sfx || '').trim() ? ` 尾镜音效/环境声:${String(sh.sfx).trim()}\n` : '')
3932
+ + `要求:\n`
3933
+ + ` 1) 第 1 镜从上一镜的**最后一个动作**继续(人物位置、朝向、情绪、光线保持一致),不得重新建立场景、不得跳回远景交代、不得切换地点;\n`
3934
+ + ` 2) 与上一镜的画面重叠**不超过 0.2 秒**(不要重演上一镜的内容);\n`
3935
+ + ` 3) **背景声连续**:环境音/音效自然延续(风声、电流声、街道底噪等),不得突然静音或更换环境声;\n`
3936
+ + ` 4) 第 1 镜的画面描述里明确写出承接关系(如「承接上一镜,<角色>保持<姿态>,继续…」)。`
3937
+ : `[CONTINUITY CONSTRAINT - HARD] This storyboard continues the LAST SHOT of "${label}" - continue only its final instant, do NOT replay it:\n`
3938
+ + ` Tail frame: ${String(sh.visual || '').trim()}\n`
3939
+ + ` Tail last action: ${String(sh.motion || '').trim()}\n`
3940
+ + (dl ? ` Tail dialogue: ${dl}\n` : '')
3941
+ + (String(sh.sfx || '').trim() ? ` Tail SFX/ambience: ${String(sh.sfx).trim()}\n` : '')
3942
+ + `Requirements:\n`
3943
+ + ` 1) Shot 1 continues from the LAST ACTION of the previous shot (same position, facing, emotion, light) - no re-establishing, no wide reset, no location change;\n`
3944
+ + ` 2) overlap with the previous shot must be <= 0.2s (do not replay the previous shot);\n`
3945
+ + ` 3) ambient background sound continues naturally (wind, electrical hum, street noise) - never cut to silence or swap ambience;\n`
3946
+ + ` 4) state the continuity explicitly in shot 1's frame description.`;
3947
+ })();
3948
+ const prevLine = cont || '';
3519
3949
  const user = [
3520
3950
  `${zh ? '剧本' : 'Script'}:\n${String(scriptText ?? '').trim()}`,
3521
3951
  `${zh ? '额外要求' : 'Extra requirements'}:${String(requirement ?? '').trim() || (zh ? '无' : 'none')}`,
3522
3952
  countLine,
3523
- ].join('\n\n');
3953
+ prevLine,
3954
+ ].filter(Boolean).join('\n\n');
3524
3955
  const text = await aiCompleteStream({
3525
3956
  system, user, model,
3526
3957
  onReasoning: (d) => onDelta && onDelta('reasoning', d),
@@ -3561,6 +3992,7 @@ window.__ModuleLoader__.load({
3561
3992
  dialogue: Array.isArray(s.dialogue)
3562
3993
  ? s.dialogue.slice(0, 6).map((d) => ({ role: String(d && d.role || '').trim().slice(0, 24), line: String(d && d.line || '').trim().slice(0, 200) })).filter((d) => d.role && d.line)
3563
3994
  : [],
3995
+ sfx: String(s.sfx || '').trim().slice(0, 120),
3564
3996
  }));
3565
3997
  }
3566
3998
  /** 本地一致性检查:分镜里出现的角色/场景/道具(含 visual/motion 文本里的 @提及)是否都在
@@ -4018,7 +4450,7 @@ window.__ModuleLoader__.load({
4018
4450
  const map = {
4019
4451
  character: T('catCharacter'), scene: T('catScene'), prop: T('catProp'),
4020
4452
  script: T('nScript'), storyboard: T('nStoryboard'),
4021
- audio: T('ttsCategory'), finalcut: T('finalcutCategory'), general: T('catGeneral'),
4453
+ audio: T('ttsCategory'), finalcut: T('finalcutCategory'), image: T('catImage'), general: T('catGeneral'),
4022
4454
  };
4023
4455
  return map[c] || c;
4024
4456
  }
@@ -4081,16 +4513,29 @@ window.__ModuleLoader__.load({
4081
4513
  if (!shots.length) return '';
4082
4514
  const zh = effLang() !== 'en';
4083
4515
  const total = shots.reduce((a, s) => a + Math.max(1, Number(s.duration) || 3), 0);
4516
+ const hasDialogue = shots.some((s) => (s.dialogue || []).length);
4084
4517
  const lines = [
4085
4518
  zh
4086
4519
  ? `【多镜头一次性生成】共 ${shots.length} 镜、总时长 ${total} 秒。严格按下列顺序与时长切镜(每镜时长不可变),镜头之间直接硬切,不要叠加转场:`
4087
4520
  : `[Multi-shot single generation] ${shots.length} shots, ${total}s total. Cut strictly in this order with exact per-shot durations, hard cuts only, no transitions:`,
4088
4521
  ];
4522
+ if (hasDialogue) {
4523
+ lines.push(zh
4524
+ ? '标注「台词」的对白必须由**画面内可见的角色当场开口说出**(口型与台词同步、语气贴合情绪、面部朝镜头可见);严禁处理成画外音/旁白;同步音频开启时按台词生成人声。'
4525
+ : 'Dialogue must be SPOKEN ON SCREEN by the visible character (lip-synced to the line, matching emotion, face toward camera); never render it as voice-over/narration; generate speech when synced audio is on.');
4526
+ }
4089
4527
  shots.forEach((s, i) => {
4090
4528
  const dur = Math.max(1, Number(s.duration) || 3);
4091
4529
  const parts = [];
4092
4530
  if (String(s.visual || '').trim()) parts.push(String(s.visual).trim());
4093
4531
  if (String(s.motion || '').trim()) parts.push((zh ? '运镜/动作:' : 'Camera/Action: ') + String(s.motion).trim());
4532
+ const dls = (s.dialogue || []).map((d) => String(d && d.role || '').trim() + ':' + String(d && d.line || '').trim()).filter((x) => x.replace(':', '').trim());
4533
+ if (dls.length) {
4534
+ parts.push(zh
4535
+ ? '台词(由' + (s.dialogue || []).map((d) => String(d && d.role || '').trim()).filter(Boolean).join('、') + '在画面内开口说出,口型与台词同步):' + dls.join(';')
4536
+ : 'Dialogue (spoken on screen by ' + (s.dialogue || []).map((d) => String(d && d.role || '').trim()).filter(Boolean).join(', ') + ', lip-synced): ' + dls.join('; '));
4537
+ }
4538
+ if (String(s.sfx || '').trim()) parts.push((zh ? '音效:' : 'SFX: ') + String(s.sfx).trim());
4094
4539
  lines.push((zh ? `镜头${i + 1}(${dur}s):` : `Shot ${i + 1} (${dur}s): `) + parts.join(zh ? ';' : '; '));
4095
4540
  });
4096
4541
  return lines.join('\n');
@@ -4152,6 +4597,27 @@ window.__ModuleLoader__.load({
4152
4597
  return metas;
4153
4598
  }
4154
4599
 
4600
+ /** 承接来源(**只认连线,不靠名字**):本分镜「衔接上一镜」输入口(prev) 连到的上游分镜表,
4601
+ * 取它的**最后一镜**作为本场第 1 镜的承接依据;未连线返回 null(不加约束)。
4602
+ * 也允许连文本节点:直接把文本作为承接描述使用。 */
4603
+ function prevSceneTail(n) {
4604
+ const e = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'prev');
4605
+ if (!e) return null;
4606
+ const src = S.graph.nodes[e.fromNode];
4607
+ if (!src || !src.data) return null;
4608
+ if (src.type === 'storyboard') {
4609
+ const shots = src.data.shots || [];
4610
+ if (!shots.length) return { label: String(src.data.label || ''), shot: null };
4611
+ return { label: String(src.data.label || ''), shot: shots[shots.length - 1] };
4612
+ }
4613
+ if (src.type === 'text' || src.type === 'script') {
4614
+ const txt = String(src.data.text || src.data.output || '').trim();
4615
+ if (!txt) return null;
4616
+ return { label: String(src.data.label || ''), shot: null, text: txt };
4617
+ }
4618
+ return null;
4619
+ }
4620
+
4155
4621
  // ============================================================
4156
4622
  // 工作流执行
4157
4623
  // ============================================================
@@ -4225,7 +4691,14 @@ window.__ModuleLoader__.load({
4225
4691
 
4226
4692
  /** 分镜节点的资产文本(自动入库与手动"存为资产"共用,避免两处格式漂移)。 */
4227
4693
  function storyboardAssetText(shots) {
4228
- return (shots || []).map((x) => `【${x.title}】${x.duration}s\n画面:${x.visual}\n动作:${x.motion}\n角色:${(x.characters || []).join('、') || '-'}|场景:${x.scene || '-'}|道具:${(x.props || []).join('、') || '-'}`).join('\n\n');
4694
+ return (shots || []).map((x) => {
4695
+ const dls = (x.dialogue || []).map((d) => `${d.role}:${d.line}`).join('\n');
4696
+ const sfx = String(x.sfx || '').trim();
4697
+ return `【${x.title}】${x.duration}s\n画面:${x.visual}\n动作:${x.motion}`
4698
+ + (dls ? `\n${dls}` : '')
4699
+ + (sfx ? `\n音效:${sfx}` : '')
4700
+ + `\n角色:${(x.characters || []).join('、') || '-'}|场景:${x.scene || '-'}|道具:${(x.props || []).join('、') || '-'}`;
4701
+ }).join('\n\n');
4229
4702
  }
4230
4703
 
4231
4704
  /** 分镜节点:剧本 → 镜头列表(含一致性检查)。 */
@@ -4236,7 +4709,8 @@ window.__ModuleLoader__.load({
4236
4709
  aiStreamClear(n.id);
4237
4710
  flushSave();
4238
4711
  try {
4239
- const shots = await aiStoryboardShots(st, n.data.requirement, n.data.count || 6, n.data.model, (kind, d) => aiStreamAppend(n.id, kind, d));
4712
+ const prev = prevSceneTail(n); // 场 N-1 的尾镜 → 作为本场第 1 镜的硬性承接约束
4713
+ const shots = await aiStoryboardShots(st, n.data.requirement, n.data.count || 6, n.data.model, (kind, d) => aiStreamAppend(n.id, kind, d), prev);
4240
4714
  // 先自动补录缺失条目(空描述),再算一致性告警:否则告警标志的条目其实马上就被自动补录了,"未登记"语义会误导用户与对话侧
4241
4715
  try { await autoRegisterBibleEntries(shots); } catch { /* 补录失败不影响分镜 */ }
4242
4716
  const warnings = await checkShotConsistency(shots);
@@ -4248,7 +4722,7 @@ window.__ModuleLoader__.load({
4248
4722
  }
4249
4723
  update((s) => {
4250
4724
  const nd = s.graph.nodes[n.id];
4251
- if (nd) { nd.data.shots = shots; nd.data.aiStatus = 'idle'; nd.data.warnings = warnings; nd.data.status = 'done'; nd.data.error = null; }
4725
+ if (nd) { nd.data.shots = shots; nd.data.aiStatus = 'idle'; nd.data.warnings = warnings; nd.data.status = 'done'; nd.data.error = null; nd.data.contFrom = prev ? prev.label : null; }
4252
4726
  s.assetsTick++;
4253
4727
  });
4254
4728
  await saveTextAssetRecord(S.activeId, {
@@ -4373,7 +4847,14 @@ window.__ModuleLoader__.load({
4373
4847
  if (!mcfg) throw new Error(T('errNoVideoModel'));
4374
4848
  const blob = await apiVideo(p, frameBlob, mcfg, {
4375
4849
  duration: n.data.duration, resolution: n.data.resolution, ratio: n.data.ratio,
4850
+ frameMode: n.data.frameMode || 'auto', // auto=尾帧降级为参考图 / hard=硬首帧(放弃参考图) / off=忽略首帧
4376
4851
  refs: refs.map((r) => r.blob),
4852
+ refNames: refs.map((r) => r.entity || r.name || ''), // 跨镜衔接标注用(图2..N 的实体名)
4853
+ // 任务提交成功即持久化 task id:轮询中断/页面关闭后仍可凭 id 回查结果(钱花了就必须能找回)
4854
+ onTask: (tid, qurl) => {
4855
+ update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.taskId = String(tid || ''); nd.data.taskQueryUrl = String(qurl || ''); } });
4856
+ flushSave();
4857
+ },
4377
4858
  audioRefs: audioBlobs.map((a) => a.blob), // 角色声音参考(@引用实体名下的音频资产)
4378
4859
  });
4379
4860
  const rec = await saveAssetRecord(S.activeId, 'video', blob, {
@@ -4383,7 +4864,7 @@ window.__ModuleLoader__.load({
4383
4864
  entity: entities.length ? entities[0].name : undefined,
4384
4865
  entities: entities.map((e) => e.name),
4385
4866
  nodeLabel: (n.data && n.data.label) || '',
4386
- category: entities.length ? entityCategoryOf(entities[0].kind) : (categoryFromName(n.data.label) || 'general'),
4867
+ category: 'finalcut', // 节点生成的视频 = 成片(实体归属由 entities 字段承担,不占用分类)
4387
4868
  });
4388
4869
  commitGenResult(n.id, rec.id, false, 'video');
4389
4870
  // 尾帧提取(通用):供 last 端口输出与链式下一镜使用
@@ -4391,11 +4872,11 @@ window.__ModuleLoader__.load({
4391
4872
  const fBlob = await extractLastFrame(blob);
4392
4873
  const fRec = await saveAssetRecord(S.activeId, 'image', fBlob, {
4393
4874
  name: T('lastFrame') + '-' + (n.data.label || n.id.slice(-4)),
4394
- prompt: 'last frame of ' + prompt.slice(0, 60), source: 'api', category: 'general',
4875
+ prompt: 'last frame of ' + prompt.slice(0, 60), source: 'api', category: 'image',
4395
4876
  });
4396
4877
  update((s) => {
4397
4878
  const nd = s.graph.nodes[n.id];
4398
- if (nd) nd.data.lastAssetId = fRec.id;
4879
+ if (nd) { nd.data.lastAssetId = fRec.id; nd.data.lastAssetVideo = rec.id; }
4399
4880
  s.assetsTick++;
4400
4881
  });
4401
4882
  } catch { /* 尾帧提取失败不影响主流程 */ }
@@ -5067,6 +5548,7 @@ window.__ModuleLoader__.load({
5067
5548
  in: T('portIn'), out: T('portOut'), last: T('lastFrame'),
5068
5549
  char: T('portChar'), scene: T('portScene'), prop: T('portProp'),
5069
5550
  visual: T('portVisual'), motion: T('portMotion'), script: T('portScript'),
5551
+ prev: T('portPrev'), tail: T('portTail'),
5070
5552
  };
5071
5553
  return map[pid] || pid;
5072
5554
  }
@@ -5396,6 +5878,30 @@ window.__ModuleLoader__.load({
5396
5878
  const [showCraft, setShowCraft] = useState(false);
5397
5879
  const [voiceDlg, setVoiceDlg] = useState(false);
5398
5880
  useEffect(() => { probeCraft(); }, []);
5881
+ // 视频产物自动提取尾帧(含外部注入/导入的视频):last 口与跨镜衔接不依赖"必须由本节点生成"
5882
+ useEffect(() => {
5883
+ if (kind !== 'video' || !n.data.assetId) return;
5884
+ if (n.data.lastAssetId && n.data.lastAssetVideo === n.data.assetId) return;
5885
+ let cancelled = false;
5886
+ (async () => {
5887
+ try {
5888
+ const blob = await assetBlob(n.data.assetId);
5889
+ if (!blob || cancelled) return;
5890
+ const fBlob = await extractLastFrame(blob);
5891
+ if (!fBlob || cancelled) return;
5892
+ const rec = await saveAssetRecord(S.activeId, 'image', fBlob, {
5893
+ name: T('lastFrame') + '-' + (n.data.label || n.id.slice(-4)),
5894
+ prompt: 'last frame of ' + String(n.data.label || ''), source: 'api', category: 'image',
5895
+ });
5896
+ update((s) => {
5897
+ const nd = s.graph.nodes[n.id];
5898
+ if (nd && nd.data.assetId === n.data.assetId) { nd.data.lastAssetId = rec.id; nd.data.lastAssetVideo = n.data.assetId; }
5899
+ s.assetsTick++;
5900
+ });
5901
+ } catch { /* 提取失败静默:不影响视频预览与 out 口 */ }
5902
+ })();
5903
+ return () => { cancelled = true; };
5904
+ }, [kind, n.data.assetId, n.data.label, S.assetsTick]);
5399
5905
  const promptEdge = S.graph.edges.find((x) => x.toNode === n.id && x.toPort === 'prompt');
5400
5906
  const promptSrc = promptEdge && S.graph.nodes[promptEdge.fromNode];
5401
5907
  const fromStoryboard = !!(promptSrc && promptSrc.type === 'storyboard');
@@ -5430,11 +5936,26 @@ window.__ModuleLoader__.load({
5430
5936
  ? h('div', { key: 'ref', className: 'dshic-hint' },
5431
5937
  T('portFrame') + ' × ' + refEdges.length + ' · ' + T('connected'))
5432
5938
  : null,
5939
+ kind === 'video' && S.graph.edges.some((x) => x.toNode === n.id && x.toPort === 'frame')
5940
+ ? h('div', { key: 'fmode', className: 'dshic-node-row' },
5941
+ h('span', { className: 'dshic-shot-label', style: { flex: 'none' } }, T('frameModeLabel')),
5942
+ h('select', {
5943
+ className: 'dshic-select', style: { flex: 1, minWidth: 0 }, title: T('frameModeHint'),
5944
+ value: n.data.frameMode || 'auto',
5945
+ onPointerDown: (e) => e.stopPropagation(),
5946
+ onChange: (e) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.frameMode = e.target.value; }),
5947
+ },
5948
+ h('option', { value: 'auto' }, T('frameModeAuto')),
5949
+ h('option', { value: 'hard' }, T('frameModeHard')),
5950
+ h('option', { value: 'off' }, T('frameModeOff')),
5951
+ ),
5952
+ )
5953
+ : null,
5433
5954
  h(RefRows, { key: 'refrows', n, portId: 'ref' }),
5434
5955
  h(MentionInfo, { key: 'mn', n }),
5435
5956
  n.data.error ? h('div', { key: 'err', className: 'dshic-err' }, n.data.error) : null,
5436
5957
  assetUrlV && kind === 'image'
5437
- ? h('img', { key: 'img', className: 'dshic-preview', src: assetUrlV, draggable: false, 'data-no-drag': '1' })
5958
+ ? h('img', { key: 'img', className: 'dshic-preview', src: assetUrlV, draggable: false, 'data-no-drag': '1', style: { cursor: 'zoom-in' }, onPointerDown: (e) => e.stopPropagation(), onClick: (e) => { e.stopPropagation(); openLightbox('image', assetUrlV, n.data.label); } })
5438
5959
  : null,
5439
5960
  assetUrlV && kind === 'video'
5440
5961
  ? h('video', { key: 'vid', className: 'dshic-video', src: assetUrlV, controls: true, preload: 'metadata', 'data-no-drag': '1', onPointerDown: (e) => e.stopPropagation() })
@@ -5522,12 +6043,22 @@ window.__ModuleLoader__.load({
5522
6043
 
5523
6044
  function HistThumb({ assetId, active, onClick }) {
5524
6045
  const url = useAssetUrl(assetId);
6046
+ const [kind, setKind] = useState('');
6047
+ useEffect(() => {
6048
+ let ok = true;
6049
+ idbGet(assetId).then((r) => { if (ok) setKind((r && r.kind) || ''); }).catch(() => { /* 忽略 */ });
6050
+ return () => { ok = false; };
6051
+ }, [assetId]);
5525
6052
  return h('div', {
5526
6053
  className: cls('dshic-hist-thumb', active && 'is-active'),
5527
6054
  title: T('history'),
5528
6055
  onClick: (e) => { e.stopPropagation(); onClick(); },
5529
6056
  onPointerDown: (e) => e.stopPropagation(),
5530
- }, url ? h('img', { src: url, draggable: false }) : h('span', {}, '…'));
6057
+ }, url
6058
+ ? (kind === 'video'
6059
+ ? h('video', { src: url + '#t=0.1', muted: true, preload: 'metadata', draggable: false })
6060
+ : h('img', { src: url, draggable: false }))
6061
+ : h('span', {}, '…'));
5531
6062
  }
5532
6063
 
5533
6064
  /** 参考图排序列表:[图N] 顺序即提示词绑定顺序,可上移/下移/解除。 */
@@ -5622,6 +6153,30 @@ window.__ModuleLoader__.load({
5622
6153
  function AssetNodeBody({ n, kind }) {
5623
6154
  const assetUrlV = useAssetUrl(n.data.assetId);
5624
6155
  const fileRef = useRef(null);
6156
+ // 视频资产自动提取尾帧:外部导入的视频也能走 last → frame 跨镜衔接(生成后/换视频后触发一次)
6157
+ useEffect(() => {
6158
+ if (kind !== 'video' || !n.data.assetId) return;
6159
+ if (n.data.lastAssetId && n.data.lastAssetVideo === n.data.assetId) return; // 已提取过当前视频
6160
+ let cancelled = false;
6161
+ (async () => {
6162
+ try {
6163
+ const blob = await assetBlob(n.data.assetId);
6164
+ if (!blob || cancelled) return;
6165
+ const fBlob = await extractLastFrame(blob);
6166
+ if (!fBlob || cancelled) return;
6167
+ const rec = await saveAssetRecord(S.activeId, 'image', fBlob, {
6168
+ name: T('lastFrame') + '-' + (n.data.label || n.data.placeholderName || n.id.slice(-4)),
6169
+ prompt: 'last frame of imported video', source: 'upload', category: 'image',
6170
+ });
6171
+ update((s) => {
6172
+ const nd = s.graph.nodes[n.id];
6173
+ if (nd && nd.data.assetId === n.data.assetId) { nd.data.lastAssetId = rec.id; nd.data.lastAssetVideo = n.data.assetId; }
6174
+ s.assetsTick++;
6175
+ });
6176
+ } catch { /* 尾帧提取失败静默:不影响视频预览与 out 口输出 */ }
6177
+ })();
6178
+ return () => { cancelled = true; };
6179
+ }, [kind, n.data.assetId, n.data.label, S.assetsTick]);
5625
6180
  const onFiles = async (e) => {
5626
6181
  const f = e.target.files && e.target.files[0];
5627
6182
  e.target.value = '';
@@ -5636,7 +6191,7 @@ window.__ModuleLoader__.load({
5636
6191
  if (!rec) return;
5637
6192
  update((s) => {
5638
6193
  const nd = s.graph.nodes[n.id];
5639
- if (nd) { nd.data.assetId = rec.id; nd.data.status = 'done'; nd.data.placeholderName = null; }
6194
+ if (nd) { nd.data.assetId = rec.id; nd.data.status = 'done'; nd.data.placeholderName = null; if (kind === 'video') { nd.data.lastAssetId = null; nd.data.lastAssetVideo = null; } }
5640
6195
  s.assetsTick++;
5641
6196
  });
5642
6197
  };
@@ -5657,7 +6212,7 @@ window.__ModuleLoader__.load({
5657
6212
  ),
5658
6213
  h('input', { key: 'file', ref: fileRef, type: 'file', accept: kind === 'image' ? 'image/*' : 'video/*', style: { display: 'none' }, onChange: onFiles }),
5659
6214
  assetUrlV && kind === 'image'
5660
- ? h('img', { key: 'img', className: 'dshic-preview', src: assetUrlV, draggable: false, 'data-no-drag': '1' })
6215
+ ? h('img', { key: 'img', className: 'dshic-preview', src: assetUrlV, draggable: false, 'data-no-drag': '1', style: { cursor: 'zoom-in' }, onPointerDown: (e) => e.stopPropagation(), onClick: (e) => { e.stopPropagation(); openLightbox('image', assetUrlV, n.data.label || n.data.placeholderName); } })
5661
6216
  : null,
5662
6217
  assetUrlV && kind === 'video'
5663
6218
  ? h('video', { key: 'vid', className: 'dshic-video', src: assetUrlV, controls: true, preload: 'metadata', 'data-no-drag': '1', onPointerDown: (e) => e.stopPropagation() })
@@ -5850,6 +6405,19 @@ window.__ModuleLoader__.load({
5850
6405
 
5851
6406
  function StoryboardNodeBody({ n }) {
5852
6407
  const running = n.data.aiStatus === 'running';
6408
+ // 分镜列表高度:右下角手柄拖拽调整(存进节点数据,随画布持久化)
6409
+ const listH = Math.max(80, Math.min(1200, Number(n.data.shotsListH) || 180));
6410
+ const startResize = (e) => {
6411
+ e.stopPropagation(); e.preventDefault();
6412
+ const startY = e.clientY; const startH = listH;
6413
+ const onMove = (ev) => {
6414
+ const nh = Math.max(80, Math.min(1200, startH + (ev.clientY - startY)));
6415
+ update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.shotsListH = nh; });
6416
+ };
6417
+ const onUp = () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); flushSave(); };
6418
+ window.addEventListener('pointermove', onMove);
6419
+ window.addEventListener('pointerup', onUp);
6420
+ };
5853
6421
  const scriptEdge = S.graph.edges.some((x) => x.toNode === n.id && x.toPort === 'script');
5854
6422
  const scriptText = () => {
5855
6423
  if (scriptEdge) {
@@ -5899,6 +6467,9 @@ window.__ModuleLoader__.load({
5899
6467
  ? h('div', { key: 'stream', className: 'dshic-stream', title: aiStreamLine(n.id) },
5900
6468
  h('span', { className: 'dshic-stream-dot' }), aiStreamLine(n.id))
5901
6469
  : null,
6470
+ n.data.contFrom
6471
+ ? h('div', { key: 'cont', className: 'dshic-hint' }, '↩ ' + T('contFrom') + ':' + n.data.contFrom + ' ' + T('lastFrame'))
6472
+ : null,
5902
6473
  n.data.aiError ? h('div', { key: 'err', className: 'dshic-err' }, n.data.aiError) : null,
5903
6474
  (n.data.warnings || []).length
5904
6475
  ? h('div', { key: 'warn', className: 'dshic-card', style: { padding: 6, gap: 3 } },
@@ -5907,13 +6478,31 @@ window.__ModuleLoader__.load({
5907
6478
  )
5908
6479
  : null,
5909
6480
  shots.length
5910
- ? h('div', { key: 'list', className: 'dshic-shot-list' },
5911
- shots.map((sh, i) => h('div', { key: i, className: 'dshic-shot-item' },
5912
- h('strong', {}, `${sh.title} · ${sh.duration}s `), sh.visual)),
6481
+ ? h('div', { key: 'listwrap', style: { position: 'relative', alignSelf: 'stretch' } },
6482
+ h('div', { key: 'list', className: 'dshic-shot-list', style: { maxHeight: listH + 'px' } },
6483
+ shots.map((sh, i) => h('div', { key: i, className: 'dshic-shot-item', onPointerDown: (e) => e.stopPropagation() },
6484
+ h('strong', {}, `${sh.title} · ${sh.duration}s `), sh.visual,
6485
+ (sh.dialogue || []).length
6486
+ ? h('div', { className: 'dshic-shot-label', style: { marginTop: 3 } }, '台词 ' + sh.dialogue.map((d) => `${d.role}:${d.line}`).join(';'))
6487
+ : null,
6488
+ String(sh.sfx || '').trim()
6489
+ ? h('div', { className: 'dshic-shot-label', style: { marginTop: 2, opacity: 0.85 } }, '音效 ' + String(sh.sfx).trim())
6490
+ : null,
6491
+ )),
6492
+ ),
6493
+ h('div', { key: 'grip', className: 'dshic-shot-grip', title: T('resizeList'), onPointerDown: startResize }, '⌟'),
5913
6494
  )
5914
6495
  : null,
5915
6496
  shots.length
5916
6497
  ? h('div', { key: 'acts', className: 'dshic-node-row', style: { alignSelf: 'flex-start' } },
6498
+ h('button', {
6499
+ key: 'copy', type: 'button', className: 'dshic-mini is-ghost', title: T('copyAll'),
6500
+ onPointerDown: (e) => e.stopPropagation(),
6501
+ onClick: async () => {
6502
+ try { await navigator.clipboard.writeText(storyboardAssetText(shots)); toast(T('copiedOk')); }
6503
+ catch { toast(T('copyFail')); }
6504
+ },
6505
+ }, T('copyAll')),
5917
6506
  // 手动"存为资产":AI 生成已自动入库(category=storyboard),这里用于留存当前分镜的一版快照
5918
6507
  h('button', {
5919
6508
  key: 'save', type: 'button', className: 'dshic-mini is-ghost', title: T('saveTextAsset'),
@@ -6817,6 +7406,13 @@ window.__ModuleLoader__.load({
6817
7406
  )
6818
7407
  : null,
6819
7408
  h('div', { className: 'dshic-asset-actions' },
7409
+ (a.kind === 'image' || a.kind === 'video')
7410
+ ? h('button', {
7411
+ type: 'button', className: 'dshic-iconbtn', title: T('zoomView'),
7412
+ onClick: (e) => { e.stopPropagation(); openLightbox(a.kind, url, a.name); },
7413
+ onPointerDown: (e) => e.stopPropagation(),
7414
+ }, Ic('expandAll', 11))
7415
+ : null,
6820
7416
  a.kind === 'image'
6821
7417
  ? h('button', {
6822
7418
  type: 'button', className: 'dshic-iconbtn', title: T('photoCraft'),
@@ -7013,8 +7609,18 @@ window.__ModuleLoader__.load({
7013
7609
  h('button', { type: 'button', className: 'dshic-iconbtn', title: T('doDelete'), onPointerDown: (e) => e.stopPropagation(), onClick: () => removeProvider(p.id) }, '×'),
7014
7610
  ),
7015
7611
  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 }),
7612
+ kind === 'video' ? h('div', { key: 'vendor', className: 'dshic-field' },
7613
+ h('span', { className: 'dshic-field-label' }, T('vendorLabel')),
7614
+ h('select', {
7615
+ className: 'dshic-select', value: detectVideoVendor(p),
7616
+ onPointerDown: (e) => e.stopPropagation(),
7617
+ onChange: (e) => { const v = e.target.value; const vd = VIDEO_VENDORS[v]; patchProvider(p.id, 'vendor', v); if (vd && vd.base && !p[urlKey]) patchProvider(p.id, urlKey, vd.base); },
7618
+ },
7619
+ Object.entries(VIDEO_VENDORS).map(([id, vd]) => h('option', { key: id, value: id }, vd.label))),
7620
+ h('div', { className: 'dshic-hint' }, (VIDEO_VENDORS[detectVideoVendor(p)] || {}).note || ''),
7621
+ ) : null,
7622
+ h(Field, { key: 'u', label: T('baseUrl'), value: p[urlKey] || '', onChange: (v) => patchProvider(p.id, urlKey, v), placeholder: kind === 'video' ? ((VIDEO_VENDORS[detectVideoVendor(p)] || {}).base || urlPh) : urlPh }),
7623
+ h(Field, { key: 'k', label: T('apiKey'), value: p.apiKey || '', onChange: (v) => patchProvider(p.id, 'apiKey', v), placeholder: kind === 'video' ? ((VIDEO_VENDORS[detectVideoVendor(p)] || {}).keyPh || 'sk-…') : 'sk-…', password: true }),
7018
7624
  kind === 'image' ? [
7019
7625
  h(Field, { key: 'size', label: T('imgSizeOverride'), value: p.size || '', onChange: (v) => patchProvider(p.id, 'size', v), placeholder: T('imgSizePlaceholder') }),
7020
7626
  h(Field, { key: 'rf', label: T('imgResponseFormat'), value: p.responseFormat || '', onChange: (v) => patchProvider(p.id, 'responseFormat', v), placeholder: 'b64_json / url(留空=API 默认)' }),
@@ -7024,13 +7630,15 @@ window.__ModuleLoader__.load({
7024
7630
  h('option', { value: 'generations' }, T('modeGenerations')),
7025
7631
  h('option', { value: 'edits' }, T('modeEdits')))),
7026
7632
  ] : [
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 } },
7633
+ (detectVideoVendor(p) === 'openai' || detectVideoVendor(p) === 'seedance') ? h(Field, { key: 'of', label: T('vidOutFormat'), value: p.outputFormat || '', onChange: (v) => patchProvider(p.id, 'outputFormat', v), placeholder: 'mp4(留空不发送)' }) : null,
7634
+ (detectVideoVendor(p) === 'openai' || detectVideoVendor(p) === 'seedance') ? h('label', { key: 'ga', className: 'dshic-row', style: { gap: 6, fontSize: 11 } },
7029
7635
  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'))),
7636
+ h('span', {}, T('vidAudioOpt'))) : null,
7031
7637
  ],
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')),
7638
+ (kind === 'image' || detectVideoVendor(p) === 'openai' || detectVideoVendor(p) === 'seedance') ? [
7639
+ h(Field, { key: 'extra', label: T('cfgExtra'), value: p.extra || '', onChange: (v) => patchProvider(p.id, 'extra', v), placeholder: '{"watermark":false,"prompt_extend":false}' }),
7640
+ h('div', { className: 'dshic-hint' }, T('cfgExtraHint')),
7641
+ ] : null,
7034
7642
  h('div', { className: 'dshic-row', style: { marginTop: 4 } },
7035
7643
  h('span', { className: 'dshic-field-label', style: { flex: 1, minWidth: 0 } }, T('modelsLabel')),
7036
7644
  h('button', { type: 'button', className: 'dshic-mini', onPointerDown: (e) => e.stopPropagation(), onClick: () => addModel(p.id) }, Ic('plus', 11), T('addModel')),
@@ -7389,19 +7997,23 @@ window.__ModuleLoader__.load({
7389
7997
  if (busy) return;
7390
7998
  setBusy(true);
7391
7999
  try {
8000
+ const same = S.dataRoot && val.trim() && S.dataRoot === val.trim().replace(/[\\/]+$/, '');
7392
8001
  await hostRpc('dataRootSet', { root: val.trim() });
7393
8002
  await refreshDataRoot();
7394
8003
  toast(T('dataRootSaved') + (S.dataRoot ? ':' + S.dataRoot : ''));
8004
+ if (same) { setBusy(false); return; } // 同目录重复保存:不重建索引(避免误清数据目录里手工补充的资产记录)
7395
8005
  assetIndexSynced = false; // 换了目录:索引要往新目录重建
7396
8006
  autoMigrateAssetsToRoot().then(() => syncAssetIndexToHost()); // 先搬存量资产,再对齐索引
7397
8007
  } catch (e) { toast('设置失败:' + ((e && e.message) || e)); }
7398
8008
  setBusy(false);
7399
8009
  };
7400
8010
  const applyRoot = async (root) => {
8011
+ const same = S.dataRoot && root && S.dataRoot === String(root).replace(/[\\/]+$/, '');
7401
8012
  await hostRpc('dataRootSet', { root });
7402
8013
  await refreshDataRoot();
7403
8014
  setVal(S.dataRoot || root);
7404
8015
  toast(T('dataRootSaved') + ':' + S.dataRoot);
8016
+ if (same) return; // 同目录:不重建索引
7405
8017
  assetIndexSynced = false; // 换了目录:索引要往新目录重建
7406
8018
  autoMigrateAssetsToRoot().then(() => syncAssetIndexToHost());
7407
8019
  };
@@ -7424,6 +8036,8 @@ window.__ModuleLoader__.load({
7424
8036
  if (busy || picking) return;
7425
8037
  setBusy(true);
7426
8038
  try {
8039
+ if (!S.dataRoot) { await refreshDataRoot(); } // 未就绪先拉一次;仍无目录才提示(不必先点"设置此目录")
8040
+ if (!S.dataRoot) { toast(T('dataRootHint')); setBusy(false); return; }
7427
8041
  const r = await hostRpc('dataRootImport');
7428
8042
  // 资产元数据 → IndexedDB(本机已存在的同 id 资产跳过,绝不覆盖)
7429
8043
  let assets = 0;
@@ -7465,8 +8079,7 @@ window.__ModuleLoader__.load({
7465
8079
  picking ? h('div', { className: 'dshic-hint', key: 'picking' }, T('dataRootPicking')) : null,
7466
8080
  h('div', { className: 'dshic-row', key: 'row2' },
7467
8081
  S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini', onPointerDown: (e) => e.stopPropagation(), onClick: openDir }, T('dataRootOpen')) : null,
7468
- S.dataRoot ? h('button', { type: 'button', className: 'dshic-mini is-ghost', disabled: busy || picking, onPointerDown: (e) => e.stopPropagation(), onClick: importFromRoot }, Ic('folder', 11), T('dataRootImportBtn')) : null,
7469
- S.dataRoot ? InfoTip(T('dataRootImportHint')) : null,
8082
+ h('button', { type: 'button', className: 'dshic-mini is-ghost', disabled: busy || picking, title: T('dataRootImportHint'), onPointerDown: (e) => e.stopPropagation(), onClick: importFromRoot }, Ic('folder', 11), T('dataRootImportBtn')),
7470
8083
  h('div', { className: 'dshic-flex' }),
7471
8084
  ),
7472
8085
  ],
@@ -8020,6 +8633,7 @@ window.__ModuleLoader__.load({
8020
8633
  S.rightOpen ? h(Rightbar) : null,
8021
8634
  ),
8022
8635
  S.menu ? h(ContextMenu, { menu: S.menu }) : null,
8636
+ S.lightbox ? h(Lightbox) : null,
8023
8637
  S._toast ? h('div', {
8024
8638
  style: {
8025
8639
  position: 'fixed', bottom: 22, left: '50%', transform: 'translateX(-50%)', zIndex: 70,
@@ -8031,6 +8645,32 @@ window.__ModuleLoader__.load({
8031
8645
  );
8032
8646
  }
8033
8647
 
8648
+ // ============================================================
8649
+ // 灯箱:图片/视频放大查看(节点预览与资产面板共用)
8650
+ // ============================================================
8651
+ function openLightbox(kind, url, name) {
8652
+ if (!url) return;
8653
+ update((s) => { s.lightbox = { kind, url, name: String(name || '') }; });
8654
+ }
8655
+ function closeLightbox() {
8656
+ update((s) => { s.lightbox = null; });
8657
+ }
8658
+ function Lightbox() {
8659
+ const lb = S.lightbox;
8660
+ useEffect(() => {
8661
+ const onKey = (e) => { if (e.key === 'Escape') closeLightbox(); };
8662
+ window.addEventListener('keydown', onKey);
8663
+ return () => window.removeEventListener('keydown', onKey);
8664
+ }, []);
8665
+ return h('div', { className: 'dshic-lightbox', onPointerDown: (e) => { if (e.target === e.currentTarget) closeLightbox(); } },
8666
+ h('div', { className: 'dshic-lightbox-cap' }, (lb.name ? lb.name + ' · ' : '') + T('lbHint')),
8667
+ lb.kind === 'video'
8668
+ ? h('video', { src: lb.url, controls: true, preload: 'metadata', style: { maxWidth: '92vw', maxHeight: '80vh' } })
8669
+ : h('img', { src: lb.url, draggable: false, style: { maxWidth: '92vw', maxHeight: '80vh', objectFit: 'contain' } }),
8670
+ h('button', { type: 'button', className: 'dshic-mini is-ghost', style: { color: '#eee', borderColor: 'rgba(255,255,255,.35)' }, onClick: closeLightbox }, '✕'),
8671
+ );
8672
+ }
8673
+
8034
8674
  // ============================================================
8035
8675
  // 侧栏图标
8036
8676
  // ============================================================