@ninkch/dsh-infinite-canvas 0.17.14 → 0.17.15
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/assets/canvas-craft/SKILL.md +58 -6
- package/assets/canvas-studio/SKILL.md +11 -7
- package/client.js +296 -89
- package/index.js +7 -2
- package/package.json +1 -1
|
@@ -39,15 +39,50 @@ B:……
|
|
|
39
39
|
|
|
40
40
|
### 粒度
|
|
41
41
|
- **一段连续的动作节拍 = 一个镜头**;不要把两个不相关的动作塞进一个镜头。
|
|
42
|
-
-
|
|
43
|
-
(如 15 秒 ≈ 4–7 镜)。整个分镜表会组合成**一条多镜头提示词,由视频模型一次性生成整段视频**
|
|
42
|
+
- **单镜头按时长谱系取值**(见下);各镜时长之和 = 目标视频总时长,**按内容写足、不设硬上限**
|
|
43
|
+
(如 15 秒 ≈ 4–7 镜,30 秒 ≈ 8–14 镜)。整个分镜表会组合成**一条多镜头提示词,由视频模型一次性生成整段视频**
|
|
44
44
|
(镜头间由模型硬切,不需要逐镜生成再拼接)。
|
|
45
45
|
- 一场通常拆 4–8 镜:建置镜(环境/全景)→ 主体镜(中近景)→ 情绪镜(特写)→ 动作镜(跟移/推拉);
|
|
46
46
|
镜越短节奏越快,每镜的提示词要越聚焦。
|
|
47
47
|
|
|
48
|
+
### 节奏谱系(时长为情绪与节拍服务,**拒绝均匀分布**)
|
|
49
|
+
| 镜头类型 | 时长 | 用途 |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| 快速冲突 / 打斗 / 追逐 | **0.3–0.8s** | 快切、追逐、冲突爆发 |
|
|
52
|
+
| 重要反转 / 冲击 / 卡点 | **1–2s** | 反转、关键瞬间 |
|
|
53
|
+
| 普通叙事 / 对话 / 环境交代 | **2–4s** | 日常推进 |
|
|
54
|
+
| 长镜头情绪沉淀 | **≥5s** | 情绪酝酿、氛围、一镜到底 |
|
|
55
|
+
|
|
56
|
+
- **同一场至少制造一次明显的节奏变化**(例:3s 叙事 → 0.6s 快切×2 → 1.5s 反转 → 5s 情绪长镜)——
|
|
57
|
+
时长全部一样就没有节奏感,画面再精致也不行。
|
|
58
|
+
- **快切镜头的提示词要极短**(一个动作、一句画内信息);信息量大的镜头才配长时长——时长与内容量必须匹配。
|
|
59
|
+
- duration 支持一位小数(0.6、1.5 都合法)。
|
|
60
|
+
|
|
61
|
+
### 情绪 → 景别(根据情绪选景别)
|
|
62
|
+
| 情绪 | 景别 |
|
|
63
|
+
|---|---|
|
|
64
|
+
| 孤独 / 绝望 / 宏大危机 | 远景 |
|
|
65
|
+
| 对峙 / 动作 / 关系建立 | 全景 |
|
|
66
|
+
| 对话 / 试探 / 信息交换 | 中景 |
|
|
67
|
+
| 震惊 / 怀疑 / 内心动摇 | 近景 |
|
|
68
|
+
| 崩溃 / 嘶吼 / 情绪爆发 | 特写 |
|
|
69
|
+
| 关键细节 / 道具揭示 / 伏笔 | 大特写 |
|
|
70
|
+
|
|
71
|
+
口诀:**冲突用全景,揭秘转近景,爆发给特写,反转可拉远**;
|
|
72
|
+
镜头距离的变化本身在讲情绪:拉开距离 → 进入内心 → 强烈冲击 → 重新审视。
|
|
73
|
+
|
|
74
|
+
### 剧情四阶(场景内部弧线,也是单集结构)
|
|
75
|
+
1. **冲突出现**:常态被打破;
|
|
76
|
+
2. **信息升级**:真相推翻旧认知;
|
|
77
|
+
3. **情绪爆发**:积压矛盾总爆发——这里是全片节奏最快处(快切 + 特写);
|
|
78
|
+
4. **结局反转**:意料之外,伏笔回收(可拉远收束)。
|
|
79
|
+
|
|
48
80
|
### 术语表
|
|
49
|
-
- **景别**:大远景 / 远景 / 全景 / 中景 / 中近景 / 近景 / 特写 /
|
|
81
|
+
- **景别**:大远景 / 远景 / 全景 / 中景 / 中近景 / 近景 / 特写 / 大特写(极致特写:只拍眼睛、嘴、道具细节)。
|
|
50
82
|
- **运镜**:固定 / 推 / 拉 / 摇 / 移 / 跟 / 升降 / 环绕;**一镜只用一种运镜**。
|
|
83
|
+
- **高级运镜(按需选用)**:希区柯克变焦(推拉+变焦的眩晕感)、低角度仰拍(英雄感)、
|
|
84
|
+
快速摇镜(运动模糊)、FPV 第一人称主观(需要连续运动的环境才成立)、
|
|
85
|
+
航拍俯冲(要有明确落点,否则画面漂浮)、机械臂跟随(多角度贴视线)。
|
|
51
86
|
|
|
52
87
|
### visual(首帧画面)公式
|
|
53
88
|
> 景别 + 主体姿态 + 环境 + 光线 + 构图重心(+ 风格)
|
|
@@ -82,13 +117,29 @@ B:……
|
|
|
82
117
|
|
|
83
118
|
## 四、视频提示词规范
|
|
84
119
|
|
|
85
|
-
|
|
120
|
+
### 导演简报六组件(顺序即权重:构图在前,质感在后)
|
|
121
|
+
**场景(环境+光线)→ 主体(身份+细节)→ 动作(一次一个节拍)→ 运镜(机位+镜头+速度)→ 音频(音乐+音效+环境)→ 节奏/风格(时长+情绪+调色)**
|
|
122
|
+
|
|
123
|
+
- **音频指令绝不能省**:视频模型原生生成声音,不写音频指令就会得到随机的环境声——
|
|
124
|
+
哪怕一句话也要写(音乐情绪、关键音效、环境底噪)。
|
|
125
|
+
- **单拍规则**:一个时间段 = 一个动作节拍;4–7 秒一个节拍,10–15 秒最多 3–4 个节拍;
|
|
126
|
+
一段里塞两个叙事转折会劣化动作与物理一致性。
|
|
127
|
+
- **时长与内容量匹配**:3 秒只够一个动作,别写"转身、拔枪、开枪"三连;4 秒塞不下三个场景变化。
|
|
128
|
+
|
|
129
|
+
### 公式:**主体动作 + 运镜方式 + 环境变化 + 节奏/时长**
|
|
86
130
|
|
|
87
131
|
- 单动作、单运镜;**不要写场景切换**(切换交给镜头之间的衔接)。
|
|
88
132
|
- 与上一镜衔接:从上一镜的**末帧**继续动作(画布支持 last 帧作为视频节点输入)。
|
|
89
|
-
- 时长与动作量匹配:3 秒只够一个动作,别写"转身、拔枪、开枪"三连。
|
|
90
133
|
- 对白/音效单独交给配音(TTS)节点,不要写进视频提示词。
|
|
91
134
|
|
|
135
|
+
### 避坑清单(提示词)
|
|
136
|
+
1. **引用模糊**:说"参考 @图1"没用——要说参考什么(形象?运镜?动作?节奏?音色?)。
|
|
137
|
+
2. **指令冲突**:同一段里别同时要"固定镜头"和"环绕镜头"。
|
|
138
|
+
3. **内容过载**:4–5 秒塞多个场景变化 = 物理不可行,动作会糊。
|
|
139
|
+
4. **关键词汤**:不要"8k、masterpiece、trending"——用技术性描述(浅景深、35mm、体积光)。
|
|
140
|
+
5. **不连贯动作**:别写"他跑起来然后停下"——写成有过渡的流畅动作语言。
|
|
141
|
+
6. **忽视音频**:音效设计大幅提升成片质量。
|
|
142
|
+
|
|
92
143
|
## 五、设定集(Series Bible)维护
|
|
93
144
|
|
|
94
145
|
- **风格**:一句话定调(题材 + 年代 + 色调 + 质感)。
|
|
@@ -101,6 +152,7 @@ B:……
|
|
|
101
152
|
- [ ] 剧本:开场 10 秒有钩子;结尾有悬念;对白口语化;每场有目标/阻碍/转折
|
|
102
153
|
- [ ] 一致性:人名/地名/道具与设定集逐字一致;角色外观描述未漂移
|
|
103
154
|
- [ ] 分镜:镜头数 × 时长 ≈ 目标时长;每镜一个动作;相邻镜头轴线与光线连续
|
|
104
|
-
- [ ]
|
|
155
|
+
- [ ] **节奏**:同一场里至少一次明显的时长对比(快切/长镜);景别随情绪推进而不是随机
|
|
156
|
+
- [ ] 提示词:图片含景别/光线/构图;视频单动作单运镜;风格词全片统一;**每条视频提示词都有音频指令**
|
|
105
157
|
- [ ] 资产:命名含项目 + 集数 + 场次(便于检索与后期对轨)
|
|
106
158
|
- [ ] 成片:字幕覆盖全部对白;配音与画面同长;转场时长 ≤ 1 秒
|
|
@@ -40,15 +40,19 @@ description: 用无限画布(Infinite Canvas)做短剧/视频创作时使用
|
|
|
40
40
|
只要 `warnings` 非空:**逐条** `canvas_update_bible` 补全(名称与告警**逐字一致**、描述写实可生成),
|
|
41
41
|
再生成参考图;**绝不忽略、绝不跳过、绝不只补一部分**。设定集更新后告警会自动重算
|
|
42
42
|
(几秒内随文档同步刷新),以最新一次 `canvas_node_status` 为准。
|
|
43
|
-
9.
|
|
44
|
-
-
|
|
45
|
-
|
|
43
|
+
9. **分镜粒度规则:一个分镜节点 = 一个完整场景视频(时长按内容需要,不设硬上限)。**
|
|
44
|
+
- 一个分镜节点只吃**一个场景**的剧本:**各镜按节奏谱系取值、支持一位小数、拒绝均匀分布**
|
|
45
|
+
(普通叙事 2–4s / 快切冲突 0.3–0.8s / 关键反转 1–2s / 情绪长镜 ≥5s;谱系与情绪景别表见 `canvas-craft`);
|
|
46
|
+
- **时长不设硬上限、系统也绝不自动压缩镜头**:各视频厂商单次上限不同且会变化
|
|
47
|
+
(如万相 3.0 支持 30s,可灵只支持 5/10s)——按内容写足,**超限时视频接口会明确报错**,
|
|
48
|
+
依据报错再决定拆分或调整,不要预先为某个猜的上限砍戏;
|
|
49
|
+
- **长剧本仍必须按场景拆分**为多个场景剧本节点(命名「第X节-场Y剧本」…),
|
|
46
50
|
每个场景剧本作为**各自分镜表**的源端——**绝不把长剧本整根连线给一个分镜表**;
|
|
47
51
|
- 全集剧本(若有)只作总纲存档,**不作为分镜源端**;
|
|
48
|
-
- 体量感:一个 10–
|
|
52
|
+
- 体量感:一个 10–30s 场景 ≈ 剧本 150–400 字(含该场全部对白);分镜表 `shots` 上限 24 镜,
|
|
49
53
|
超长剧本会被截断且触发生成告警(warnings 会出现"剧本较长…请按场景拆分");
|
|
50
54
|
- **镜头数量不固定**:分镜表镜头数默认「自动(按内容节拍)」——一个连续动作段落 = 一镜,
|
|
51
|
-
3–10
|
|
55
|
+
3–10 镜都正常,绝不凑数、绝不用固定数量;
|
|
52
56
|
- 拆分操作:全集剧本已生成后,按场景把对应片段 `canvas_write_node(target:'output')`
|
|
53
57
|
写入各场景剧本节点(这是**分配已有内容**,不是代写)。
|
|
54
58
|
10. **汇报固定要素**:项目名 + 项目 id + 节点名/id + 成稿摘要(若已生成);有错误如实说明。
|
|
@@ -84,7 +88,7 @@ description: 用无限画布(Infinite Canvas)做短剧/视频创作时使用
|
|
|
84
88
|
- 新项目:`canvas_create_project({ name, scriptText: 本集创作提示词, nodeName: '第X节剧本' })`
|
|
85
89
|
- 已有项目:`canvas_write_node({ projectId, text: 提示词, target: 'idea', name: '第X节剧本' })`
|
|
86
90
|
- 触发生成:`canvas_run_node({ projectId, nodeId })` → `canvas_node_status` 轮询直到 done/error
|
|
87
|
-
- 生成契约(自动注入,无需复述):整集 **4–10 场、每场 10–
|
|
91
|
+
- 生成契约(自动注入,无需复述):整集 **4–10 场、每场 10–30 秒成片(按内容写足,时长不设硬上限)**;**每场以「【场N】场景名(地点·时间)」单独一行开头**
|
|
88
92
|
——这是"铺开到场景"自动拆分的解析标记,格式不可变;对白逐行「角色:台词」;结尾附人物/场景/道具清单;
|
|
89
93
|
- **【铁律】不要把成稿直接填进 output**——生成是节点的工作(用户在画布上看得到过程与结果)。
|
|
90
94
|
仅当用户明确说"你直接把剧本写出来、不用节点生成"时,才允许 `target:'output'` 直接写成稿。
|
|
@@ -104,7 +108,7 @@ description: 用无限画布(Infinite Canvas)做短剧/视频创作时使用
|
|
|
104
108
|
- `canvas_run_node({ 分镜表 })` → `canvas_node_status` 读 `shots`(镜头清单以此为准,绝不编造镜头表);
|
|
105
109
|
- `canvas_create_node({ projectId, type: 'video-gen', name: '第X节-场Y视频', connectFrom: 分镜表节点id, duration: 各镜时长之和 })`
|
|
106
110
|
(不传 connectFrom 且项目里有已拆解镜头的分镜表时会自动连线;prompt 留空);
|
|
107
|
-
- **【禁止逐镜建节点】**一段 10–
|
|
111
|
+
- **【禁止逐镜建节点】**一段 10–30 秒的场景视频**不需要逐镜的图片节点或"镜头图→镜头视频"链路**:
|
|
108
112
|
多镜头一致性靠角色/场景/道具**参考图资产**(@引用自动挂载)保证,视频模型在一次调用里完成全部镜头。
|
|
109
113
|
最多为**开场首帧或 1–2 个关键镜头**补定帧图(接同一视频节点的 frame/ref 端口)。
|
|
110
114
|
一旦发现自己在批量创建"第X节-场Y-镜Z"这类镜头图片/视频节点 = 走错路,**立即停止**并回到本条。
|
package/client.js
CHANGED
|
@@ -59,7 +59,8 @@ window.__ModuleLoader__.load({
|
|
|
59
59
|
const CATEGORIES = ['character', 'scene', 'prop', 'script', 'storyboard', 'audio', 'finalcut', 'image', 'general'];
|
|
60
60
|
const RATIOS = ['16:9', '9:16', '1:1', '4:3', '3:4', '21:9'];
|
|
61
61
|
const IMAGE_SIZES = ['1K', '2K', '4K'];
|
|
62
|
-
|
|
62
|
+
// 时长选项放宽到 30s(万相 3.0 等支持长视频);各厂商上限不同——超限由接口报错,系统不做静默截断
|
|
63
|
+
const VIDEO_DURATIONS = [4, 5, 6, 8, 10, 12, 15, 20, 25, 30];
|
|
63
64
|
const VIDEO_RESOLUTIONS = ['480P', '720P', '1080P'];
|
|
64
65
|
|
|
65
66
|
// ============================================================
|
|
@@ -349,7 +350,7 @@ window.__ModuleLoader__.load({
|
|
|
349
350
|
'shotsUnit': '镜',
|
|
350
351
|
'savedOk': '已存为资产',
|
|
351
352
|
'warnUnregistered': '未在设定集/资产中登记',
|
|
352
|
-
'warnScriptTooLong': '剧本较长({n} 字)≈
|
|
353
|
+
'warnScriptTooLong': '剧本较长({n} 字)≈ 超过单个分镜的镜头容量(24 镜):请按场景拆分为多个场景剧本节点(每个分镜只连一个场景剧本)',
|
|
353
354
|
'warnEmptyDesc': '已登记但描述为空(待补全)',
|
|
354
355
|
'catMention': '提及',
|
|
355
356
|
'assetCategoriesFixed': '已按命名规范纠正 {n} 个资产的分类',
|
|
@@ -753,7 +754,7 @@ window.__ModuleLoader__.load({
|
|
|
753
754
|
'shotsUnit': 'shots',
|
|
754
755
|
'savedOk': 'Saved as asset',
|
|
755
756
|
'warnUnregistered': 'not in bible/assets',
|
|
756
|
-
'warnScriptTooLong': 'Script is long ({n} chars) ≈ beyond one storyboard\'s
|
|
757
|
+
'warnScriptTooLong': 'Script is long ({n} chars) ≈ beyond one storyboard\'s shot capacity (24 shots): split the script into multiple scene script nodes (each storyboard takes ONE scene)',
|
|
757
758
|
'warnEmptyDesc': 'registered but description is empty (fill it in)',
|
|
758
759
|
'catMention': 'Mention',
|
|
759
760
|
'assetCategoriesFixed': 'Re-categorized {n} assets by naming convention',
|
|
@@ -1360,7 +1361,8 @@ window.__ModuleLoader__.load({
|
|
|
1360
1361
|
for (const p of settings.videoProviders || []) {
|
|
1361
1362
|
for (const m of (p.models || [])) {
|
|
1362
1363
|
if (!m || !m.id || !String(m.model || '').trim()) continue;
|
|
1363
|
-
|
|
1364
|
+
// vendor 必须带出去:否则「显式选了厂商」在请求侧丢失,只能靠模型名/URL 猜协议(曾把万相误判成 Seedance)
|
|
1365
|
+
out.push({ ref: p.id + '|' + m.id, providerId: p.id, providerName: p.name || p.id, modelId: m.id, model: String(m.model).trim(), url: p.url || '', apiKey: p.apiKey || '', extra: p.extra || '', outputFormat: p.outputFormat || '', generateAudio: p.generateAudio, protocol: p.protocol || 'generic', watermark: p.watermark, vendor: p.vendor || '' });
|
|
1364
1366
|
}
|
|
1365
1367
|
}
|
|
1366
1368
|
return out;
|
|
@@ -2187,9 +2189,26 @@ window.__ModuleLoader__.load({
|
|
|
2187
2189
|
async function seedanceVideo(prompt, frameBlob, refs, s, opts) {
|
|
2188
2190
|
if (!s.url) throw new Error('未配置视频 API');
|
|
2189
2191
|
if (!s.model) throw new Error('请在设置里为该视频 API 填写模型名(Seedance 协议要求显式指定模型)');
|
|
2190
|
-
const base = s.url.replace(/\/+$/, '');
|
|
2191
2192
|
const headers = { 'Content-Type': 'application/json', ...(s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {}) };
|
|
2192
2193
|
const o = opts || {};
|
|
2194
|
+
// 端点归一化:配置里常填「基础地址」(裸域或 …/v1),此前直接 POST 到基础地址 → 404 Invalid URL。
|
|
2195
|
+
// · 已带任务路径(contents/generations/tasks)→ 原样用(方舟官方)
|
|
2196
|
+
// · 方舟官方域名 → 补官方任务路径
|
|
2197
|
+
// · 其余(中转站/网关,如 api.relayrouter.ai/v1)→ 走 content[] 提交端点 /v1/video/generations + /v1/video/query 轮询
|
|
2198
|
+
let base = String(s.url).replace(/\/+$/, '');
|
|
2199
|
+
if (!/contents\/generations\/tasks$/.test(base)) {
|
|
2200
|
+
const host = base.replace(/^https?:\/\//i, '').split('/')[0].toLowerCase();
|
|
2201
|
+
const isArkHost = /(^|\.)volces\.com$/.test(host) || /(^|\.)volcengineapi\.com$/.test(host) || /^ark\./.test(host);
|
|
2202
|
+
if (isArkHost) {
|
|
2203
|
+
base = base + '/api/v3/contents/generations/tasks';
|
|
2204
|
+
} else {
|
|
2205
|
+
const gw = /\/v1$/.test(base) ? base : base + '/v1';
|
|
2206
|
+
// 衔接说明前置(网关 content[] 路径不重复添加,由这里负责)
|
|
2207
|
+
const gwRefs = o.hardFrame ? [] : (o.refs || []).slice(0, 8);
|
|
2208
|
+
const gwText = (frameBlob && gwRefs.length) ? (videoRefNote(o.refNames) + '\n' + prompt) : prompt;
|
|
2209
|
+
return await relayVideoContentFallback(gw + '/video/generations', gwText, frameBlob, o, s, headers);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2193
2212
|
// first_frame 与 reference_image 互斥(网关 400)。跨镜衔接(尾帧+参考图同连)→
|
|
2194
2213
|
// 首帧降级为第一张衔接参考图:全部按 reference_image 传、提示词按序标注、比例不锁 adaptive
|
|
2195
2214
|
const rawRefs = o.hardFrame ? [] : (refs || []).slice(0, 8);
|
|
@@ -2210,8 +2229,9 @@ window.__ModuleLoader__.load({
|
|
|
2210
2229
|
duration: Number(o.duration) || 5,
|
|
2211
2230
|
resolution: (o.resolution || '720P').toLowerCase(),
|
|
2212
2231
|
watermark: s.watermark === true,
|
|
2213
|
-
//
|
|
2214
|
-
|
|
2232
|
+
// generate_audio 官方默认 true(2.5/2.0 系列)——仅在用户勾选时显式发送 true;不发送时用厂商默认,
|
|
2233
|
+
// 避免"未勾选就被静默静音"(透传原则:厂商默认优先)
|
|
2234
|
+
...(s.generateAudio === true ? { generate_audio: true } : {}),
|
|
2215
2235
|
};
|
|
2216
2236
|
try { const extra = parseExtraParams(s.extra); if (extra) Object.assign(body, extra); } catch (e) { throw new Error('视频 API 扩展参数配置有误:' + ((e && e.message) || e)); }
|
|
2217
2237
|
body.ratio = (frameBlob && !downgrade) ? 'adaptive' : (o.ratio || '16:9');
|
|
@@ -2256,7 +2276,7 @@ window.__ModuleLoader__.load({
|
|
|
2256
2276
|
model: s.model,
|
|
2257
2277
|
content,
|
|
2258
2278
|
metadata: {
|
|
2259
|
-
duration: Math.
|
|
2279
|
+
duration: Math.round(Number(o.duration)) || 5,
|
|
2260
2280
|
ratio: o.ratio || '16:9',
|
|
2261
2281
|
resolution: String(o.resolution || '720p').toLowerCase(),
|
|
2262
2282
|
},
|
|
@@ -2295,7 +2315,7 @@ window.__ModuleLoader__.load({
|
|
|
2295
2315
|
async function soraVideosFallback(base, prompt, o, s, headers) {
|
|
2296
2316
|
const root = base.replace(/\/v1\/video\/generations$/i, '').replace(/\/+$/, '');
|
|
2297
2317
|
const ep = root + '/v1/videos';
|
|
2298
|
-
const secs = String(Math.
|
|
2318
|
+
const secs = String(Math.round(Number(o.duration)) || 5);
|
|
2299
2319
|
const vertical = (o.ratio || '16:9') === '9:16';
|
|
2300
2320
|
const hd = String(o.resolution || '720p').toLowerCase().includes('1080');
|
|
2301
2321
|
const size = hd ? (vertical ? '1080x1920' : '1920x1080') : (vertical ? '720x1280' : '1280x720');
|
|
@@ -2340,15 +2360,105 @@ window.__ModuleLoader__.load({
|
|
|
2340
2360
|
veo: { label: 'Google Veo(Gemini API)', base: 'https://generativelanguage.googleapis.com', keyPh: 'API Key', note: '模型 id 如 veo-3.0-fast-generate-preview;图生视频走 inlineData base64' },
|
|
2341
2361
|
luma: { label: 'Luma Dream Machine', base: 'https://api.lumalabs.ai/dream-machine/v1', note: '图生视频需要公网图片 URL(本地图片仅支持文生视频)' },
|
|
2342
2362
|
pixverse: { label: 'PixVerse', base: 'https://app-api.pixverse.ai/openapi/v2', keyPh: 'API-KEY', note: '图片支持 base64;时长 5/8s;清晰度映射 540p/720p/1080p' },
|
|
2343
|
-
stability: { label: 'Stability AI
|
|
2363
|
+
stability: { label: 'Stability AI 图生视频(已下线)', base: 'https://api.stability.ai', note: '⚠ 官方 API 已不再提供视频端点(2026 审计:现行 spec 0 条 video 路径)——保留适配器仅为历史配置,调用会报 404' },
|
|
2344
2364
|
};
|
|
2365
|
+
/** 厂商 base URL 归一化:网关常把地址写成 …/v1 或 …/api/v1,直接拼接会变成 …/v1/v1/… 而 404。
|
|
2366
|
+
* 各厂商预设 base 本身都不以 /v1 结尾,所以安全地剥掉拖尾的 /v1、/api/v1。 */
|
|
2367
|
+
function vendorBase(raw, fallback) {
|
|
2368
|
+
let b = String(raw || fallback || '').trim().replace(/\/+$/, '');
|
|
2369
|
+
b = b.replace(/\/(api\/)?v1$/i, '');
|
|
2370
|
+
return b;
|
|
2371
|
+
}
|
|
2372
|
+
/** 已知网关的厂商路径前缀:网关把各厂商 API 挂在各自前缀下(如 RelayRouter 的百炼 = /alibailian)。
|
|
2373
|
+
* 不在表里的网关,把前缀直接写进 Base URL 即可(如 https://gw.example.com/alibailian)。 */
|
|
2374
|
+
const KNOWN_GATEWAY_PREFIX = [
|
|
2375
|
+
{ host: /(^|\.)relayrouter\.ai$/i, wan: '/alibailian' },
|
|
2376
|
+
];
|
|
2377
|
+
function gatewayPrefixFor(hostname, vendor) {
|
|
2378
|
+
const h = String(hostname || '').toLowerCase();
|
|
2379
|
+
for (const g of KNOWN_GATEWAY_PREFIX) { if (g.host.test(h)) return g[vendor] || ''; }
|
|
2380
|
+
return '';
|
|
2381
|
+
}
|
|
2382
|
+
/** 万相(DashScope/百炼)端点推导。兼容四种写法:
|
|
2383
|
+
* 1) 官方域名 https://dashscope.aliyuncs.com → /api/v1/services/...
|
|
2384
|
+
* 2) 网关裸域 https://api.relayrouter.ai → 自动补厂商前缀 /alibailian
|
|
2385
|
+
* 3) 网关带前缀 https://api.relayrouter.ai/alibailian → 前缀原样保留
|
|
2386
|
+
* 4) 直接粘贴完整端点 …/…/video-synthesis → 反推前缀
|
|
2387
|
+
* 另外统一剥掉误写的拖尾 /v1、/api/v1。返回 { apiRoot, submitUrl, taskUrl }。 */
|
|
2388
|
+
function wanEndpoints(rawUrl) {
|
|
2389
|
+
const u = new URL(String(rawUrl).trim());
|
|
2390
|
+
let path = u.pathname.replace(/\/+$/, '');
|
|
2391
|
+
const full = /\/api\/v1\/services\/aigc\/video-generation\/video-synthesis$/.exec(path);
|
|
2392
|
+
if (full) path = path.slice(0, full.index); // 完整端点 → 取其前缀部分
|
|
2393
|
+
else path = path.replace(/\/(api\/)?v1$/i, ''); // 误带 /v1 或 /api/v1 → 剥掉
|
|
2394
|
+
if (!path) path = gatewayPrefixFor(u.hostname, 'wan'); // 裸域:已知网关自动补厂商前缀
|
|
2395
|
+
const apiRoot = u.origin + path;
|
|
2396
|
+
return {
|
|
2397
|
+
apiRoot,
|
|
2398
|
+
submitUrl: apiRoot + '/api/v1/services/aigc/video-generation/video-synthesis',
|
|
2399
|
+
taskUrl: (tid) => apiRoot + '/api/v1/tasks/' + encodeURIComponent(tid),
|
|
2400
|
+
};
|
|
2401
|
+
}
|
|
2402
|
+
/** 读取生成视频的真实尺寸与时长(用于「厂商是否忽略了参数」的可见校验)。 */
|
|
2403
|
+
function probeVideoMeta(blob) {
|
|
2404
|
+
return new Promise((resolve) => {
|
|
2405
|
+
let url = '';
|
|
2406
|
+
try {
|
|
2407
|
+
url = URL.createObjectURL(blob);
|
|
2408
|
+
const v = document.createElement('video');
|
|
2409
|
+
v.preload = 'metadata';
|
|
2410
|
+
v.muted = true;
|
|
2411
|
+
const done = (r) => { try { URL.revokeObjectURL(url); } catch { /* 忽略 */ } resolve(r); };
|
|
2412
|
+
v.onloadedmetadata = () => done({ w: v.videoWidth, h: v.videoHeight, d: v.duration });
|
|
2413
|
+
v.onerror = () => done(null);
|
|
2414
|
+
setTimeout(() => done(null), 10000);
|
|
2415
|
+
v.src = url;
|
|
2416
|
+
} catch { resolve(null); }
|
|
2417
|
+
});
|
|
2418
|
+
}
|
|
2419
|
+
/** 扩展参数透传(所有厂商 runner 通用):顶层字段直接覆盖 body;
|
|
2420
|
+
* 若 body.parameters 存在则深合并;不存在时若扩展参数里带了 parameters 则整体挂上。
|
|
2421
|
+
* 这是厂商/网关参数名不一致时的统一逃生舱。 */
|
|
2422
|
+
function mergeExtra(body, s) {
|
|
2423
|
+
try {
|
|
2424
|
+
const ex = parseExtraParams(s && s.extra);
|
|
2425
|
+
if (!ex) return body;
|
|
2426
|
+
const exParams = ex.parameters;
|
|
2427
|
+
Object.assign(body, ex);
|
|
2428
|
+
if (exParams && typeof exParams === 'object') body.parameters = { ...(body.parameters || {}), ...exParams };
|
|
2429
|
+
} catch (e) { throw new Error('扩展参数配置有误:' + ((e && e.message) || e)); }
|
|
2430
|
+
return body;
|
|
2431
|
+
}
|
|
2345
2432
|
function detectVideoVendor(s) {
|
|
2346
|
-
if (s
|
|
2347
|
-
if (s
|
|
2433
|
+
if (!s) return 'openai';
|
|
2434
|
+
if (s.vendor) return s.vendor;
|
|
2435
|
+
const url = String(s.url || '').toLowerCase();
|
|
2436
|
+
const model = String(s.model || '').toLowerCase();
|
|
2437
|
+
// 模型名优先:第三方网关(relayrouter 等)的 URL 看不出真实厂商,只有模型名能说明
|
|
2438
|
+
if (/wanx|万相|(^|[^a-z])wan(\d|[-_]\d|x)/.test(model)) return 'wan';
|
|
2439
|
+
if (/(^|[^a-z])kling/.test(model)) return 'kling';
|
|
2440
|
+
if (/minimax|hailuo/.test(model)) return 'minimax';
|
|
2441
|
+
if (/vidu/.test(model)) return 'vidu';
|
|
2442
|
+
if (/runway/.test(model)) return 'runway';
|
|
2443
|
+
if (/(^|[^a-z])veo/.test(model)) return 'veo';
|
|
2444
|
+
if (/luma|ray[-_]?[12]/.test(model)) return 'luma';
|
|
2445
|
+
if (/pixverse/.test(model)) return 'pixverse';
|
|
2446
|
+
if (/stable-?video|(^|[^a-z])svd/.test(model)) return 'stability';
|
|
2447
|
+
if (/seedance/.test(model)) return 'seedance';
|
|
2448
|
+
// URL 兜底识别
|
|
2449
|
+
if (url.includes('dashscope')) return 'wan';
|
|
2450
|
+
if (url.includes('klingai')) return 'kling';
|
|
2451
|
+
if (url.includes('minimax')) return 'minimax';
|
|
2452
|
+
if (url.includes('vidu')) return 'vidu';
|
|
2453
|
+
if (url.includes('runway')) return 'runway';
|
|
2454
|
+
if (url.includes('generativelanguage')) return 'veo';
|
|
2455
|
+
if (url.includes('lumalabs')) return 'luma';
|
|
2456
|
+
if (url.includes('pixverse')) return 'pixverse';
|
|
2457
|
+
if (url.includes('stability')) return 'stability';
|
|
2458
|
+
if (s.protocol === 'seedance' || url.includes('contents/generations/tasks')) return 'seedance';
|
|
2348
2459
|
return 'openai';
|
|
2349
2460
|
}
|
|
2350
|
-
function b64Pure(blob) {
|
|
2351
|
-
return new Promise((res, rej) => {
|
|
2461
|
+
function b64Pure(blob) { return new Promise((res, rej) => {
|
|
2352
2462
|
const r = new FileReader();
|
|
2353
2463
|
r.onload = () => res(String(r.result).split(',')[1] || '');
|
|
2354
2464
|
r.onerror = () => rej(new Error('图片编码失败'));
|
|
@@ -2382,27 +2492,33 @@ window.__ModuleLoader__.load({
|
|
|
2382
2492
|
const sg = b64u(new Uint8Array(await crypto.subtle.sign('HMAC', key, enc.encode(hj + '.' + pj))));
|
|
2383
2493
|
const jwt = hj + '.' + pj + '.' + sg;
|
|
2384
2494
|
const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt };
|
|
2385
|
-
const base =
|
|
2495
|
+
const base = vendorBase(s.url, VIDEO_VENDORS.kling.base);
|
|
2386
2496
|
const path = frameBlob ? 'image2video' : 'text2video';
|
|
2387
2497
|
const body = {
|
|
2388
|
-
model_name: s.model || 'kling-
|
|
2389
|
-
|
|
2390
|
-
aspect_ratio: (o.ratio || '16:9') === '9:16' ? '9:16' : (o.ratio || '16:9') === '1:1' ? '1:1' : '16:9',
|
|
2391
|
-
duration: String(Math.
|
|
2498
|
+
model_name: s.model || 'kling-v2-6', prompt,
|
|
2499
|
+
mode: 'std', // cfg_scale 仅 v1.x 支持(官方 SDK 注明 v2.x 不支持)→ 不默认发送,需要时走扩展参数
|
|
2500
|
+
...(frameBlob ? {} : { aspect_ratio: (o.ratio || '16:9') === '9:16' ? '9:16' : (o.ratio || '16:9') === '1:1' ? '1:1' : '16:9' }),
|
|
2501
|
+
duration: String(Math.round(Number(o.duration)) || 5),
|
|
2392
2502
|
};
|
|
2393
2503
|
if (frameBlob) body.image = await blobToDataUrlScaled(frameBlob);
|
|
2394
|
-
|
|
2504
|
+
mergeExtra(body, s);
|
|
2505
|
+
// 端点单复数兼容:2026 起新版官方 provider 用 /v1/videos/*,旧文档为单数 /v1/video/* —— 404 自动回退
|
|
2506
|
+
let r1 = await fetchViaProxy(base + '/v1/videos/' + path, { headers, body, label: 'Kling 提交' });
|
|
2507
|
+
if (r1.status === 404) r1 = await fetchViaProxy(base + '/v1/video/' + path, { headers, body, label: 'Kling 提交(旧端点)' });
|
|
2395
2508
|
if (!r1.ok) throw new Error('Kling 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
|
|
2396
2509
|
const j1 = JSON.parse(r1.text || '{}');
|
|
2397
2510
|
const tid = j1 && j1.data && j1.data.task_id;
|
|
2398
2511
|
if (!tid) throw new Error('Kling 响应中没有 task_id:' + r1.text.slice(0, 200));
|
|
2399
2512
|
const url = await vendorPoll('Kling', 8000, async () => {
|
|
2400
|
-
|
|
2513
|
+
let r = await fetchViaProxy(base + '/v1/videos/' + path + '/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Kling 查询' });
|
|
2514
|
+
if (r && r.status === 404) r = await fetchViaProxy(base + '/v1/video/' + path + '/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Kling 查询(旧端点)' });
|
|
2401
2515
|
if (!r || !r.ok) return { state: 'wait' };
|
|
2402
2516
|
let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
|
|
2403
2517
|
const st = j && j.data && j.data.task_status;
|
|
2404
2518
|
if (st === 'succeed') {
|
|
2405
|
-
|
|
2519
|
+
// 官方结果字段:data.task_result.videos[](含 url/watermark_url/duration);旧网关可能是 data.result.videos
|
|
2520
|
+
const v = (j.data.task_result && j.data.task_result.videos && j.data.task_result.videos[0])
|
|
2521
|
+
|| (j.data.result && j.data.result.videos && j.data.result.videos[0]);
|
|
2406
2522
|
return v && v.url ? { state: 'done', url: v.url } : { state: 'fail', error: '成功但无视频地址' };
|
|
2407
2523
|
}
|
|
2408
2524
|
if (st === 'failed') return { state: 'fail', error: (j.data && j.data.task_status_msg) || st };
|
|
@@ -2412,20 +2528,51 @@ window.__ModuleLoader__.load({
|
|
|
2412
2528
|
},
|
|
2413
2529
|
// —— 阿里通义万相 Wan:DashScope 异步,X-DashScope-Async ——
|
|
2414
2530
|
async wan(prompt, frameBlob, s, o) {
|
|
2415
|
-
|
|
2531
|
+
// 端点推导(官方域名 / 网关裸域 / 网关带厂商前缀 / 完整端点 四种写法都支持)
|
|
2532
|
+
let ep;
|
|
2533
|
+
try { ep = wanEndpoints(String(s.url || VIDEO_VENDORS.wan.base)); } catch { throw new Error('万相视频 API 地址无效:' + String(s.url || '')); }
|
|
2416
2534
|
const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || ''), 'X-DashScope-Async': 'enable' };
|
|
2535
|
+
// 万相 3.0 官方参数(百炼文档):parameters = { resolution: 1080P/720P/480P, ratio: adaptive/21:9/16:9/4:3/1:1/3:4/9:16,
|
|
2536
|
+
// duration: [2,30], audio, seed, prompt_extend, watermark } —— **没有 size 参数**(那是 2.x 旧版的)。
|
|
2537
|
+
// · 用户可变项(透传,不转换):resolution / ratio / duration —— 节点上设什么发什么
|
|
2538
|
+
// · 稳定默认(总是发送):watermark:false 去水印优先、prompt_extend:true 提示词扩展
|
|
2539
|
+
// · 媒体走 input.media[]:first_frame(≤1)/ reference_image(≤10),支持 Base64;首帧与参考图同传时
|
|
2540
|
+
// 首帧降级为首张参考图(与网关组合限制对齐,衔接说明前置)
|
|
2541
|
+
const rawRefs = o.hardFrame ? [] : (o.refs || []).slice(0, 10);
|
|
2542
|
+
let text = prompt;
|
|
2543
|
+
if (frameBlob && rawRefs.length) text = videoRefNote(o.refNames) + '\n' + prompt;
|
|
2544
|
+
const media = [];
|
|
2545
|
+
if (frameBlob) media.push({ type: rawRefs.length ? 'reference_image' : 'first_frame', url: await blobToDataUrlScaled(frameBlob) });
|
|
2546
|
+
for (const b of rawRefs) media.push({ type: 'reference_image', url: await blobToDataUrlScaled(b) });
|
|
2417
2547
|
const body = {
|
|
2418
|
-
model: s.model || '
|
|
2419
|
-
input: { prompt, ...(
|
|
2420
|
-
parameters: {
|
|
2548
|
+
model: s.model || 'wan3.0-video',
|
|
2549
|
+
input: { prompt: text, ...(media.length ? { media } : {}) },
|
|
2550
|
+
parameters: {
|
|
2551
|
+
resolution: String(o.resolution || '1080P'),
|
|
2552
|
+
ratio: o.ratio || '16:9',
|
|
2553
|
+
duration: Math.round(Number(o.duration)) || 5,
|
|
2554
|
+
watermark: false,
|
|
2555
|
+
prompt_extend: true,
|
|
2556
|
+
},
|
|
2421
2557
|
};
|
|
2422
|
-
|
|
2423
|
-
|
|
2558
|
+
// 扩展参数:厂商/网关参数名不一致时的逃生舱(顶层字段直接覆盖,parameters 内做深合并)
|
|
2559
|
+
// 例:{"parameters":{"audio":false}} 或 {"parameters":{"seed":42}}
|
|
2560
|
+
try {
|
|
2561
|
+
const ex = parseExtraParams(s.extra);
|
|
2562
|
+
if (ex) {
|
|
2563
|
+
const exParams = ex.parameters;
|
|
2564
|
+
Object.assign(body, ex);
|
|
2565
|
+
if (exParams && typeof exParams === 'object') body.parameters = { ...body.parameters, ...exParams };
|
|
2566
|
+
}
|
|
2567
|
+
} catch (e) { throw new Error('万相扩展参数配置有误:' + ((e && e.message) || e)); }
|
|
2568
|
+
const r1 = await fetchViaProxy(ep.submitUrl, { headers, body, label: 'Wan 提交' });
|
|
2569
|
+
if (!r1.ok) throw new Error('Wan 提交 HTTP ' + r1.status + ' @ ' + ep.submitUrl + ':' + r1.text.slice(0, 300));
|
|
2424
2570
|
let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Wan 响应非 JSON:' + r1.text.slice(0, 200)); }
|
|
2425
2571
|
const tid = j1 && j1.output && j1.output.task_id;
|
|
2426
2572
|
if (!tid) throw new Error('Wan 响应中没有 task_id:' + r1.text.slice(0, 200));
|
|
2573
|
+
if (o.onTask) { try { o.onTask(tid, ep.taskUrl(tid)); } catch { /* 持久化失败不影响生成 */ } }
|
|
2427
2574
|
const url = await vendorPoll('Wan', 8000, async () => {
|
|
2428
|
-
const r = await fetchViaProxy(
|
|
2575
|
+
const r = await fetchViaProxy(ep.taskUrl(tid), { method: 'GET', headers: { Authorization: 'Bearer ' + (s.apiKey || '') }, timeoutMs: 60000, label: 'Wan 查询' });
|
|
2429
2576
|
if (!r || !r.ok) return { state: 'wait' };
|
|
2430
2577
|
let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
|
|
2431
2578
|
const st = j && j.output && j.output.task_status;
|
|
@@ -2437,14 +2584,15 @@ window.__ModuleLoader__.load({
|
|
|
2437
2584
|
},
|
|
2438
2585
|
// —— MiniMax 海螺:异步 task_id + query 接口 ——
|
|
2439
2586
|
async minimax(prompt, frameBlob, s, o) {
|
|
2440
|
-
const base =
|
|
2587
|
+
const base = vendorBase(s.url, VIDEO_VENDORS.minimax.base);
|
|
2441
2588
|
const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || '') };
|
|
2442
2589
|
const body = {
|
|
2443
|
-
model: s.model || 'MiniMax-Hailuo-02', prompt,
|
|
2444
|
-
duration:
|
|
2445
|
-
resolution: String(o.resolution || '')
|
|
2590
|
+
model: s.model || 'MiniMax-Hailuo-02', prompt,
|
|
2591
|
+
duration: Math.round(Number(o.duration)) || 6, // 官方为整数(2.3/02 在 768P 下 6 或 10,1080P 仅 6)
|
|
2592
|
+
resolution: String(o.resolution || '768P'), // 透传(官方枚举 512P/720P/768P/1080P,按模型×时长受限)
|
|
2446
2593
|
};
|
|
2447
2594
|
if (frameBlob) body.first_frame_image = await blobToDataUrlScaled(frameBlob);
|
|
2595
|
+
mergeExtra(body, s);
|
|
2448
2596
|
const r1 = await fetchViaProxy(base + '/v1/video_generation', { headers, body, label: 'MiniMax 提交' });
|
|
2449
2597
|
if (!r1.ok) throw new Error('MiniMax 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
|
|
2450
2598
|
let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('MiniMax 响应非 JSON'); }
|
|
@@ -2456,8 +2604,14 @@ window.__ModuleLoader__.load({
|
|
|
2456
2604
|
let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
|
|
2457
2605
|
const st = String(j.status || '').toLowerCase();
|
|
2458
2606
|
if (st === 'success' || st === 'file_downloaded') {
|
|
2459
|
-
|
|
2460
|
-
|
|
2607
|
+
// 官方两步:任务成功先得 file_id,再经 /v1/files/retrieve 换 download_url(1 小时时效)
|
|
2608
|
+
const fid = j.file_id || (j.file && j.file.file_id);
|
|
2609
|
+
if (!fid) return { state: 'fail', error: '成功但响应中没有 file_id' };
|
|
2610
|
+
const fr = await fetchViaProxy(base + '/v1/files/retrieve?file_id=' + encodeURIComponent(fid), { method: 'GET', headers, timeoutMs: 60000, label: 'MiniMax 取文件' });
|
|
2611
|
+
if (!fr || !fr.ok) return { state: 'fail', error: '取文件信息 HTTP ' + (fr ? fr.status : '无响应') };
|
|
2612
|
+
let fj = null; try { fj = JSON.parse(fr.text); } catch { return { state: 'fail', error: '文件信息非 JSON' }; }
|
|
2613
|
+
const u = fj && fj.file && (fj.file.download_url || fj.file.url);
|
|
2614
|
+
return u ? { state: 'done', url: u } : { state: 'fail', error: '成功但无视频下载地址' };
|
|
2461
2615
|
}
|
|
2462
2616
|
if (st === 'fail') return { state: 'fail', error: (j.base_resp && j.base_resp.status_msg) || '失败' };
|
|
2463
2617
|
return { state: 'wait' };
|
|
@@ -2466,44 +2620,48 @@ window.__ModuleLoader__.load({
|
|
|
2466
2620
|
},
|
|
2467
2621
|
// —— 生数 Vidu:img2video/text2video,图片 base64 直传 ——
|
|
2468
2622
|
async vidu(prompt, frameBlob, s, o) {
|
|
2469
|
-
const base =
|
|
2623
|
+
const base = vendorBase(s.url, VIDEO_VENDORS.vidu.base);
|
|
2470
2624
|
const headers = { 'Content-Type': 'application/json', Authorization: 'Token ' + (s.apiKey || '') };
|
|
2471
2625
|
const path = frameBlob ? 'img2video' : 'text2video';
|
|
2472
2626
|
const body = {
|
|
2473
|
-
model: s.model || '
|
|
2474
|
-
duration: Math.
|
|
2475
|
-
resolution: String(o.resolution || '').toLowerCase()
|
|
2627
|
+
model: s.model || 'viduq3-pro', prompt,
|
|
2628
|
+
duration: Math.round(Number(o.duration)) || 5,
|
|
2629
|
+
resolution: String(o.resolution || '720p').toLowerCase(), // 官方枚举 540p/720p/1080p(Q4 另有 2K/4K)
|
|
2630
|
+
...(frameBlob ? {} : { aspect_ratio: o.ratio || '16:9' }), // aspect_ratio 仅文生视频有(i2v 比例跟随首帧图)
|
|
2476
2631
|
};
|
|
2477
|
-
if (frameBlob) body.images = [await blobToDataUrlScaled(frameBlob)];
|
|
2632
|
+
if (frameBlob) body.images = [await blobToDataUrlScaled(frameBlob)]; // images 数组:URL 或 data:base64(首尾帧时传 2 张)
|
|
2633
|
+
mergeExtra(body, s);
|
|
2478
2634
|
const r1 = await fetchViaProxy(base + '/ent/v2/' + path, { headers, body, label: 'Vidu 提交' });
|
|
2479
2635
|
if (!r1.ok) throw new Error('Vidu 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
|
|
2480
2636
|
let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Vidu 响应非 JSON'); }
|
|
2481
2637
|
const tid = j1 && (j1.task_id || j1.id);
|
|
2482
2638
|
if (!tid) throw new Error('Vidu 响应中没有 task_id:' + r1.text.slice(0, 200));
|
|
2483
2639
|
const url = await vendorPoll('Vidu', 8000, async () => {
|
|
2484
|
-
const r = await fetchViaProxy(base + '/ent/v2/tasks/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Vidu 查询' });
|
|
2640
|
+
const r = await fetchViaProxy(base + '/ent/v2/tasks/' + tid + '/creations', { method: 'GET', headers, timeoutMs: 60000, label: 'Vidu 查询' });
|
|
2485
2641
|
if (!r || !r.ok) return { state: 'wait' };
|
|
2486
2642
|
let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
|
|
2487
2643
|
const st = String(j.state || '').toLowerCase();
|
|
2488
2644
|
if (st === 'success') {
|
|
2489
|
-
|
|
2645
|
+
// 官方结果:creations[].url(24 小时时效,需转存)
|
|
2646
|
+
const vids = (Array.isArray(j.creations) && j.creations) || (j.resp && (j.resp.videos || j.resp.creations)) || [];
|
|
2490
2647
|
const v = vids[0] || {};
|
|
2491
2648
|
const u = v.url || v.video_url;
|
|
2492
2649
|
return u ? { state: 'done', url: u } : { state: 'fail', error: '成功但无视频地址' };
|
|
2493
2650
|
}
|
|
2494
|
-
if (st === 'failed') return { state: 'fail', error: j.err_msg || '失败' };
|
|
2651
|
+
if (st === 'failed') return { state: 'fail', error: j.err_msg || ('err_code ' + j.err_code) || '失败' };
|
|
2495
2652
|
return { state: 'wait' };
|
|
2496
2653
|
});
|
|
2497
2654
|
return await downloadVideo(url, {});
|
|
2498
2655
|
},
|
|
2499
2656
|
// —— Runway Gen-4:X-Runway-Version 头,tasks 轮询 ——
|
|
2500
2657
|
async runway(prompt, frameBlob, s, o) {
|
|
2501
|
-
const base =
|
|
2658
|
+
const base = vendorBase(s.url, VIDEO_VENDORS.runway.base);
|
|
2502
2659
|
const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || ''), 'X-Runway-Version': '2024-11-06' };
|
|
2503
2660
|
const ratio = (o.ratio || '16:9') === '9:16' ? '720:1280' : '1280:720';
|
|
2504
|
-
const dur = Math.
|
|
2505
|
-
let ep = '/v1/text_to_video', body = { model: '
|
|
2506
|
-
if (frameBlob) { ep = '/v1/image_to_video'; body = { model: 'gen4_turbo', promptImage: await blobToDataUrlScaled(frameBlob), promptText: prompt, ratio, duration: dur }; }
|
|
2661
|
+
const dur = Math.round(Number(o.duration)) || 5;
|
|
2662
|
+
let ep = '/v1/text_to_video', body = { model: s.model || 'gen4.5', promptText: prompt, ratio, duration: dur };
|
|
2663
|
+
if (frameBlob) { ep = '/v1/image_to_video'; body = { model: s.model || 'gen4_turbo', promptImage: await blobToDataUrlScaled(frameBlob), promptText: prompt, ratio, duration: dur }; }
|
|
2664
|
+
mergeExtra(body, s);
|
|
2507
2665
|
const r1 = await fetchViaProxy(base + ep, { headers, body, label: 'Runway 提交' });
|
|
2508
2666
|
if (!r1.ok) throw new Error('Runway 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
|
|
2509
2667
|
let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Runway 响应非 JSON'); }
|
|
@@ -2521,12 +2679,18 @@ window.__ModuleLoader__.load({
|
|
|
2521
2679
|
},
|
|
2522
2680
|
// —— Google Veo(Gemini API):predictLongRunning + operation 轮询 ——
|
|
2523
2681
|
async veo(prompt, frameBlob, s, o) {
|
|
2524
|
-
const base =
|
|
2525
|
-
const model = s.model || 'veo-3.
|
|
2682
|
+
const base = vendorBase(s.url, VIDEO_VENDORS.veo.base);
|
|
2683
|
+
const model = s.model || 'veo-3.1-generate-preview';
|
|
2526
2684
|
const ep = base + '/v1beta/models/' + encodeURIComponent(model) + ':predictLongRunning?key=' + encodeURIComponent(s.apiKey || '');
|
|
2527
2685
|
const inst = { prompt };
|
|
2528
2686
|
if (frameBlob) inst.image = { bytesBase64Encoded: await b64Pure(await blobToDataUrlScaled(frameBlob)), mimeType: 'image/png' };
|
|
2529
|
-
|
|
2687
|
+
// 官方字段(Gemini API SDK):parameters.{aspectRatio(16:9|9:16), durationSeconds(int), resolution(720p|1080p)}
|
|
2688
|
+
const body = { instances: [inst], parameters: {
|
|
2689
|
+
aspectRatio: (o.ratio || '16:9') === '9:16' ? '9:16' : '16:9',
|
|
2690
|
+
durationSeconds: Math.round(Number(o.duration)) || 5,
|
|
2691
|
+
...(o.resolution ? { resolution: String(o.resolution).toLowerCase() } : {}),
|
|
2692
|
+
} };
|
|
2693
|
+
mergeExtra(body, s);
|
|
2530
2694
|
const r1 = await fetchViaProxy(ep, { headers: { 'Content-Type': 'application/json' }, body, label: 'Veo 提交' });
|
|
2531
2695
|
if (!r1.ok) throw new Error('Veo 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
|
|
2532
2696
|
let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Veo 响应非 JSON'); }
|
|
@@ -2547,10 +2711,16 @@ window.__ModuleLoader__.load({
|
|
|
2547
2711
|
},
|
|
2548
2712
|
// —— Luma Dream Machine:图片需公网 URL,本地图片仅文生视频 ——
|
|
2549
2713
|
async luma(prompt, frameBlob, s, o) {
|
|
2550
|
-
|
|
2714
|
+
// Luma 的 base 本身以 /v1 结尾(路径只拼 /generations),不能用 vendorBase 剥掉 → 这里改为「保证以 /v1 结尾」
|
|
2715
|
+
let base = String(s.url || VIDEO_VENDORS.luma.base).trim().replace(/\/+$/, '');
|
|
2716
|
+
if (!/\/v1$/i.test(base)) base = base + '/v1';
|
|
2551
2717
|
const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || '') };
|
|
2552
|
-
const body = { prompt, model:
|
|
2718
|
+
const body = { prompt, model: s.model || 'ray-2',
|
|
2719
|
+
resolution: String(o.resolution || '720p').toLowerCase(), // 官方枚举 540p/720p/1080p/4k
|
|
2720
|
+
duration: (Math.round(Number(o.duration)) || 5) + 's', // 官方为字符串 "5s"/"9s"
|
|
2721
|
+
aspect_ratio: o.ratio || '16:9' }; // 官方枚举 1:1/16:9/9:16/4:3/3:4/21:9/9:21
|
|
2553
2722
|
if (frameBlob) throw new Error('Luma 图生视频需要公网图片 URL——本地图片请改用文生视频,或换其他厂商');
|
|
2723
|
+
mergeExtra(body, s);
|
|
2554
2724
|
const r1 = await fetchViaProxy(base + '/generations', { headers, body, label: 'Luma 提交' });
|
|
2555
2725
|
if (!r1.ok) throw new Error('Luma 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
|
|
2556
2726
|
let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Luma 响应非 JSON'); }
|
|
@@ -2568,17 +2738,31 @@ window.__ModuleLoader__.load({
|
|
|
2568
2738
|
},
|
|
2569
2739
|
// —— PixVerse:API-KEY 头 + Ai-trace-id,base64 图片 ——
|
|
2570
2740
|
async pixverse(prompt, frameBlob, s, o) {
|
|
2571
|
-
const base =
|
|
2741
|
+
const base = vendorBase(s.url, VIDEO_VENDORS.pixverse.base);
|
|
2572
2742
|
const headers = { 'Content-Type': 'application/json', 'API-KEY': s.apiKey || '', 'Ai-trace-id': 'dshic-' + Date.now().toString(36) };
|
|
2573
|
-
|
|
2743
|
+
// 官方端点(Apidog 文档):文生 /video/text/generate、图生 /video/img/generate(旧 /video/text2video 已弃用)
|
|
2744
|
+
const path = frameBlob ? 'img/generate' : 'text/generate';
|
|
2745
|
+
// 图生视频的图片必须是 img_id(整数)——先用 multipart 上传拿 id(官方不收 base64/URL 字段)
|
|
2746
|
+
let imgId = null;
|
|
2747
|
+
if (frameBlob) {
|
|
2748
|
+
const fd = new FormData();
|
|
2749
|
+
fd.append('image', frameBlob, 'frame.png');
|
|
2750
|
+
const ser = new Response(fd);
|
|
2751
|
+
const up = await fetchViaProxy(base + '/openapi/v2/image/upload', { headers: { 'API-KEY': headers['API-KEY'], 'Ai-trace-id': headers['Ai-trace-id'] }, bodyRaw: arrayBufferToBase64(await ser.arrayBuffer()), bodyContentType: ser.headers.get('content-type'), label: 'PixVerse 上传图片' });
|
|
2752
|
+
if (!up || !up.ok) throw new Error('PixVerse 上传图片 HTTP ' + (up ? up.status : '无响应') + ':' + (up ? up.text.slice(0, 200) : ''));
|
|
2753
|
+
let uj = null; try { uj = JSON.parse(up.text); } catch { throw new Error('PixVerse 上传响应非 JSON'); }
|
|
2754
|
+
imgId = uj && uj.Resp && uj.Resp.img_id;
|
|
2755
|
+
if (!imgId) throw new Error('PixVerse 上传响应中没有 img_id:' + up.text.slice(0, 200));
|
|
2756
|
+
}
|
|
2574
2757
|
const body = {
|
|
2575
|
-
model:
|
|
2576
|
-
duration: Math.
|
|
2577
|
-
quality: String(o.resolution || '').toLowerCase()
|
|
2578
|
-
aspect_ratio: (o.ratio || '16:9') === '9:16' ? '9:16' : '16:9',
|
|
2758
|
+
model: s.model || 'v6', prompt,
|
|
2759
|
+
duration: Math.round(Number(o.duration)) || 5, // C1/V6: 1-15 任意整数;v5.5/v5.6 仅 5/8/10
|
|
2760
|
+
quality: String(o.resolution || '720p').toLowerCase(), // 官方枚举 360p/540p/720p/1080p
|
|
2761
|
+
...(frameBlob ? {} : { aspect_ratio: (o.ratio || '16:9') === '9:16' ? '9:16' : '16:9' }), // aspect_ratio 仅文生视频有(i2v 跟随输入图)
|
|
2579
2762
|
};
|
|
2580
|
-
if (frameBlob) body.
|
|
2581
|
-
|
|
2763
|
+
if (frameBlob) body.img_id = imgId;
|
|
2764
|
+
mergeExtra(body, s);
|
|
2765
|
+
const r1 = await fetchViaProxy(base + '/openapi/v2/video/' + path, { headers, body, label: 'PixVerse 提交' });
|
|
2582
2766
|
if (!r1.ok) throw new Error('PixVerse 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
|
|
2583
2767
|
let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('PixVerse 响应非 JSON'); }
|
|
2584
2768
|
const vid = j1 && j1.Resp && j1.Resp.video_id;
|
|
@@ -2589,15 +2773,16 @@ window.__ModuleLoader__.load({
|
|
|
2589
2773
|
let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
|
|
2590
2774
|
const resp = j && j.Resp;
|
|
2591
2775
|
if (!resp) return { state: 'wait' };
|
|
2592
|
-
|
|
2593
|
-
if (resp.
|
|
2594
|
-
return { state: '
|
|
2776
|
+
// 官方状态语义(现行文档):1=完成(Resp.url 可用);5=生成中、9=排队、10=处理中;7=审核失败、8=失败(6=已删除)
|
|
2777
|
+
if (resp.status === 1 && resp.url) return { state: 'done', url: resp.url };
|
|
2778
|
+
if (resp.status === 7 || resp.status === 8) return { state: 'fail', error: resp.msg || ('status ' + resp.status) };
|
|
2779
|
+
return { state: 'wait' }; // 5/9/10(历史 2/3)= 排队/处理中
|
|
2595
2780
|
});
|
|
2596
2781
|
return await downloadVideo(url, {});
|
|
2597
2782
|
},
|
|
2598
2783
|
// —— Stability AI:SVD 纯图生视频(multipart,无提示词),结果为 base64 JSON ——
|
|
2599
2784
|
async stability(prompt, frameBlob, s, o) {
|
|
2600
|
-
const base =
|
|
2785
|
+
const base = vendorBase(s.url, VIDEO_VENDORS.stability.base);
|
|
2601
2786
|
if (!frameBlob) throw new Error('Stability 图生视频必须有首帧图(该服务不支持纯文生视频、无提示词输入)');
|
|
2602
2787
|
const fd = new FormData();
|
|
2603
2788
|
fd.append('image', frameBlob, 'frame.png');
|
|
@@ -3865,7 +4050,7 @@ window.__ModuleLoader__.load({
|
|
|
3865
4050
|
'要求:',
|
|
3866
4051
|
'- 本节点输出**一集**的剧本:成片时长 1-2 分钟(60-120 秒),共 4-10 个场景;',
|
|
3867
4052
|
'- **每个场景必须以「【场N】场景名(地点·时间)」单独一行开头**(如【场1】城市街头(夜))——这是后续自动铺开到分镜的分场标记,格式不可省略或改写;',
|
|
3868
|
-
'-
|
|
4053
|
+
'- 每场按内容写足(成片 10–30 秒量级,约 150–400 字含该场全部对白);全部场景时长之和 ≈ 1-3 分钟;',
|
|
3869
4054
|
'- 场景头之后:可视化动作描写 + 对白(角色:台词,单独成行);需要的声音(环境音/旁白)标注在括号里;',
|
|
3870
4055
|
'- **【资产锚定·硬性】正文中所有角色/场景/道具名称一律写成 @名称@ 引用形式**(前后各一个 @ 精确包裹,名称与设定集逐字一致,如「@陈拾@ 在 @城市街头@ 的 @铁皮围挡@ 旁」)——下游分镜生成靠这些锚点识别资产并挂接参考图;叙述性文字正常书写,不要把动作描述包进 @…@;',
|
|
3871
4056
|
'- 结尾单独一行「人物:」与「场景/道具:」清单,便于后续资产制作。',
|
|
@@ -3874,7 +4059,7 @@ window.__ModuleLoader__.load({
|
|
|
3874
4059
|
'Requirements:',
|
|
3875
4060
|
'- This node outputs ONE episode: 1-2 minutes of screen time (60-120s), 4-10 scenes;',
|
|
3876
4061
|
'- **Every scene MUST start with its own line "【SCENE N】name (location · time)"** — this marker drives the automatic per-scene expansion and must not be omitted;',
|
|
3877
|
-
'- Each scene
|
|
4062
|
+
'- Each scene is written to what it needs (10-30 seconds of screen time, ≈150-400 words incl. dialogue); all scenes sum to ≈1-3 minutes;',
|
|
3878
4063
|
'- After each scene header: visual action + dialogue ("Character: line" on its own lines); mark sounds in brackets;',
|
|
3879
4064
|
'- **[ASSET ANCHORING - HARD] wrap every character/scene/prop name as @Name@** (an @ on each side, names verbatim from the bible, e.g. "@Chen@ stands by @Wall@ at @Street@") - downstream storyboard generation relies on these anchors to attach references; keep narration text normal, never wrap action descriptions inside @...@;',
|
|
3880
4065
|
'- End with "Characters:" and "Locations/Props:" lists for asset production.',
|
|
@@ -3929,7 +4114,9 @@ window.__ModuleLoader__.load({
|
|
|
3929
4114
|
'- **【完整覆盖】分镜必须覆盖所给剧本的全部内容**:每一个动作、每一句对白都要落在某个镜头里——不概述、不省略、不跳过、不"挑选重点";镜头数量与总时长由剧本体量自然决定,宁多勿漏(容量超限由上层拆分剧本处理,不由分镜偷工减料);',
|
|
3930
4115
|
'- **镜头按实际内容切分**:一个连续动作段落 = 一镜,数量由内容自然决定(3-10 镜都正常)——绝不凑数、绝不使用固定数量;',
|
|
3931
4116
|
'- **JSON 字符串值内部禁止出现半角双引号 "**——引用文字(牌匾/字幕/台词原文)一律用「」;',
|
|
3932
|
-
'- duration
|
|
4117
|
+
'- duration 为该镜时长,**支持一位小数**;**按节奏谱系分配、拒绝均匀分布**:普通叙事/对话/环境交代 2–4s,快速冲突/打斗/追逐 0.3–0.8s(快切),重要反转/冲击/卡点 1–2s,长镜头情绪沉淀 ≥5s——同一场里至少制造一次明显的节奏变化(例:3s 叙事 → 0.6s 快切×2 → 1.5s 反转 → 5s 情绪长镜),画面再精致、时长全一样就没有节奏感;总时长按内容需要写足(不设硬上限);镜头顺序即叙事顺序,末镜收束情绪;**若下方给了「承接约束」,第 1 镜必须承接上一场尾镜的画面与人物状态(不得重新建立场景/跳回远景),无承接约束时首镜才交代场景**;',
|
|
4118
|
+
'- **按情绪选景别**:孤独/绝望/宏大危机=远景;对峙/动作/关系建立=全景;对话/试探/信息交换=中景;震惊/怀疑/内心动摇=近景;崩溃/嘶吼/情绪爆发=特写;关键细节/道具揭示=大特写——冲突用全景、揭秘转近景、爆发给特写、反转可拉远,景别变化本身在讲情绪;',
|
|
4119
|
+
'- **快切镜头(<1.5s)的 visual/motion 要极短**(一个动作、一句画内信息),信息量大的镜头才配长时长——时长与内容量必须匹配;',
|
|
3933
4120
|
'- 只输出 JSON,不要输出任何其他内容。',
|
|
3934
4121
|
].join('\n')
|
|
3935
4122
|
: [
|
|
@@ -3946,7 +4133,9 @@ window.__ModuleLoader__.load({
|
|
|
3946
4133
|
'- **[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;',
|
|
3947
4134
|
'- **Never use straight double quotes " inside JSON string values** — quoted text (signs/subtitles/lines) uses 「」 instead;',
|
|
3948
4135
|
'- **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;',
|
|
3949
|
-
'- duration is the shot length in seconds,
|
|
4136
|
+
'- duration is the shot length in seconds, **one decimal allowed**; **assign by pacing spectrum, never uniform**: normal narrative/dialogue 2-4s, fast conflict/fight/chase 0.3-0.8s (rapid cuts), key reversal/impact 1-2s, long emotional take >=5s — every scene must contain at least one obvious rhythm change (e.g. 3s narrative -> 0.6s cuts x2 -> 1.5s reversal -> 5s emotional take); identical durations kill the rhythm; total duration follows what the scene needs - no hard cap (vendor limits differ and change; if exceeded the video call reports it explicitly, then you split or adjust); 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**;',
|
|
4137
|
+
'- **Pick the shot size by emotion**: loneliness/despair/grand crisis = wide; confrontation/action/relationship = full; dialogue/probing/info exchange = medium; shock/suspicion/inner shake = close-up; breakdown/screaming/emotional burst = extreme close-up; key detail/prop reveal = big close-up — conflicts play wide, reveals move close, bursts go extreme, reversals can pull wide; the changing distance itself tells the emotion;',
|
|
4138
|
+
'- **Rapid cuts (<1.5s) get minimal visual/motion** (one action, one piece of in-frame information); long durations are earned by information density — duration must match content;',
|
|
3950
4139
|
'- Output JSON only.',
|
|
3951
4140
|
].join('\n');
|
|
3952
4141
|
const methodology = await loadSkillRules(settings.skillStoryboard, zh)
|
|
@@ -4024,24 +4213,16 @@ window.__ModuleLoader__.load({
|
|
|
4024
4213
|
const tail = raw.slice(-160);
|
|
4025
4214
|
throw new Error(T('errParseStoryboard') + '(正文 ' + raw.length + ' 字;开头: ' + head + ';结尾: ' + tail + ')');
|
|
4026
4215
|
}
|
|
4027
|
-
//
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
const sum = capped.reduce((a, s) => a + (Number(s.duration) || 0), 0);
|
|
4031
|
-
if (sum <= 15) break;
|
|
4032
|
-
let maxI = -1, maxV = 1;
|
|
4033
|
-
capped.forEach((s, i) => { const d = Number(s.duration) || 0; if (d > maxV) { maxV = d; maxI = i; } });
|
|
4034
|
-
if (maxI < 0) break;
|
|
4035
|
-
capped[maxI].duration = maxV - 1;
|
|
4036
|
-
}
|
|
4037
|
-
return capped.slice(0, 24).map((s, i) => ({
|
|
4216
|
+
// 不做时长压缩:镜头时长由内容决定(各视频厂商单次上限不同,超限时由视频接口明确报错,
|
|
4217
|
+
// 比系统静默砍戏更好——用户要求「不要强行截断」)。仅做数据合法性兜底(≥1 的整数)。
|
|
4218
|
+
return shots.slice(0, 24).map((s, i) => ({
|
|
4038
4219
|
title: String(s.title || (T('shotPrefix') + (i + 1))).slice(0, 24),
|
|
4039
4220
|
visual: String(s.visual || ''),
|
|
4040
4221
|
motion: String(s.motion || ''),
|
|
4041
4222
|
characters: Array.isArray(s.characters) ? s.characters.map(String).slice(0, 6) : [],
|
|
4042
4223
|
scene: String(s.scene || ''),
|
|
4043
4224
|
props: Array.isArray(s.props) ? s.props.map(String).slice(0, 6) : [],
|
|
4044
|
-
duration: Number.isFinite(Number(s.duration)) ?
|
|
4225
|
+
duration: Number.isFinite(Number(s.duration)) ? Math.max(0.3, Math.round(Number(s.duration) * 10) / 10) : 3,
|
|
4045
4226
|
dialogue: Array.isArray(s.dialogue)
|
|
4046
4227
|
? 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)
|
|
4047
4228
|
: [],
|
|
@@ -4778,10 +4959,10 @@ window.__ModuleLoader__.load({
|
|
|
4778
4959
|
// 先自动补录缺失条目(空描述),再算一致性告警:否则告警标志的条目其实马上就被自动补录了,"未登记"语义会误导用户与对话侧
|
|
4779
4960
|
try { await autoRegisterBibleEntries(shots); } catch { /* 补录失败不影响分镜 */ }
|
|
4780
4961
|
const warnings = await checkShotConsistency(shots);
|
|
4781
|
-
//
|
|
4782
|
-
// 而不是让一个分镜表硬吃全集(shots 上限 24
|
|
4962
|
+
// 容量告警:镜头数上限 24(UI/数据上限)。剧本过长说明应该按场景拆分成多个场景剧本节点,
|
|
4963
|
+
// 而不是让一个分镜表硬吃全集(shots 上限 24 会截断覆盖面)。时长本身不设上限,不做压缩。
|
|
4783
4964
|
const stLen = st.length;
|
|
4784
|
-
if (stLen >
|
|
4965
|
+
if (stLen > 600) {
|
|
4785
4966
|
warnings.push(T('warnScriptTooLong').replace('{n}', String(stLen)));
|
|
4786
4967
|
}
|
|
4787
4968
|
update((s) => {
|
|
@@ -4931,6 +5112,20 @@ window.__ModuleLoader__.load({
|
|
|
4931
5112
|
category: 'finalcut', // 节点生成的视频 = 成片(实体归属由 entities 字段承担,不占用分类)
|
|
4932
5113
|
});
|
|
4933
5114
|
commitGenResult(n.id, rec.id, false, 'video');
|
|
5115
|
+
// 实测结果与设置不一致时留可见提示(厂商忽略 size/duration 是常见情况,必须让人看见而不是静默)
|
|
5116
|
+
try {
|
|
5117
|
+
const meta = await probeVideoMeta(blob);
|
|
5118
|
+
if (meta && meta.w && meta.h) {
|
|
5119
|
+
const wantRatio = String((n.data && n.data.ratio) || '16:9');
|
|
5120
|
+
const gotLandscape = meta.w > meta.h;
|
|
5121
|
+
const notes = [];
|
|
5122
|
+
if (wantRatio === '16:9' && !gotLandscape) notes.push('厂商返回 ' + meta.w + '×' + meta.h + '(竖屏),与设置的 16:9 不符:该厂商可能忽略 size 参数,可在供应商「扩展参数」里覆盖(如 {"parameters":{"size":"1920*1080"}})');
|
|
5123
|
+
else if (wantRatio === '9:16' && gotLandscape) notes.push('厂商返回 ' + meta.w + '×' + meta.h + '(横屏),与设置的 9:16 不符');
|
|
5124
|
+
const wantDur = Number(n.data && n.data.duration) || 0;
|
|
5125
|
+
if (wantDur && meta.d && Math.abs(meta.d - wantDur) > 1.5) notes.push('实际时长 ' + meta.d.toFixed(1) + 's ≠ 设置 ' + wantDur + 's(厂商单次上限或已钳制)');
|
|
5126
|
+
if (notes.length) update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.genWarn = notes.join(';'); });
|
|
5127
|
+
}
|
|
5128
|
+
} catch { /* 探测失败不影响生成 */ }
|
|
4934
5129
|
// 尾帧提取(通用):供 last 端口输出与链式下一镜使用
|
|
4935
5130
|
try {
|
|
4936
5131
|
const fBlob = await extractLastFrame(blob);
|
|
@@ -5057,6 +5252,13 @@ window.__ModuleLoader__.load({
|
|
|
5057
5252
|
if (t.closest && t.closest('input,textarea,select,button,video,a,label,[data-no-drag]')) return;
|
|
5058
5253
|
e.stopPropagation();
|
|
5059
5254
|
closeMenu();
|
|
5255
|
+
// 点击即置顶:被点的节点(含拖动中的整组)绘制在最上层,避免被其他节点盖住
|
|
5256
|
+
update((s) => {
|
|
5257
|
+
const z = (s.zTop || 100) + 1;
|
|
5258
|
+
s.zTop = z;
|
|
5259
|
+
const ids = s.sel.has(n.id) ? [...s.sel, n.id] : [n.id];
|
|
5260
|
+
for (const id of ids) { const nd = s.graph.nodes[id]; if (nd) nd.z = z; }
|
|
5261
|
+
});
|
|
5060
5262
|
if (e.shiftKey) {
|
|
5061
5263
|
const next = new Set(S.sel);
|
|
5062
5264
|
if (next.has(n.id)) next.delete(n.id); else next.add(n.id);
|
|
@@ -5527,7 +5729,7 @@ window.__ModuleLoader__.load({
|
|
|
5527
5729
|
return h('div', {
|
|
5528
5730
|
'data-node-id': n.id,
|
|
5529
5731
|
className: cls('dshic-node', 'type-' + n.type, 'st-' + st, selected && 'is-selected', n.hManual && 'h-manual'),
|
|
5530
|
-
style: { left: n.x, top: n.y, width: n.w, ...(n.hManual ? { height: n.hManual } : {}) },
|
|
5732
|
+
style: { left: n.x, top: n.y, width: n.w, zIndex: Number(n.z) || 1, ...(n.hManual ? { height: n.hManual } : {}) },
|
|
5531
5733
|
ref: refCb,
|
|
5532
5734
|
onPointerDown: (e) => onNodePointerDown(e, n),
|
|
5533
5735
|
},
|
|
@@ -5702,7 +5904,11 @@ window.__ModuleLoader__.load({
|
|
|
5702
5904
|
: [
|
|
5703
5905
|
h(MiniSelect, {
|
|
5704
5906
|
key: 'dur', value: String(n.data.duration || 5), title: T('durationLabel'), width: 60,
|
|
5705
|
-
|
|
5907
|
+
// 当前值不在预设列表里(如分镜总时长 32s)也要能显示与保留:动态并入选项
|
|
5908
|
+
options: (VIDEO_DURATIONS.includes(Number(n.data.duration))
|
|
5909
|
+
? VIDEO_DURATIONS
|
|
5910
|
+
: [...VIDEO_DURATIONS, Math.round(Number(n.data.duration) || 5)].sort((a, b) => a - b))
|
|
5911
|
+
.map((x) => ({ value: String(x), label: x + 's' })),
|
|
5706
5912
|
onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.duration = Number(v); }),
|
|
5707
5913
|
}),
|
|
5708
5914
|
h(MiniSelect, {
|
|
@@ -6026,6 +6232,7 @@ window.__ModuleLoader__.load({
|
|
|
6026
6232
|
: null,
|
|
6027
6233
|
h(RefRows, { key: 'refrows', n, portId: 'ref' }),
|
|
6028
6234
|
h(MentionInfo, { key: 'mn', n }),
|
|
6235
|
+
n.data.genWarn ? h('div', { key: 'gw', className: 'dshic-hint', style: { color: 'var(--dsw-alias-state-warning-primary, #d08700)' } }, '⚠ ' + n.data.genWarn) : null,
|
|
6029
6236
|
n.data.error ? h('div', { key: 'err', className: 'dshic-err' }, n.data.error) : null,
|
|
6030
6237
|
assetUrlV && kind === 'image'
|
|
6031
6238
|
? 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); } })
|
|
@@ -6653,7 +6860,7 @@ window.__ModuleLoader__.load({
|
|
|
6653
6860
|
const baseY = n.y - 40;
|
|
6654
6861
|
// 生成时长 = 各镜时长之和(多镜头一次性生成的总时长),由视频模型内部切镜
|
|
6655
6862
|
const total = shots.reduce((a, sh) => a + Math.max(1, Number(sh.duration) || 3), 0);
|
|
6656
|
-
const videoGen = mk('video-gen', baseX + 360, baseY + 20, { prompt: '', duration: Math.max(
|
|
6863
|
+
const videoGen = mk('video-gen', baseX + 360, baseY + 20, { prompt: '', duration: Math.max(1, Math.round(total)) });
|
|
6657
6864
|
const videoAsset = mk('video-asset', baseX + 720, baseY + 20);
|
|
6658
6865
|
entityNames.forEach((nm, i) => {
|
|
6659
6866
|
let an = findAssetNode(nm);
|
|
@@ -8722,11 +8929,11 @@ window.__ModuleLoader__.load({
|
|
|
8722
8929
|
nodes.filter((nd) => nd.type === 'group').map((n) => h(NodeShell, { key: n.id, n })),
|
|
8723
8930
|
nodes.filter((nd) => nd.type !== 'group').map((n) => h(NodeShell, { key: n.id, n })),
|
|
8724
8931
|
h('div', {
|
|
8725
|
-
className: 'dshic-marquee', style: { display: 'none' },
|
|
8932
|
+
className: 'dshic-marquee', style: { display: 'none', zIndex: 9999 },
|
|
8726
8933
|
ref: (el) => { marqueeEl = el; },
|
|
8727
8934
|
}),
|
|
8728
|
-
h('div', { className: 'dshic-guide-v', style: { display: 'none' }, ref: (el) => { vGuidEl = el; } }),
|
|
8729
|
-
h('div', { className: 'dshic-guide-h', style: { display: 'none' }, ref: (el) => { hGuidEl = el; } }),
|
|
8935
|
+
h('div', { className: 'dshic-guide-v', style: { display: 'none', zIndex: 9999 }, ref: (el) => { vGuidEl = el; } }),
|
|
8936
|
+
h('div', { className: 'dshic-guide-h', style: { display: 'none', zIndex: 9999 }, ref: (el) => { hGuidEl = el; } }),
|
|
8730
8937
|
),
|
|
8731
8938
|
h(Minimap),
|
|
8732
8939
|
nodes.length === 0 ? h('div', { className: 'dshic-empty' }, h('div', { className: 'dshic-empty-card' },
|
package/index.js
CHANGED
|
@@ -784,8 +784,8 @@ function nextNodePos(doc, type, connectFromId) {
|
|
|
784
784
|
const H = (n) => Number(n && n.h) || 160;
|
|
785
785
|
const W = (n) => Number(n && n.w) || 280;
|
|
786
786
|
const nodes = Object.values(doc.nodes || {});
|
|
787
|
-
const EST_H = { storyboard:
|
|
788
|
-
const estH = EST_H[type] ||
|
|
787
|
+
const EST_H = { storyboard: 560, 'video-gen': 620, 'image-gen': 560, script: 420, finalcut: 420, text: 220 };
|
|
788
|
+
const estH = EST_H[type] || 300;
|
|
789
789
|
const estW = type === 'finalcut' ? 300 : 280;
|
|
790
790
|
let x = 80;
|
|
791
791
|
let y = 40;
|
|
@@ -796,8 +796,13 @@ function nextNodePos(doc, type, connectFromId) {
|
|
|
796
796
|
} else {
|
|
797
797
|
const same = nodes.filter((n) => n.type === type);
|
|
798
798
|
if (same.length) {
|
|
799
|
+
// 同类型竖向成列:跟在同类型最后一个节点下方
|
|
799
800
|
x = Math.min(...same.map((n) => n.x || 0));
|
|
800
801
|
y = Math.max(...same.map((n) => (n.y || 0) + H(n))) + GAP_Y;
|
|
802
|
+
} else if (nodes.length) {
|
|
803
|
+
// 该类型还没有节点 → 在最右侧另起一列(绝不插进已有列里跟别的类型混排)
|
|
804
|
+
x = Math.max(...nodes.map((n) => (n.x || 0) + W(n))) + GAP_X;
|
|
805
|
+
y = Math.min(...nodes.map((n) => n.y || 0));
|
|
801
806
|
}
|
|
802
807
|
}
|
|
803
808
|
const hit = (yy) => nodes.find((n) => (n.x || 0) < x + estW + MIN_GAP && (n.x || 0) + W(n) + MIN_GAP > x
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ninkch/dsh-infinite-canvas",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.15",
|
|
4
4
|
"description": "Node-based short-drama studio for DeepSeek Harness: script → storyboard → assets → shots → voiced, subtitled final cut on an infinite canvas.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|