@ninkch/dsh-infinite-canvas 0.17.14 → 0.17.16

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.
@@ -39,15 +39,50 @@ B:……
39
39
 
40
40
  ### 粒度
41
41
  - **一段连续的动作节拍 = 一个镜头**;不要把两个不相关的动作塞进一个镜头。
42
- - **单镜头 2–5 秒**(一个镜头拍一个连续动作段落);各镜时长之和 = 目标视频总时长
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. **分镜容量铁律:一个分镜节点 = 一个 10–15s 的完整场景视频,是剧本的一小部分。**
44
- - 一个分镜节点只吃**一个场景**的剧本:各镜 2–5s、时长之和 **≤ 15s(上限)**;
45
- - **长剧本必须按场景拆分**为多个场景剧本节点(命名「第X节-场Y剧本」…),
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–15s 场景 ≈ 剧本 60–150 字(含该场全部对白);分镜表 `shots` 上限 24 镜,
52
+ - 体量感:一个 10–30s 场景 ≈ 剧本 150–400 字(含该场全部对白);分镜表 `shots` 上限 24 镜,
49
53
  超长剧本会被截断且触发生成告警(warnings 会出现"剧本较长…请按场景拆分");
50
54
  - **镜头数量不固定**:分镜表镜头数默认「自动(按内容节拍)」——一个连续动作段落 = 一镜,
51
- 3–10 镜都正常,绝不凑数、绝不用固定数量;全部镜头时长之和 ≤ 15s(超限客户端会自动压缩最长镜);
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–15 秒成片**;**每场以「【场N】场景名(地点·时间)」单独一行开头**
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–15 秒的视频**不需要逐镜的图片节点或"镜头图→镜头视频"链路**:
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
- const VIDEO_DURATIONS = [4, 5, 6, 8, 10, 12, 15];
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
  // ============================================================
@@ -246,7 +247,7 @@ window.__ModuleLoader__.load({
246
247
  'delProjectWarn': '将删除该项目及其全部资产(共 {n} 个),且无法恢复。请输入项目名以确认:',
247
248
  'delProjectInput': '项目名',
248
249
  'imgMode': '参考图模式',
249
- 'modeGenerations': '文生图 /images/generations(忽略参考图)',
250
+ 'modeGenerations': '生图 /images/generations(支持多参考图)',
250
251
  'modeEdits': '参考图编辑 /images/edits(多参考图上传)',
251
252
  'catMismatch': '提示:资产分类为「{a}」,端口期望「{b}」,已保留连接',
252
253
  'nStoryboard': '分镜表',
@@ -349,7 +350,7 @@ window.__ModuleLoader__.load({
349
350
  'shotsUnit': '镜',
350
351
  'savedOk': '已存为资产',
351
352
  'warnUnregistered': '未在设定集/资产中登记',
352
- 'warnScriptTooLong': '剧本较长({n} 字)≈ 超过单分镜 10-15s 成片容量:请按场景拆分为多个场景剧本节点(每个分镜只连一个场景剧本,长剧本内容会被 24 镜上限截断)',
353
+ 'warnScriptTooLong': '剧本较长({n} 字)≈ 超过单个分镜的镜头容量(24 镜):请按场景拆分为多个场景剧本节点(每个分镜只连一个场景剧本)',
353
354
  'warnEmptyDesc': '已登记但描述为空(待补全)',
354
355
  'catMention': '提及',
355
356
  'assetCategoriesFixed': '已按命名规范纠正 {n} 个资产的分类',
@@ -650,7 +651,7 @@ window.__ModuleLoader__.load({
650
651
  'delProjectWarn': 'This deletes the project and all its assets ({n}). Type the project name to confirm:',
651
652
  'delProjectInput': 'Project name',
652
653
  'imgMode': 'Reference mode',
653
- 'modeGenerations': 'text-to-image /images/generations (refs ignored)',
654
+ 'modeGenerations': 'image /images/generations (with references)',
654
655
  'modeEdits': 'image edit /images/edits (multi-reference upload)',
655
656
  'catMismatch': 'Note: asset category is "{a}", port expects "{b}". Connected anyway.',
656
657
  'nStoryboard': 'Storyboard',
@@ -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 10-15s capacity: split the script into multiple scene script nodes (each storyboard eats ONE scene; long scripts get truncated at the 24-shot cap)',
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
- 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 });
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;
@@ -2084,7 +2086,8 @@ window.__ModuleLoader__.load({
2084
2086
  fd.append('prompt', prompt);
2085
2087
  fd.append('n', '1');
2086
2088
  if (s.size) fd.append('size', s.size);
2087
- refs.forEach((b, i) => fd.append('image[]', b, 'ref-' + i + '.png'));
2089
+ fd.append('watermark', 'false'); // 稳定默认:去水印优先(与 generations 路径一致)
2090
+ refs.forEach((b, i) => fd.append('image[]', b.blob || b, 'ref-' + i + '.png'));
2088
2091
  // FormData 经 Response 序列化为 multipart 字节(含 boundary)→ base64 经代理原样转发
2089
2092
  const serialized = new Response(fd);
2090
2093
  const bodyRaw = arrayBufferToBase64(await serialized.arrayBuffer());
@@ -2105,7 +2108,16 @@ window.__ModuleLoader__.load({
2105
2108
  const body = {
2106
2109
  ...(s.model ? { model: s.model } : {}),
2107
2110
  prompt, n: 1,
2111
+ // 稳定默认(总是发送):去水印优先——多数图片厂商该参数默认 true 会加水印;扩展参数可覆盖
2112
+ watermark: false,
2108
2113
  };
2114
+ // 多参考生图:generations 模式同样携带参考图(主流约定 image 数组 + data:URL,按 collectRefs 顺序对应提示词里的 [图N])。
2115
+ // 厂商不支持该字段时会明确报错——此时换 edits 模式或在扩展参数里调整字段名。
2116
+ if (refs.length) {
2117
+ const imgs = [];
2118
+ for (const b of refs) imgs.push(await blobToDataUrlScaled(b.blob || b));
2119
+ body.image = imgs;
2120
+ }
2109
2121
  // 响应格式:留空 = 用 API 默认(响应解析同时支持 b64_json 与 url)
2110
2122
  if (String(s.responseFormat || '').trim()) body.response_format = String(s.responseFormat).trim();
2111
2123
  // 尺寸:留空 = 按节点上的「清晰度 × 比例」计算;填了就用你配置的值
@@ -2187,9 +2199,26 @@ window.__ModuleLoader__.load({
2187
2199
  async function seedanceVideo(prompt, frameBlob, refs, s, opts) {
2188
2200
  if (!s.url) throw new Error('未配置视频 API');
2189
2201
  if (!s.model) throw new Error('请在设置里为该视频 API 填写模型名(Seedance 协议要求显式指定模型)');
2190
- const base = s.url.replace(/\/+$/, '');
2191
2202
  const headers = { 'Content-Type': 'application/json', ...(s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {}) };
2192
2203
  const o = opts || {};
2204
+ // 端点归一化:配置里常填「基础地址」(裸域或 …/v1),此前直接 POST 到基础地址 → 404 Invalid URL。
2205
+ // · 已带任务路径(contents/generations/tasks)→ 原样用(方舟官方)
2206
+ // · 方舟官方域名 → 补官方任务路径
2207
+ // · 其余(中转站/网关,如 api.relayrouter.ai/v1)→ 走 content[] 提交端点 /v1/video/generations + /v1/video/query 轮询
2208
+ let base = String(s.url).replace(/\/+$/, '');
2209
+ if (!/contents\/generations\/tasks$/.test(base)) {
2210
+ const host = base.replace(/^https?:\/\//i, '').split('/')[0].toLowerCase();
2211
+ const isArkHost = /(^|\.)volces\.com$/.test(host) || /(^|\.)volcengineapi\.com$/.test(host) || /^ark\./.test(host);
2212
+ if (isArkHost) {
2213
+ base = base + '/api/v3/contents/generations/tasks';
2214
+ } else {
2215
+ const gw = /\/v1$/.test(base) ? base : base + '/v1';
2216
+ // 衔接说明前置(网关 content[] 路径不重复添加,由这里负责)
2217
+ const gwRefs = o.hardFrame ? [] : (o.refs || []).slice(0, 8);
2218
+ const gwText = (frameBlob && gwRefs.length) ? (videoRefNote(o.refNames) + '\n' + prompt) : prompt;
2219
+ return await relayVideoContentFallback(gw + '/video/generations', gwText, frameBlob, o, s, headers);
2220
+ }
2221
+ }
2193
2222
  // first_frame 与 reference_image 互斥(网关 400)。跨镜衔接(尾帧+参考图同连)→
2194
2223
  // 首帧降级为第一张衔接参考图:全部按 reference_image 传、提示词按序标注、比例不锁 adaptive
2195
2224
  const rawRefs = o.hardFrame ? [] : (refs || []).slice(0, 8);
@@ -2210,8 +2239,9 @@ window.__ModuleLoader__.load({
2210
2239
  duration: Number(o.duration) || 5,
2211
2240
  resolution: (o.resolution || '720P').toLowerCase(),
2212
2241
  watermark: s.watermark === true,
2213
- // 勾选才发送/置 true:与通用路径的显式语义一致(默认不发送,用网关默认值)
2214
- generate_audio: s.generateAudio === true,
2242
+ // generate_audio 官方默认 true(2.5/2.0 系列)——仅在用户勾选时显式发送 true;不发送时用厂商默认,
2243
+ // 避免"未勾选就被静默静音"(透传原则:厂商默认优先)
2244
+ ...(s.generateAudio === true ? { generate_audio: true } : {}),
2215
2245
  };
2216
2246
  try { const extra = parseExtraParams(s.extra); if (extra) Object.assign(body, extra); } catch (e) { throw new Error('视频 API 扩展参数配置有误:' + ((e && e.message) || e)); }
2217
2247
  body.ratio = (frameBlob && !downgrade) ? 'adaptive' : (o.ratio || '16:9');
@@ -2256,7 +2286,7 @@ window.__ModuleLoader__.load({
2256
2286
  model: s.model,
2257
2287
  content,
2258
2288
  metadata: {
2259
- duration: Math.max(4, Math.min(15, Math.round(Number(o.duration) || 5))),
2289
+ duration: Math.round(Number(o.duration)) || 5,
2260
2290
  ratio: o.ratio || '16:9',
2261
2291
  resolution: String(o.resolution || '720p').toLowerCase(),
2262
2292
  },
@@ -2295,7 +2325,7 @@ window.__ModuleLoader__.load({
2295
2325
  async function soraVideosFallback(base, prompt, o, s, headers) {
2296
2326
  const root = base.replace(/\/v1\/video\/generations$/i, '').replace(/\/+$/, '');
2297
2327
  const ep = root + '/v1/videos';
2298
- const secs = String(Math.max(4, Math.min(15, Math.round(Number(o.duration) || 5))));
2328
+ const secs = String(Math.round(Number(o.duration)) || 5);
2299
2329
  const vertical = (o.ratio || '16:9') === '9:16';
2300
2330
  const hd = String(o.resolution || '720p').toLowerCase().includes('1080');
2301
2331
  const size = hd ? (vertical ? '1080x1920' : '1920x1080') : (vertical ? '720x1280' : '1280x720');
@@ -2340,15 +2370,105 @@ window.__ModuleLoader__.load({
2340
2370
  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
2371
  luma: { label: 'Luma Dream Machine', base: 'https://api.lumalabs.ai/dream-machine/v1', note: '图生视频需要公网图片 URL(本地图片仅支持文生视频)' },
2342
2372
  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 图生视频', base: 'https://api.stability.ai', note: 'SVD 纯图生视频(无提示词/无音频);必须有首帧图' },
2373
+ stability: { label: 'Stability AI 图生视频(已下线)', base: 'https://api.stability.ai', note: '⚠ 官方 API 已不再提供视频端点(2026 审计:现行 spec 0 条 video 路径)——保留适配器仅为历史配置,调用会报 404' },
2344
2374
  };
2375
+ /** 厂商 base URL 归一化:网关常把地址写成 …/v1 或 …/api/v1,直接拼接会变成 …/v1/v1/… 而 404。
2376
+ * 各厂商预设 base 本身都不以 /v1 结尾,所以安全地剥掉拖尾的 /v1、/api/v1。 */
2377
+ function vendorBase(raw, fallback) {
2378
+ let b = String(raw || fallback || '').trim().replace(/\/+$/, '');
2379
+ b = b.replace(/\/(api\/)?v1$/i, '');
2380
+ return b;
2381
+ }
2382
+ /** 已知网关的厂商路径前缀:网关把各厂商 API 挂在各自前缀下(如 RelayRouter 的百炼 = /alibailian)。
2383
+ * 不在表里的网关,把前缀直接写进 Base URL 即可(如 https://gw.example.com/alibailian)。 */
2384
+ const KNOWN_GATEWAY_PREFIX = [
2385
+ { host: /(^|\.)relayrouter\.ai$/i, wan: '/alibailian' },
2386
+ ];
2387
+ function gatewayPrefixFor(hostname, vendor) {
2388
+ const h = String(hostname || '').toLowerCase();
2389
+ for (const g of KNOWN_GATEWAY_PREFIX) { if (g.host.test(h)) return g[vendor] || ''; }
2390
+ return '';
2391
+ }
2392
+ /** 万相(DashScope/百炼)端点推导。兼容四种写法:
2393
+ * 1) 官方域名 https://dashscope.aliyuncs.com → /api/v1/services/...
2394
+ * 2) 网关裸域 https://api.relayrouter.ai → 自动补厂商前缀 /alibailian
2395
+ * 3) 网关带前缀 https://api.relayrouter.ai/alibailian → 前缀原样保留
2396
+ * 4) 直接粘贴完整端点 …/…/video-synthesis → 反推前缀
2397
+ * 另外统一剥掉误写的拖尾 /v1、/api/v1。返回 { apiRoot, submitUrl, taskUrl }。 */
2398
+ function wanEndpoints(rawUrl) {
2399
+ const u = new URL(String(rawUrl).trim());
2400
+ let path = u.pathname.replace(/\/+$/, '');
2401
+ const full = /\/api\/v1\/services\/aigc\/video-generation\/video-synthesis$/.exec(path);
2402
+ if (full) path = path.slice(0, full.index); // 完整端点 → 取其前缀部分
2403
+ else path = path.replace(/\/(api\/)?v1$/i, ''); // 误带 /v1 或 /api/v1 → 剥掉
2404
+ if (!path) path = gatewayPrefixFor(u.hostname, 'wan'); // 裸域:已知网关自动补厂商前缀
2405
+ const apiRoot = u.origin + path;
2406
+ return {
2407
+ apiRoot,
2408
+ submitUrl: apiRoot + '/api/v1/services/aigc/video-generation/video-synthesis',
2409
+ taskUrl: (tid) => apiRoot + '/api/v1/tasks/' + encodeURIComponent(tid),
2410
+ };
2411
+ }
2412
+ /** 读取生成视频的真实尺寸与时长(用于「厂商是否忽略了参数」的可见校验)。 */
2413
+ function probeVideoMeta(blob) {
2414
+ return new Promise((resolve) => {
2415
+ let url = '';
2416
+ try {
2417
+ url = URL.createObjectURL(blob);
2418
+ const v = document.createElement('video');
2419
+ v.preload = 'metadata';
2420
+ v.muted = true;
2421
+ const done = (r) => { try { URL.revokeObjectURL(url); } catch { /* 忽略 */ } resolve(r); };
2422
+ v.onloadedmetadata = () => done({ w: v.videoWidth, h: v.videoHeight, d: v.duration });
2423
+ v.onerror = () => done(null);
2424
+ setTimeout(() => done(null), 10000);
2425
+ v.src = url;
2426
+ } catch { resolve(null); }
2427
+ });
2428
+ }
2429
+ /** 扩展参数透传(所有厂商 runner 通用):顶层字段直接覆盖 body;
2430
+ * 若 body.parameters 存在则深合并;不存在时若扩展参数里带了 parameters 则整体挂上。
2431
+ * 这是厂商/网关参数名不一致时的统一逃生舱。 */
2432
+ function mergeExtra(body, s) {
2433
+ try {
2434
+ const ex = parseExtraParams(s && s.extra);
2435
+ if (!ex) return body;
2436
+ const exParams = ex.parameters;
2437
+ Object.assign(body, ex);
2438
+ if (exParams && typeof exParams === 'object') body.parameters = { ...(body.parameters || {}), ...exParams };
2439
+ } catch (e) { throw new Error('扩展参数配置有误:' + ((e && e.message) || e)); }
2440
+ return body;
2441
+ }
2345
2442
  function detectVideoVendor(s) {
2346
- if (s && s.vendor) return s.vendor;
2347
- if (s && (s.protocol === 'seedance' || String(s.url || '').includes('contents/generations/tasks'))) return 'seedance';
2443
+ if (!s) return 'openai';
2444
+ if (s.vendor) return s.vendor;
2445
+ const url = String(s.url || '').toLowerCase();
2446
+ const model = String(s.model || '').toLowerCase();
2447
+ // 模型名优先:第三方网关(relayrouter 等)的 URL 看不出真实厂商,只有模型名能说明
2448
+ if (/wanx|万相|(^|[^a-z])wan(\d|[-_]\d|x)/.test(model)) return 'wan';
2449
+ if (/(^|[^a-z])kling/.test(model)) return 'kling';
2450
+ if (/minimax|hailuo/.test(model)) return 'minimax';
2451
+ if (/vidu/.test(model)) return 'vidu';
2452
+ if (/runway/.test(model)) return 'runway';
2453
+ if (/(^|[^a-z])veo/.test(model)) return 'veo';
2454
+ if (/luma|ray[-_]?[12]/.test(model)) return 'luma';
2455
+ if (/pixverse/.test(model)) return 'pixverse';
2456
+ if (/stable-?video|(^|[^a-z])svd/.test(model)) return 'stability';
2457
+ if (/seedance/.test(model)) return 'seedance';
2458
+ // URL 兜底识别
2459
+ if (url.includes('dashscope')) return 'wan';
2460
+ if (url.includes('klingai')) return 'kling';
2461
+ if (url.includes('minimax')) return 'minimax';
2462
+ if (url.includes('vidu')) return 'vidu';
2463
+ if (url.includes('runway')) return 'runway';
2464
+ if (url.includes('generativelanguage')) return 'veo';
2465
+ if (url.includes('lumalabs')) return 'luma';
2466
+ if (url.includes('pixverse')) return 'pixverse';
2467
+ if (url.includes('stability')) return 'stability';
2468
+ if (s.protocol === 'seedance' || url.includes('contents/generations/tasks')) return 'seedance';
2348
2469
  return 'openai';
2349
2470
  }
2350
- function b64Pure(blob) {
2351
- return new Promise((res, rej) => {
2471
+ function b64Pure(blob) { return new Promise((res, rej) => {
2352
2472
  const r = new FileReader();
2353
2473
  r.onload = () => res(String(r.result).split(',')[1] || '');
2354
2474
  r.onerror = () => rej(new Error('图片编码失败'));
@@ -2382,27 +2502,33 @@ window.__ModuleLoader__.load({
2382
2502
  const sg = b64u(new Uint8Array(await crypto.subtle.sign('HMAC', key, enc.encode(hj + '.' + pj))));
2383
2503
  const jwt = hj + '.' + pj + '.' + sg;
2384
2504
  const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt };
2385
- const base = String(s.url || VIDEO_VENDORS.kling.base).replace(/\/+$/, '');
2505
+ const base = vendorBase(s.url, VIDEO_VENDORS.kling.base);
2386
2506
  const path = frameBlob ? 'image2video' : 'text2video';
2387
2507
  const body = {
2388
- model_name: s.model || 'kling-v1', prompt,
2389
- cfg_scale: 0.5, mode: 'std',
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.max(5, Math.min(10, Number(o.duration) || 5)) <= 5 ? 5 : 10),
2508
+ model_name: s.model || 'kling-v2-6', prompt,
2509
+ mode: 'std', // cfg_scale 仅 v1.x 支持(官方 SDK 注明 v2.x 不支持)→ 不默认发送,需要时走扩展参数
2510
+ ...(frameBlob ? {} : { aspect_ratio: (o.ratio || '16:9') === '9:16' ? '9:16' : (o.ratio || '16:9') === '1:1' ? '1:1' : '16:9' }),
2511
+ duration: String(Math.round(Number(o.duration)) || 5),
2392
2512
  };
2393
2513
  if (frameBlob) body.image = await blobToDataUrlScaled(frameBlob);
2394
- const r1 = await fetchViaProxy(base + '/v1/video/' + path, { headers, body, label: 'Kling 提交' });
2514
+ mergeExtra(body, s);
2515
+ // 端点单复数兼容:2026 起新版官方 provider 用 /v1/videos/*,旧文档为单数 /v1/video/* —— 404 自动回退
2516
+ let r1 = await fetchViaProxy(base + '/v1/videos/' + path, { headers, body, label: 'Kling 提交' });
2517
+ if (r1.status === 404) r1 = await fetchViaProxy(base + '/v1/video/' + path, { headers, body, label: 'Kling 提交(旧端点)' });
2395
2518
  if (!r1.ok) throw new Error('Kling 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2396
2519
  const j1 = JSON.parse(r1.text || '{}');
2397
2520
  const tid = j1 && j1.data && j1.data.task_id;
2398
2521
  if (!tid) throw new Error('Kling 响应中没有 task_id:' + r1.text.slice(0, 200));
2399
2522
  const url = await vendorPoll('Kling', 8000, async () => {
2400
- const r = await fetchViaProxy(base + '/v1/video/' + path + '/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Kling 查询' });
2523
+ let r = await fetchViaProxy(base + '/v1/videos/' + path + '/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Kling 查询' });
2524
+ if (r && r.status === 404) r = await fetchViaProxy(base + '/v1/video/' + path + '/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Kling 查询(旧端点)' });
2401
2525
  if (!r || !r.ok) return { state: 'wait' };
2402
2526
  let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2403
2527
  const st = j && j.data && j.data.task_status;
2404
2528
  if (st === 'succeed') {
2405
- const v = j.data.result && j.data.result.videos && j.data.result.videos[0];
2529
+ // 官方结果字段:data.task_result.videos[](含 url/watermark_url/duration);旧网关可能是 data.result.videos
2530
+ const v = (j.data.task_result && j.data.task_result.videos && j.data.task_result.videos[0])
2531
+ || (j.data.result && j.data.result.videos && j.data.result.videos[0]);
2406
2532
  return v && v.url ? { state: 'done', url: v.url } : { state: 'fail', error: '成功但无视频地址' };
2407
2533
  }
2408
2534
  if (st === 'failed') return { state: 'fail', error: (j.data && j.data.task_status_msg) || st };
@@ -2412,20 +2538,51 @@ window.__ModuleLoader__.load({
2412
2538
  },
2413
2539
  // —— 阿里通义万相 Wan:DashScope 异步,X-DashScope-Async ——
2414
2540
  async wan(prompt, frameBlob, s, o) {
2415
- const base = String(s.url || VIDEO_VENDORS.wan.base).replace(/\/+$/, '');
2541
+ // 端点推导(官方域名 / 网关裸域 / 网关带厂商前缀 / 完整端点 四种写法都支持)
2542
+ let ep;
2543
+ try { ep = wanEndpoints(String(s.url || VIDEO_VENDORS.wan.base)); } catch { throw new Error('万相视频 API 地址无效:' + String(s.url || '')); }
2416
2544
  const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || ''), 'X-DashScope-Async': 'enable' };
2545
+ // 万相 3.0 官方参数(百炼文档):parameters = { resolution: 1080P/720P/480P, ratio: adaptive/21:9/16:9/4:3/1:1/3:4/9:16,
2546
+ // duration: [2,30], audio, seed, prompt_extend, watermark } —— **没有 size 参数**(那是 2.x 旧版的)。
2547
+ // · 用户可变项(透传,不转换):resolution / ratio / duration —— 节点上设什么发什么
2548
+ // · 稳定默认(总是发送):watermark:false 去水印优先、prompt_extend:true 提示词扩展
2549
+ // · 媒体走 input.media[]:first_frame(≤1)/ reference_image(≤10),支持 Base64;首帧与参考图同传时
2550
+ // 首帧降级为首张参考图(与网关组合限制对齐,衔接说明前置)
2551
+ const rawRefs = o.hardFrame ? [] : (o.refs || []).slice(0, 10);
2552
+ let text = prompt;
2553
+ if (frameBlob && rawRefs.length) text = videoRefNote(o.refNames) + '\n' + prompt;
2554
+ const media = [];
2555
+ if (frameBlob) media.push({ type: rawRefs.length ? 'reference_image' : 'first_frame', url: await blobToDataUrlScaled(frameBlob) });
2556
+ for (const b of rawRefs) media.push({ type: 'reference_image', url: await blobToDataUrlScaled(b) });
2417
2557
  const body = {
2418
- model: s.model || 'wan2.2-t2v-plus',
2419
- input: { prompt, ...(frameBlob ? { img_url: await blobToDataUrlScaled(frameBlob) } : {}) },
2420
- parameters: { prompt_extend: true },
2558
+ model: s.model || 'wan3.0-video',
2559
+ input: { prompt: text, ...(media.length ? { media } : {}) },
2560
+ parameters: {
2561
+ resolution: String(o.resolution || '1080P'),
2562
+ ratio: o.ratio || '16:9',
2563
+ duration: Math.round(Number(o.duration)) || 5,
2564
+ watermark: false,
2565
+ prompt_extend: true,
2566
+ },
2421
2567
  };
2422
- const r1 = await fetchViaProxy(base + '/api/v1/services/aigc/video-generation/video-synthesis', { headers, body, label: 'Wan 提交' });
2423
- if (!r1.ok) throw new Error('Wan 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2568
+ // 扩展参数:厂商/网关参数名不一致时的逃生舱(顶层字段直接覆盖,parameters 内做深合并)
2569
+ // 例:{"parameters":{"audio":false}} 或 {"parameters":{"seed":42}}
2570
+ try {
2571
+ const ex = parseExtraParams(s.extra);
2572
+ if (ex) {
2573
+ const exParams = ex.parameters;
2574
+ Object.assign(body, ex);
2575
+ if (exParams && typeof exParams === 'object') body.parameters = { ...body.parameters, ...exParams };
2576
+ }
2577
+ } catch (e) { throw new Error('万相扩展参数配置有误:' + ((e && e.message) || e)); }
2578
+ const r1 = await fetchViaProxy(ep.submitUrl, { headers, body, label: 'Wan 提交' });
2579
+ if (!r1.ok) throw new Error('Wan 提交 HTTP ' + r1.status + ' @ ' + ep.submitUrl + ':' + r1.text.slice(0, 300));
2424
2580
  let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Wan 响应非 JSON:' + r1.text.slice(0, 200)); }
2425
2581
  const tid = j1 && j1.output && j1.output.task_id;
2426
2582
  if (!tid) throw new Error('Wan 响应中没有 task_id:' + r1.text.slice(0, 200));
2583
+ if (o.onTask) { try { o.onTask(tid, ep.taskUrl(tid)); } catch { /* 持久化失败不影响生成 */ } }
2427
2584
  const url = await vendorPoll('Wan', 8000, async () => {
2428
- const r = await fetchViaProxy(base + '/api/v1/tasks/' + tid, { method: 'GET', headers: { Authorization: 'Bearer ' + (s.apiKey || '') }, timeoutMs: 60000, label: 'Wan 查询' });
2585
+ const r = await fetchViaProxy(ep.taskUrl(tid), { method: 'GET', headers: { Authorization: 'Bearer ' + (s.apiKey || '') }, timeoutMs: 60000, label: 'Wan 查询' });
2429
2586
  if (!r || !r.ok) return { state: 'wait' };
2430
2587
  let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2431
2588
  const st = j && j.output && j.output.task_status;
@@ -2437,14 +2594,15 @@ window.__ModuleLoader__.load({
2437
2594
  },
2438
2595
  // —— MiniMax 海螺:异步 task_id + query 接口 ——
2439
2596
  async minimax(prompt, frameBlob, s, o) {
2440
- const base = String(s.url || VIDEO_VENDORS.minimax.base).replace(/\/+$/, '');
2597
+ const base = vendorBase(s.url, VIDEO_VENDORS.minimax.base);
2441
2598
  const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || '') };
2442
2599
  const body = {
2443
- model: s.model || 'MiniMax-Hailuo-02', prompt, prompt_optimizer: true,
2444
- duration: String(Math.max(6, Math.min(10, Number(o.duration) || 6)) <= 6 ? 6 : 10),
2445
- resolution: String(o.resolution || '').toLowerCase().includes('1080') ? '1080P' : '768P',
2600
+ model: s.model || 'MiniMax-Hailuo-02', prompt,
2601
+ duration: Math.round(Number(o.duration)) || 6, // 官方为整数(2.3/02 在 768P 下 6 或 10,1080P 仅 6)
2602
+ resolution: String(o.resolution || '768P'), // 透传(官方枚举 512P/720P/768P/1080P,按模型×时长受限)
2446
2603
  };
2447
2604
  if (frameBlob) body.first_frame_image = await blobToDataUrlScaled(frameBlob);
2605
+ mergeExtra(body, s);
2448
2606
  const r1 = await fetchViaProxy(base + '/v1/video_generation', { headers, body, label: 'MiniMax 提交' });
2449
2607
  if (!r1.ok) throw new Error('MiniMax 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2450
2608
  let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('MiniMax 响应非 JSON'); }
@@ -2456,8 +2614,14 @@ window.__ModuleLoader__.load({
2456
2614
  let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2457
2615
  const st = String(j.status || '').toLowerCase();
2458
2616
  if (st === 'success' || st === 'file_downloaded') {
2459
- const u = j.file && (j.file.download_url || j.file.url);
2460
- return u ? { state: 'done', url: u } : { state: 'fail', error: '成功但无视频地址' };
2617
+ // 官方两步:任务成功先得 file_id,再经 /v1/files/retrieve 换 download_url(1 小时时效)
2618
+ const fid = j.file_id || (j.file && j.file.file_id);
2619
+ if (!fid) return { state: 'fail', error: '成功但响应中没有 file_id' };
2620
+ const fr = await fetchViaProxy(base + '/v1/files/retrieve?file_id=' + encodeURIComponent(fid), { method: 'GET', headers, timeoutMs: 60000, label: 'MiniMax 取文件' });
2621
+ if (!fr || !fr.ok) return { state: 'fail', error: '取文件信息 HTTP ' + (fr ? fr.status : '无响应') };
2622
+ let fj = null; try { fj = JSON.parse(fr.text); } catch { return { state: 'fail', error: '文件信息非 JSON' }; }
2623
+ const u = fj && fj.file && (fj.file.download_url || fj.file.url);
2624
+ return u ? { state: 'done', url: u } : { state: 'fail', error: '成功但无视频下载地址' };
2461
2625
  }
2462
2626
  if (st === 'fail') return { state: 'fail', error: (j.base_resp && j.base_resp.status_msg) || '失败' };
2463
2627
  return { state: 'wait' };
@@ -2466,44 +2630,48 @@ window.__ModuleLoader__.load({
2466
2630
  },
2467
2631
  // —— 生数 Vidu:img2video/text2video,图片 base64 直传 ——
2468
2632
  async vidu(prompt, frameBlob, s, o) {
2469
- const base = String(s.url || VIDEO_VENDORS.vidu.base).replace(/\/+$/, '');
2633
+ const base = vendorBase(s.url, VIDEO_VENDORS.vidu.base);
2470
2634
  const headers = { 'Content-Type': 'application/json', Authorization: 'Token ' + (s.apiKey || '') };
2471
2635
  const path = frameBlob ? 'img2video' : 'text2video';
2472
2636
  const body = {
2473
- model: s.model || 'vidu2.0', prompt,
2474
- duration: Math.max(4, Math.min(8, Number(o.duration) || 4)) <= 4 ? 4 : 8,
2475
- resolution: String(o.resolution || '').toLowerCase().includes('1080') ? '1080p' : '720p',
2637
+ model: s.model || 'viduq3-pro', prompt,
2638
+ duration: Math.round(Number(o.duration)) || 5,
2639
+ resolution: String(o.resolution || '720p').toLowerCase(), // 官方枚举 540p/720p/1080p(Q4 另有 2K/4K)
2640
+ ...(frameBlob ? {} : { aspect_ratio: o.ratio || '16:9' }), // aspect_ratio 仅文生视频有(i2v 比例跟随首帧图)
2476
2641
  };
2477
- if (frameBlob) body.images = [await blobToDataUrlScaled(frameBlob)];
2642
+ if (frameBlob) body.images = [await blobToDataUrlScaled(frameBlob)]; // images 数组:URL 或 data:base64(首尾帧时传 2 张)
2643
+ mergeExtra(body, s);
2478
2644
  const r1 = await fetchViaProxy(base + '/ent/v2/' + path, { headers, body, label: 'Vidu 提交' });
2479
2645
  if (!r1.ok) throw new Error('Vidu 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2480
2646
  let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Vidu 响应非 JSON'); }
2481
2647
  const tid = j1 && (j1.task_id || j1.id);
2482
2648
  if (!tid) throw new Error('Vidu 响应中没有 task_id:' + r1.text.slice(0, 200));
2483
2649
  const url = await vendorPoll('Vidu', 8000, async () => {
2484
- const r = await fetchViaProxy(base + '/ent/v2/tasks/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Vidu 查询' });
2650
+ const r = await fetchViaProxy(base + '/ent/v2/tasks/' + tid + '/creations', { method: 'GET', headers, timeoutMs: 60000, label: 'Vidu 查询' });
2485
2651
  if (!r || !r.ok) return { state: 'wait' };
2486
2652
  let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2487
2653
  const st = String(j.state || '').toLowerCase();
2488
2654
  if (st === 'success') {
2489
- const vids = (j.resp && (j.resp.videos || j.resp.creations)) || j.videos || [];
2655
+ // 官方结果:creations[].url(24 小时时效,需转存)
2656
+ const vids = (Array.isArray(j.creations) && j.creations) || (j.resp && (j.resp.videos || j.resp.creations)) || [];
2490
2657
  const v = vids[0] || {};
2491
2658
  const u = v.url || v.video_url;
2492
2659
  return u ? { state: 'done', url: u } : { state: 'fail', error: '成功但无视频地址' };
2493
2660
  }
2494
- if (st === 'failed') return { state: 'fail', error: j.err_msg || '失败' };
2661
+ if (st === 'failed') return { state: 'fail', error: j.err_msg || ('err_code ' + j.err_code) || '失败' };
2495
2662
  return { state: 'wait' };
2496
2663
  });
2497
2664
  return await downloadVideo(url, {});
2498
2665
  },
2499
2666
  // —— Runway Gen-4:X-Runway-Version 头,tasks 轮询 ——
2500
2667
  async runway(prompt, frameBlob, s, o) {
2501
- const base = String(s.url || VIDEO_VENDORS.runway.base).replace(/\/+$/, '');
2668
+ const base = vendorBase(s.url, VIDEO_VENDORS.runway.base);
2502
2669
  const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || ''), 'X-Runway-Version': '2024-11-06' };
2503
2670
  const ratio = (o.ratio || '16:9') === '9:16' ? '720:1280' : '1280:720';
2504
- const dur = Math.max(5, Math.min(10, Number(o.duration) || 5)) <= 5 ? 5 : 10;
2505
- let ep = '/v1/text_to_video', body = { model: 'gen4_turbo', promptText: prompt, ratio, duration: dur };
2506
- if (frameBlob) { ep = '/v1/image_to_video'; body = { model: 'gen4_turbo', promptImage: await blobToDataUrlScaled(frameBlob), promptText: prompt, ratio, duration: dur }; }
2671
+ const dur = Math.round(Number(o.duration)) || 5;
2672
+ let ep = '/v1/text_to_video', body = { model: s.model || 'gen4.5', promptText: prompt, ratio, duration: dur };
2673
+ if (frameBlob) { ep = '/v1/image_to_video'; body = { model: s.model || 'gen4_turbo', promptImage: await blobToDataUrlScaled(frameBlob), promptText: prompt, ratio, duration: dur }; }
2674
+ mergeExtra(body, s);
2507
2675
  const r1 = await fetchViaProxy(base + ep, { headers, body, label: 'Runway 提交' });
2508
2676
  if (!r1.ok) throw new Error('Runway 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2509
2677
  let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Runway 响应非 JSON'); }
@@ -2521,12 +2689,18 @@ window.__ModuleLoader__.load({
2521
2689
  },
2522
2690
  // —— Google Veo(Gemini API):predictLongRunning + operation 轮询 ——
2523
2691
  async veo(prompt, frameBlob, s, o) {
2524
- const base = String(s.url || VIDEO_VENDORS.veo.base).replace(/\/+$/, '');
2525
- const model = s.model || 'veo-3.0-fast-generate-preview';
2692
+ const base = vendorBase(s.url, VIDEO_VENDORS.veo.base);
2693
+ const model = s.model || 'veo-3.1-generate-preview';
2526
2694
  const ep = base + '/v1beta/models/' + encodeURIComponent(model) + ':predictLongRunning?key=' + encodeURIComponent(s.apiKey || '');
2527
2695
  const inst = { prompt };
2528
2696
  if (frameBlob) inst.image = { bytesBase64Encoded: await b64Pure(await blobToDataUrlScaled(frameBlob)), mimeType: 'image/png' };
2529
- const body = { instances: [inst], parameters: { aspectRatio: (o.ratio || '16:9') === '9:16' ? '9:16' : '16:9' } };
2697
+ // 官方字段(Gemini API SDK):parameters.{aspectRatio(16:9|9:16), durationSeconds(int), resolution(720p|1080p)}
2698
+ const body = { instances: [inst], parameters: {
2699
+ aspectRatio: (o.ratio || '16:9') === '9:16' ? '9:16' : '16:9',
2700
+ durationSeconds: Math.round(Number(o.duration)) || 5,
2701
+ ...(o.resolution ? { resolution: String(o.resolution).toLowerCase() } : {}),
2702
+ } };
2703
+ mergeExtra(body, s);
2530
2704
  const r1 = await fetchViaProxy(ep, { headers: { 'Content-Type': 'application/json' }, body, label: 'Veo 提交' });
2531
2705
  if (!r1.ok) throw new Error('Veo 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2532
2706
  let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Veo 响应非 JSON'); }
@@ -2547,10 +2721,16 @@ window.__ModuleLoader__.load({
2547
2721
  },
2548
2722
  // —— Luma Dream Machine:图片需公网 URL,本地图片仅文生视频 ——
2549
2723
  async luma(prompt, frameBlob, s, o) {
2550
- const base = String(s.url || VIDEO_VENDORS.luma.base).replace(/\/+$/, '');
2724
+ // Luma 的 base 本身以 /v1 结尾(路径只拼 /generations),不能用 vendorBase 剥掉 → 这里改为「保证以 /v1 结尾」
2725
+ let base = String(s.url || VIDEO_VENDORS.luma.base).trim().replace(/\/+$/, '');
2726
+ if (!/\/v1$/i.test(base)) base = base + '/v1';
2551
2727
  const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || '') };
2552
- 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') };
2728
+ const body = { prompt, model: s.model || 'ray-2',
2729
+ resolution: String(o.resolution || '720p').toLowerCase(), // 官方枚举 540p/720p/1080p/4k
2730
+ duration: (Math.round(Number(o.duration)) || 5) + 's', // 官方为字符串 "5s"/"9s"
2731
+ aspect_ratio: o.ratio || '16:9' }; // 官方枚举 1:1/16:9/9:16/4:3/3:4/21:9/9:21
2553
2732
  if (frameBlob) throw new Error('Luma 图生视频需要公网图片 URL——本地图片请改用文生视频,或换其他厂商');
2733
+ mergeExtra(body, s);
2554
2734
  const r1 = await fetchViaProxy(base + '/generations', { headers, body, label: 'Luma 提交' });
2555
2735
  if (!r1.ok) throw new Error('Luma 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2556
2736
  let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Luma 响应非 JSON'); }
@@ -2568,17 +2748,31 @@ window.__ModuleLoader__.load({
2568
2748
  },
2569
2749
  // —— PixVerse:API-KEY 头 + Ai-trace-id,base64 图片 ——
2570
2750
  async pixverse(prompt, frameBlob, s, o) {
2571
- const base = String(s.url || VIDEO_VENDORS.pixverse.base).replace(/\/+$/, '');
2751
+ const base = vendorBase(s.url, VIDEO_VENDORS.pixverse.base);
2572
2752
  const headers = { 'Content-Type': 'application/json', 'API-KEY': s.apiKey || '', 'Ai-trace-id': 'dshic-' + Date.now().toString(36) };
2573
- const path = frameBlob ? 'img2video' : 'text2video';
2753
+ // 官方端点(Apidog 文档):文生 /video/text/generate、图生 /video/img/generate(旧 /video/text2video 已弃用)
2754
+ const path = frameBlob ? 'img/generate' : 'text/generate';
2755
+ // 图生视频的图片必须是 img_id(整数)——先用 multipart 上传拿 id(官方不收 base64/URL 字段)
2756
+ let imgId = null;
2757
+ if (frameBlob) {
2758
+ const fd = new FormData();
2759
+ fd.append('image', frameBlob, 'frame.png');
2760
+ const ser = new Response(fd);
2761
+ 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 上传图片' });
2762
+ if (!up || !up.ok) throw new Error('PixVerse 上传图片 HTTP ' + (up ? up.status : '无响应') + ':' + (up ? up.text.slice(0, 200) : ''));
2763
+ let uj = null; try { uj = JSON.parse(up.text); } catch { throw new Error('PixVerse 上传响应非 JSON'); }
2764
+ imgId = uj && uj.Resp && uj.Resp.img_id;
2765
+ if (!imgId) throw new Error('PixVerse 上传响应中没有 img_id:' + up.text.slice(0, 200));
2766
+ }
2574
2767
  const body = {
2575
- model: 'v4.5', prompt,
2576
- duration: Math.max(5, Math.min(8, Number(o.duration) || 5)) <= 5 ? 5 : 8,
2577
- quality: String(o.resolution || '').toLowerCase().includes('1080') ? '1080p' : '720p',
2578
- aspect_ratio: (o.ratio || '16:9') === '9:16' ? '9:16' : '16:9',
2768
+ model: s.model || 'v6', prompt,
2769
+ duration: Math.round(Number(o.duration)) || 5, // C1/V6: 1-15 任意整数;v5.5/v5.6 仅 5/8/10
2770
+ quality: String(o.resolution || '720p').toLowerCase(), // 官方枚举 360p/540p/720p/1080p
2771
+ ...(frameBlob ? {} : { aspect_ratio: (o.ratio || '16:9') === '9:16' ? '9:16' : '16:9' }), // aspect_ratio 仅文生视频有(i2v 跟随输入图)
2579
2772
  };
2580
- if (frameBlob) body.image = await b64Pure(await blobToDataUrlScaled(frameBlob));
2581
- const r1 = await fetchViaProxy(base + '/video/' + path, { headers, body, label: 'PixVerse 提交' });
2773
+ if (frameBlob) body.img_id = imgId;
2774
+ mergeExtra(body, s);
2775
+ const r1 = await fetchViaProxy(base + '/openapi/v2/video/' + path, { headers, body, label: 'PixVerse 提交' });
2582
2776
  if (!r1.ok) throw new Error('PixVerse 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2583
2777
  let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('PixVerse 响应非 JSON'); }
2584
2778
  const vid = j1 && j1.Resp && j1.Resp.video_id;
@@ -2589,15 +2783,16 @@ window.__ModuleLoader__.load({
2589
2783
  let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2590
2784
  const resp = j && j.Resp;
2591
2785
  if (!resp) return { state: 'wait' };
2592
- if (resp.code === 0 && resp.url) return { state: 'done', url: resp.url };
2593
- if (resp.code !== 0 && resp.code !== 6 && resp.code !== 7 && resp.code !== 8) return { state: 'fail', error: resp.msg || ('code ' + resp.code) };
2594
- return { state: 'wait' }; // 6/7/8 = 生成中/排队中/处理中
2786
+ // 官方状态语义(现行文档):1=完成(Resp.url 可用);5=生成中、9=排队、10=处理中;7=审核失败、8=失败(6=已删除)
2787
+ if (resp.status === 1 && resp.url) return { state: 'done', url: resp.url };
2788
+ if (resp.status === 7 || resp.status === 8) return { state: 'fail', error: resp.msg || ('status ' + resp.status) };
2789
+ return { state: 'wait' }; // 5/9/10(历史 2/3)= 排队/处理中
2595
2790
  });
2596
2791
  return await downloadVideo(url, {});
2597
2792
  },
2598
2793
  // —— Stability AI:SVD 纯图生视频(multipart,无提示词),结果为 base64 JSON ——
2599
2794
  async stability(prompt, frameBlob, s, o) {
2600
- const base = String(s.url || VIDEO_VENDORS.stability.base).replace(/\/+$/, '');
2795
+ const base = vendorBase(s.url, VIDEO_VENDORS.stability.base);
2601
2796
  if (!frameBlob) throw new Error('Stability 图生视频必须有首帧图(该服务不支持纯文生视频、无提示词输入)');
2602
2797
  const fd = new FormData();
2603
2798
  fd.append('image', frameBlob, 'frame.png');
@@ -3865,7 +4060,7 @@ window.__ModuleLoader__.load({
3865
4060
  '要求:',
3866
4061
  '- 本节点输出**一集**的剧本:成片时长 1-2 分钟(60-120 秒),共 4-10 个场景;',
3867
4062
  '- **每个场景必须以「【场N】场景名(地点·时间)」单独一行开头**(如【场1】城市街头(夜))——这是后续自动铺开到分镜的分场标记,格式不可省略或改写;',
3868
- '- 每场容量 10-15 秒成片(约 60-150 字,含该场全部对白);全部场景时长之和 ≈ 1-2 分钟;',
4063
+ '- 每场按内容写足(成片 10–30 秒量级,约 150–400 字含该场全部对白);全部场景时长之和 ≈ 1-3 分钟;',
3869
4064
  '- 场景头之后:可视化动作描写 + 对白(角色:台词,单独成行);需要的声音(环境音/旁白)标注在括号里;',
3870
4065
  '- **【资产锚定·硬性】正文中所有角色/场景/道具名称一律写成 @名称@ 引用形式**(前后各一个 @ 精确包裹,名称与设定集逐字一致,如「@陈拾@ 在 @城市街头@ 的 @铁皮围挡@ 旁」)——下游分镜生成靠这些锚点识别资产并挂接参考图;叙述性文字正常书写,不要把动作描述包进 @…@;',
3871
4066
  '- 结尾单独一行「人物:」与「场景/道具:」清单,便于后续资产制作。',
@@ -3874,7 +4069,7 @@ window.__ModuleLoader__.load({
3874
4069
  'Requirements:',
3875
4070
  '- This node outputs ONE episode: 1-2 minutes of screen time (60-120s), 4-10 scenes;',
3876
4071
  '- **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 holds 10-15 seconds of screen time (≈60-150 words incl. dialogue); all scenes sum to ≈1-2 minutes;',
4072
+ '- 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
4073
  '- After each scene header: visual action + dialogue ("Character: line" on its own lines); mark sounds in brackets;',
3879
4074
  '- **[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
4075
  '- End with "Characters:" and "Locations/Props:" lists for asset production.',
@@ -3929,7 +4124,9 @@ window.__ModuleLoader__.load({
3929
4124
  '- **【完整覆盖】分镜必须覆盖所给剧本的全部内容**:每一个动作、每一句对白都要落在某个镜头里——不概述、不省略、不跳过、不"挑选重点";镜头数量与总时长由剧本体量自然决定,宁多勿漏(容量超限由上层拆分剧本处理,不由分镜偷工减料);',
3930
4125
  '- **镜头按实际内容切分**:一个连续动作段落 = 一镜,数量由内容自然决定(3-10 镜都正常)——绝不凑数、绝不使用固定数量;',
3931
4126
  '- **JSON 字符串值内部禁止出现半角双引号 "**——引用文字(牌匾/字幕/台词原文)一律用「」;',
3932
- '- duration 为该镜时长,2-5 的整数(一个镜头拍一个连续动作段落);全部 duration 之和 **≤ 15 秒**(一个分镜表 = 一个 10-15s 的场景视频)——超了就合并精简镜头,而不是拉长单镜;镜头顺序即叙事顺序,末镜收束情绪;**若下方给了「承接约束」,第 1 镜必须承接上一场尾镜的画面与人物状态(不得重新建立场景/跳回远景),无承接约束时首镜才交代场景**;',
4127
+ '- duration 为该镜时长,**支持一位小数**;**按节奏谱系分配、拒绝均匀分布**:普通叙事/对话/环境交代 2–4s,快速冲突/打斗/追逐 0.3–0.8s(快切),重要反转/冲击/卡点 1–2s,长镜头情绪沉淀 ≥5s——同一场里至少制造一次明显的节奏变化(例:3s 叙事 → 0.6s 快切×2 → 1.5s 反转 → 5s 情绪长镜),画面再精致、时长全一样就没有节奏感;总时长按内容需要写足(不设硬上限);镜头顺序即叙事顺序,末镜收束情绪;**若下方给了「承接约束」,第 1 镜必须承接上一场尾镜的画面与人物状态(不得重新建立场景/跳回远景),无承接约束时首镜才交代场景**;',
4128
+ '- **按情绪选景别**:孤独/绝望/宏大危机=远景;对峙/动作/关系建立=全景;对话/试探/信息交换=中景;震惊/怀疑/内心动摇=近景;崩溃/嘶吼/情绪爆发=特写;关键细节/道具揭示=大特写——冲突用全景、揭秘转近景、爆发给特写、反转可拉远,景别变化本身在讲情绪;',
4129
+ '- **快切镜头(<1.5s)的 visual/motion 要极短**(一个动作、一句画内信息),信息量大的镜头才配长时长——时长与内容量必须匹配;',
3933
4130
  '- 只输出 JSON,不要输出任何其他内容。',
3934
4131
  ].join('\n')
3935
4132
  : [
@@ -3946,7 +4143,9 @@ window.__ModuleLoader__.load({
3946
4143
  '- **[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
4144
  '- **Never use straight double quotes " inside JSON string values** — quoted text (signs/subtitles/lines) uses 「」 instead;',
3948
4145
  '- **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, 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**;',
4146
+ '- 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**;',
4147
+ '- **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;',
4148
+ '- **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
4149
  '- Output JSON only.',
3951
4150
  ].join('\n');
3952
4151
  const methodology = await loadSkillRules(settings.skillStoryboard, zh)
@@ -4024,24 +4223,16 @@ window.__ModuleLoader__.load({
4024
4223
  const tail = raw.slice(-160);
4025
4224
  throw new Error(T('errParseStoryboard') + '(正文 ' + raw.length + ' 字;开头: ' + head + ';结尾: ' + tail + ')');
4026
4225
  }
4027
- // 硬性容量保证:一个分镜表 ≤ 15s(视频模型单次上限)——超限从最长镜头开始逐秒压缩(每镜最低 1s)
4028
- const capped = shots.map((s) => ({ ...s }));
4029
- for (let guard = 0; guard < 300; guard++) {
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) => ({
4226
+ // 不做时长压缩:镜头时长由内容决定(各视频厂商单次上限不同,超限时由视频接口明确报错,
4227
+ // 比系统静默砍戏更好——用户要求「不要强行截断」)。仅做数据合法性兜底(≥1 的整数)。
4228
+ return shots.slice(0, 24).map((s, i) => ({
4038
4229
  title: String(s.title || (T('shotPrefix') + (i + 1))).slice(0, 24),
4039
4230
  visual: String(s.visual || ''),
4040
4231
  motion: String(s.motion || ''),
4041
4232
  characters: Array.isArray(s.characters) ? s.characters.map(String).slice(0, 6) : [],
4042
4233
  scene: String(s.scene || ''),
4043
4234
  props: Array.isArray(s.props) ? s.props.map(String).slice(0, 6) : [],
4044
- duration: Number.isFinite(Number(s.duration)) ? clamp(Math.round(Number(s.duration)), 1, 15) : 3,
4235
+ duration: Number.isFinite(Number(s.duration)) ? Math.max(0.3, Math.round(Number(s.duration) * 10) / 10) : 3,
4045
4236
  dialogue: Array.isArray(s.dialogue)
4046
4237
  ? 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
4238
  : [],
@@ -4778,10 +4969,10 @@ window.__ModuleLoader__.load({
4778
4969
  // 先自动补录缺失条目(空描述),再算一致性告警:否则告警标志的条目其实马上就被自动补录了,"未登记"语义会误导用户与对话侧
4779
4970
  try { await autoRegisterBibleEntries(shots); } catch { /* 补录失败不影响分镜 */ }
4780
4971
  const warnings = await checkShotConsistency(shots);
4781
- // 容量告警:一个分镜 = 一个 10-15s 的完整场景。剧本过长说明应该按场景拆分成多个场景剧本节点,
4782
- // 而不是让一个分镜表硬吃全集(shots 上限 24 会悄悄截断覆盖面)
4972
+ // 容量告警:镜头数上限 24(UI/数据上限)。剧本过长说明应该按场景拆分成多个场景剧本节点,
4973
+ // 而不是让一个分镜表硬吃全集(shots 上限 24 会截断覆盖面)。时长本身不设上限,不做压缩。
4783
4974
  const stLen = st.length;
4784
- if (stLen > 400) {
4975
+ if (stLen > 600) {
4785
4976
  warnings.push(T('warnScriptTooLong').replace('{n}', String(stLen)));
4786
4977
  }
4787
4978
  update((s) => {
@@ -4931,6 +5122,20 @@ window.__ModuleLoader__.load({
4931
5122
  category: 'finalcut', // 节点生成的视频 = 成片(实体归属由 entities 字段承担,不占用分类)
4932
5123
  });
4933
5124
  commitGenResult(n.id, rec.id, false, 'video');
5125
+ // 实测结果与设置不一致时留可见提示(厂商忽略 size/duration 是常见情况,必须让人看见而不是静默)
5126
+ try {
5127
+ const meta = await probeVideoMeta(blob);
5128
+ if (meta && meta.w && meta.h) {
5129
+ const wantRatio = String((n.data && n.data.ratio) || '16:9');
5130
+ const gotLandscape = meta.w > meta.h;
5131
+ const notes = [];
5132
+ if (wantRatio === '16:9' && !gotLandscape) notes.push('厂商返回 ' + meta.w + '×' + meta.h + '(竖屏),与设置的 16:9 不符:该厂商可能忽略 size 参数,可在供应商「扩展参数」里覆盖(如 {"parameters":{"size":"1920*1080"}})');
5133
+ else if (wantRatio === '9:16' && gotLandscape) notes.push('厂商返回 ' + meta.w + '×' + meta.h + '(横屏),与设置的 9:16 不符');
5134
+ const wantDur = Number(n.data && n.data.duration) || 0;
5135
+ if (wantDur && meta.d && Math.abs(meta.d - wantDur) > 1.5) notes.push('实际时长 ' + meta.d.toFixed(1) + 's ≠ 设置 ' + wantDur + 's(厂商单次上限或已钳制)');
5136
+ if (notes.length) update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.genWarn = notes.join(';'); });
5137
+ }
5138
+ } catch { /* 探测失败不影响生成 */ }
4934
5139
  // 尾帧提取(通用):供 last 端口输出与链式下一镜使用
4935
5140
  try {
4936
5141
  const fBlob = await extractLastFrame(blob);
@@ -5057,6 +5262,13 @@ window.__ModuleLoader__.load({
5057
5262
  if (t.closest && t.closest('input,textarea,select,button,video,a,label,[data-no-drag]')) return;
5058
5263
  e.stopPropagation();
5059
5264
  closeMenu();
5265
+ // 点击即置顶:被点的节点(含拖动中的整组)绘制在最上层,避免被其他节点盖住
5266
+ update((s) => {
5267
+ const z = (s.zTop || 100) + 1;
5268
+ s.zTop = z;
5269
+ const ids = s.sel.has(n.id) ? [...s.sel, n.id] : [n.id];
5270
+ for (const id of ids) { const nd = s.graph.nodes[id]; if (nd) nd.z = z; }
5271
+ });
5060
5272
  if (e.shiftKey) {
5061
5273
  const next = new Set(S.sel);
5062
5274
  if (next.has(n.id)) next.delete(n.id); else next.add(n.id);
@@ -5527,7 +5739,7 @@ window.__ModuleLoader__.load({
5527
5739
  return h('div', {
5528
5740
  'data-node-id': n.id,
5529
5741
  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 } : {}) },
5742
+ style: { left: n.x, top: n.y, width: n.w, zIndex: Number(n.z) || 1, ...(n.hManual ? { height: n.hManual } : {}) },
5531
5743
  ref: refCb,
5532
5744
  onPointerDown: (e) => onNodePointerDown(e, n),
5533
5745
  },
@@ -5702,7 +5914,11 @@ window.__ModuleLoader__.load({
5702
5914
  : [
5703
5915
  h(MiniSelect, {
5704
5916
  key: 'dur', value: String(n.data.duration || 5), title: T('durationLabel'), width: 60,
5705
- options: VIDEO_DURATIONS.map((x) => ({ value: String(x), label: x + 's' })),
5917
+ // 当前值不在预设列表里(如分镜总时长 32s)也要能显示与保留:动态并入选项
5918
+ options: (VIDEO_DURATIONS.includes(Number(n.data.duration))
5919
+ ? VIDEO_DURATIONS
5920
+ : [...VIDEO_DURATIONS, Math.round(Number(n.data.duration) || 5)].sort((a, b) => a - b))
5921
+ .map((x) => ({ value: String(x), label: x + 's' })),
5706
5922
  onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.duration = Number(v); }),
5707
5923
  }),
5708
5924
  h(MiniSelect, {
@@ -6026,6 +6242,7 @@ window.__ModuleLoader__.load({
6026
6242
  : null,
6027
6243
  h(RefRows, { key: 'refrows', n, portId: 'ref' }),
6028
6244
  h(MentionInfo, { key: 'mn', n }),
6245
+ n.data.genWarn ? h('div', { key: 'gw', className: 'dshic-hint', style: { color: 'var(--dsw-alias-state-warning-primary, #d08700)' } }, '⚠ ' + n.data.genWarn) : null,
6029
6246
  n.data.error ? h('div', { key: 'err', className: 'dshic-err' }, n.data.error) : null,
6030
6247
  assetUrlV && kind === 'image'
6031
6248
  ? 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 +6870,7 @@ window.__ModuleLoader__.load({
6653
6870
  const baseY = n.y - 40;
6654
6871
  // 生成时长 = 各镜时长之和(多镜头一次性生成的总时长),由视频模型内部切镜
6655
6872
  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(3, Math.min(20, total)) });
6873
+ const videoGen = mk('video-gen', baseX + 360, baseY + 20, { prompt: '', duration: Math.max(1, Math.round(total)) });
6657
6874
  const videoAsset = mk('video-asset', baseX + 720, baseY + 20);
6658
6875
  entityNames.forEach((nm, i) => {
6659
6876
  let an = findAssetNode(nm);
@@ -8722,11 +8939,11 @@ window.__ModuleLoader__.load({
8722
8939
  nodes.filter((nd) => nd.type === 'group').map((n) => h(NodeShell, { key: n.id, n })),
8723
8940
  nodes.filter((nd) => nd.type !== 'group').map((n) => h(NodeShell, { key: n.id, n })),
8724
8941
  h('div', {
8725
- className: 'dshic-marquee', style: { display: 'none' },
8942
+ className: 'dshic-marquee', style: { display: 'none', zIndex: 9999 },
8726
8943
  ref: (el) => { marqueeEl = el; },
8727
8944
  }),
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; } }),
8945
+ h('div', { className: 'dshic-guide-v', style: { display: 'none', zIndex: 9999 }, ref: (el) => { vGuidEl = el; } }),
8946
+ h('div', { className: 'dshic-guide-h', style: { display: 'none', zIndex: 9999 }, ref: (el) => { hGuidEl = el; } }),
8730
8947
  ),
8731
8948
  h(Minimap),
8732
8949
  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: 420, 'video-gen': 320, 'image-gen': 260, script: 300, finalcut: 300, text: 200 };
788
- const estH = EST_H[type] || 220;
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.14",
3
+ "version": "0.17.16",
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",