fix: canvas image/video download - fix data URL parsing in cvDownloadPreview (v1.5.21, hot 1.5.23)
This commit is contained in:
+162
-43
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user