feat: publish CLMM video hot update v1.5.24
This commit is contained in:
+206
-8
@@ -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) };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user