Files
codex-market-app/electron/lib/canvas.js
T

872 lines
37 KiB
JavaScript
Raw Normal View History

2026-07-15 18:01:56 +08:00
// 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");
2026-07-15 18:01:56 +08:00
// —— 从 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);
});
}
2026-07-15 18:01:56 +08:00
// —— 下载任意远程资源 → 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);
}
2026-07-15 18:01:56 +08:00
}
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 {};
}
2026-07-15 18:01:56 +08:00
// —— 组装 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 <token>,
// 返回体公网直链在 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 "";
}
2026-07-15 18:01:56 +08:00
function findVideoUrl(o) {
const likely = findLikelyVideoUrl(o);
if (likely) return likely;
if (!o) return "";
if (typeof o === "string") return "";
2026-07-15 18:01:56 +08:00
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"]) {
2026-07-15 18:01:56 +08:00
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;
2026-07-15 18:01:56 +08:00
}
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)}`;
}
2026-07-15 18:01:56 +08:00
// —— Seedance/TokenDun 视频:异步任务制(建任务 → 轮询 → 下载成品转 base64)——
// mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 可省略,使用上游默认。
2026-07-15 18:01:56 +08:00
// 图生视频素材两条来路: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");
2026-07-15 18:01:56 +08:00
// 输入模式: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
2026-07-15 18:01:56 +08:00
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;
2026-07-15 18:01:56 +08:00
try {
// 参考图/素材:本中转支持 image(单图) / images(多图) / ref_assets;base64 先传图床换公网 URL
2026-07-15 18:01:56 +08:00
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;
2026-07-15 18:01:56 +08:00
} 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 || "<upstream-default>",
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 });
2026-07-15 18:01:56 +08:00
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)}` };
2026-07-15 18:01:56 +08:00
}
let data;
const rawCreate = await cres.text().catch(() => "");
2026-07-15 18:01:56 +08:00
try {
data = JSON.parse(rawCreate);
2026-07-15 18:01:56 +08:00
} catch {
seedanceDebugLog({ type: "seedance_video_create_non_json", ...debugBase, detail: rawCreate.slice(0, 500) });
return { ok: false, error: `视频建任务返回非 JSON:${rawCreate.slice(0, 300)}` };
2026-07-15 18:01:56 +08:00
}
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 });
2026-07-15 18:01:56 +08:00
// —— 轮询:每 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 });
2026-07-15 18:01:56 +08:00
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 });
2026-07-15 18:01:56 +08:00
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 || "" });
2026-07-15 18:01:56 +08:00
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";
2026-07-15 18:01:56 +08:00
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) };
2026-07-15 18:01:56 +08:00
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
2026-07-15 18:01:56 +08:00
) {
return { ok: false, error: "1.5 的 1080p/15s 需要首帧图;请改用首帧图生视频,或降低文生视频规格" };
}
if (mode === "first") {
const img = await resolveGrokImage(params.imageUrl || params.image);
2026-07-15 18:01:56 +08:00
if (!img) return { ok: false, error: "Grok 图生视频需要首帧图:上传 / 手填公网URL / 连接上游文生图" };
body.image = { url: img };
2026-07-15 19:09:45 +08:00
} 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;
2026-07-15 19:09:45 +08:00
body.resolution = "720p";
delete body.seconds;
const dur = parseInt(params.seconds, 10);
if (!isNaN(dur) && dur > 0) body.duration = dur;
2026-07-15 19:09:45 +08:00
if (!body.aspect_ratio) body.aspect_ratio = params.aspectRatio || "16:9";
body.reference_images = resolved.map((url) => ({ url }));
2026-07-15 18:01:56 +08:00
} else if (mode !== "text") {
2026-07-15 19:09:45 +08:00
return { ok: false, error: "Grok 视频新增接口只支持「文生视频」「首帧图生视频」和「多参考图」" };
2026-07-15 18:01:56 +08:00
}
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 });
2026-07-15 18:01:56 +08:00
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)}` };
2026-07-15 18:01:56 +08:00
}
const requestId = pickTaskId(data);
2026-07-15 18:01:56 +08:00
if (!requestId) return { ok: false, error: `Grok 视频建任务未返回 request_id:${JSON.stringify(data).slice(0, 300)}` };
grokDebugLog({ type: "grok_video_accepted", ...debugBase, request_id: requestId });
2026-07-15 18:01:56 +08:00
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 = [];
2026-07-15 18:01:56 +08:00
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));
2026-07-15 18:01:56 +08:00
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 });
2026-07-15 18:01:56 +08:00
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) });
2026-07-15 18:01:56 +08:00
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(" | ") : "") +
")",
};
2026-07-15 18:01:56 +08:00
} 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 };