feat: publish CLMM video hot update v1.5.24
This commit is contained in:
+12
-12
@@ -53,13 +53,13 @@ canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }),
|
||||
- **图生图参考图**:`image`(用户上传的 base64 dataURL)→ 主进程 `uploadMedia()` 传图床换公网直链 → 塞进请求体字段 `refField`(默认 `image`,前端高级项可改)。上传失败直接返回 `{ok:false,error}`,不静默降级。流式与非流式两条 body 都带该字段。
|
||||
- 错误信息里带上**实际请求的 URL 和 model**,方便排查(如 404 时提示 `POST {url} model={model} → 404`)。
|
||||
- **video** `{ mode, prompt, model?, image?, lastFrameImage?, referenceImages?[], ratio?, quality?, seconds?, referenceField?, endpointPath? }` —— **Seedance 2.0**,使用新中转契约。
|
||||
- 创建端点默认 `/v1/video/generations`;若分组 `baseUrl` 已以 `/v1` 结尾,拼接时不得产生重复的 `/v1/v1`。
|
||||
- 请求体是扁平结构:`prompt` 必填,固定 `async:true`;`duration` 仅发送整数 `5/10/15`;`ratio`、`quality` 非自动时才发送;`model` 可省略并使用上游默认。
|
||||
- `mode`:`"text"` 只发送文本 | `"first"` 发送 `image` | `"firstlast"` 将首尾两图作为 `images` | `"reference"` 按 `referenceField` 发送 `images` 或 `ref_assets`。旧值 `"image"` 归一为 `"first"`。
|
||||
- Seedance 节点通过 CLMM 聚合中转提交,创建端点默认 `/v1/videos`;若分组 `baseUrl` 已以 `/v1` 结尾,拼接时不得产生重复的 `/v1/v1`。
|
||||
- 请求体遵循 CLMM 文档:`model`、`prompt`、`aspect_ratio`、`resolution`、`size`、`seconds`、`mySeconds`、`reference_image_urls`。模型包含 `-Ns` 时使用 `seconds:"1"`;包含 `-gz` 时将 `mySeconds` 固定为模型声明的 N 秒。
|
||||
- `mode`:`"text"` 只发送空参考图数组 | `"first"` 发送一张 `reference_image_urls` | `"firstlast"` 发送首尾两张 | `"reference"` 发送多张参考图。旧值 `"image"` 归一为 `"first"`。
|
||||
- 图片经 `resolveMat` 处理:`data:` 先上传图床换为公网 URL,`http(s)` 原样发送;上游请求中禁止出现 base64。
|
||||
- 不发送旧版 `input_mode`、`metadata`、`resolution`、`generate_audio`、`watermark`、`camera_fixed`、`seed`、`reference_video` 字段。
|
||||
- 创建响应任务 ID 兼容 `request_id/requestId/task_id/taskId/id`,并递归检查 `data/result/task`;字符串和数字 ID 都支持。
|
||||
- **grokVideo** `{ mode, prompt, model, image?, referenceImages?[], resolution?, seconds?, aspectRatio?, endpointPath? }` —— **Grok 视频**。首帧图生视频默认走 `model:"grok-video-1.5"`,提交体为 `{ model, prompt, seconds:"6", size:"1280x720", image:{url:"https://.../image.png"} }`;上传/上游传来的 dataURL 会先转公网 URL,再放入 `image.url`。`resolution + aspectRatio` 只在本地映射为 `size`(如 `9:16 + 720p -> "720x1280"`),不下发 `duration/resolution/image_url/aspect_ratio`。`mode:"reference"` 走旧 Reference-to-Video:强制 `model:"grok-imagine-video"`、`resolution:"720p"`,把 `referenceImages[]` 解析为 `reference_images: [{url}]`,并下发 `aspect_ratio`。
|
||||
- **grokVideo** `{ mode, prompt, model, image?, referenceImages?[], resolution?, seconds?, aspectRatio?, endpointPath? }` —— **Grok 视频**。与 Seedance 节点一样通过 CLMM 提交,使用 `{ model, prompt, aspect_ratio, resolution, size, seconds, mySeconds?, reference_image_urls }`;上传/上游传来的 dataURL 会先转公网 URL。模型名必须使用模型列表返回的完整名称,不再发送 Grok 专用的 `image:{url}` 或 `reference_images:[{url}]` 字段。
|
||||
- **tts** 增加 `model`(默认 `tts-1` 可保留但允许覆盖)、`format`、可选 `speed`。
|
||||
|
||||
### 新增 canvasModels(后端 + preload)
|
||||
@@ -130,14 +130,14 @@ canvasRun: (kind, params) => ipcRenderer.invoke("canvas-run", { kind, params }),
|
||||
- 仅 image 节点用到渐进预览;video/tts 不变。
|
||||
|
||||
## 【v5 增补 · 接入 TokenDun/Seedance 真实视频接口】
|
||||
Seedance 使用异步任务制:建任务→轮询→下载。以下内容按当前 `/v1/video/generations` 文档更新,覆盖早期火山 `metadata/input_mode` 版本。
|
||||
Seedance 和 Grok 节点都使用 CLMM 异步任务制:建任务→轮询→下载。以下内容按 `/v1/videos` 文档更新,覆盖早期 Seedance/Grok 专有参数版本。
|
||||
|
||||
### 后端 runVideo 重写(canvas.js)——完全替换旧 /v1/videos 逻辑
|
||||
- 端点:create = `joinUrl(base, endpointPath||"/v1/video/generations")`;poll = `create + "/" + taskId`(GET)。鉴权 `Bearer <key>`(分组由 groupId 解析,同 v3)。
|
||||
- **body 组装**(严格照 Seedance):
|
||||
- 基础字段 `{ prompt, async:true }`;`model` 可选。
|
||||
- `params.seconds` 仅为 `5/10/15` 时转成整数 `duration`;`ratio`、`quality` 非 `auto` 时按顶层字段发送。
|
||||
- 文生视频不发送图片字段;首帧发送 `image:string`;多图发送 `images:string[]`;全能参考发送 `ref_assets:string[]`。
|
||||
### 后端 runVideo 重写(canvas.js)——统一使用 CLMM /v1/videos 逻辑
|
||||
- 端点:create = `joinUrl(base, endpointPath||"/v1/videos")`;poll = `create + "/" + taskId`(GET)。鉴权 `Bearer <key>`(分组由 groupId 解析,同 v3)。
|
||||
- **body 组装**(严格照 CLMM):
|
||||
- 基础字段 `{ model, prompt, aspect_ratio, resolution, size, seconds, reference_image_urls:[] }`。
|
||||
- 模型包含 `-Ns` 时发送 `seconds:"1"` 和 `mySeconds`;包含 `-gz` 时 `mySeconds` 固定为模型声明的 N 秒。
|
||||
- 文生视频发送空的 `reference_image_urls`;首帧、首尾帧、多参考图都统一发送公网 URL 数组。
|
||||
- 图片必须是公网 URL;本地 data URL 先经 `uploadMedia()` 转成公网地址。
|
||||
- **建任务**:POST create。2xx 才继续;非 2xx 返回状态、实际 URL、model 和上游详情。响应先读取 text 再解析 JSON,确保非 JSON 错误不会丢失原文。任务 ID 由通用 `pickTaskId()` 提取。
|
||||
- **轮询**:GET poll,每 5s 一次,最多约 10 分钟(120 次)。用 `normalizeVideoStatus`;succeeded → `findVideoUrl`(深挖 `video_url/result_url/url`)→ 下载转 base64 → `{ok:true,mime:"video/mp4",dataUrl,meta}`;failed/超时 → 明确错误。仍严禁伪造成功。
|
||||
@@ -147,6 +147,6 @@ Seedance 使用异步任务制:建任务→轮询→下载。以下内容按
|
||||
- 模式:首帧图、双图参考、多参考图/全能参考、文生视频。
|
||||
- `prompt` 在所有模式必填;模型可留空使用上游默认,也可通过分组拉取当前可用模型。
|
||||
- 比例:16:9 / 9:16 / 1:1 / 4:3;画质:自动 / `hd`;时长:自动 / 5s / 10s / 15s。
|
||||
- 高级:接口地址(默认 `/v1/video/generations`)和多参考图字段(`images/ref_assets`)。
|
||||
- 高级:接口地址(默认 `/v1/videos`)。多参考图统一使用 CLMM 字段 `reference_image_urls`。
|
||||
- 不展示旧接口已不支持的分辨率、音频、水印、固定镜头、seed、参考视频控件。
|
||||
- 运行 params:`{ mode, prompt, model?, ratio, quality, seconds, referenceField, image?, lastFrameImage?, referenceImages?, endpointPath?, groupId }`。
|
||||
|
||||
+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) };
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
"author": "Codex Market",
|
||||
"license": "MIT",
|
||||
"main": "electron/bootstrap.js",
|
||||
"hotContentVersion": "1.5.23",
|
||||
"hotContentVersion": "1.5.24",
|
||||
"hotMinAppVersion": "1.5.0",
|
||||
"scripts": {
|
||||
"start": "electron .",
|
||||
|
||||
+50
-42
@@ -2053,10 +2053,9 @@ function cvNodeBody(kind) {
|
||||
</div>
|
||||
<select class="cv-field cv-field-group" title="中转分组"></select>
|
||||
<div class="cv-model-row">
|
||||
<input class="cv-field cv-field-model" placeholder="模型(可留空,默认 video-v1)" />
|
||||
<select class="cv-field cv-field-model" title="CLMM 模型"><option value="">先拉取模型</option></select>
|
||||
<button class="cv-fetch-models" title="拉取该分组的模型">拉取</button>
|
||||
</div>
|
||||
<datalist class="cv-node-datalist"></datalist>
|
||||
<div class="cv-row2">
|
||||
<select class="cv-field cv-field-ratio" title="比例">
|
||||
<option value="16:9">16:9</option>
|
||||
@@ -2076,10 +2075,10 @@ function cvNodeBody(kind) {
|
||||
<option value="10">10s</option>
|
||||
<option value="15">15s</option>
|
||||
</select>
|
||||
<span class="cv-node-hint">接口时长仅支持 5 / 10 / 15 秒</span>
|
||||
</div>
|
||||
<div class="cv-node-hint">CLMM 文档接口:模型名、时长和参考图按上游能力校验</div>
|
||||
<details class="cv-adv"><summary>⚙️ 高级</summary>
|
||||
<input class="cv-field cv-field-endpoint" placeholder="/v1/video/generations" />
|
||||
<input class="cv-field cv-field-endpoint" placeholder="/v1/videos" />
|
||||
<select class="cv-field cv-field-refsource" title="多参考图字段">
|
||||
<option value="images">多参考图字段 images</option>
|
||||
<option value="ref_assets">全能参考字段 ref_assets</option>
|
||||
@@ -2115,10 +2114,9 @@ function cvNodeBody(kind) {
|
||||
</div>
|
||||
<select class="cv-field cv-field-group" title="中转分组"></select>
|
||||
<div class="cv-model-row">
|
||||
<input class="cv-field cv-field-model" value="grok-video-1.5" placeholder="grok-video-1.5" />
|
||||
<select class="cv-field cv-field-model" title="CLMM 模型"><option value="">先拉取模型</option></select>
|
||||
<button class="cv-fetch-models" title="拉取该分组的模型">拉取</button>
|
||||
</div>
|
||||
<datalist class="cv-node-datalist"></datalist>
|
||||
<div class="cv-row2">
|
||||
<select class="cv-field cv-field-ratio" title="视频格式">
|
||||
<option value="16:9">16:9 横屏</option>
|
||||
@@ -2144,9 +2142,9 @@ function cvNodeBody(kind) {
|
||||
<option value="15">15s</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="cv-node-hint cv-grok-hint">首帧图生视频使用 grok-video-1.5;多参考图使用 grok-imagine-video + 720p</div>
|
||||
<div class="cv-node-hint cv-grok-hint">通过 CLMM 聚合接口提交,模型请使用拉取到的完整名称</div>
|
||||
<details class="cv-adv"><summary>⚙️ 高级</summary>
|
||||
<input class="cv-field cv-field-endpoint" placeholder="/v1/videos/generations" />
|
||||
<input class="cv-field cv-field-endpoint" placeholder="/v1/videos" />
|
||||
</details>
|
||||
<button class="cv-run">运行</button>
|
||||
<div class="cv-node-err hidden"></div>
|
||||
@@ -2467,13 +2465,44 @@ function cvFillGroupSelect(sel) {
|
||||
if (want) sel.value = want;
|
||||
}
|
||||
|
||||
// 节点创建后统一初始化:唯一化 datalist + 让模型框指向它 + 填充分组下拉
|
||||
function cvSetNodeModelOptions(nodeEl, models) {
|
||||
if (!nodeEl) return;
|
||||
const model = nodeEl.querySelector(".cv-field-model");
|
||||
const list = Array.isArray(models) ? models.filter(Boolean) : [];
|
||||
if (model && model.tagName === "SELECT") {
|
||||
const current = model.value.trim();
|
||||
const options = ['<option value="">请选择模型</option>']
|
||||
.concat(list.map((item) => `<option value="${esc(item)}">${esc(item)}</option>`));
|
||||
if (current && !list.includes(current)) {
|
||||
options.splice(1, 0, `<option value="${esc(current)}">${esc(current)}(当前)</option>`);
|
||||
}
|
||||
model.innerHTML = options.join("");
|
||||
if (current) model.value = current;
|
||||
return;
|
||||
}
|
||||
const dl = nodeEl.querySelector(".cv-node-datalist");
|
||||
if (dl) dl.innerHTML = list.map((item) => `<option value="${esc(item)}"></option>`).join("");
|
||||
}
|
||||
|
||||
// 节点创建后统一初始化:视频模型迁移为真正的下拉框,其它节点继续使用 datalist。
|
||||
function cvSetupNode(id) {
|
||||
const nodeEl = document.getElementById(`node-${id}`);
|
||||
if (!nodeEl) return;
|
||||
let model = nodeEl.querySelector(".cv-field-model");
|
||||
const isVideo = !!nodeEl.querySelector(".cv-node-video, .cv-node-grokVideo");
|
||||
if (isVideo && model && model.tagName !== "SELECT") {
|
||||
const current = model.value.trim();
|
||||
const replacement = document.createElement("select");
|
||||
replacement.className = model.className;
|
||||
replacement.title = "CLMM 模型";
|
||||
replacement.innerHTML = '<option value="">请选择模型</option>' + (current
|
||||
? `<option value="${esc(current)}">${esc(current)}(当前)</option>`
|
||||
: "");
|
||||
model.replaceWith(replacement);
|
||||
model = replacement;
|
||||
}
|
||||
const dl = nodeEl.querySelector(".cv-node-datalist");
|
||||
const model = nodeEl.querySelector(".cv-field-model");
|
||||
if (dl && model) {
|
||||
if (dl && model && model.tagName !== "SELECT") {
|
||||
const listId = `cv-models-node-${id}`;
|
||||
dl.id = listId;
|
||||
model.setAttribute("list", listId);
|
||||
@@ -2483,18 +2512,15 @@ function cvSetupNode(id) {
|
||||
if (nodeEl.querySelector(".cv-field-vmode")) cvSyncVideoMode(nodeEl);
|
||||
}
|
||||
|
||||
// 分组下拉变化:清空旧模型候选;若模型框为空,用该分组默认 model 预填
|
||||
// 分组下拉变化:清空旧模型候选,不自动锁定第一个模型,保留手动选择。
|
||||
function cvOnGroupChange(nodeEl, groupId) {
|
||||
const dl = nodeEl.querySelector(".cv-node-datalist");
|
||||
if (dl) dl.innerHTML = "";
|
||||
const model = nodeEl.querySelector(".cv-field-model");
|
||||
if (model && !model.value.trim()) {
|
||||
const g = (CANVAS.groups || []).find((x) => String(x.id) === String(groupId));
|
||||
if (g && g.model) model.value = g.model;
|
||||
}
|
||||
if (model && model.tagName === "SELECT") model.innerHTML = '<option value="">请点击“拉取”选择模型</option>';
|
||||
}
|
||||
|
||||
// 拉取某节点所选分组的模型 → 填充该节点自己的 datalist
|
||||
// 拉取某节点所选分组的模型 → 填充完整模型下拉/候选列表
|
||||
async function cvFetchNodeModels(nodeId, btn) {
|
||||
const nodeEl = document.getElementById(`node-${nodeId}`);
|
||||
if (!nodeEl) return;
|
||||
@@ -2504,15 +2530,14 @@ async function cvFetchNodeModels(nodeId, btn) {
|
||||
toast("请先选择中转分组(或在『路由中转』添加分组)", "");
|
||||
return;
|
||||
}
|
||||
const dl = nodeEl.querySelector(".cv-node-datalist");
|
||||
busy(btn, true, "…");
|
||||
try {
|
||||
if (!apic.canvasModels) throw new Error("拉取模型能力尚未就绪(后端未接入)");
|
||||
const r = await apic.canvasModels(groupId);
|
||||
if (!r || !r.ok) throw new Error((r && r.error) || "拉取失败");
|
||||
const models = Array.isArray(r.models) ? r.models : [];
|
||||
if (dl) dl.innerHTML = models.map((m) => `<option value="${esc(m)}"></option>`).join("");
|
||||
toast(`已拉取 ${models.length} 个模型,点模型框可选`, "ok");
|
||||
cvSetNodeModelOptions(nodeEl, models);
|
||||
toast(`已拉取 ${models.length} 个模型,请从下拉框手动选择`, "ok");
|
||||
} catch (e) {
|
||||
toast("拉取模型失败:" + (e.message || e), "err");
|
||||
} finally {
|
||||
@@ -2646,12 +2671,6 @@ function cvSyncVideoMode(nodeEl) {
|
||||
const box = nodeEl.querySelector(".cv-vmat-" + slot);
|
||||
if (box) box.classList.toggle("hidden", !show[slot]);
|
||||
});
|
||||
if (mode === "reference" && nodeEl.querySelector(".cv-node-grokVideo")) {
|
||||
const model = nodeEl.querySelector(".cv-field-model");
|
||||
const resolution = nodeEl.querySelector(".cv-field-vsize");
|
||||
if (model) model.value = "grok-imagine-video";
|
||||
if (resolution) resolution.value = "720p";
|
||||
}
|
||||
}
|
||||
|
||||
// 读取单个节点的「输出产物」:文本节点实时读 textarea,其它读运行后缓存的 dataUrl
|
||||
@@ -2956,7 +2975,7 @@ async function cvRunNode(nodeId, btn) {
|
||||
if (endpointPath) params.endpointPath = endpointPath;
|
||||
} else if (kind === "grokVideo") {
|
||||
const mode = val(".cv-field-vmode") || "text";
|
||||
const model = val(".cv-field-model") || "grok-video-1.5";
|
||||
const model = val(".cv-field-model");
|
||||
const endpointPath = val(".cv-field-endpoint");
|
||||
const duration = val(".cv-field-duration");
|
||||
const mats = CANVAS.videoMaterials[nodeId] || {};
|
||||
@@ -2977,14 +2996,6 @@ async function cvRunNode(nodeId, btn) {
|
||||
aspectRatio: val(".cv-field-ratio") || "16:9",
|
||||
};
|
||||
if (!params.prompt) return cvSetErr(nodeEl, "请填写 Grok 视频提示词,或连接一个文本节点作为输入");
|
||||
if (
|
||||
mode === "text" &&
|
||||
/grok-(?:imagine-)?video-1\.5/i.test(model) &&
|
||||
params.resolution === "1080p" &&
|
||||
String(duration) === "15"
|
||||
) {
|
||||
return cvSetErr(nodeEl, "1.5 的 1080p/15s 需要首帧图;请切到「首帧图生视频」并提供图片,或降低文生视频规格");
|
||||
}
|
||||
if (mode === "first") {
|
||||
const first = matUrl("first") || mats.first || up.image || "";
|
||||
if (!first) return cvSetErr(nodeEl, "首帧图生视频需要首帧:连上游『文生图』/ 上传 / 手填公网URL");
|
||||
@@ -2994,8 +3005,6 @@ async function cvRunNode(nodeId, btn) {
|
||||
const refs = [up.image].concat(mats.refs || [], matUrls("refs")).filter(Boolean);
|
||||
if (!refs.length) return cvSetErr(nodeEl, "多参考图模式请连接上游图片、上传参考图,或填写公网图片URL");
|
||||
params.referenceImages = refs;
|
||||
params.model = "grok-imagine-video";
|
||||
params.resolution = "720p";
|
||||
params.aspectRatio = params.aspectRatio || "16:9";
|
||||
}
|
||||
if (duration) params.seconds = duration;
|
||||
@@ -3099,7 +3108,7 @@ $$("[data-cv-add]").forEach((b) =>
|
||||
const cvGoto = document.getElementById("cv-goto-router");
|
||||
if (cvGoto) cvGoto.addEventListener("click", () => activatePanel("router"));
|
||||
|
||||
// 工具条「🔄 拉取模型」:用当前激活分组拉取,一次性灌进所有节点的 datalist
|
||||
// 工具条「🔄 拉取模型」:用当前激活分组拉取,一次性灌进所有节点的模型选择器
|
||||
const cvModelsBtn = document.getElementById("cv-models-btn");
|
||||
if (cvModelsBtn)
|
||||
cvModelsBtn.addEventListener("click", async (ev) => {
|
||||
@@ -3110,10 +3119,9 @@ if (cvModelsBtn)
|
||||
const r = await apic.canvasModels(CANVAS.activeGroupId || undefined);
|
||||
if (!r || !r.ok) throw new Error((r && r.error) || "拉取失败");
|
||||
const models = Array.isArray(r.models) ? r.models : [];
|
||||
const opts = models.map((m) => `<option value="${esc(m)}"></option>`).join("");
|
||||
const dls = document.querySelectorAll("#drawflow .cv-node-datalist");
|
||||
dls.forEach((dl) => { dl.innerHTML = opts; });
|
||||
toast(`已按当前分组拉取 ${models.length} 个模型,填充 ${dls.length} 个节点`, "ok");
|
||||
const nodes = document.querySelectorAll("#drawflow .drawflow-node");
|
||||
nodes.forEach((node) => cvSetNodeModelOptions(node, models));
|
||||
toast(`已按当前分组拉取 ${models.length} 个模型,可在各节点手动切换`, "ok");
|
||||
} catch (e) {
|
||||
toast("拉取模型失败:" + (e.message || e), "err");
|
||||
} finally {
|
||||
|
||||
Reference in New Issue
Block a user