fix: canvas image/video download - fix data URL parsing in cvDownloadPreview (v1.5.21, hot 1.5.23)

This commit is contained in:
mac
2026-07-20 08:48:56 +08:00
parent 42e23663ee
commit aadd2b04ae
28 changed files with 5881 additions and 329 deletions
+162 -43
View File
@@ -48,6 +48,17 @@ function grokDebugLog(entry) {
} 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;
@@ -81,7 +92,7 @@ function proxyAgentFor(targetUrl, proxyUrl) {
}
}
function requestBuffer(url, agent) {
function requestBuffer(url, agent, extraHeaders = {}) {
return new Promise((resolve, reject) => {
let u;
try {
@@ -95,13 +106,13 @@ function requestBuffer(url, agent) {
url,
{
agent,
headers: { "User-Agent": "codex-market" },
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).then(resolve, reject);
requestBuffer(new URL(loc, url).toString(), agent, extraHeaders).then(resolve, reject);
return;
}
if (res.statusCode < 200 || res.statusCode >= 300) {
@@ -120,14 +131,14 @@ function requestBuffer(url, agent) {
}
// —— 下载任意远程资源 → base64 字符串 ——
async function downloadAsBase64(url) {
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)).toString("base64");
return (await requestBuffer(url, agent, extraHeaders)).toString("base64");
} catch (e) {
proxyErr = String((e && e.message) || e);
// 代理下载失败时再直连兜底,保留原行为。
@@ -137,7 +148,7 @@ async function downloadAsBase64(url) {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), 120000);
try {
const res = await fetch(url, { headers: { "User-Agent": "codex-market" }, signal: ctrl.signal });
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");
@@ -149,6 +160,18 @@ async function downloadAsBase64(url) {
}
}
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 {};
}
// —— 组装 data URL ——
function toDataUrl(mime, b64) {
return `data:${mime};base64,${b64}`;
@@ -399,9 +422,39 @@ async function runTts(base, llm, params) {
// —— 递归深挖视频地址(覆盖 result_url、data.content.video_url、data.data.content.video_url 等嵌套)——
// port 自 提示词提取/server.js
function findVideoUrl(o) {
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) ? o : "";
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 "";
}
function findVideoUrl(o) {
const likely = findLikelyVideoUrl(o);
if (likely) return likely;
if (!o) return "";
if (typeof o === "string") return "";
if (Array.isArray(o)) {
for (const v of o) {
const u = findVideoUrl(v);
@@ -410,7 +463,7 @@ function findVideoUrl(o) {
return "";
}
if (typeof o === "object") {
for (const k of ["video_url", "result_url", "url"]) {
for (const k of ["video_url", "result_url", "content_url", "download_url", "file_url", "output_url"]) {
const v = o[k];
if (typeof v === "string" && /^https?:\/\//.test(v)) return v;
}
@@ -432,13 +485,16 @@ function normalizeVideoStatus(raw) {
}
function joinUrl(base, p) {
return trimSlash(base) + (String(p || "").startsWith("/") ? p : "/" + 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;
}
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" && o[k]) return o[k];
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];
@@ -456,27 +512,52 @@ function pushRecentPoll(arr, value) {
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)}`;
}
// —— Seedance/TokenDun 视频:异步任务制(建任务 → 轮询 → 下载成品转 base64)——
// mode: "text"(文生视频,只用 prompt) | "image"(图生视频)。model 须用户填写。
// 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");
const model = String(params.model || "").trim();
const createUrl = joinUrl(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 body = { prompt: (params.prompt || "").trim(), async: true };
if (model) body.model = model; // 文档允许省略,交给上游使用默认 video-v1
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
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;
try {
// 参考图/素材:本中转支持 image(单图) / images(多图) / ref_assets;base64 先传兰空换公网URL
// 参考图/素材:本中转支持 image(单图) / images(多图) / ref_assets;base64 先传图床换公网 URL
if (mode === "first") {
const img = await resolveMat(params.imageUrl || params.image);
if (!img) return { ok: false, error: "首帧图生视频需要首帧图:连上游文生图 / 上传 / 手填公网URL" };
@@ -492,7 +573,7 @@ async function runVideo(base, llm, params) {
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;
body[params.referenceField === "ref_assets" ? "ref_assets" : "images"] = resolved;
} else if (mode === "video") {
return { ok: false, error: "该中转不支持「参考视频」模式,请改用 文生 / 首帧 / 多参考图" };
}
@@ -500,6 +581,22 @@ async function runVideo(base, llm, params) {
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 });
const cres = await fetch(createUrl, {
method: "POST",
headers: headers(llm.key),
@@ -507,21 +604,23 @@ async function runVideo(base, llm, params) {
});
if (!cres.ok) {
const detail = await cres.text().catch(() => "");
return { ok: false, error: `视频建任务失败(${cres.status}):${detail.slice(0, 300)}` };
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)}` };
}
let data;
const rawCreate = await cres.text().catch(() => "");
try {
data = await cres.json();
data = JSON.parse(rawCreate);
} catch {
const detail = await cres.text().catch(() => "");
return { ok: false, error: `视频建任务返回非 JSON:${detail.slice(0, 300)}` };
seedanceDebugLog({ type: "seedance_video_create_non_json", ...debugBase, detail: rawCreate.slice(0, 500) });
return { ok: false, error: `视频建任务返回非 JSON:${rawCreate.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)}` };
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 });
// —— 轮询:每 5s,最多 120 次(~10min),对齐 提示词提取 ——
// poll 是 GET:只带 Authorization(不要带 Content-Type,个别网关对「GET 携带 body 类头」会返回非 2xx 而被误跳过)
@@ -548,16 +647,25 @@ async function runVideo(base, llm, params) {
if (st === "succeeded") {
const url = findVideoUrl(pd);
if (!url) return { ok: false, error: `视频任务完成但未找到视频地址:${JSON.stringify(pd).slice(0, 300)}` };
const b64 = await downloadAsBase64(url);
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 });
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 });
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 || "" });
return { ok: false, error: "视频轮询 10 分钟未完成(taskId=" + taskId + ",最近一次轮询:" + (lastPoll || "无") + ")" };
} catch (e) {
return { ok: false, error: "视频生成异常:" + String((e && e.message) || e) };
@@ -566,37 +674,39 @@ async function runVideo(base, llm, params) {
// —— Grok 视频:独立新增接口,按 /v1/videos/generations + /v1/videos/{request_id} 调用 ——
async function runGrokVideo(base, llm, params) {
let model = params.model || "grok-imagine-video";
let model = params.model || "grok-video-1.5";
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;
const seconds = String(params.seconds || "6").trim();
const body = { model, prompt, seconds, size: grokSizeFromParams(params) };
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
/grok-(?:imagine-)?video-1\.5/i.test(model) &&
body.size === "1920x1080" &&
Number(body.seconds) === 15
) {
return { ok: false, error: "1.5 的 1080p/15s 需要首帧图;请改用首帧图生视频,或降低文生视频规格" };
}
if (mode === "first") {
const img = await resolveMat(params.imageUrl || params.image);
const img = await resolveGrokImage(params.imageUrl || params.image);
if (!img) return { ok: false, error: "Grok 图生视频需要首帧图:上传 / 手填公网URL / 连接上游文生图" };
body.image = { image_url: img };
body.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;
delete body.size;
body.resolution = "720p";
delete body.seconds;
const dur = parseInt(params.seconds, 10);
if (!isNaN(dur) && dur > 0) body.duration = dur;
if (!body.aspect_ratio) body.aspect_ratio = params.aspectRatio || "16:9";
body.reference_images = resolved.map((url) => ({ url }));
} else if (mode !== "text") {
@@ -611,10 +721,13 @@ async function runGrokVideo(base, llm, params) {
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,
},
};
@@ -669,7 +782,13 @@ async function runGrokVideo(base, llm, params) {
lastPoll = "status=succeeded,但暂未返回视频地址";
continue;
}
const b64 = await downloadAsBase64(url);
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 });
return { ok: true, mime: "video/mp4", dataUrl: toDataUrl("video/mp4", b64), url, _model: model };
}