feat: publish CLMM video hot update v1.5.24

This commit is contained in:
mac
2026-07-20 19:29:39 +08:00
parent aadd2b04ae
commit fd7d26f7d7
4 changed files with 269 additions and 63 deletions
+206 -8
View File
@@ -18,6 +18,27 @@ function hostOf(baseUrl) {
}
}
function uniqStrings(list) {
return Array.from(new Set((list || []).map((v) => String(v || "").trim()).filter(Boolean)));
}
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);
const name = String(g.name || "").toLowerCase();
const model = String(g.model || "").toLowerCase();
if (/grok/.test(name) || /grok/.test(model)) {
out.push("grok-video-1.5", "grok-imagine-video-1.5", "grok-imagine-video");
}
if (/seedance/.test(name) || /seedance/.test(model)) {
out.push("seedance2.0", "seedance-2.0");
}
return uniqStrings(out);
}
// —— 去尾斜杠 ——
function trimSlash(u) {
return (u || "").replace(/\/$/, "");
@@ -537,11 +558,11 @@ function grokSizeFromParams(params) {
return `${shortSide}x${roundEven((shortSide * rh) / rw)}`;
}
// —— Seedance/TokenDun 视频:异步任务制(建任务 → 轮询 → 下载成品转 base64)——
// —— 旧 Seedance 专用适配(保留作历史兼容,当前画布由 runClmmVideoV2 统一提交)——
// mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 可省略,使用上游默认。
// 图生视频素材两条来路:params.imageUrl(手填公网URL,优先) 或 params.image(上游 base64)。
// base64 会先经 uploadMedia() 换成公网直链再发上游(Seedance 绝不接受 base64)。
async function runVideo(base, llm, params) {
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
@@ -672,8 +693,8 @@ async function runVideo(base, llm, params) {
}
}
// —— Grok 视频:独立新增接口,按 /v1/videos/generations + /v1/videos/{request_id} 调用 ——
async function runGrokVideo(base, llm, params) {
// —— 旧 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 视频提示词" };
@@ -815,6 +836,171 @@ async function runGrokVideo(base, llm, params) {
}
}
// —— 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();
return /^(480|720|1080)p$/i.test(value) ? value : "720p";
}
function clmmSizeFromParamsV2(params, resolution) {
const ratio = String(params.aspectRatio || params.ratio || "16:9").trim();
const match = ratio.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/);
const shortSide = /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) {
@@ -830,8 +1016,8 @@ async function run({ kind, params }, onPartial) {
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 if (kind === "video") 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;
@@ -851,7 +1037,13 @@ function groups() {
return {
ok: true,
activeId,
groups: list.map((g) => ({ id: g.id, name: g.name || "", model: g.model || "", host: hostOf(g.baseUrl) })),
groups: list.map((g) => ({
id: g.id,
name: g.name || "",
model: g.model || "",
models: modelCandidatesForGroup(g),
host: hostOf(g.baseUrl),
})),
};
}
@@ -861,9 +1053,15 @@ async function listModels(groupId) {
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);
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) };
}
}