@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/README.md +56 -18
- package/README.zh-CN.md +83 -40
- package/assets/canvas-studio/SKILL.md +31 -7
- package/client.js +710 -70
- package/docs/guide.en.md +225 -0
- package/docs/guide.zh-CN.md +229 -0
- package/index.js +34 -3
- package/package.json +1 -1
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': {
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
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
|
|
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
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
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() +
|
|
2228
|
+
const deadline = Date.now() + 120 * 60 * 1000; // 2 小时:查询层绝不先放弃
|
|
2160
2229
|
while (Date.now() < deadline) {
|
|
2161
2230
|
await sleep(6000);
|
|
2162
|
-
|
|
2163
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
2197
|
-
|
|
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('
|
|
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
|
|
2234
|
-
|
|
2621
|
+
// 首帧图生视频:构图以首帧为准——Seedance 系要求首帧模式必须 ratio=adaptive(仅纯首帧模式)
|
|
2622
|
+
if (frameBlob && !downgrade) {
|
|
2623
|
+
body.ratio = 'adaptive';
|
|
2235
2624
|
try { body.image = await blobToDataUrlScaled(frameBlob); } catch { /* 忽略首帧编码失败 */ }
|
|
2236
|
-
|
|
2237
|
-
|
|
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(
|
|
2243
|
-
: await Promise.all(
|
|
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
|
-
|
|
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
|
-
|
|
2289
|
-
|
|
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('
|
|
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;
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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:
|
|
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: '
|
|
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
|
|
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: '
|
|
5911
|
-
|
|
5912
|
-
h('
|
|
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
|
-
|
|
7017
|
-
|
|
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
|
-
|
|
7033
|
-
|
|
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
|
-
|
|
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
|
// ============================================================
|