// canvas.js —— 创意画布主进程中转执行层:复用 router.llmGroup() 的当前中转分组, // 统一在主进程发起 image/video/tts 请求并把成品下载转 base64 data URL 返回渲染层。 // key 只在主进程流转;渲染层被 CSP 拦截的远程 URL 一律由这里下载转 base64。 const router = require("./router"); const fs = require("fs"); const http = require("http"); const https = require("https"); const os = require("os"); const path = require("path"); const { execFileSync } = require("child_process"); // —— 从 baseUrl 解析出主机名(失败则原样返回)—— function hostOf(baseUrl) { try { return new URL(baseUrl).host; } catch { return baseUrl || ""; } } function uniqStrings(list) { return Array.from(new Set((list || []).map((v) => String(v || "").trim()).filter(Boolean))); } function groupText(g) { return `${(g && g.name) || ""} ${(g && g.model) || ""}`.toLowerCase(); } function isSeedanceV2Model(model) { return /^(?:bh2\.0-|sd2\.0-|sdvip|gz-sd)/i.test(String(model || "").trim()); } function isSeedanceV2Group(g) { const text = groupText(g); return /seedance[\s_-]*v\s*2|seedancev2|seedance_v2/.test(text) || isSeedanceV2Model(g && g.model); } function isSeedanceGroup(g) { return /seedance/.test(groupText(g)); } function isGrokGroup(g) { return /grok/.test(groupText(g)); } function modelCandidatesForGroup(g) { const out = []; if (!g) return out; if (g.model) out.push(g.model); if (Array.isArray(g.oneMillionModels)) out.push(...g.oneMillionModels); if (isGrokGroup(g)) { out.push("grok-video-1.5", "grok-imagine-video-1.5", "grok-imagine-video"); } if (isSeedanceV2Group(g)) { out.push( "bh2.0-fast-720p", "bh2.0-720p", "bh2.0-1080p", "bh2.0-mini-720p", "SD2.0-720P", "SD2.0-720P-fast", "SD2.0-1080P", "sdvip720p", "sdvip1080p", "gz-sd720p", "gz-sd1080p" ); } else if (isSeedanceGroup(g)) { out.push("seedance2.0", "seedance-2.0"); } return uniqStrings(out); } // —— 去尾斜杠 —— function trimSlash(u) { return (u || "").replace(/\/$/, ""); } // —— 可复用的异步 sleep(不留定时器句柄泄漏隐患)—— function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } // —— 统一请求头(与 router.fetchModels 的 User-Agent 一致)—— function headers(key) { return { Authorization: "Bearer " + (key || ""), "User-Agent": "codex-market", "Content-Type": "application/json", }; } function grokDebugLog(entry) { try { const dir = path.join(os.homedir(), "Library", "Application Support", "codex-market"); fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync( path.join(dir, "grok-video-debug.jsonl"), JSON.stringify({ time: new Date().toISOString(), ...entry }) + "\n" ); } catch {} } function seedanceDebugLog(entry) { try { const dir = path.join(os.homedir(), "Library", "Application Support", "codex-market"); fs.mkdirSync(dir, { recursive: true }); fs.appendFileSync( path.join(dir, "seedance-video-debug.jsonl"), JSON.stringify({ time: new Date().toISOString(), ...entry }) + "\n" ); } catch {} } function systemProxyUrl() { const envProxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy || ""; if (envProxy) return envProxy; if (process.platform !== "darwin") return ""; try { const out = execFileSync("scutil", ["--proxy"], { encoding: "utf8", timeout: 1000 }); const get = (name) => { const m = out.match(new RegExp("^\\s*" + name + "\\s*:\\s*(.+)$", "m")); return m ? m[1].trim() : ""; }; const httpsOn = get("HTTPSEnable") === "1"; const httpOn = get("HTTPEnable") === "1"; const host = httpsOn ? get("HTTPSProxy") : httpOn ? get("HTTPProxy") : ""; const port = httpsOn ? get("HTTPSPort") : httpOn ? get("HTTPPort") : ""; return host && port ? `http://${host}:${port}` : ""; } catch { return ""; } } function proxyAgentFor(targetUrl, proxyUrl) { try { if (/^https:/i.test(targetUrl)) { const { HttpsProxyAgent } = require("https-proxy-agent"); return new HttpsProxyAgent(proxyUrl); } const { HttpProxyAgent } = require("http-proxy-agent"); return new HttpProxyAgent(proxyUrl); } catch { return null; } } function requestBuffer(url, agent, extraHeaders = {}) { return new Promise((resolve, reject) => { let u; try { u = new URL(url); } catch (e) { reject(e); return; } const mod = u.protocol === "https:" ? https : http; const req = mod.get( url, { agent, headers: { "User-Agent": "codex-market", ...extraHeaders }, }, (res) => { const loc = res.headers.location; if (res.statusCode >= 300 && res.statusCode < 400 && loc) { res.resume(); requestBuffer(new URL(loc, url).toString(), agent, extraHeaders).then(resolve, reject); return; } if (res.statusCode < 200 || res.statusCode >= 300) { res.resume(); reject(new Error("HTTP " + res.statusCode)); return; } const chunks = []; res.on("data", (c) => chunks.push(c)); res.on("end", () => resolve(Buffer.concat(chunks))); } ); req.setTimeout(120000, () => req.destroy(new Error("下载超时"))); req.on("error", reject); }); } // —— 下载任意远程资源 → base64 字符串 —— async function downloadAsBase64(url, extraHeaders = {}) { const proxyUrl = systemProxyUrl(); let proxyErr = ""; if (proxyUrl) { const agent = proxyAgentFor(url, proxyUrl); if (agent) { try { return (await requestBuffer(url, agent, extraHeaders)).toString("base64"); } catch (e) { proxyErr = String((e && e.message) || e); // 代理下载失败时再直连兜底,保留原行为。 } } } const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 120000); try { const res = await fetch(url, { headers: { "User-Agent": "codex-market", ...extraHeaders }, signal: ctrl.signal }); if (!res.ok) throw new Error("下载失败 HTTP " + res.status); const buf = Buffer.from(await res.arrayBuffer()); return buf.toString("base64"); } catch (e) { const directErr = String((e && e.message) || e); throw new Error("下载失败:" + directErr + (proxyErr ? ";代理下载也失败:" + proxyErr : "")); } finally { clearTimeout(timer); } } function trustedDownloadHeaders(url, llm) { let host = ""; try { host = new URL(url).host.toLowerCase(); } catch {} if (!host || !llm || !llm.key) return {}; if (host === "api.tokendun.com" || host.endsWith(".tokendun.com") || host === "pulseaify.com" || host.endsWith(".pulseaify.com")) { return { Authorization: "Bearer " + llm.key }; } return {}; } // —— 组装 data URL —— function toDataUrl(mime, b64) { return `data:${mime};base64,${b64}`; } // ============================================================================ // 素材外链托管:兰空图床(Lsky Pro)——图生图参考图 / 图生视频素材统一走这里换公网直链 // 桌面端没有对外 Web 服务器,Seedance/火山上游只接受「可访问的图片 URL」,base64 会被拒收; // 部分中转的图生图模型也用图片 URL 传参考图。所以 base64 素材先 POST 到兰空 /api/v1/upload // 换成 http(s) 直链,再交给上游。 // 兰空契约:POST multipart/form-data(字段 file)+ 头 Authorization: Bearer , // 返回体公网直链在 data.links.url。 // 默认写死用户自建图床;可用环境变量 CANVAS_UPLOAD_ENDPOINT / CANVAS_UPLOAD_AUTH 覆盖(开发期切换)。 // ============================================================================ const MEDIA_HOST = { // 兰空图床基址(不带尾斜杠)。上传端点固定为 {base}/api/v1/upload。 base: trimSlash(process.env.CANVAS_UPLOAD_ENDPOINT || "https://tc.tokendun.com"), // 兰空上传 token(个人令牌,长期有效)。留空则上传时报错。 token: process.env.CANVAS_UPLOAD_AUTH || "9|u2ZJoV8p2KJU6zBdy1MtTbiqMV9TgbHBLvURVWwt", }; // 从图床响应里挑出公网 URL(兰空 data.links.url,兼容 {url}/{data.url}/{data:[{url}]} 等形态) function pickUploadedUrl(j) { if (!j || typeof j !== "object") return ""; if (typeof j.url === "string") return j.url; const d = j.data; if (d && typeof d === "object") { // 兰空 Pro:data.links.url if (d.links && typeof d.links.url === "string") return d.links.url; if (typeof d.url === "string") return d.url; if (Array.isArray(d) && d[0] && typeof d[0].url === "string") return d[0].url; } return ""; } // —— base64 dataURL → 兰空公网直链 —— // 端点/令牌未配置 / 上传失败 / 未拿到合法 URL → 抛错(绝不把 base64 兜底发给上游)。 async function uploadMedia(dataUrl) { if (!MEDIA_HOST.base || !MEDIA_HOST.token) { throw new Error( "素材需要图床外链:图床地址或上传令牌未配置(桌面端无法把 base64 图直接交给上游)。" ); } // dataURL → mime + Buffer → Blob,组 multipart(Node 18+ 原生 FormData/Blob) const m = /^data:([^;,]+)?(;base64)?,(.*)$/is.exec(String(dataUrl || "")); if (!m) throw new Error("参考图不是合法的 data URL"); const mime = m[1] || "image/png"; const buf = Buffer.from(m[3], m[2] ? "base64" : "utf-8"); const ext = (mime.split("/")[1] || "png").replace(/[^a-z0-9]/gi, "") || "png"; const fd = new FormData(); fd.append("file", new Blob([buf], { type: mime }), "ref." + ext); const endpoint = MEDIA_HOST.base + "/api/v1/upload"; let res; try { // 注意:不手设 Content-Type,让 fetch 自动带 multipart boundary res = await fetch(endpoint, { method: "POST", headers: { Authorization: "Bearer " + MEDIA_HOST.token, Accept: "application/json", "User-Agent": "codex-market" }, body: fd, }); } catch (e) { throw new Error("素材上传请求失败:" + String((e && e.message) || e)); } if (!res.ok) { const detail = await res.text().catch(() => ""); throw new Error(`素材上传失败(${res.status}):${detail.slice(0, 200)}`); } let j; try { j = await res.json(); } catch { throw new Error("图床返回非 JSON,无法解析上传结果"); } // 兰空失败时也返回 200 + {status:false,message},据此给出明确错误 if (j && j.status === false) throw new Error("图床上传被拒:" + (j.message || "未知原因")); const url = pickUploadedUrl(j); if (!url || !/^https?:\/\//i.test(url)) throw new Error("图床未返回合法的公网 URL"); return url; } // —— 单个素材 → 公网直链:data URL 上传兰空换外链;http(s) 原样;空串返回 "" —— async function resolveMat(v) { const s = String(v || "").trim(); if (!s) return ""; if (/^data:/i.test(s)) return await uploadMedia(s); if (/^https?:\/\//i.test(s)) return s; throw new Error("素材需为 data URL 或公网 http(s) 地址"); } // 当前中转是否可用:ready = 有可用分组。画布节点会按 groupId 显式选用插件专用分组。 function status() { const usable = router.usableGroups(); const llm = router.llmGroup() || (usable && usable[0]); if (!llm) return { ready: false, provider: null, reason: "未配置可用中转分组" }; return { ready: true, provider: { name: llm.name || "", model: llm.model || "", host: hostOf(llm.baseUrl) }, }; } // —— 读图片流式 SSE:逐行取 data:{...},partial 帧回调 onPartial,completed/末帧作最终图 —— // 返回 { ok:true, mime, dataUrl } 或 null(非 SSE / 没拿到任何帧 → 交由调用方回退非流式)。 async function readImageSse(res, onPartial) { const decoder = new TextDecoder(); let buf = ""; let lastB64 = ""; // 最近一帧(流自然结束时用作最终图) let finalB64 = ""; // completed 事件的最终图 let sawData = false; for await (const chunk of res.body) { buf += decoder.decode(chunk, { stream: true }); let i; while ((i = buf.indexOf("\n")) >= 0) { const line = buf.slice(0, i).trim(); buf = buf.slice(i + 1); if (!line.startsWith("data:")) continue; sawData = true; const data = line.slice(5).trim(); if (data === "[DONE]") continue; let ev; try { ev = JSON.parse(data); } catch { continue; } const b64 = ev && (ev.b64_json || (ev.data && ev.data[0] && ev.data[0].b64_json)); if (!b64) continue; const type = String((ev && ev.type) || ""); lastB64 = b64; if (/partial/i.test(type)) { if (onPartial) { try { onPartial(toDataUrl("image/png", b64)); } catch {} } } else if (/completed/i.test(type)) { finalB64 = b64; } } } if (!sawData) return null; // 首包/全程都不是 data: → 不是 SSE,回退 const b64 = finalB64 || lastB64; if (!b64) return null; return { ok: true, mime: "image/png", dataUrl: toDataUrl("image/png", b64) }; } // —— 回退:非流式单次 POST —— gpt-image 不加 response_format,其它模型加 b64_json 容错 —— async function runImageNonStream(base, llm, params, url, model) { const isGptImage = /gpt-image/i.test(model); const body = { model, prompt: params.prompt, size: params.size || "1024x1024", ...(params.extra || {}), }; if (!isGptImage) body.response_format = "b64_json"; // 参考图外链(图生图)——runImage 已解析好 _refUrl,非流式回退同样带上 if (params._refUrl) body[params.refField || "image"] = params._refUrl; try { const res = await fetch(url, { method: "POST", headers: headers(llm.key), body: JSON.stringify(body) }); if (!res.ok) { const detail = await res.text().catch(() => ""); return { ok: false, error: `图像接口 ${res.status}:POST ${url} model=${model}${detail ? " → " + detail.slice(0, 240) : ""}` }; } const j = await res.json(); const item = (j && j.data && j.data[0]) || {}; let b64 = item.b64_json; const sourceUrl = item.url || ""; if (!b64 && item.url) b64 = await downloadAsBase64(item.url); // url 形式:主进程再下载转 base64 if (!b64) return { ok: false, error: `图像接口未返回 b64_json 或 url:POST ${url} model=${model}` }; return { ok: true, mime: "image/png", dataUrl: toDataUrl("image/png", b64), url: sourceUrl, _model: model }; } catch (e) { return { ok: false, error: `图像接口请求失败:POST ${url} model=${model} → ${String((e && e.message) || e)}` }; } } // —— 文生图:stream-first —— POST {base}{endpointPath||"/v1/images/generations"} —— // model 必须由用户填写(图像模型),为空不回退聊天模型,直接报错。 // 优先流式(partial 渐进帧经 onPartial 回传);非 2xx / 非 SSE / 未取到帧 → 回退非流式。 async function runImage(base, llm, params, onPartial) { const model = params.model; if (!model) return { ok: false, error: "请先填写图像模型" }; // 图生图:参考图(base64) → 兰空图床外链,只换一次,供流式/非流式两条路复用 if (params.image && !params._refUrl) { const img = String(params.image).trim(); if (/^data:/i.test(img)) { try { params._refUrl = await uploadMedia(img); } catch (e) { return { ok: false, error: "参考图上传图床失败:" + String((e && e.message) || e) }; } } else if (/^https?:\/\//i.test(img)) { params._refUrl = img; // 已是公网URL,直接用 } } const path = params.endpointPath || "/v1/images/generations"; const url = base + path; try { const body = { model, prompt: params.prompt, size: params.size || "1024x1024", n: 1, stream: true, partial_images: 2, ...(params.extra || {}), }; // 参考图外链塞进请求体(字段名可配,默认 image) if (params._refUrl) body[params.refField || "image"] = params._refUrl; const res = await fetch(url, { method: "POST", headers: headers(llm.key), body: JSON.stringify(body), }); const ct = res.headers.get("content-type") || ""; if (res.ok && res.body && /event-stream/i.test(ct)) { const streamed = await readImageSse(res, onPartial); if (streamed && streamed.ok) return { ...streamed, _model: model }; } // 非 2xx / 非 SSE / 流里没拿到帧 → 回退非流式 } catch { // 流式请求异常 → 回退非流式 } return runImageNonStream(base, llm, params, url, model); } // —— TTS 配音:POST {base}/v1/audio/speech(响应为二进制音频)—— async function runTts(base, llm, params) { const model = params.model || "tts-1"; const body = { model, input: params.text, voice: params.voice || "alloy", response_format: params.format || "mp3", }; if (params.speed !== undefined && params.speed !== null && params.speed !== "") body.speed = params.speed; const res = await fetch(base + "/v1/audio/speech", { method: "POST", headers: headers(llm.key), body: JSON.stringify(body), }); if (!res.ok) { const detail = await res.text().catch(() => ""); return { ok: false, error: `TTS 接口返回 HTTP ${res.status}:${detail.slice(0, 300)}` }; } const b64 = Buffer.from(await res.arrayBuffer()).toString("base64"); return { ok: true, mime: "audio/mpeg", dataUrl: toDataUrl("audio/mpeg", b64), _model: model }; } // —— 递归深挖视频地址(覆盖 result_url、data.content.video_url、data.data.content.video_url 等嵌套)—— // port 自 提示词提取/server.js function isLikelyVideoUrl(u) { try { const p = new URL(u).pathname.toLowerCase(); return /\.(mp4|mov|webm|m4v)(?:$|\.)/.test(p) || p.endsWith(".m3u8"); } catch { return false; } } function findLikelyVideoUrl(o) { if (!o) return ""; if (typeof o === "string") return /^https?:\/\//.test(o) && isLikelyVideoUrl(o) ? o : ""; if (Array.isArray(o)) { for (const v of o) { const u = findLikelyVideoUrl(v); if (u) return u; } return ""; } if (typeof o === "object") { for (const v of Object.values(o)) { const u = findLikelyVideoUrl(v); if (u) return u; } } return ""; } function findVideoUrl(o) { const likely = findLikelyVideoUrl(o); if (likely) return likely; if (!o) return ""; if (typeof o === "string") return ""; if (Array.isArray(o)) { for (const v of o) { const u = findVideoUrl(v); if (u) return u; } return ""; } if (typeof o === "object") { for (const k of ["video_url", "result_url", "content_url", "download_url", "file_url", "output_url"]) { const v = o[k]; if (typeof v === "string" && /^https?:\/\//.test(v)) return v; } for (const v of Object.values(o)) { const u = findVideoUrl(v); if (u) return u; } } return ""; } // —— 归一任务状态:各中转大小写/用词不一 —— port 自 提示词提取/server.js function normalizeVideoStatus(raw) { const s = String(raw || "").toLowerCase(); if (["succeeded", "success", "succeed", "done", "completed", "complete", "finished"].includes(s)) return "succeeded"; if (["failed", "fail", "failure", "error", "cancelled", "canceled", "timeout", "timed_out", "expired"].includes(s)) return "failed"; if (["in_progress", "running", "processing", "generating"].includes(s)) return "running"; return s || "queued"; } function joinUrl(base, p) { const root = trimSlash(base); const suffix = String(p || "").startsWith("/") ? String(p || "") : "/" + String(p || ""); if (/\/v1$/i.test(root) && /^\/v1(?:\/|$)/i.test(suffix)) return root + suffix.slice(3); return root + suffix; } function pickTaskId(o) { if (!o || typeof o !== "object") return ""; for (const k of ["request_id", "requestId", "task_id", "taskId", "id"]) { if ((typeof o[k] === "string" || typeof o[k] === "number") && String(o[k])) return String(o[k]); } for (const k of ["data", "result", "task"]) { const v = o[k]; if (v && typeof v === "object" && !Array.isArray(v)) { const id = pickTaskId(v); if (id) return id; } } return ""; } function pushRecentPoll(arr, value) { if (!value) return; arr.push(String(value).slice(0, 500)); if (arr.length > 3) arr.shift(); } async function resolveGrokImage(v) { const s = String(v || "").trim(); if (!s) return ""; if (/^data:image\/[^;,]+;base64,/i.test(s)) return await uploadMedia(s); if (/^data:/i.test(s)) throw new Error("Grok 首帧图需为 image/* 的 base64 data URL 或公网图片 URL"); if (/^https?:\/\//i.test(s)) return s; throw new Error("Grok 首帧图需为 base64 data URL 或公网 http(s) 地址"); } function grokSizeFromParams(params) { const rawSize = String((params && params.size) || "").trim(); if (/^\d+x\d+$/i.test(rawSize)) return rawSize.toLowerCase(); const resolution = String((params && params.resolution) || "720p").trim().toLowerCase(); const shortSide = resolution === "1080p" ? 1080 : resolution === "480p" ? 480 : 720; const roundEven = (n) => Math.max(2, Math.round(n / 2) * 2); const ratio = String((params && (params.aspectRatio || params.ratio)) || "16:9").trim(); const match = ratio.match(/^(\d+)\s*:\s*(\d+)$/); if (!match) return `${roundEven((shortSide * 16) / 9)}x${shortSide}`; const rw = Math.max(1, parseInt(match[1], 10)); const rh = Math.max(1, parseInt(match[2], 10)); if (rw === rh) return `${shortSide}x${shortSide}`; if (rw > rh) return `${roundEven((shortSide * rw) / rh)}x${shortSide}`; return `${shortSide}x${roundEven((shortSide * rh) / rw)}`; } // —— 旧 Seedance 专用适配(保留作历史兼容,当前画布由 runClmmVideoV2 统一提交)—— // mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 可省略,使用上游默认。 // 图生视频素材两条来路:params.imageUrl(手填公网URL,优先) 或 params.image(上游 base64)。 // base64 会先经 uploadMedia() 换成公网直链再发上游(Seedance 绝不接受 base64)。 async function runVideoLegacy(base, llm, params) { const model = String(params.model || "").trim(); const createUrl = joinUrl(base, params.endpointPath || "/v1/video/generations"); // 输入模式:text/first/firstlast/reference/video;兼容旧值 image→first let mode = String(params.mode || "text"); if (mode === "image") mode = "first"; // —— body 组装:严格照中转文档(pulseaify Seedance)——扁平字段,非火山 metadata 格式 —— // prompt 必填;duration 整数(5/10/15);ratio 顶层;quality 可选;async:true 走异步轮询 const body = { prompt: (params.prompt || "").trim(), async: true }; if (model) body.model = model; // 文档允许省略,交给上游使用默认 video-v1 const dur = parseInt(params.seconds, 10); if ([5, 10, 15].includes(dur)) body.duration = dur; // -1/空/auto → 省略,用中转默认(10) if (params.ratio && params.ratio !== "auto") body.ratio = params.ratio; if (params.quality && params.quality !== "auto") body.quality = params.quality; try { // 参考图/素材:本中转支持 image(单图) / images(多图) / ref_assets;base64 先传图床换公网 URL if (mode === "first") { const img = await resolveMat(params.imageUrl || params.image); if (!img) return { ok: false, error: "首帧图生视频需要首帧图:连上游文生图 / 上传 / 手填公网URL" }; body.image = img; } else if (mode === "firstlast") { // 本中转文档无「首尾帧」字段,退化为多图参考 images:[首,尾] const first = await resolveMat(params.image); const last = await resolveMat(params.lastFrameImage); const arr = [first, last].filter(Boolean); if (arr.length < 2) return { ok: false, error: "首尾帧模式需首帧和尾帧两张图(本中转会作多图参考处理)" }; body.images = arr; } else if (mode === "reference") { const refs = Array.isArray(params.referenceImages) ? params.referenceImages : []; const resolved = (await Promise.all(refs.map((r) => resolveMat(r)))).filter(Boolean); if (!resolved.length) return { ok: false, error: "多参考图模式需至少 1 张参考图" }; body[params.referenceField === "ref_assets" ? "ref_assets" : "images"] = resolved; } else if (mode === "video") { return { ok: false, error: "该中转不支持「参考视频」模式,请改用 文生 / 首帧 / 多参考图" }; } // 文/图生视频 prompt 都必填(文档要求) if (!body.prompt) return { ok: false, error: "请填写提示词(本接口文生/图生视频都需要 prompt)" }; // —— 建任务 —— const debugBase = { base: hostOf(base), create_url: createUrl, mode, request: { model: body.model || "", duration: body.duration || "", ratio: body.ratio || "", quality: body.quality || "", async: body.async, has_image: typeof body.image === "string" && /^https?:\/\//i.test(body.image), image_count: Array.isArray(body.images) ? body.images.length : 0, ref_asset_count: Array.isArray(body.ref_assets) ? body.ref_assets.length : 0, }, }; seedanceDebugLog({ type: "seedance_video_create", ...debugBase }); const cres = await fetch(createUrl, { method: "POST", headers: headers(llm.key), body: JSON.stringify(body), }); if (!cres.ok) { const detail = await cres.text().catch(() => ""); seedanceDebugLog({ type: "seedance_video_create_failed", ...debugBase, status: cres.status, detail: detail.slice(0, 500) }); return { ok: false, error: `视频建任务失败(${cres.status}):POST ${createUrl} model=${body.model || "<默认>"} → ${detail.slice(0, 300)}` }; } let data; const rawCreate = await cres.text().catch(() => ""); try { data = JSON.parse(rawCreate); } catch { seedanceDebugLog({ type: "seedance_video_create_non_json", ...debugBase, detail: rawCreate.slice(0, 500) }); return { ok: false, error: `视频建任务返回非 JSON:${rawCreate.slice(0, 300)}` }; } const taskId = pickTaskId(data); if (!taskId) { seedanceDebugLog({ type: "seedance_video_missing_task_id", ...debugBase, response: JSON.stringify(data).slice(0, 500) }); return { ok: false, error: `视频建任务未返回任务ID:${JSON.stringify(data).slice(0, 300)}` }; } seedanceDebugLog({ type: "seedance_video_accepted", ...debugBase, task_id: taskId }); // —— 轮询:每 5s,最多 120 次(~10min),对齐 提示词提取 —— // poll 是 GET:只带 Authorization(不要带 Content-Type,个别网关对「GET 携带 body 类头」会返回非 2xx 而被误跳过) const pollUrl = createUrl + "/" + encodeURIComponent(taskId); const pollHeaders = { Authorization: "Bearer " + (llm.key || ""), "User-Agent": "codex-market" }; let lastPoll = ""; // 记录最近一次轮询的异常/状态,超时时回传便于排查 for (let i = 0; i < 120; i++) { await sleep(5000); let pd; try { const pr = await fetch(pollUrl, { headers: pollHeaders }); if (!pr.ok) { lastPoll = `HTTP ${pr.status}: ` + (await pr.text().catch(() => "")).slice(0, 200); continue; } pd = await pr.json(); } catch (e) { lastPoll = "请求异常:" + String((e && e.message) || e); continue; } const d = pd && pd.data && typeof pd.data === "object" && !Array.isArray(pd.data) ? pd.data : pd; const st = normalizeVideoStatus((d && (d.status || d.state)) || (pd && pd.status)); lastPoll = "status=" + st; if (st === "succeeded") { const url = findVideoUrl(pd); if (!url) return { ok: false, error: `视频任务完成但未找到视频地址:${JSON.stringify(pd).slice(0, 300)}` }; let b64; try { b64 = await downloadAsBase64(url, trustedDownloadHeaders(url, llm)); } catch (e) { seedanceDebugLog({ type: "seedance_video_download_failed", ...debugBase, task_id: taskId, status: st, url, error: String((e && e.message) || e) }); throw e; } seedanceDebugLog({ type: "seedance_video_done", ...debugBase, task_id: taskId, status: st, url }); return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model }; } if (st === "failed") { const err = (d && d.error && (d.error.message || d.error)) || (d && (d.fail_reason || d.message || d.reason)) || (pd && pd.message) || st; const raw = JSON.stringify(pd).slice(0, 500); seedanceDebugLog({ type: "seedance_video_failed", ...debugBase, task_id: taskId, status: st, response: raw }); return { ok: false, error: "视频任务失败:" + (typeof err === "string" ? err : JSON.stringify(err)) + "\n上游原始返回:" + raw }; } // queued/running → 继续等 } seedanceDebugLog({ type: "seedance_video_timeout", ...debugBase, task_id: taskId, last_poll: lastPoll || "" }); return { ok: false, error: "视频轮询 10 分钟未完成(taskId=" + taskId + ",最近一次轮询:" + (lastPoll || "无") + ")" }; } catch (e) { return { ok: false, error: "视频生成异常:" + String((e && e.message) || e) }; } } // —— 旧 Grok 专用适配(保留作历史兼容,当前画布由 runClmmVideoV2 统一提交)—— async function runGrokVideoLegacy(base, llm, params) { let model = params.model || "grok-video-1.5"; const prompt = (params.prompt || "").trim(); if (!prompt) return { ok: false, error: "请填写 Grok 视频提示词" }; const seconds = String(params.seconds || "6").trim(); const body = { model, prompt, seconds, size: grokSizeFromParams(params) }; let mode = String(params.mode || "text"); if (mode === "image") mode = "first"; try { if ( mode === "text" && /grok-(?:imagine-)?video-1\.5/i.test(model) && body.size === "1920x1080" && Number(body.seconds) === 15 ) { return { ok: false, error: "1.5 的 1080p/15s 需要首帧图;请改用首帧图生视频,或降低文生视频规格" }; } if (mode === "first") { const img = await resolveGrokImage(params.imageUrl || params.image); if (!img) return { ok: false, error: "Grok 图生视频需要首帧图:上传 / 手填公网URL / 连接上游文生图" }; body.image = { url: img }; } else if (mode === "reference") { const refs = Array.isArray(params.referenceImages) ? params.referenceImages : []; const resolved = (await Promise.all(refs.map((r) => resolveMat(r)))).filter(Boolean); if (!resolved.length) return { ok: false, error: "Grok 多参考图模式请至少提供 1 张参考图" }; model = "grok-imagine-video"; body.model = model; delete body.size; body.resolution = "720p"; delete body.seconds; const dur = parseInt(params.seconds, 10); if (!isNaN(dur) && dur > 0) body.duration = dur; if (!body.aspect_ratio) body.aspect_ratio = params.aspectRatio || "16:9"; body.reference_images = resolved.map((url) => ({ url })); } else if (mode !== "text") { return { ok: false, error: "Grok 视频新增接口只支持「文生视频」「首帧图生视频」和「多参考图」" }; } const createUrl = joinUrl(base, params.endpointPath || "/v1/videos/generations"); const debugBase = { base: hostOf(base), create_url: createUrl, mode, request: { model: body.model, prompt: body.prompt, seconds: body.seconds || "", size: body.size || "", resolution: body.resolution || "", duration: body.duration || "", aspect_ratio: body.aspect_ratio || "", has_image: !!body.image, image_is_url: !!(body.image && typeof body.image === "object" && typeof body.image.url === "string" && /^https?:\/\//i.test(body.image.url)), reference_image_count: Array.isArray(body.reference_images) ? body.reference_images.length : 0, }, }; grokDebugLog({ type: "grok_video_create", ...debugBase }); const cres = await fetch(createUrl, { method: "POST", headers: headers(llm.key), body: JSON.stringify(body), }); if (!cres.ok) { const detail = await cres.text().catch(() => ""); grokDebugLog({ type: "grok_video_create_failed", ...debugBase, status: cres.status, detail: detail.slice(0, 300) }); return { ok: false, error: `Grok 视频建任务失败(${cres.status}):POST ${createUrl} → ${detail.slice(0, 300)}` }; } let data; let rawCreate = ""; try { rawCreate = await cres.text(); data = JSON.parse(rawCreate); } catch { return { ok: false, error: `Grok 视频建任务返回非 JSON:${rawCreate.slice(0, 300)}` }; } const requestId = pickTaskId(data); if (!requestId) return { ok: false, error: `Grok 视频建任务未返回 request_id:${JSON.stringify(data).slice(0, 300)}` }; grokDebugLog({ type: "grok_video_accepted", ...debugBase, request_id: requestId }); const pollUrl = joinUrl(base, "/v1/videos/" + encodeURIComponent(requestId)); const pollHeaders = { Authorization: "Bearer " + (llm.key || ""), Accept: "application/json", "User-Agent": "codex-market" }; let lastPoll = ""; const recentPolls = []; for (let i = 0; i < 120; i++) { await sleep(5000); let pd; try { const pr = await fetch(pollUrl, { headers: pollHeaders }); if (!pr.ok) { lastPoll = `HTTP ${pr.status}: ` + (await pr.text().catch(() => "")).slice(0, 200); continue; } pd = await pr.json(); } catch (e) { lastPoll = "请求异常:" + String((e && e.message) || e); continue; } pushRecentPoll(recentPolls, JSON.stringify(pd)); const d = pd && pd.data && typeof pd.data === "object" && !Array.isArray(pd.data) ? pd.data : pd; const st = normalizeVideoStatus((d && (d.status || d.state)) || (pd && pd.status)); lastPoll = "status=" + st; if (st === "succeeded") { const url = findVideoUrl(pd); if (!url) { lastPoll = "status=succeeded,但暂未返回视频地址"; continue; } let b64; try { b64 = await downloadAsBase64(url, trustedDownloadHeaders(url, llm)); } catch (e) { grokDebugLog({ type: "grok_video_download_failed", ...debugBase, request_id: requestId, status: st, url, error: String((e && e.message) || e) }); throw e; } grokDebugLog({ type: "grok_video_done", ...debugBase, request_id: requestId, status: st, url }); return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model }; } if (st === "failed") { const err = (d && d.error && (d.error.message || d.error)) || (d && (d.fail_reason || d.message || d.reason)) || (pd && pd.message) || st; grokDebugLog({ type: "grok_video_failed", ...debugBase, request_id: requestId, status: st, error: typeof err === "string" ? err : JSON.stringify(err) }); return { ok: false, error: "Grok 视频任务失败:" + (typeof err === "string" ? err : JSON.stringify(err)) }; } } return { ok: false, error: "Grok 视频轮询 10 分钟未完成(GET " + pollUrl + ",request_id=" + requestId + ",最近一次轮询:" + (lastPoll || "无") + (recentPolls.length ? ",最近原始返回:" + recentPolls.join(" | ") : "") + ")", }; } catch (e) { return { ok: false, error: "Grok 视频生成异常:" + String((e && e.message) || e) }; } } // —— Seedance V2(Binghuo 原生):POST /v1/video/generations + GET /v1/video/generations/:task_id —— // 对应 sub2api platform=seedance_v2,与 CLMM /v1/videos 契约分离,避免字段被改写。 function seedanceV2EndpointPath(endpointPath) { const path = String(endpointPath || "").trim(); if (!path) return "/v1/video/generations"; // 若用户仍填 CLMM 路径,也归一到 V2 原生路径(sub2api 两边都能进同一 handler,但原生更稳) if (/\/v1\/videos(?:\/generations)?$/i.test(path) || /\/videos(?:\/generations)?$/i.test(path)) { return "/v1/video/generations"; } return path; } function seedanceV2DurationSeconds(params, model) { const raw = Number.parseInt(String((params && params.seconds) || ""), 10); if (Number.isInteger(raw) && raw > 0) return raw; const fixed = String(model || "").match(/(?:^|[-_])(\d+)s(?:$|[-_])/i); if (fixed) return Number(fixed[1]) || 5; return 5; } async function runSeedanceV2(base, llm, params) { const model = String(params.model || "").trim(); const prompt = String(params.prompt || "").trim(); if (!model) return { ok: false, error: "Seedance V2 必须选择模型完整名称" }; if (!prompt) return { ok: false, error: "Seedance V2 必须填写提示词" }; let mode = String(params.mode || "text"); if (mode === "image") mode = "first"; const body = { model, prompt, duration: seedanceV2DurationSeconds(params, model), ratio: String(params.ratio || params.aspectRatio || "16:9"), resolution: clmmResolutionFromParamsV2(params), }; try { if (mode === "first") { const image = await resolveMat(params.imageUrl || params.image); if (!image) return { ok: false, error: "Seedance V2 首帧图生视频需要首帧图" }; body.start_frame = [image]; } else if (mode === "firstlast") { const first = await resolveMat(params.imageUrl || params.image); const last = await resolveMat(params.lastFrameImage); if (!first || !last) return { ok: false, error: "首尾帧模式需要首帧和尾帧两张图片" }; body.start_frame = [first]; body.end_frame = [last]; } else if (mode === "reference") { const materials = Array.isArray(params.referenceImages) ? params.referenceImages : []; const refs = (await Promise.all(materials.map((item) => resolveMat(item)))).filter(Boolean); if (!refs.length) return { ok: false, error: "多参考图模式至少需要 1 张参考图" }; body.images = refs; } else if (mode !== "text") { return { ok: false, error: "Seedance V2 暂不支持参考视频模式,请改用 文生 / 首帧 / 多参考图" }; } const createUrl = joinUrl(base, seedanceV2EndpointPath(params.endpointPath)); const debugBase = { base: hostOf(base), create_url: createUrl, mode, request: { model: body.model, duration: body.duration, ratio: body.ratio, resolution: body.resolution, image_count: Array.isArray(body.images) ? body.images.length : 0, has_start_frame: Array.isArray(body.start_frame) && body.start_frame.length > 0, has_end_frame: Array.isArray(body.end_frame) && body.end_frame.length > 0, }, }; seedanceDebugLog({ type: "seedance_v2_video_create", ...debugBase }); const cres = await fetch(createUrl, { method: "POST", headers: headers(llm.key), body: JSON.stringify(body), }); const rawCreate = await cres.text().catch(() => ""); if (!cres.ok) { seedanceDebugLog({ type: "seedance_v2_video_create_failed", ...debugBase, status: cres.status, detail: rawCreate.slice(0, 500) }); return { ok: false, error: `Seedance V2 建任务失败(${cres.status}):POST ${createUrl} → ${rawCreate.slice(0, 300)}` }; } let data; try { data = JSON.parse(rawCreate); } catch { seedanceDebugLog({ type: "seedance_v2_video_create_non_json", ...debugBase, detail: rawCreate.slice(0, 500) }); return { ok: false, error: `Seedance V2 建任务返回非 JSON:${rawCreate.slice(0, 300)}` }; } const taskId = pickTaskId(data); if (!taskId) { seedanceDebugLog({ type: "seedance_v2_video_missing_task_id", ...debugBase, response: JSON.stringify(data).slice(0, 500) }); return { ok: false, error: `Seedance V2 建任务未返回 task_id:${JSON.stringify(data).slice(0, 300)}` }; } seedanceDebugLog({ type: "seedance_v2_video_accepted", ...debugBase, task_id: taskId }); // 原生路径:/v1/video/generations/:task_id // 若 createUrl 是 /v1/videos,则沿 createUrl/:id 轮询(sub2api 也会路由到 V2 status) const pollUrl = /\/generations$/i.test(createUrl.replace(/\/$/, "")) ? createUrl.replace(/\/$/, "") + "/" + encodeURIComponent(taskId) : createUrl.replace(/\/$/, "") + "/" + encodeURIComponent(taskId); const pollHeaders = { Authorization: "Bearer " + (llm.key || ""), Accept: "application/json", "User-Agent": "codex-market" }; let lastPoll = ""; for (let i = 0; i < 300; i++) { await sleep(i === 0 ? 1000 : 3000); try { const pr = await fetch(pollUrl, { headers: pollHeaders }); const rawPoll = await pr.text().catch(() => ""); if (!pr.ok) { lastPoll = `HTTP ${pr.status}: ${rawPoll.slice(0, 200)}`; continue; } const pd = JSON.parse(rawPoll); const d = pd && pd.data && typeof pd.data === "object" && !Array.isArray(pd.data) ? pd.data : pd; const st = normalizeVideoStatus((d && (d.status || d.state)) || (pd && pd.status)); lastPoll = "status=" + st; if (st === "succeeded") { const url = clmmVideoUrlV2(pd) || clmmVideoUrlV2(d); if (!url) return { ok: false, error: `Seedance V2 任务完成但未找到视频地址:${JSON.stringify(pd).slice(0, 300)}` }; const b64 = await downloadAsBase64(url, trustedDownloadHeaders(url, llm)); seedanceDebugLog({ type: "seedance_v2_video_done", ...debugBase, task_id: taskId, url }); return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model }; } if (st === "failed") { const err = (d && d.error && (d.error.message || d.error)) || (d && (d.fail_reason || d.message || d.reason)) || (pd && pd.message) || st; seedanceDebugLog({ type: "seedance_v2_video_failed", ...debugBase, task_id: taskId, status: st, response: rawPoll.slice(0, 500) }); return { ok: false, error: `Seedance V2 任务失败:${typeof err === "string" ? err : JSON.stringify(err)}` }; } } catch (e) { lastPoll = "请求异常:" + String((e && e.message) || e); } } seedanceDebugLog({ type: "seedance_v2_video_timeout", ...debugBase, task_id: taskId, last_poll: lastPoll || "" }); return { ok: false, error: `Seedance V2 轮询 15 分钟未完成(task_id=${taskId},最近一次:${lastPoll || "无"})` }; } catch (e) { return { ok: false, error: "Seedance V2 视频生成异常:" + String((e && e.message) || e) }; } } // —— CLMM 视频统一适配:Seedance/Grok 节点都通过中转的 /videos 契约提交 —— // 文档契约:POST /videos,字段使用 aspect_ratio/resolution/size/seconds/mySeconds/reference_image_urls。 function clmmResolutionFromParamsV2(params) { const value = String(params.resolution || params.vsize || "720p").trim(); // 2K/4K 由中转和具体模型决定是否可用;客户端只负责保留并透传。 return /^(480|720|1080)p$/i.test(value) || /^(2|4)k$/i.test(value) ? value.toLowerCase() : "720p"; } function clmmSizeFromParamsV2(params, resolution) { const ratio = String(params.aspectRatio || params.ratio || "16:9").trim(); const match = ratio.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/); // 采用常见视频尺寸:2K=2560x1440,4K=3840x2160(横屏基准短边)。 const shortSide = /4k/i.test(resolution) ? 2160 : /2k/i.test(resolution) ? 1440 : /1080/i.test(resolution) ? 1080 : /480/i.test(resolution) ? 480 : 720; if (!match) return `${Math.round(shortSide * 16 / 9)}x${shortSide}`; const rw = Number(match[1]); const rh = Number(match[2]); if (!rw || !rh) return `${Math.round(shortSide * 16 / 9)}x${shortSide}`; if (rw === rh) return `${shortSide}x${shortSide}`; if (rw > rh) return `${Math.round(shortSide * rw / rh)}x${shortSide}`; return `${shortSide}x${Math.round(shortSide * rh / rw)}`; } function clmmSecondsPayloadV2(model, requestedSeconds) { const requested = Number.isInteger(requestedSeconds) && requestedSeconds > 0 ? requestedSeconds : 10; const durationMatch = String(model || "").match(/-(\d+)s(?:-|$)/i); if (!durationMatch) return { seconds: String(requested) }; const maxSeconds = Number(durationMatch[1]); if (!maxSeconds) return { seconds: String(requested) }; if (/-gz(?:-|$)/i.test(model)) return { seconds: "1", mySeconds: String(maxSeconds) }; if (requested > maxSeconds) return { error: `模型 ${model} 最长 ${maxSeconds}s,当前选择 ${requested}s` }; return { seconds: "1", mySeconds: String(requested) }; } function clmmVideoUrlV2(payload) { if (!payload || typeof payload !== "object") return ""; const direct = [payload.video_url, payload.url, payload.result_url].find((v) => typeof v === "string" && /^https?:\/\//i.test(v)); if (direct) return direct; if (payload.metadata && typeof payload.metadata === "object" && /^https?:\/\//i.test(String(payload.metadata.url || ""))) { return payload.metadata.url; } return findVideoUrl(payload); } function clmmEndpointPathV2(endpointPath) { const path = String(endpointPath || "").trim(); if (!path || /\/v1\/video\/generations$|\/v1\/videos\/generations$/i.test(path)) return "/v1/videos"; return path; } async function runClmmVideoV2(base, llm, params, debugLog, label) { const model = String(params.model || "").trim(); const prompt = String(params.prompt || "").trim(); if (!model) return { ok: false, error: `${label} 视频必须填写 CLMM 模型完整名称` }; if (!prompt) return { ok: false, error: "请填写视频提示词(CLMM 接口要求 prompt)" }; let mode = String(params.mode || "text"); if (mode === "image") mode = "first"; const resolution = clmmResolutionFromParamsV2(params); const requestedSeconds = Number.parseInt(params.seconds, 10); const duration = clmmSecondsPayloadV2(model, requestedSeconds); if (duration.error) return { ok: false, error: duration.error }; const body = { model, prompt, aspect_ratio: String(params.aspectRatio || params.ratio || "16:9"), resolution, size: clmmSizeFromParamsV2(params, resolution), seconds: duration.seconds, }; if (duration.mySeconds) body.mySeconds = duration.mySeconds; try { let refs = []; if (mode === "first") { const image = await resolveMat(params.imageUrl || params.image); if (!image) return { ok: false, error: `${label} 首帧图生视频需要首帧图` }; refs = [image]; } else if (mode === "firstlast") { const first = await resolveMat(params.imageUrl || params.image); const last = await resolveMat(params.lastFrameImage); refs = [first, last].filter(Boolean); if (refs.length < 2) return { ok: false, error: "首尾帧模式需要首帧和尾帧两张图片" }; } else if (mode === "reference") { const materials = Array.isArray(params.referenceImages) ? params.referenceImages : []; refs = (await Promise.all(materials.map((item) => resolveMat(item)))).filter(Boolean); if (!refs.length) return { ok: false, error: "多参考图模式至少需要 1 张参考图" }; } else if (mode !== "text") { return { ok: false, error: "CLMM 视频接口不支持参考视频模式" }; } body.reference_image_urls = refs; const createUrl = joinUrl(base, clmmEndpointPathV2(params.endpointPath)); const debugBase = { base: hostOf(base), create_url: createUrl, mode, request: { model: body.model, aspect_ratio: body.aspect_ratio, resolution: body.resolution, size: body.size, seconds: body.seconds, mySeconds: body.mySeconds || "", reference_image_count: refs.length, }, }; debugLog({ type: `${label.toLowerCase()}_clmm_video_create`, ...debugBase }); const cres = await fetch(createUrl, { method: "POST", headers: headers(llm.key), body: JSON.stringify(body), }); const rawCreate = await cres.text().catch(() => ""); if (!cres.ok) { debugLog({ type: `${label.toLowerCase()}_clmm_video_create_failed`, ...debugBase, status: cres.status, detail: rawCreate.slice(0, 500) }); return { ok: false, error: `${label} 视频建任务失败(${cres.status}):POST ${createUrl} → ${rawCreate.slice(0, 300)}` }; } let data; try { data = JSON.parse(rawCreate); } catch { return { ok: false, error: `${label} 视频建任务返回非 JSON:${rawCreate.slice(0, 300)}` }; } const taskId = pickTaskId(data); if (!taskId) return { ok: false, error: `${label} 视频建任务未返回 task_id:${JSON.stringify(data).slice(0, 300)}` }; debugLog({ type: `${label.toLowerCase()}_clmm_video_accepted`, ...debugBase, task_id: taskId }); const pollUrl = createUrl.replace(/\/$/, "") + "/" + encodeURIComponent(taskId); const pollHeaders = { Authorization: "Bearer " + (llm.key || ""), Accept: "application/json", "User-Agent": "codex-market" }; let lastPoll = ""; for (let i = 0; i < 300; i++) { await sleep(i === 0 ? 1000 : 3000); try { const pr = await fetch(pollUrl, { headers: pollHeaders }); const rawPoll = await pr.text().catch(() => ""); if (!pr.ok) { lastPoll = `HTTP ${pr.status}: ${rawPoll.slice(0, 200)}`; continue; } const pd = JSON.parse(rawPoll); const d = pd && pd.data && typeof pd.data === "object" && !Array.isArray(pd.data) ? pd.data : pd; const st = normalizeVideoStatus((d && (d.status || d.state)) || (pd && pd.status)); lastPoll = "status=" + st; if (st === "succeeded") { const url = clmmVideoUrlV2(pd); if (!url) return { ok: false, error: `${label} 视频任务完成但未找到视频地址:${JSON.stringify(pd).slice(0, 300)}` }; const b64 = await downloadAsBase64(url, trustedDownloadHeaders(url, llm)); debugLog({ type: `${label.toLowerCase()}_clmm_video_done`, ...debugBase, task_id: taskId, url }); return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model }; } if (st === "failed") { const err = (d && d.error && (d.error.message || d.error)) || (d && (d.fail_reason || d.message || d.reason)) || (pd && pd.message) || st; return { ok: false, error: `${label} 视频任务失败:${typeof err === "string" ? err : JSON.stringify(err)}` }; } } catch (e) { lastPoll = "请求异常:" + String((e && e.message) || e); } } return { ok: false, error: `${label} 视频轮询 15 分钟未完成(task_id=${taskId},最近一次:${lastPoll || "无"})` }; } catch (e) { return { ok: false, error: `${label} 视频生成异常:` + String((e && e.message) || e) }; } } // 统一执行入口:dispatch 到 image/video/tts,返回 { ok, mime, dataUrl, meta } 或 { ok:false, error }。 // onPartial:可选,仅 image 流式生成时用来回传渐进帧(undefined 时安全忽略)。 async function run({ kind, params }, onPartial) { const p = params || {}; // 解析渠道:指定 groupId 则按 id 取该分组,否则回退当前可用 llmGroup const g = p.groupId ? router.groupById(p.groupId) : router.llmGroup(); if (!g) return { ok: false, error: "未配置可用中转分组" }; if (!g.baseUrl || !g.key) return { ok: false, error: "所选分组缺少地址或密钥" }; const base = trimSlash(g.baseUrl); const host = hostOf(g.baseUrl); const t0 = Date.now(); try { let r; if (kind === "image") r = await runImage(base, g, p, onPartial); else if (kind === "tts") r = await runTts(base, g, p); else if (kind === "video") { // Seedance V2 分组走 Binghuo 原生;旧 Seedance/CLMM 分组继续走 /v1/videos const forceV2 = isSeedanceV2Model(p.model) || /seedance[\s_-]*v\s*2|seedancev2|\/v1\/video\/generations/i.test( String(p.endpointPath || "") + " " + String(g.name || "") + " " + String(g.model || "") ); if (forceV2 || isSeedanceV2Group(g)) r = await runSeedanceV2(base, g, p); else r = await runClmmVideoV2(base, g, p, seedanceDebugLog, "Seedance"); } else if (kind === "grokVideo") r = await runClmmVideoV2(base, g, p, grokDebugLog, "Grok"); else return { ok: false, error: "未知的画布任务类型:" + kind }; const model = (r && r._model) || p.model || g.model; if (r && r._model !== undefined) delete r._model; if (r && r.ok) r.meta = { model, ms: Date.now() - t0, host }; return r; } catch (e) { return { ok: false, error: String((e && e.message) || e), meta: { model: p.model || g.model, ms: Date.now() - t0, host } }; } } // 列出全部可用中转分组(供前端每个节点的「分组」下拉):返回 activeId 与精简分组信息。 function groups() { const list = router.usableGroups(); if (!list || !list.length) return { ok: false, error: "未配置可用中转分组" }; const st = router.status(); const activeId = (st && st.activeId) || (router.llmGroup() || {}).id || null; return { ok: true, activeId, groups: list.map((g) => ({ id: g.id, name: g.name || "", model: g.model || "", models: modelCandidatesForGroup(g), host: hostOf(g.baseUrl), codexImage: !!g.codexImage, codexVideo: !!g.codexVideo, seedanceV2: isSeedanceV2Group(g), seedance: isSeedanceGroup(g), grok: isGrokGroup(g), })), }; } // 拉取指定/当前中转分组可用的模型列表(复用 router.fetchModels),供前端填充模型下拉/datalist。 async function listModels(groupId) { const g = groupId ? router.groupById(groupId) : router.llmGroup(); if (!g) return { ok: false, error: "未配置可用中转分组" }; if (!g.baseUrl || !g.key) return { ok: false, error: "所选分组缺少地址或密钥" }; try { const fetched = await router.fetchModels(g.baseUrl, g.key).catch(() => []); const models = uniqStrings([...fetched, ...modelCandidatesForGroup(g)]); if (!models.length) { return { ok: false, error: "该分组未返回可用模型,也没有配置默认模型" }; } return { ok: true, models }; } catch (e) { const models = uniqStrings(modelCandidatesForGroup(g)); if (models.length) return { ok: true, models, warning: String((e && e.message) || e) }; return { ok: false, error: String((e && e.message) || e) }; } } module.exports = { status, groups, run, listModels, downloadAsBase64, clmmResolutionFromParamsV2, clmmSizeFromParamsV2, };