// 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 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 = 有可用 llmGroup。host 从 baseUrl 解析域名。 function status() { const llm = router.llmGroup(); 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/TokenDun 视频:异步任务制(建任务 → 轮询 → 下载成品转 base64)—— // mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 可省略,使用上游默认。 // 图生视频素材两条来路:params.imageUrl(手填公网URL,优先) 或 params.image(上游 base64)。 // base64 会先经 uploadMedia() 换成公网直链再发上游(Seedance 绝不接受 base64)。 async function runVideo(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 视频:独立新增接口,按 /v1/videos/generations + /v1/videos/{request_id} 调用 —— async function runGrokVideo(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) }; } } // 统一执行入口: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") r = await runVideo(base, g, p); else if (kind === "grokVideo") r = await runGrokVideo(base, g, p); 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 || "", host: hostOf(g.baseUrl) })), }; } // 拉取指定/当前中转分组可用的模型列表(复用 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 models = await router.fetchModels(g.baseUrl, g.key); return { ok: true, models }; } catch (e) { return { ok: false, error: String((e && e.message) || e) }; } } module.exports = { status, groups, run, listModels, downloadAsBase64 };