@ninkch/dsh-infinite-canvas 0.17.13 → 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/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
  // ============================================================
@@ -252,9 +253,23 @@ window.__ModuleLoader__.load({
252
253
  'nStoryboard': '分镜表',
253
254
  'nScript': '剧本',
254
255
  'vendorLabel': '厂商格式(选后只需填 Base URL + 模型 id)',
256
+ 'showKey': '显示明文',
257
+ 'hideKey': '隐藏(脱敏显示)',
258
+ 'supportBtn': '帮助与反馈',
259
+ 'supportTitle': '帮助与反馈',
260
+ 'supportNote': '有问题或改进建议,提 Issue 或进群反馈。',
261
+ 'supportGithub': 'GitHub 仓库',
262
+ 'supportIssues': '访问 GitHub,提 Issue',
263
+ 'supportWechat': '交流群',
264
+ 'supportWechatHint': '扫码添加,进入交流群',
265
+ 'supportCopy': '复制地址',
266
+ 'supportCopied': '已复制到剪贴板',
267
+ 'supportOpen': '打开',
268
+ 'supportQrMissing': '二维码待放置:assets/support/wechat.png',
269
+ 'supportClose': '关闭',
255
270
  'portScript': '剧本',
256
- 'portPrev': '衔接上一镜(尾镜)',
257
- 'portTail': '尾镜(供下一分镜衔接)',
271
+ 'portPrev': '衔接上一镜',
272
+ 'portTail': '尾镜输出',
258
273
  'aiScript': 'AI 生成剧本',
259
274
  'aiStoryboard': 'AI 生成分镜',
260
275
  'expand': '铺开到画布',
@@ -335,7 +350,7 @@ window.__ModuleLoader__.load({
335
350
  'shotsUnit': '镜',
336
351
  'savedOk': '已存为资产',
337
352
  'warnUnregistered': '未在设定集/资产中登记',
338
- 'warnScriptTooLong': '剧本较长({n} 字)≈ 超过单分镜 10-15s 成片容量:请按场景拆分为多个场景剧本节点(每个分镜只连一个场景剧本,长剧本内容会被 24 镜上限截断)',
353
+ 'warnScriptTooLong': '剧本较长({n} 字)≈ 超过单个分镜的镜头容量(24 镜):请按场景拆分为多个场景剧本节点(每个分镜只连一个场景剧本)',
339
354
  'warnEmptyDesc': '已登记但描述为空(待补全)',
340
355
  'catMention': '提及',
341
356
  'assetCategoriesFixed': '已按命名规范纠正 {n} 个资产的分类',
@@ -643,6 +658,20 @@ window.__ModuleLoader__.load({
643
658
  'nScript': 'Script',
644
659
  'portScript': 'script',
645
660
  'vendorLabel': 'Vendor format (then just Base URL + model id)',
661
+ 'showKey': 'Show key',
662
+ 'hideKey': 'Hide key (masked)',
663
+ 'supportBtn': 'Help & feedback',
664
+ 'supportTitle': 'Help & feedback',
665
+ 'supportNote': 'Found a bug or have an idea? File an issue or join the group.',
666
+ 'supportGithub': 'GitHub repository',
667
+ 'supportIssues': 'Visit GitHub & file an issue',
668
+ 'supportWechat': 'Community group',
669
+ 'supportWechatHint': 'Scan to join the group',
670
+ 'supportCopy': 'Copy URL',
671
+ 'supportCopied': 'Copied to clipboard',
672
+ 'supportOpen': 'Open',
673
+ 'supportQrMissing': 'QR not added yet: assets/support/wechat.png',
674
+ 'supportClose': 'Close',
646
675
  'portPrev': 'Continue prev tail',
647
676
  'portTail': 'Tail shot (for next)',
648
677
  'aiScript': 'AI write script',
@@ -725,7 +754,7 @@ window.__ModuleLoader__.load({
725
754
  'shotsUnit': 'shots',
726
755
  'savedOk': 'Saved as asset',
727
756
  'warnUnregistered': 'not in bible/assets',
728
- '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)',
729
758
  'warnEmptyDesc': 'registered but description is empty (fill it in)',
730
759
  'catMention': 'Mention',
731
760
  'assetCategoriesFixed': 'Re-categorized {n} assets by naming convention',
@@ -1039,6 +1068,8 @@ window.__ModuleLoader__.load({
1039
1068
  .dshic-port:hover,.dshic-port.is-active{background:var(--dsw-alias-brand-primary);border-color:var(--dsw-alias-brand-primary);
1040
1069
  box-shadow:0 0 0 3px color-mix(in srgb,var(--dsw-alias-brand-primary) 25%,transparent);}
1041
1070
  .dshic-port-wrap{position:absolute;width:0;height:0;}
1071
+ /* 角点端口(分镜尾镜输出/衔接输入):显式以锚点为中心,保证连线正好从点位中心垂直穿出 */
1072
+ .dshic-port.is-corner{left:-5.5px;top:-5.5px;}
1042
1073
  .dshic-port-label{position:absolute;font-size:9px;color:var(--dsw-alias-label-secondary);white-space:nowrap;pointer-events:none;opacity:0;transition:opacity .12s;}
1043
1074
  .dshic-node:hover .dshic-port-label{opacity:1;}
1044
1075
  .dshic-marquee{position:absolute;border:1px solid var(--dsw-alias-brand-primary);
@@ -1330,7 +1361,8 @@ window.__ModuleLoader__.load({
1330
1361
  for (const p of settings.videoProviders || []) {
1331
1362
  for (const m of (p.models || [])) {
1332
1363
  if (!m || !m.id || !String(m.model || '').trim()) continue;
1333
- 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 || '' });
1334
1366
  }
1335
1367
  }
1336
1368
  return out;
@@ -1432,8 +1464,9 @@ window.__ModuleLoader__.load({
1432
1464
  // @ 引用(mention):提示词里写 @张山 → 自动带上该实体的设定集描述与其参考图资产
1433
1465
  // 以下为纯函数(无副作用),供 scripts/test-mentions.mjs 抽取单测
1434
1466
  // <<<MENTION_PURE_BEGIN>>>
1435
- /** @名称 的词法:到空白或常见标点为止(支持中英文名与 - _ ·) */
1436
- const MENTION_RE = /@([^\s@,。;:、,.;:!?!?()()\[\]【】"'“”<>《》/\\|]{1,24})/g;
1467
+ /** @资产名@ 的词法(双 @ 精确包裹):终止符是闭合 @,绝不可能把后文吞进名称;
1468
+ * 名称内禁止 @、空白与常见标点。前端渲染时隐藏两个 @,名称显示为蓝色加粗。 */
1469
+ const MENTION_RE = /@([^@\s,。;:、,.;:!?!?()()\[\]【】"'“”<>《》/\\|]{1,64}?)@/g;
1437
1470
  /** 解析提示词里的 @名称(含位置,按出现顺序)。 */
1438
1471
  function parseMentions(text) {
1439
1472
  const out = [];
@@ -1451,7 +1484,7 @@ window.__ModuleLoader__.load({
1451
1484
  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
1452
1485
  .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
1453
1486
  }
1454
- /** 把提示词转成高亮 HTML:@名称 用 .dshic-mention(蓝色加粗)标记。 */
1487
+ /** 把提示词转成高亮 HTML:@资产名@ 两个 @ 隐藏(占位但不可见),名称显示为蓝色加粗。 */
1455
1488
  function highlightMentions(text) {
1456
1489
  const src = String(text || '');
1457
1490
  let out = '';
@@ -1460,16 +1493,31 @@ window.__ModuleLoader__.load({
1460
1493
  let m;
1461
1494
  while ((m = re.exec(src)) !== null) {
1462
1495
  out += escapeHtml(src.slice(last, m.index));
1463
- out += '<span class="dshic-mention">' + escapeHtml(m[0]) + '</span>';
1496
+ out += '<span style="visibility:hidden">@</span><span class="dshic-mention">' + escapeHtml(m[1]) + '</span><span style="visibility:hidden">@</span>';
1464
1497
  last = m.index + m[0].length;
1465
1498
  }
1466
1499
  out += escapeHtml(src.slice(last));
1467
1500
  return out === '' ? '' : out + '\n'; // 末尾补换行,保证背景层与 textarea 行高对齐
1468
1501
  }
1469
- /** 送给模型前去掉 @ 标记(保留名称本体)。 */
1502
+ /** 送给模型前去掉 @ 包裹标记(保留名称本体)。 */
1470
1503
  function stripMentionMarks(text) {
1471
1504
  return String(text || '').replace(new RegExp(MENTION_RE.source, 'g'), '$1');
1472
1505
  }
1506
+ /** 展示用:把 @资产名@ 渲染为「蓝色加粗资产名」(隐藏两个 @,前后各留 2px 视觉空隙)。 */
1507
+ function mentionNodes(text) {
1508
+ const src = String(text || '');
1509
+ const out = [];
1510
+ const re = new RegExp(MENTION_RE.source, 'g');
1511
+ let m;
1512
+ let last = 0;
1513
+ while ((m = re.exec(src)) !== null) {
1514
+ if (m.index > last) out.push(src.slice(last, m.index));
1515
+ out.push(h('span', { className: 'dshic-mention', style: { margin: '0 2px' } }, m[1]));
1516
+ last = m.index + m[0].length;
1517
+ }
1518
+ if (last < src.length) out.push(src.slice(last));
1519
+ return out.length ? out : [src];
1520
+ }
1473
1521
  function entityLabelOf(kind) {
1474
1522
  return kind === 'character' ? '角色' : kind === 'scene' ? '场景' : kind === 'prop' ? '道具' : '元素';
1475
1523
  }
@@ -2141,9 +2189,26 @@ window.__ModuleLoader__.load({
2141
2189
  async function seedanceVideo(prompt, frameBlob, refs, s, opts) {
2142
2190
  if (!s.url) throw new Error('未配置视频 API');
2143
2191
  if (!s.model) throw new Error('请在设置里为该视频 API 填写模型名(Seedance 协议要求显式指定模型)');
2144
- const base = s.url.replace(/\/+$/, '');
2145
2192
  const headers = { 'Content-Type': 'application/json', ...(s.apiKey ? { Authorization: 'Bearer ' + s.apiKey } : {}) };
2146
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
+ }
2147
2212
  // first_frame 与 reference_image 互斥(网关 400)。跨镜衔接(尾帧+参考图同连)→
2148
2213
  // 首帧降级为第一张衔接参考图:全部按 reference_image 传、提示词按序标注、比例不锁 adaptive
2149
2214
  const rawRefs = o.hardFrame ? [] : (refs || []).slice(0, 8);
@@ -2164,8 +2229,9 @@ window.__ModuleLoader__.load({
2164
2229
  duration: Number(o.duration) || 5,
2165
2230
  resolution: (o.resolution || '720P').toLowerCase(),
2166
2231
  watermark: s.watermark === true,
2167
- // 勾选才发送/置 true:与通用路径的显式语义一致(默认不发送,用网关默认值)
2168
- generate_audio: s.generateAudio === true,
2232
+ // generate_audio 官方默认 true(2.5/2.0 系列)——仅在用户勾选时显式发送 true;不发送时用厂商默认,
2233
+ // 避免"未勾选就被静默静音"(透传原则:厂商默认优先)
2234
+ ...(s.generateAudio === true ? { generate_audio: true } : {}),
2169
2235
  };
2170
2236
  try { const extra = parseExtraParams(s.extra); if (extra) Object.assign(body, extra); } catch (e) { throw new Error('视频 API 扩展参数配置有误:' + ((e && e.message) || e)); }
2171
2237
  body.ratio = (frameBlob && !downgrade) ? 'adaptive' : (o.ratio || '16:9');
@@ -2210,7 +2276,7 @@ window.__ModuleLoader__.load({
2210
2276
  model: s.model,
2211
2277
  content,
2212
2278
  metadata: {
2213
- duration: Math.max(4, Math.min(15, Math.round(Number(o.duration) || 5))),
2279
+ duration: Math.round(Number(o.duration)) || 5,
2214
2280
  ratio: o.ratio || '16:9',
2215
2281
  resolution: String(o.resolution || '720p').toLowerCase(),
2216
2282
  },
@@ -2249,7 +2315,7 @@ window.__ModuleLoader__.load({
2249
2315
  async function soraVideosFallback(base, prompt, o, s, headers) {
2250
2316
  const root = base.replace(/\/v1\/video\/generations$/i, '').replace(/\/+$/, '');
2251
2317
  const ep = root + '/v1/videos';
2252
- const secs = String(Math.max(4, Math.min(15, Math.round(Number(o.duration) || 5))));
2318
+ const secs = String(Math.round(Number(o.duration)) || 5);
2253
2319
  const vertical = (o.ratio || '16:9') === '9:16';
2254
2320
  const hd = String(o.resolution || '720p').toLowerCase().includes('1080');
2255
2321
  const size = hd ? (vertical ? '1080x1920' : '1920x1080') : (vertical ? '720x1280' : '1280x720');
@@ -2294,15 +2360,105 @@ window.__ModuleLoader__.load({
2294
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' },
2295
2361
  luma: { label: 'Luma Dream Machine', base: 'https://api.lumalabs.ai/dream-machine/v1', note: '图生视频需要公网图片 URL(本地图片仅支持文生视频)' },
2296
2362
  pixverse: { label: 'PixVerse', base: 'https://app-api.pixverse.ai/openapi/v2', keyPh: 'API-KEY', note: '图片支持 base64;时长 5/8s;清晰度映射 540p/720p/1080p' },
2297
- stability: { label: 'Stability AI 图生视频', base: 'https://api.stability.ai', note: 'SVD 纯图生视频(无提示词/无音频);必须有首帧图' },
2363
+ stability: { label: 'Stability AI 图生视频(已下线)', base: 'https://api.stability.ai', note: '⚠ 官方 API 已不再提供视频端点(2026 审计:现行 spec 0 条 video 路径)——保留适配器仅为历史配置,调用会报 404' },
2298
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
+ }
2299
2432
  function detectVideoVendor(s) {
2300
- if (s && s.vendor) return s.vendor;
2301
- if (s && (s.protocol === 'seedance' || String(s.url || '').includes('contents/generations/tasks'))) return 'seedance';
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';
2302
2459
  return 'openai';
2303
2460
  }
2304
- function b64Pure(blob) {
2305
- return new Promise((res, rej) => {
2461
+ function b64Pure(blob) { return new Promise((res, rej) => {
2306
2462
  const r = new FileReader();
2307
2463
  r.onload = () => res(String(r.result).split(',')[1] || '');
2308
2464
  r.onerror = () => rej(new Error('图片编码失败'));
@@ -2336,27 +2492,33 @@ window.__ModuleLoader__.load({
2336
2492
  const sg = b64u(new Uint8Array(await crypto.subtle.sign('HMAC', key, enc.encode(hj + '.' + pj))));
2337
2493
  const jwt = hj + '.' + pj + '.' + sg;
2338
2494
  const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + jwt };
2339
- const base = String(s.url || VIDEO_VENDORS.kling.base).replace(/\/+$/, '');
2495
+ const base = vendorBase(s.url, VIDEO_VENDORS.kling.base);
2340
2496
  const path = frameBlob ? 'image2video' : 'text2video';
2341
2497
  const body = {
2342
- model_name: s.model || 'kling-v1', prompt,
2343
- cfg_scale: 0.5, mode: 'std',
2344
- aspect_ratio: (o.ratio || '16:9') === '9:16' ? '9:16' : (o.ratio || '16:9') === '1:1' ? '1:1' : '16:9',
2345
- duration: String(Math.max(5, Math.min(10, Number(o.duration) || 5)) <= 5 ? 5 : 10),
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),
2346
2502
  };
2347
2503
  if (frameBlob) body.image = await blobToDataUrlScaled(frameBlob);
2348
- const r1 = await fetchViaProxy(base + '/v1/video/' + path, { headers, body, label: 'Kling 提交' });
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 提交(旧端点)' });
2349
2508
  if (!r1.ok) throw new Error('Kling 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2350
2509
  const j1 = JSON.parse(r1.text || '{}');
2351
2510
  const tid = j1 && j1.data && j1.data.task_id;
2352
2511
  if (!tid) throw new Error('Kling 响应中没有 task_id:' + r1.text.slice(0, 200));
2353
2512
  const url = await vendorPoll('Kling', 8000, async () => {
2354
- const r = await fetchViaProxy(base + '/v1/video/' + path + '/' + tid, { method: 'GET', headers, timeoutMs: 60000, label: 'Kling 查询' });
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 查询(旧端点)' });
2355
2515
  if (!r || !r.ok) return { state: 'wait' };
2356
2516
  let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2357
2517
  const st = j && j.data && j.data.task_status;
2358
2518
  if (st === 'succeed') {
2359
- const v = j.data.result && j.data.result.videos && j.data.result.videos[0];
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]);
2360
2522
  return v && v.url ? { state: 'done', url: v.url } : { state: 'fail', error: '成功但无视频地址' };
2361
2523
  }
2362
2524
  if (st === 'failed') return { state: 'fail', error: (j.data && j.data.task_status_msg) || st };
@@ -2366,20 +2528,51 @@ window.__ModuleLoader__.load({
2366
2528
  },
2367
2529
  // —— 阿里通义万相 Wan:DashScope 异步,X-DashScope-Async ——
2368
2530
  async wan(prompt, frameBlob, s, o) {
2369
- const base = String(s.url || VIDEO_VENDORS.wan.base).replace(/\/+$/, '');
2531
+ // 端点推导(官方域名 / 网关裸域 / 网关带厂商前缀 / 完整端点 四种写法都支持)
2532
+ let ep;
2533
+ try { ep = wanEndpoints(String(s.url || VIDEO_VENDORS.wan.base)); } catch { throw new Error('万相视频 API 地址无效:' + String(s.url || '')); }
2370
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) });
2371
2547
  const body = {
2372
- model: s.model || 'wan2.2-t2v-plus',
2373
- input: { prompt, ...(frameBlob ? { img_url: await blobToDataUrlScaled(frameBlob) } : {}) },
2374
- parameters: { prompt_extend: true },
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
+ },
2375
2557
  };
2376
- const r1 = await fetchViaProxy(base + '/api/v1/services/aigc/video-generation/video-synthesis', { headers, body, label: 'Wan 提交' });
2377
- if (!r1.ok) throw new Error('Wan 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
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));
2378
2570
  let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Wan 响应非 JSON:' + r1.text.slice(0, 200)); }
2379
2571
  const tid = j1 && j1.output && j1.output.task_id;
2380
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 { /* 持久化失败不影响生成 */ } }
2381
2574
  const url = await vendorPoll('Wan', 8000, async () => {
2382
- const r = await fetchViaProxy(base + '/api/v1/tasks/' + tid, { method: 'GET', headers: { Authorization: 'Bearer ' + (s.apiKey || '') }, timeoutMs: 60000, label: 'Wan 查询' });
2575
+ const r = await fetchViaProxy(ep.taskUrl(tid), { method: 'GET', headers: { Authorization: 'Bearer ' + (s.apiKey || '') }, timeoutMs: 60000, label: 'Wan 查询' });
2383
2576
  if (!r || !r.ok) return { state: 'wait' };
2384
2577
  let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2385
2578
  const st = j && j.output && j.output.task_status;
@@ -2391,14 +2584,15 @@ window.__ModuleLoader__.load({
2391
2584
  },
2392
2585
  // —— MiniMax 海螺:异步 task_id + query 接口 ——
2393
2586
  async minimax(prompt, frameBlob, s, o) {
2394
- const base = String(s.url || VIDEO_VENDORS.minimax.base).replace(/\/+$/, '');
2587
+ const base = vendorBase(s.url, VIDEO_VENDORS.minimax.base);
2395
2588
  const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || '') };
2396
2589
  const body = {
2397
- model: s.model || 'MiniMax-Hailuo-02', prompt, prompt_optimizer: true,
2398
- duration: String(Math.max(6, Math.min(10, Number(o.duration) || 6)) <= 6 ? 6 : 10),
2399
- resolution: String(o.resolution || '').toLowerCase().includes('1080') ? '1080P' : '768P',
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,按模型×时长受限)
2400
2593
  };
2401
2594
  if (frameBlob) body.first_frame_image = await blobToDataUrlScaled(frameBlob);
2595
+ mergeExtra(body, s);
2402
2596
  const r1 = await fetchViaProxy(base + '/v1/video_generation', { headers, body, label: 'MiniMax 提交' });
2403
2597
  if (!r1.ok) throw new Error('MiniMax 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2404
2598
  let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('MiniMax 响应非 JSON'); }
@@ -2410,8 +2604,14 @@ window.__ModuleLoader__.load({
2410
2604
  let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2411
2605
  const st = String(j.status || '').toLowerCase();
2412
2606
  if (st === 'success' || st === 'file_downloaded') {
2413
- const u = j.file && (j.file.download_url || j.file.url);
2414
- return u ? { state: 'done', url: u } : { state: 'fail', error: '成功但无视频地址' };
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: '成功但无视频下载地址' };
2415
2615
  }
2416
2616
  if (st === 'fail') return { state: 'fail', error: (j.base_resp && j.base_resp.status_msg) || '失败' };
2417
2617
  return { state: 'wait' };
@@ -2420,44 +2620,48 @@ window.__ModuleLoader__.load({
2420
2620
  },
2421
2621
  // —— 生数 Vidu:img2video/text2video,图片 base64 直传 ——
2422
2622
  async vidu(prompt, frameBlob, s, o) {
2423
- const base = String(s.url || VIDEO_VENDORS.vidu.base).replace(/\/+$/, '');
2623
+ const base = vendorBase(s.url, VIDEO_VENDORS.vidu.base);
2424
2624
  const headers = { 'Content-Type': 'application/json', Authorization: 'Token ' + (s.apiKey || '') };
2425
2625
  const path = frameBlob ? 'img2video' : 'text2video';
2426
2626
  const body = {
2427
- model: s.model || 'vidu2.0', prompt,
2428
- duration: Math.max(4, Math.min(8, Number(o.duration) || 4)) <= 4 ? 4 : 8,
2429
- resolution: String(o.resolution || '').toLowerCase().includes('1080') ? '1080p' : '720p',
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 比例跟随首帧图)
2430
2631
  };
2431
- if (frameBlob) body.images = [await blobToDataUrlScaled(frameBlob)];
2632
+ if (frameBlob) body.images = [await blobToDataUrlScaled(frameBlob)]; // images 数组:URL 或 data:base64(首尾帧时传 2 张)
2633
+ mergeExtra(body, s);
2432
2634
  const r1 = await fetchViaProxy(base + '/ent/v2/' + path, { headers, body, label: 'Vidu 提交' });
2433
2635
  if (!r1.ok) throw new Error('Vidu 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2434
2636
  let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Vidu 响应非 JSON'); }
2435
2637
  const tid = j1 && (j1.task_id || j1.id);
2436
2638
  if (!tid) throw new Error('Vidu 响应中没有 task_id:' + r1.text.slice(0, 200));
2437
2639
  const url = await vendorPoll('Vidu', 8000, async () => {
2438
- 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 查询' });
2439
2641
  if (!r || !r.ok) return { state: 'wait' };
2440
2642
  let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2441
2643
  const st = String(j.state || '').toLowerCase();
2442
2644
  if (st === 'success') {
2443
- const vids = (j.resp && (j.resp.videos || j.resp.creations)) || j.videos || [];
2645
+ // 官方结果:creations[].url(24 小时时效,需转存)
2646
+ const vids = (Array.isArray(j.creations) && j.creations) || (j.resp && (j.resp.videos || j.resp.creations)) || [];
2444
2647
  const v = vids[0] || {};
2445
2648
  const u = v.url || v.video_url;
2446
2649
  return u ? { state: 'done', url: u } : { state: 'fail', error: '成功但无视频地址' };
2447
2650
  }
2448
- 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) || '失败' };
2449
2652
  return { state: 'wait' };
2450
2653
  });
2451
2654
  return await downloadVideo(url, {});
2452
2655
  },
2453
2656
  // —— Runway Gen-4:X-Runway-Version 头,tasks 轮询 ——
2454
2657
  async runway(prompt, frameBlob, s, o) {
2455
- const base = String(s.url || VIDEO_VENDORS.runway.base).replace(/\/+$/, '');
2658
+ const base = vendorBase(s.url, VIDEO_VENDORS.runway.base);
2456
2659
  const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || ''), 'X-Runway-Version': '2024-11-06' };
2457
2660
  const ratio = (o.ratio || '16:9') === '9:16' ? '720:1280' : '1280:720';
2458
- const dur = Math.max(5, Math.min(10, Number(o.duration) || 5)) <= 5 ? 5 : 10;
2459
- let ep = '/v1/text_to_video', body = { model: 'gen4_turbo', promptText: prompt, ratio, duration: dur };
2460
- if (frameBlob) { ep = '/v1/image_to_video'; body = { model: 'gen4_turbo', promptImage: await blobToDataUrlScaled(frameBlob), promptText: prompt, ratio, duration: dur }; }
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);
2461
2665
  const r1 = await fetchViaProxy(base + ep, { headers, body, label: 'Runway 提交' });
2462
2666
  if (!r1.ok) throw new Error('Runway 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2463
2667
  let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Runway 响应非 JSON'); }
@@ -2475,12 +2679,18 @@ window.__ModuleLoader__.load({
2475
2679
  },
2476
2680
  // —— Google Veo(Gemini API):predictLongRunning + operation 轮询 ——
2477
2681
  async veo(prompt, frameBlob, s, o) {
2478
- const base = String(s.url || VIDEO_VENDORS.veo.base).replace(/\/+$/, '');
2479
- const model = s.model || 'veo-3.0-fast-generate-preview';
2682
+ const base = vendorBase(s.url, VIDEO_VENDORS.veo.base);
2683
+ const model = s.model || 'veo-3.1-generate-preview';
2480
2684
  const ep = base + '/v1beta/models/' + encodeURIComponent(model) + ':predictLongRunning?key=' + encodeURIComponent(s.apiKey || '');
2481
2685
  const inst = { prompt };
2482
2686
  if (frameBlob) inst.image = { bytesBase64Encoded: await b64Pure(await blobToDataUrlScaled(frameBlob)), mimeType: 'image/png' };
2483
- const body = { instances: [inst], parameters: { aspectRatio: (o.ratio || '16:9') === '9:16' ? '9:16' : '16:9' } };
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);
2484
2694
  const r1 = await fetchViaProxy(ep, { headers: { 'Content-Type': 'application/json' }, body, label: 'Veo 提交' });
2485
2695
  if (!r1.ok) throw new Error('Veo 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2486
2696
  let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Veo 响应非 JSON'); }
@@ -2501,10 +2711,16 @@ window.__ModuleLoader__.load({
2501
2711
  },
2502
2712
  // —— Luma Dream Machine:图片需公网 URL,本地图片仅文生视频 ——
2503
2713
  async luma(prompt, frameBlob, s, o) {
2504
- const base = String(s.url || VIDEO_VENDORS.luma.base).replace(/\/+$/, '');
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';
2505
2717
  const headers = { 'Content-Type': 'application/json', Authorization: 'Bearer ' + (s.apiKey || '') };
2506
- const body = { prompt, model: 'ray-2', resolution: String(o.resolution || '').toLowerCase().includes('1080') ? '1080p' : '720p', duration: (Math.max(5, Math.min(9, Number(o.duration) || 5)) <= 5 ? '5s' : '9s') };
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
2507
2722
  if (frameBlob) throw new Error('Luma 图生视频需要公网图片 URL——本地图片请改用文生视频,或换其他厂商');
2723
+ mergeExtra(body, s);
2508
2724
  const r1 = await fetchViaProxy(base + '/generations', { headers, body, label: 'Luma 提交' });
2509
2725
  if (!r1.ok) throw new Error('Luma 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2510
2726
  let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('Luma 响应非 JSON'); }
@@ -2522,17 +2738,31 @@ window.__ModuleLoader__.load({
2522
2738
  },
2523
2739
  // —— PixVerse:API-KEY 头 + Ai-trace-id,base64 图片 ——
2524
2740
  async pixverse(prompt, frameBlob, s, o) {
2525
- const base = String(s.url || VIDEO_VENDORS.pixverse.base).replace(/\/+$/, '');
2741
+ const base = vendorBase(s.url, VIDEO_VENDORS.pixverse.base);
2526
2742
  const headers = { 'Content-Type': 'application/json', 'API-KEY': s.apiKey || '', 'Ai-trace-id': 'dshic-' + Date.now().toString(36) };
2527
- const path = frameBlob ? 'img2video' : 'text2video';
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
+ }
2528
2757
  const body = {
2529
- model: 'v4.5', prompt,
2530
- duration: Math.max(5, Math.min(8, Number(o.duration) || 5)) <= 5 ? 5 : 8,
2531
- quality: String(o.resolution || '').toLowerCase().includes('1080') ? '1080p' : '720p',
2532
- aspect_ratio: (o.ratio || '16:9') === '9:16' ? '9:16' : '16:9',
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 跟随输入图)
2533
2762
  };
2534
- if (frameBlob) body.image = await b64Pure(await blobToDataUrlScaled(frameBlob));
2535
- const r1 = await fetchViaProxy(base + '/video/' + path, { headers, body, label: 'PixVerse 提交' });
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 提交' });
2536
2766
  if (!r1.ok) throw new Error('PixVerse 提交 HTTP ' + r1.status + ':' + r1.text.slice(0, 300));
2537
2767
  let j1 = null; try { j1 = JSON.parse(r1.text); } catch { throw new Error('PixVerse 响应非 JSON'); }
2538
2768
  const vid = j1 && j1.Resp && j1.Resp.video_id;
@@ -2543,15 +2773,16 @@ window.__ModuleLoader__.load({
2543
2773
  let j = null; try { j = JSON.parse(r.text); } catch { return { state: 'wait' }; }
2544
2774
  const resp = j && j.Resp;
2545
2775
  if (!resp) return { state: 'wait' };
2546
- if (resp.code === 0 && resp.url) return { state: 'done', url: resp.url };
2547
- if (resp.code !== 0 && resp.code !== 6 && resp.code !== 7 && resp.code !== 8) return { state: 'fail', error: resp.msg || ('code ' + resp.code) };
2548
- return { state: 'wait' }; // 6/7/8 = 生成中/排队中/处理中
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)= 排队/处理中
2549
2780
  });
2550
2781
  return await downloadVideo(url, {});
2551
2782
  },
2552
2783
  // —— Stability AI:SVD 纯图生视频(multipart,无提示词),结果为 base64 JSON ——
2553
2784
  async stability(prompt, frameBlob, s, o) {
2554
- const base = String(s.url || VIDEO_VENDORS.stability.base).replace(/\/+$/, '');
2785
+ const base = vendorBase(s.url, VIDEO_VENDORS.stability.base);
2555
2786
  if (!frameBlob) throw new Error('Stability 图生视频必须有首帧图(该服务不支持纯文生视频、无提示词输入)');
2556
2787
  const fd = new FormData();
2557
2788
  fd.append('image', frameBlob, 'frame.png');
@@ -3398,6 +3629,11 @@ window.__ModuleLoader__.load({
3398
3629
  const nodes = { ...((live && live.nodes) ? live.nodes : local.nodes) };
3399
3630
  for (const [nid, n] of Object.entries(full.nodes || {})) {
3400
3631
  if (deadNodes.has(nid)) continue;
3632
+ const cur = nodes[nid];
3633
+ // 生成产物保护:本地分镜已有 shots 而主机副本没有 → 主机副本是竞态过期数据,保留本地
3634
+ // (否则刚生成完的分镜会被一次过期合并清空,客户端再推回去就永久丢失)
3635
+ if (cur && cur.type === 'storyboard' && Array.isArray(cur.data && cur.data.shots) && cur.data.shots.length
3636
+ && !(n && n.data && Array.isArray(n.data.shots) && n.data.shots.length)) continue;
3401
3637
  nodes[nid] = n; // Host 为准(Agent 写入是有意的)
3402
3638
  }
3403
3639
  const deadEdges = new Set([...(local.edgeTombstones || []), ...(full.edgeTombstones || []), ...((live && live.edgeTombstones) || [])]);
@@ -3814,16 +4050,18 @@ window.__ModuleLoader__.load({
3814
4050
  '要求:',
3815
4051
  '- 本节点输出**一集**的剧本:成片时长 1-2 分钟(60-120 秒),共 4-10 个场景;',
3816
4052
  '- **每个场景必须以「【场N】场景名(地点·时间)」单独一行开头**(如【场1】城市街头(夜))——这是后续自动铺开到分镜的分场标记,格式不可省略或改写;',
3817
- '- 每场容量 10-15 秒成片(约 60-150 字,含该场全部对白);全部场景时长之和 ≈ 1-2 分钟;',
4053
+ '- 每场按内容写足(成片 10–30 秒量级,约 150–400 字含该场全部对白);全部场景时长之和 ≈ 1-3 分钟;',
3818
4054
  '- 场景头之后:可视化动作描写 + 对白(角色:台词,单独成行);需要的声音(环境音/旁白)标注在括号里;',
4055
+ '- **【资产锚定·硬性】正文中所有角色/场景/道具名称一律写成 @名称@ 引用形式**(前后各一个 @ 精确包裹,名称与设定集逐字一致,如「@陈拾@ 在 @城市街头@ 的 @铁皮围挡@ 旁」)——下游分镜生成靠这些锚点识别资产并挂接参考图;叙述性文字正常书写,不要把动作描述包进 @…@;',
3819
4056
  '- 结尾单独一行「人物:」与「场景/道具:」清单,便于后续资产制作。',
3820
4057
  ].join('\n') : [
3821
4058
  `Idea / source:\n${String(idea ?? '').trim()}`,
3822
4059
  'Requirements:',
3823
4060
  '- This node outputs ONE episode: 1-2 minutes of screen time (60-120s), 4-10 scenes;',
3824
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;',
3825
- '- Each scene holds 10-15 seconds of screen time (≈60-150 words incl. dialogue); all scenes sum to ≈1-2 minutes;',
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;',
3826
4063
  '- After each scene header: visual action + dialogue ("Character: line" on its own lines); mark sounds in brackets;',
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 @...@;',
3827
4065
  '- End with "Characters:" and "Locations/Props:" lists for asset production.',
3828
4066
  ].join('\n');
3829
4067
  return await aiCompleteStream({
@@ -3867,7 +4105,7 @@ window.__ModuleLoader__.load({
3867
4105
  '只输出一个 JSON 对象:{"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"陈拾","line":"收件时间,1999……"}],"sfx":"邮包搭扣声、撕纸声"}]}。',
3868
4106
  '规则:',
3869
4107
  '- visual 是本镜画面描述(景别、人物外观与站位、场景、光线、构图,60 字内);',
3870
- '- **visual 与 motion 里的角色/场景/道具必须写成 @名称 引用形式**(如 `@张山`、`@褶皱巷`),插件会自动带上该实体的设定描述与其参考图资产,保证跨镜一致;',
4108
+ '- **visual 与 motion 里的角色/场景/道具必须写成 @资产名@ 引用形式**(前后各一个 @ 精确包裹,如「@陈拾@ 在 @城市街头@ 的围挡旁拿起 @奇怪快递件@」);名称必须与设定集/资产**逐字一致**;剧本原文里已出现的 @资产名@ 锚点要在镜头中原样保留(名称逐字一致);名称内不得含 @、空格或标点;@ 后绝不允许直接跟其他文字(必须以 @ 闭合)——系统会隐藏两个 @ 并把名称显示为蓝色加粗,未登记的 @名称@ 会被一致性检查提示补充;',
3871
4109
  '- motion 是本镜动作与运镜(主体动作节拍、机位运动方向与速度、情绪节奏,50 字内);与 visual 不重复;',
3872
4110
  '- characters/scene/props 使用剧本与设定集中的名称(不带 @),便于匹配资产;无道具给空数组;',
3873
4111
  '- **dialogue 是本镜的角色台词**:把剧本中落在这一镜的对白逐行搬进来({"role":"角色名","line":"台词原文"}),台词必须**逐字来自剧本**、不新写不改写;本镜无台词给空数组;一镜尽量只放一个角色的台词(单说话人镜头利于声音参考提取);',
@@ -3876,7 +4114,9 @@ window.__ModuleLoader__.load({
3876
4114
  '- **【完整覆盖】分镜必须覆盖所给剧本的全部内容**:每一个动作、每一句对白都要落在某个镜头里——不概述、不省略、不跳过、不"挑选重点";镜头数量与总时长由剧本体量自然决定,宁多勿漏(容量超限由上层拆分剧本处理,不由分镜偷工减料);',
3877
4115
  '- **镜头按实际内容切分**:一个连续动作段落 = 一镜,数量由内容自然决定(3-10 镜都正常)——绝不凑数、绝不使用固定数量;',
3878
4116
  '- **JSON 字符串值内部禁止出现半角双引号 "**——引用文字(牌匾/字幕/台词原文)一律用「」;',
3879
- '- duration 为该镜时长,2-5 的整数(一个镜头拍一个连续动作段落);全部 duration 之和 **≤ 15 秒**(一个分镜表 = 一个 10-15s 的场景视频)——超了就合并精简镜头,而不是拉长单镜;镜头顺序即叙事顺序,末镜收束情绪;**若下方给了「承接约束」,第 1 镜必须承接上一场尾镜的画面与人物状态(不得重新建立场景/跳回远景),无承接约束时首镜才交代场景**;',
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 要极短**(一个动作、一句画内信息),信息量大的镜头才配长时长——时长与内容量必须匹配;',
3880
4120
  '- 只输出 JSON,不要输出任何其他内容。',
3881
4121
  ].join('\n')
3882
4122
  : [
@@ -3884,7 +4124,7 @@ window.__ModuleLoader__.load({
3884
4124
  'Output only one JSON object: {"shots":[{"title":"…","visual":"…","motion":"…","characters":["…"],"scene":"…","props":["…"],"duration":3,"dialogue":[{"role":"Chen","line":"…"}],"sfx":"zipper snap, paper tear"}]}.',
3885
4125
  'Rules:',
3886
4126
  '- visual = the shot\'s frame description (shot size, wardrobe, staging, light, framing, <=60 words);',
3887
- '- **characters/scenes/props inside visual and motion must be written as @name references** (e.g. `@ZhangSan`); the plugin then attaches that entity\'s bible description and reference images automatically;',
4127
+ '- **characters/scenes/props inside visual and motion must be written as @Name@ references** (an @ on each side, e.g. "@Chen@ picks up @Parcel@ by @Street@"); names must match the bible/assets VERBATIM, with no @, space or punctuation inside; an @ must never be followed directly by other text (always close with @) - the UI hides the @s and shows the name in blue bold; unregistered @Name@ is flagged by the consistency check;',
3888
4128
  '- motion = action & camera for the shot (action beats, camera move, emotional rhythm, <=50 words); do not repeat visual;',
3889
4129
  '- characters/scene/props arrays must use plain names from the script and series bible (no @) so assets match; empty props array if none;',
3890
4130
  '- **dialogue = the character lines that fall in this shot**, moved verbatim from the script ({"role":"Name","line":"exact line"}); never rewrite lines; empty array if none; keep one speaker per shot when possible (helps voice-reference extraction);',
@@ -3893,7 +4133,9 @@ window.__ModuleLoader__.load({
3893
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;',
3894
4134
  '- **Never use straight double quotes " inside JSON string values** — quoted text (signs/subtitles/lines) uses 「」 instead;',
3895
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;',
3896
- '- duration is the shot length in seconds, integer 2-5 (one continuous action beat per shot); the sum of durations must be **≤ 15 seconds** (one storyboard = one 10-15s scene video) — if over, merge and tighten shots instead of stretching; shot order = narrative order; last shot lands the emotion; **if a CONTINUITY CONSTRAINT is given below, shot 1 must continue the previous scene\'s last frame and character state (no re-establishing, no wide reset); only without a constraint does shot 1 establish the scene**;',
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;',
3897
4139
  '- Output JSON only.',
3898
4140
  ].join('\n');
3899
4141
  const methodology = await loadSkillRules(settings.skillStoryboard, zh)
@@ -3971,24 +4213,16 @@ window.__ModuleLoader__.load({
3971
4213
  const tail = raw.slice(-160);
3972
4214
  throw new Error(T('errParseStoryboard') + '(正文 ' + raw.length + ' 字;开头: ' + head + ';结尾: ' + tail + ')');
3973
4215
  }
3974
- // 硬性容量保证:一个分镜表 ≤ 15s(视频模型单次上限)——超限从最长镜头开始逐秒压缩(每镜最低 1s)
3975
- const capped = shots.map((s) => ({ ...s }));
3976
- for (let guard = 0; guard < 300; guard++) {
3977
- const sum = capped.reduce((a, s) => a + (Number(s.duration) || 0), 0);
3978
- if (sum <= 15) break;
3979
- let maxI = -1, maxV = 1;
3980
- capped.forEach((s, i) => { const d = Number(s.duration) || 0; if (d > maxV) { maxV = d; maxI = i; } });
3981
- if (maxI < 0) break;
3982
- capped[maxI].duration = maxV - 1;
3983
- }
3984
- return capped.slice(0, 24).map((s, i) => ({
4216
+ // 不做时长压缩:镜头时长由内容决定(各视频厂商单次上限不同,超限时由视频接口明确报错,
4217
+ // 比系统静默砍戏更好——用户要求「不要强行截断」)。仅做数据合法性兜底(≥1 的整数)。
4218
+ return shots.slice(0, 24).map((s, i) => ({
3985
4219
  title: String(s.title || (T('shotPrefix') + (i + 1))).slice(0, 24),
3986
4220
  visual: String(s.visual || ''),
3987
4221
  motion: String(s.motion || ''),
3988
4222
  characters: Array.isArray(s.characters) ? s.characters.map(String).slice(0, 6) : [],
3989
4223
  scene: String(s.scene || ''),
3990
4224
  props: Array.isArray(s.props) ? s.props.map(String).slice(0, 6) : [],
3991
- duration: Number.isFinite(Number(s.duration)) ? clamp(Math.round(Number(s.duration)), 1, 15) : 3,
4225
+ duration: Number.isFinite(Number(s.duration)) ? Math.max(0.3, Math.round(Number(s.duration) * 10) / 10) : 3,
3992
4226
  dialogue: Array.isArray(s.dialogue)
3993
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)
3994
4228
  : [],
@@ -4028,11 +4262,9 @@ window.__ModuleLoader__.load({
4028
4262
  for (const nm of (sh.characters || [])) check(title, T('catCharacter'), nm);
4029
4263
  check(title, T('catScene'), sh.scene);
4030
4264
  for (const nm of (sh.props || [])) check(title, T('catProp'), nm);
4031
- // visual/motion 文本里的 @提及 同样纳入检查(数组外的实体也要登记,杜绝检查漏)
4265
+ // visual/motion 文本里的 @资产名@ 提及同样纳入检查(未登记的会提示补充资产——这是检查的价值所在)
4032
4266
  const text = String(sh.visual || '') + ' ' + String(sh.motion || '');
4033
- for (const m of (text.match(/@[^\s@,。;、!?::()"'))]+/g) || [])) {
4034
- check(title, T('catMention'), m.slice(1).replace(/[))]+$/, ''));
4035
- }
4267
+ for (const mm of parseMentions(text)) check(title, T('catMention'), mm.name);
4036
4268
  }
4037
4269
  return warnings;
4038
4270
  }
@@ -4457,15 +4689,28 @@ window.__ModuleLoader__.load({
4457
4689
  function portWorldPos(nodeId, portId, dir) {
4458
4690
  const n = S.graph.nodes[nodeId];
4459
4691
  if (!n) return { x: 0, y: 0 };
4692
+ // 分镜承接端口专属位置(用户设计):tail=节点底部右侧(输出)、prev=节点顶部右侧(输入)
4693
+ if (n.type === 'storyboard' && portId === 'tail') return { x: n.x + n.w - 48, y: n.y + n.h };
4694
+ if (n.type === 'storyboard' && portId === 'prev') return { x: n.x + n.w - 48, y: n.y };
4460
4695
  const list = dir === 'out' ? (PORTS[n.type].outputs || []) : (PORTS[n.type].inputs || []);
4461
4696
  const i = list.findIndex((p) => p.id === portId);
4462
- const y = n.y + 38 + Math.max(0, i) * 22;
4697
+ const stack = (n.type === 'storyboard' && dir === 'in' && i > 0) ? i - 1 : i; // prev 是角点不占常规队列
4698
+ const y = n.y + 38 + Math.max(0, stack) * 22;
4463
4699
  return { x: dir === 'out' ? n.x + n.w : n.x, y };
4464
4700
  }
4465
4701
  function edgeGeom(e) {
4466
4702
  const a = portWorldPos(e.fromNode, e.fromPort, 'out');
4467
4703
  const b = portWorldPos(e.toNode, e.toPort, 'in');
4468
- const dx = Math.max(42, Math.abs(b.x - a.x) * 0.5);
4704
+ const off = Math.abs(b.x - a.x);
4705
+ // 端口横向对齐(同一列上下相接,如分镜承接链):直接连直线,不再外扩控制点画弯线
4706
+ if (off < 12 && Math.abs(b.y - a.y) > 24) {
4707
+ return {
4708
+ d: `M ${a.x} ${a.y} L ${b.x} ${b.y}`,
4709
+ mx: (a.x + b.x) / 2,
4710
+ my: (a.y + b.y) / 2,
4711
+ };
4712
+ }
4713
+ const dx = Math.max(42, off * 0.5);
4469
4714
  const c1x = a.x + dx, c2x = b.x - dx;
4470
4715
  return {
4471
4716
  d: `M ${a.x} ${a.y} C ${c1x} ${a.y}, ${c2x} ${b.y}, ${b.x} ${b.y}`,
@@ -4714,10 +4959,10 @@ window.__ModuleLoader__.load({
4714
4959
  // 先自动补录缺失条目(空描述),再算一致性告警:否则告警标志的条目其实马上就被自动补录了,"未登记"语义会误导用户与对话侧
4715
4960
  try { await autoRegisterBibleEntries(shots); } catch { /* 补录失败不影响分镜 */ }
4716
4961
  const warnings = await checkShotConsistency(shots);
4717
- // 容量告警:一个分镜 = 一个 10-15s 的完整场景。剧本过长说明应该按场景拆分成多个场景剧本节点,
4718
- // 而不是让一个分镜表硬吃全集(shots 上限 24 会悄悄截断覆盖面)
4962
+ // 容量告警:镜头数上限 24(UI/数据上限)。剧本过长说明应该按场景拆分成多个场景剧本节点,
4963
+ // 而不是让一个分镜表硬吃全集(shots 上限 24 会截断覆盖面)。时长本身不设上限,不做压缩。
4719
4964
  const stLen = st.length;
4720
- if (stLen > 400) {
4965
+ if (stLen > 600) {
4721
4966
  warnings.push(T('warnScriptTooLong').replace('{n}', String(stLen)));
4722
4967
  }
4723
4968
  update((s) => {
@@ -4867,6 +5112,20 @@ window.__ModuleLoader__.load({
4867
5112
  category: 'finalcut', // 节点生成的视频 = 成片(实体归属由 entities 字段承担,不占用分类)
4868
5113
  });
4869
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 { /* 探测失败不影响生成 */ }
4870
5129
  // 尾帧提取(通用):供 last 端口输出与链式下一镜使用
4871
5130
  try {
4872
5131
  const fBlob = await extractLastFrame(blob);
@@ -4993,6 +5252,13 @@ window.__ModuleLoader__.load({
4993
5252
  if (t.closest && t.closest('input,textarea,select,button,video,a,label,[data-no-drag]')) return;
4994
5253
  e.stopPropagation();
4995
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
+ });
4996
5262
  if (e.shiftKey) {
4997
5263
  const next = new Set(S.sel);
4998
5264
  if (next.has(n.id)) next.delete(n.id); else next.add(n.id);
@@ -5278,6 +5544,10 @@ window.__ModuleLoader__.load({
5278
5544
  script: 'M6 3h9l4 4v14H6zM15 3v4h4M9 12h7M9 16h7M9 8h3',
5279
5545
  board: 'M4 4h16v16H4zM4 9h16M9 4v5M15 4v5M9 13h6M9 17h6',
5280
5546
  expandAll: 'M4 12h6M4 12l3-3M4 12l3 3M20 12h-6M20 12l-3-3M20 12l-3 3M12 4v6M12 4l-3 3M12 4l3 3M12 20v-6M12 20l-3-3M12 20l3-3',
5547
+ eye: 'M2.5 12S6 6.5 12 6.5 21.5 12 21.5 12 18 17.5 12 17.5 2.5 12 2.5 12zM12 15a3 3 0 100-6 3 3 0 000 6z',
5548
+ eyeOff: 'M4 4l16 16M9.9 5.1A9.6 9.6 0 0112 5c6 0 9.5 7 9.5 7a17 17 0 01-3.3 4.2M6.2 7.4A17 17 0 002.5 12S6 19 12 19a9.4 9.4 0 003.9-.8M10 10a3 3 0 004 4',
5549
+ help: 'M12 21a9 9 0 100-18 9 9 0 000 18zM9.4 9.4a2.6 2.6 0 114.2 2.1c-.9.7-1.6 1.2-1.6 2.3M12 17h.01',
5550
+ link: 'M10 13a5 5 0 007.1 0l2.4-2.4a5 5 0 00-7.1-7.1L11 4.9M14 11a5 5 0 00-7.1 0L4.5 13.4a5 5 0 007.1 7.1L13 19.1',
5281
5551
  };
5282
5552
  function Ic(name, size) { return h(Icon, { d: ICONS[name], size: size || 14 }); }
5283
5553
 
@@ -5322,7 +5592,7 @@ window.__ModuleLoader__.load({
5322
5592
  return () => ro.disconnect();
5323
5593
  }
5324
5594
 
5325
- function PortEl({ n, port, dir }) {
5595
+ function PortEl({ n, port, dir, corner }) {
5326
5596
  const isActive = drag.mode === 'link' && drag.linkFrom && drag.linkFrom.node.id === n.id && drag.linkFrom.port === port.id;
5327
5597
  let compat = false;
5328
5598
  const link = drag.mode === 'link' ? drag.linkFrom : null;
@@ -5332,7 +5602,7 @@ window.__ModuleLoader__.load({
5332
5602
  }
5333
5603
  return h('button', {
5334
5604
  type: 'button',
5335
- className: cls('dshic-port', 'k-' + port.kind, isActive && 'is-active', compat && 'is-compat'),
5605
+ className: cls('dshic-port', 'k-' + port.kind, corner && 'is-corner', isActive && 'is-active', compat && 'is-compat'),
5336
5606
  'data-node-id': n.id,
5337
5607
  ...(dir === 'out' ? { 'data-port-out': port.id } : { 'data-port-in': port.id }),
5338
5608
  'data-kind': port.kind,
@@ -5459,7 +5729,7 @@ window.__ModuleLoader__.load({
5459
5729
  return h('div', {
5460
5730
  'data-node-id': n.id,
5461
5731
  className: cls('dshic-node', 'type-' + n.type, 'st-' + st, selected && 'is-selected', n.hManual && 'h-manual'),
5462
- 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 } : {}) },
5463
5733
  ref: refCb,
5464
5734
  onPointerDown: (e) => onNodePointerDown(e, n),
5465
5735
  },
@@ -5499,14 +5769,19 @@ window.__ModuleLoader__.load({
5499
5769
  h('div', { className: 'dshic-rs dshic-rs-s', title: T('resizeHeightHint'), onPointerDown: (e) => startResize(e, 's'), onDoubleClick: resetHeight }),
5500
5770
  h('div', { className: 'dshic-rs dshic-rs-n', title: T('resizeHint'), onPointerDown: (e) => startResize(e, 'n') }),
5501
5771
  h('div', { className: 'dshic-rs dshic-rs-se', title: T('resizeHint'), onPointerDown: (e) => startResize(e, 'se') }),
5502
- ...(PORTS[n.type].inputs || []).map((p, i) =>
5503
- h('div', { key: 'in-' + p.id, style: { position: 'absolute', left: -5.5, top: 32.5 + i * 22 }, className: 'dshic-port-wrap' },
5504
- h(PortEl, { n, port: p, dir: 'in' }),
5505
- h('span', { className: 'dshic-port-label', style: { left: 14, top: 0 } }, portLabel(p.id)))),
5506
- ...(PORTS[n.type].outputs || []).map((p, i) =>
5507
- h('div', { key: 'out-' + p.id, style: { position: 'absolute', right: -5.5, top: 32.5 + i * 22 }, className: 'dshic-port-wrap' },
5508
- h(PortEl, { n, port: p, dir: 'out' }),
5509
- h('span', { className: 'dshic-port-label', style: { right: 14, top: 0 } }, portLabel(p.id)))),
5772
+ ...(PORTS[n.type].inputs || []).map((p, i) => {
5773
+ const isCorner = n.type === 'storyboard' && p.id === 'prev'; // 分镜承接:顶部右侧角点
5774
+ const stack = (n.type === 'storyboard' && i > 0) ? i - 1 : i;
5775
+ return h('div', { key: 'in-' + p.id, style: { position: 'absolute', left: isCorner ? undefined : -5.5, right: isCorner ? 48 : undefined, top: isCorner ? 0 : 32.5 + stack * 22 }, className: 'dshic-port-wrap' },
5776
+ h(PortEl, { n, port: p, dir: 'in', corner: isCorner }),
5777
+ h('span', { className: 'dshic-port-label', style: isCorner ? { right: -6, top: -22 } : { left: 14, top: 0 } }, portLabel(p.id)));
5778
+ }),
5779
+ ...(PORTS[n.type].outputs || []).map((p, i) => {
5780
+ const isCorner = n.type === 'storyboard' && p.id === 'tail'; // 分镜尾镜:底部右侧角点
5781
+ return h('div', { key: 'out-' + p.id, style: { position: 'absolute', right: isCorner ? 48 : -5.5, bottom: isCorner ? 0 : undefined, top: isCorner ? undefined : 32.5 + i * 22 }, className: 'dshic-port-wrap' },
5782
+ h(PortEl, { n, port: p, dir: 'out', corner: isCorner }),
5783
+ h('span', { className: 'dshic-port-label', style: isCorner ? { right: -6, top: 12 } : { right: 14, top: 0 } }, portLabel(p.id)));
5784
+ }),
5510
5785
  );
5511
5786
  }
5512
5787
 
@@ -5629,7 +5904,11 @@ window.__ModuleLoader__.load({
5629
5904
  : [
5630
5905
  h(MiniSelect, {
5631
5906
  key: 'dur', value: String(n.data.duration || 5), title: T('durationLabel'), width: 60,
5632
- options: VIDEO_DURATIONS.map((x) => ({ value: String(x), label: x + 's' })),
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' })),
5633
5912
  onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.duration = Number(v); }),
5634
5913
  }),
5635
5914
  h(MiniSelect, {
@@ -5953,6 +6232,7 @@ window.__ModuleLoader__.load({
5953
6232
  : null,
5954
6233
  h(RefRows, { key: 'refrows', n, portId: 'ref' }),
5955
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,
5956
6236
  n.data.error ? h('div', { key: 'err', className: 'dshic-err' }, n.data.error) : null,
5957
6237
  assetUrlV && kind === 'image'
5958
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); } })
@@ -6223,11 +6503,12 @@ window.__ModuleLoader__.load({
6223
6503
  }
6224
6504
 
6225
6505
  function TextNodeBody({ n }) {
6226
- return h('textarea', {
6227
- className: 'dshic-textarea', value: n.data.text || '',
6506
+ // 文本节点同样走高亮输入层:输入 @资产名@ 自动显示为蓝色加粗(两个 @ 隐藏),与资产库强绑定
6507
+ return h(PromptArea, {
6508
+ value: n.data.text || '',
6228
6509
  placeholder: T('textPlaceholder'),
6229
- onChange: (e) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.text = e.target.value; }),
6230
- onPointerDown: (e) => e.stopPropagation(),
6510
+ minHeight: 96,
6511
+ onChange: (v) => update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.text = v; }),
6231
6512
  });
6232
6513
  }
6233
6514
 
@@ -6409,10 +6690,12 @@ window.__ModuleLoader__.load({
6409
6690
  const listH = Math.max(80, Math.min(1200, Number(n.data.shotsListH) || 180));
6410
6691
  const startResize = (e) => {
6411
6692
  e.stopPropagation(); e.preventDefault();
6412
- const startY = e.clientY; const startH = listH;
6693
+ const startY = e.clientY; const startH = listH; const startNodeH = Number(n.h) || 0;
6413
6694
  const onMove = (ev) => {
6414
6695
  const nh = Math.max(80, Math.min(1200, startH + (ev.clientY - startY)));
6415
- update((s) => { const nd = s.graph.nodes[n.id]; if (nd) nd.data.shotsListH = nh; });
6696
+ const dh = nh - startH;
6697
+ // 节点高度随列表一起向下生长(不是内部挤压上面内容)
6698
+ update((s) => { const nd = s.graph.nodes[n.id]; if (nd) { nd.data.shotsListH = nh; if (startNodeH > 0) nd.h = Math.max(120, startNodeH + dh); } });
6416
6699
  };
6417
6700
  const onUp = () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); flushSave(); };
6418
6701
  window.addEventListener('pointermove', onMove);
@@ -6481,7 +6764,7 @@ window.__ModuleLoader__.load({
6481
6764
  ? h('div', { key: 'listwrap', style: { position: 'relative', alignSelf: 'stretch' } },
6482
6765
  h('div', { key: 'list', className: 'dshic-shot-list', style: { maxHeight: listH + 'px' } },
6483
6766
  shots.map((sh, i) => h('div', { key: i, className: 'dshic-shot-item', onPointerDown: (e) => e.stopPropagation() },
6484
- h('strong', {}, `${sh.title} · ${sh.duration}s `), sh.visual,
6767
+ h('strong', {}, `${sh.title} · ${sh.duration}s `), ...mentionNodes(sh.visual),
6485
6768
  (sh.dialogue || []).length
6486
6769
  ? h('div', { className: 'dshic-shot-label', style: { marginTop: 3 } }, '台词 ' + sh.dialogue.map((d) => `${d.role}:${d.line}`).join(';'))
6487
6770
  : null,
@@ -6577,7 +6860,7 @@ window.__ModuleLoader__.load({
6577
6860
  const baseY = n.y - 40;
6578
6861
  // 生成时长 = 各镜时长之和(多镜头一次性生成的总时长),由视频模型内部切镜
6579
6862
  const total = shots.reduce((a, sh) => a + Math.max(1, Number(sh.duration) || 3), 0);
6580
- const videoGen = mk('video-gen', baseX + 360, baseY + 20, { prompt: '', duration: Math.max(3, Math.min(20, total)) });
6863
+ const videoGen = mk('video-gen', baseX + 360, baseY + 20, { prompt: '', duration: Math.max(1, Math.round(total)) });
6581
6864
  const videoAsset = mk('video-asset', baseX + 720, baseY + 20);
6582
6865
  entityNames.forEach((nm, i) => {
6583
6866
  let an = findAssetNode(nm);
@@ -6952,6 +7235,8 @@ window.__ModuleLoader__.load({
6952
7235
  : h('button', { type: 'button', className: 'dshic-btn', style: { fontWeight: 600, maxWidth: 180, overflow: 'hidden' }, onDoubleClick: () => setNaming(true), title: T('renameHint') },
6953
7236
  h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, proj ? proj.name : '')),
6954
7237
  h('div', { className: 'dshic-toolbar-sep' }),
7238
+ h(Btn, { onClick: () => update((s) => { s.supportOpen = true; }), title: T('supportBtn') }, Ic('help', 13), T('supportBtn')),
7239
+ h('div', { className: 'dshic-toolbar-sep' }),
6955
7240
  h('button', {
6956
7241
  type: 'button', className: cls('dshic-btn'), style: { fontSize: 12, color: 'var(--dsw-alias-label-secondary)', gap: 4 },
6957
7242
  title: T('updateBtn') + ((S.updateInfo && S.updateInfo.runningVersion) ? '(当前 v' + S.updateInfo.runningVersion + ')' : ''), disabled: S.updateChecking,
@@ -7538,13 +7823,35 @@ window.__ModuleLoader__.load({
7538
7823
 
7539
7824
  /** 图片/视频模型多配置列表:名称 / 地址 / Key / 模型(/模式),可增删、设默认。 */
7540
7825
  function Field({ label, value, onChange, placeholder, password }) {
7826
+ // 敏感字段(API Key):默认脱敏,点击小眼睛切换明文显示
7827
+ const [reveal, setReveal] = useState(false);
7828
+ if (!password) {
7829
+ return h('div', { className: 'dshic-field' },
7830
+ h('span', { className: 'dshic-field-label' }, label),
7831
+ h('input', {
7832
+ className: 'dshic-input', type: 'text', value, placeholder: placeholder || '',
7833
+ onChange: (e) => onChange(e.target.value),
7834
+ onPointerDown: (e) => e.stopPropagation(),
7835
+ }));
7836
+ }
7541
7837
  return h('div', { className: 'dshic-field' },
7542
7838
  h('span', { className: 'dshic-field-label' }, label),
7543
- h('input', {
7544
- className: 'dshic-input', type: password ? 'password' : 'text', value, placeholder: placeholder || '',
7545
- onChange: (e) => onChange(e.target.value),
7546
- onPointerDown: (e) => e.stopPropagation(),
7547
- }));
7839
+ h('div', { style: { position: 'relative', display: 'flex', alignItems: 'center' } },
7840
+ h('input', {
7841
+ className: 'dshic-input', type: reveal ? 'text' : 'password', value, placeholder: placeholder || '',
7842
+ style: { paddingRight: 30 },
7843
+ onChange: (e) => onChange(e.target.value),
7844
+ onPointerDown: (e) => e.stopPropagation(),
7845
+ }),
7846
+ h('button', {
7847
+ type: 'button', className: 'dshic-iconbtn',
7848
+ title: reveal ? T('hideKey') : T('showKey'),
7849
+ 'aria-label': reveal ? T('hideKey') : T('showKey'),
7850
+ style: { position: 'absolute', right: 3, top: '50%', transform: 'translateY(-50%)', width: 22, height: 22 },
7851
+ onPointerDown: (e) => e.stopPropagation(),
7852
+ onClick: () => setReveal((v) => !v),
7853
+ }, reveal ? Ic('eyeOff', 12) : Ic('eye', 12)),
7854
+ ));
7548
7855
  }
7549
7856
  /** ⓘ 悬停说明:界面简洁化——长解释一律收进悬停提示(原生 title),不再平铺文字。 */
7550
7857
  function InfoTip(text) {
@@ -8136,6 +8443,17 @@ window.__ModuleLoader__.load({
8136
8443
  h(ModelProviderEditor, { key: 'imglist', kind: 'image' }),
8137
8444
  h(ModelProviderEditor, { key: 'vidlist', kind: 'video' }),
8138
8445
  h(ApiListEditor, { key: 'ttslist', kind: 'tts' }),
8446
+ // 帮助与反馈:一行入口(点开弹窗看二维码与提 Issue)
8447
+ h('div', {
8448
+ key: 'support', className: 'dshic-card', style: { cursor: 'pointer' },
8449
+ onClick: () => update((s) => { s.supportOpen = true; }),
8450
+ },
8451
+ h('div', { className: 'dshic-row' },
8452
+ Ic('help', 12),
8453
+ h('strong', { style: { fontSize: 12, flex: 1, minWidth: 0 } }, T('supportBtn')),
8454
+ h('span', { className: 'dshic-hint', style: { flex: 'none' } }, T('supportIssues') + ' / ' + T('supportWechat') + ' ›'),
8455
+ ),
8456
+ ),
8139
8457
  h('div', { key: 'note', className: 'dshic-hint' }, 'DeepSeek Harness · dsh-infinite-canvas' + ((S.updateInfo && S.updateInfo.runningVersion) ? ' v' + S.updateInfo.runningVersion : '')),
8140
8458
  ];
8141
8459
  }
@@ -8611,11 +8929,11 @@ window.__ModuleLoader__.load({
8611
8929
  nodes.filter((nd) => nd.type === 'group').map((n) => h(NodeShell, { key: n.id, n })),
8612
8930
  nodes.filter((nd) => nd.type !== 'group').map((n) => h(NodeShell, { key: n.id, n })),
8613
8931
  h('div', {
8614
- className: 'dshic-marquee', style: { display: 'none' },
8932
+ className: 'dshic-marquee', style: { display: 'none', zIndex: 9999 },
8615
8933
  ref: (el) => { marqueeEl = el; },
8616
8934
  }),
8617
- h('div', { className: 'dshic-guide-v', style: { display: 'none' }, ref: (el) => { vGuidEl = el; } }),
8618
- 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; } }),
8619
8937
  ),
8620
8938
  h(Minimap),
8621
8939
  nodes.length === 0 ? h('div', { className: 'dshic-empty' }, h('div', { className: 'dshic-empty-card' },
@@ -8634,6 +8952,7 @@ window.__ModuleLoader__.load({
8634
8952
  ),
8635
8953
  S.menu ? h(ContextMenu, { menu: S.menu }) : null,
8636
8954
  S.lightbox ? h(Lightbox) : null,
8955
+ S.supportOpen ? h(SupportDialog) : null,
8637
8956
  S._toast ? h('div', {
8638
8957
  style: {
8639
8958
  position: 'fixed', bottom: 22, left: '50%', transform: 'translateX(-50%)', zIndex: 70,
@@ -8671,6 +8990,50 @@ window.__ModuleLoader__.load({
8671
8990
  );
8672
8991
  }
8673
8992
 
8993
+ // ============================================================
8994
+ // 帮助与反馈:GitHub 仓库/Issue + 微信交流群二维码(插件静态资源)
8995
+ // ============================================================
8996
+ const SUPPORT_GITHUB = 'https://github.com/NINKCH/dsh-infinite_canvas';
8997
+ const SUPPORT_ISSUES = 'https://github.com/NINKCH/dsh-infinite_canvas/issues/new';
8998
+ const SUPPORT_QR_URL = '/' + NS + '/api/plugin-asset?rel=' + encodeURIComponent('assets/support/wechat.png');
8999
+ function copyText(text, okMsg) {
9000
+ try {
9001
+ if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(text).then(() => toast(okMsg)).catch(() => toast(text)); return; }
9002
+ } catch { /* 降级 */ }
9003
+ toast(text);
9004
+ }
9005
+ function SupportDialog() {
9006
+ const [qrFailed, setQrFailed] = useState(false);
9007
+ const close = () => update((s) => { s.supportOpen = false; });
9008
+ useEffect(() => {
9009
+ const onKey = (e) => { if (e.key === 'Escape') close(); };
9010
+ window.addEventListener('keydown', onKey);
9011
+ return () => window.removeEventListener('keydown', onKey);
9012
+ }, []);
9013
+ return h('div', { className: 'dshic-modal-overlay', onPointerDown: (e) => { if (e.target === e.currentTarget) close(); } },
9014
+ h('div', { className: 'dshic-modal', style: { width: 300, alignItems: 'center', textAlign: 'center' } },
9015
+ h('div', { className: 'dshic-modal-title' }, T('supportTitle')),
9016
+ h('div', { className: 'dshic-modal-warn', style: { marginTop: 6 } }, T('supportNote')),
9017
+ h('div', { style: { marginTop: 10 } },
9018
+ qrFailed
9019
+ ? h('div', { className: 'dshic-hint' }, T('supportQrMissing'))
9020
+ : h('img', {
9021
+ src: SUPPORT_QR_URL, alt: 'WeChat group QR', draggable: false,
9022
+ style: { width: 180, height: 180, objectFit: 'contain', borderRadius: 8, background: '#fff', padding: 6 },
9023
+ onError: () => setQrFailed(true),
9024
+ })),
9025
+ h('div', { className: 'dshic-hint', style: { marginTop: 6 } }, T('supportWechatHint')),
9026
+ h('button', {
9027
+ type: 'button', className: 'dshic-mini is-ghost', style: { marginTop: 10 },
9028
+ onPointerDown: (e) => e.stopPropagation(),
9029
+ onClick: () => { try { window.open(SUPPORT_ISSUES, '_blank'); } catch { copyText(SUPPORT_ISSUES, T('supportCopied')); } },
9030
+ }, T('supportIssues') + ' ↗'),
9031
+ h('div', { className: 'dshic-modal-actions', style: { marginTop: 8, justifyContent: 'center' } },
9032
+ h('button', { type: 'button', className: 'dshic-mini', onPointerDown: (e) => e.stopPropagation(), onClick: close }, T('supportClose')),
9033
+ ),
9034
+ ));
9035
+ }
9036
+
8674
9037
  // ============================================================
8675
9038
  // 侧栏图标
8676
9039
  // ============================================================