579 lines
26 KiB
JavaScript
579 lines
26 KiB
JavaScript
// canvas.js —— 创意画布主进程中转执行层:复用 router.llmGroup() 的当前中转分组,
|
||
// 统一在主进程发起 image/video/tts 请求并把成品下载转 base64 data URL 返回渲染层。
|
||
// key 只在主进程流转;渲染层被 CSP 拦截的远程 URL 一律由这里下载转 base64。
|
||
const router = require("./router");
|
||
|
||
// —— 从 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",
|
||
};
|
||
}
|
||
|
||
// —— 下载任意远程资源 → base64 字符串 ——
|
||
async function downloadAsBase64(url) {
|
||
const res = await fetch(url);
|
||
if (!res.ok) throw new Error("下载失败 HTTP " + res.status);
|
||
const buf = Buffer.from(await res.arrayBuffer());
|
||
return buf.toString("base64");
|
||
}
|
||
|
||
// —— 组装 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 findVideoUrl(o) {
|
||
if (!o || 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", "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) {
|
||
return trimSlash(base) + (String(p || "").startsWith("/") ? p : "/" + p);
|
||
}
|
||
|
||
// —— 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 = params.model;
|
||
if (!model) return { ok: false, error: "请先填写视频模型" };
|
||
const createUrl = 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 = { model, prompt: (params.prompt || "").trim(), async: true };
|
||
const dur = parseInt(params.seconds, 10);
|
||
if (!isNaN(dur) && dur > 0) body.duration = dur; // -1/空/auto → 省略,用中转默认(10)
|
||
if (params.ratio && params.ratio !== "auto") body.ratio = params.ratio; // 顶层 ratio
|
||
// 注:文档的 quality 字段示例值是 "hd",与 UI 的 720p/1080p 不同类,未确认合法值前不下发,避免 400
|
||
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.images = resolved;
|
||
} else if (mode === "video") {
|
||
return { ok: false, error: "该中转不支持「参考视频」模式,请改用 文生 / 首帧 / 多参考图" };
|
||
}
|
||
// 文/图生视频 prompt 都必填(文档要求)
|
||
if (!body.prompt) return { ok: false, error: "请填写提示词(本接口文生/图生视频都需要 prompt)" };
|
||
|
||
// —— 建任务 ——
|
||
const cres = await fetch(createUrl, {
|
||
method: "POST",
|
||
headers: headers(llm.key),
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!cres.ok) {
|
||
const detail = await cres.text().catch(() => "");
|
||
return { ok: false, error: `视频建任务失败(${cres.status}):${detail.slice(0, 300)}` };
|
||
}
|
||
let data;
|
||
try {
|
||
data = await cres.json();
|
||
} catch {
|
||
const detail = await cres.text().catch(() => "");
|
||
return { ok: false, error: `视频建任务返回非 JSON:${detail.slice(0, 300)}` };
|
||
}
|
||
const taskId =
|
||
(data.data && (data.data.task_id || data.data.id)) ||
|
||
data.task_id ||
|
||
data.id ||
|
||
(data.result && (data.result.task_id || data.result.id));
|
||
if (!taskId) return { ok: false, error: `视频建任务未返回任务ID:${JSON.stringify(data).slice(0, 300)}` };
|
||
|
||
// —— 轮询:每 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)}` };
|
||
const b64 = await downloadAsBase64(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);
|
||
return { ok: false, error: "视频任务失败:" + (typeof err === "string" ? err : JSON.stringify(err)) + "\n上游原始返回:" + raw };
|
||
}
|
||
// queued/running → 继续等
|
||
}
|
||
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-imagine-video";
|
||
const prompt = (params.prompt || "").trim();
|
||
if (!prompt) return { ok: false, error: "请填写 Grok 视频提示词" };
|
||
|
||
const body = { model, prompt };
|
||
if (params.resolution && params.resolution !== "auto") body.resolution = params.resolution;
|
||
const dur = parseInt(params.seconds, 10);
|
||
if (!isNaN(dur) && dur > 0) body.duration = dur;
|
||
|
||
let mode = String(params.mode || "text");
|
||
if (mode === "image") mode = "first";
|
||
try {
|
||
if (
|
||
mode === "text" &&
|
||
/grok-imagine-video-1\.5/i.test(model) &&
|
||
body.resolution === "1080p" &&
|
||
Number(body.duration) === 15
|
||
) {
|
||
return { ok: false, error: "1.5 的 1080p/15s 需要首帧图;请改用首帧图生视频,或降低文生视频规格" };
|
||
}
|
||
if (mode === "first") {
|
||
const img = await resolveMat(params.imageUrl || params.image);
|
||
if (!img) return { ok: false, error: "Grok 图生视频需要首帧图:上传 / 手填公网URL / 连接上游文生图" };
|
||
body.image = { 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;
|
||
body.resolution = "720p";
|
||
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 cres = await fetch(createUrl, {
|
||
method: "POST",
|
||
headers: headers(llm.key),
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!cres.ok) {
|
||
const detail = await cres.text().catch(() => "");
|
||
return { ok: false, error: `Grok 视频建任务失败(${cres.status}):${detail.slice(0, 300)}` };
|
||
}
|
||
const data = await cres.json();
|
||
const requestId = data.request_id || data.id || (data.data && (data.data.request_id || data.data.id));
|
||
if (!requestId) return { ok: false, error: `Grok 视频建任务未返回 request_id:${JSON.stringify(data).slice(0, 300)}` };
|
||
|
||
const pollUrl = joinUrl(base, "/v1/videos/" + encodeURIComponent(requestId));
|
||
const pollHeaders = { Authorization: "Bearer " + (llm.key || ""), Accept: "application/json", "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: `Grok 视频已完成但未找到视频地址:${JSON.stringify(pd).slice(0, 500)}` };
|
||
const b64 = await downloadAsBase64(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: "Grok 视频任务失败:" + (typeof err === "string" ? err : JSON.stringify(err)) };
|
||
}
|
||
}
|
||
return { ok: false, error: "Grok 视频轮询 10 分钟未完成(request_id=" + requestId + ",最近一次轮询:" + (lastPoll || "无") + ")" };
|
||
} 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 };
|