Files
omniai-server/src/routes/ai.js
T
2026-06-04 18:58:45 +08:00

2277 lines
84 KiB
JavaScript

"use strict";
const crypto = require("node:crypto");
const { requireAuth, keyManager, preauthorizeCall, pool, withTransaction, deductImageGenerationCredits } = require("./context");
const { putObject, isOssConfigured } = require("../ossClient");
const { buildImageProviderDebug, resolveImageProviderCandidates, resolveVideoProvider, resolveTextProvider, getPostUrl } = require("../aiProviderRouter");
const { shouldSkipProvider, recordProviderSuccess, recordProviderFailure } = require("../providerCircuitBreaker");
const {
isEnterpriseVideoBillingUser,
markEnterpriseVideoCreditsAccepted,
prepareEnterpriseVideoBilling,
refundEnterpriseVideoCredits,
reserveEnterpriseVideoCredits,
calculateEnterpriseVideoCredits,
getEnterpriseVideoCreditRate,
} = require("../enterpriseVideoBilling");
const {
startPolling,
updateTaskInDb,
extractProviderTaskId,
extractImageUrl,
extractGeminiImageUrl,
extractVideoUrl,
parseKlingCredential,
createKlingJwt,
taskEvents,
} = require("../aiTaskWorker");
const {
buildDashscopeImageSuperResolveBody,
buildDashscopeVideoStyleTransformBody,
normalizeImageUpscaleFactor,
normalizeVideoStyleTransformOptions,
} = require("../aiUpscaleHelpers");
const GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES = new Map([
["gpt-image-2", "1K"],
]);
const GRSAI_IMAGE_MAX_QUALITY = new Map([
["gpt-image-2", "2K"],
]);
const DASHSCOPE_IMAGE_MAX_QUALITY = new Map([
["wan2.7-image", "2K"],
]);
const ALIYUN_VIDEOENHAN_ENDPOINT = "https://videoenhan.cn-shanghai.aliyuncs.com/";
const ALIYUN_VIDEOENHAN_VERSION = "2020-03-20";
function toViapiAccessibleUrl(url) {
if (!url) return url;
const match = url.match(/^(https?:\/\/)([^.]+)\.oss-cn-(?!shanghai)[^.]+(\.aliyuncs\.com\/.*)$/i);
if (match) {
return `${match[1]}${match[2]}.oss-accelerate${match[3]}`;
}
return url;
}
const SUPER_RESOLVE_POLL_INTERVAL_MS = 3000;
const SUPER_RESOLVE_MAX_POLL_ATTEMPTS = 200;
const IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS = 90_000;
const GEMINI_IMAGE_SUBMIT_TIMEOUT_MS = 180_000;
const DASHSCOPE_VIDEO_STYLE_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis";
const DASHSCOPE_IMAGE_EDIT_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis";
const MAX_USER_ACTIVE_GENERATION_TASKS = 3;
const GENERATION_CONCURRENCY_LIMIT_MESSAGE = "最多只能同时进行3个任务";
const GPT_IMAGE_ASPECT_RATIO_TO_PIXELS = {
"1:1": { "1K": "1024x1024", "2K": "2048x2048", "4K": "2880x2880" },
"16:9": { "1K": "1774x887", "2K": "2048x1152", "4K": "3840x2160" },
"9:16": { "1K": "887x1774", "2K": "1152x2048", "4K": "2160x3840" },
"4:3": { "1K": "1536x1152", "2K": "2048x1536", "4K": "3072x2304" },
"3:4": { "1K": "1152x1536", "2K": "1536x2048", "4K": "2304x3072" },
};
function mapAspectRatioToPixels(ratio, quality) {
const q = String(quality || "1K").toUpperCase();
const map = GPT_IMAGE_ASPECT_RATIO_TO_PIXELS[ratio || "1:1"];
return map ? (map[q] || map["1K"]) : "1024x1024";
}
function mapAspectRatioToDashscopeSize(ratio, quality) {
return mapAspectRatioToPixels(ratio, quality).replace("x", "*");
}
function normalizeQuality(value, fallback = "1K") {
const q = String(value || fallback).trim().toUpperCase();
if (q === "4K" || q === "2K" || q === "1K") return q;
return fallback;
}
function clampImageQualityForModel(model, quality) {
const normalized = normalizeQuality(quality, "2K");
const maxQuality = DASHSCOPE_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase());
if (maxQuality === "2K" && normalized === "4K") return "2K";
if (maxQuality === "1K" && normalized !== "1K") return "1K";
return normalized;
}
function clampGrsaiImageQualityForModel(model, quality) {
const normalized = normalizeQuality(quality, "1K");
const maxQuality = GRSAI_IMAGE_MAX_QUALITY.get(String(model || "").toLowerCase());
if (maxQuality === "2K" && normalized === "4K") return "2K";
if (maxQuality === "1K" && normalized !== "1K") return "1K";
return normalized;
}
function normalizeDuration(value, min = 4, max = 15, fallback = 5) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return fallback;
return Math.max(min, Math.min(max, Math.round(numeric)));
}
function normalizeRatio(value, fallback = "16:9") {
const ratio = String(value || fallback).trim();
return ratio === "auto" ? "adaptive" : ratio;
}
function normalizeVideoResolution(value, allowed, fallback = "720p") {
const resolution = String(value || "").trim().toLowerCase();
return allowed.includes(resolution) ? resolution : fallback;
}
function normalizeS2vResolution(value) {
const resolution = String(value || "").trim().toLowerCase();
return resolution === "480p" ? "480P" : "720P";
}
function normalizeS2vStyle(value) {
const style = String(value || "").trim().toLowerCase();
return ["speech", "sing", "performance"].includes(style) ? style : "speech";
}
function normalizePublicHttpUrl(value) {
const url = String(value || "").trim();
return /^https?:\/\//i.test(url) ? url : "";
}
function percentEncodeRpc(value) {
return encodeURIComponent(String(value))
.replace(/!/g, "%21")
.replace(/'/g, "%27")
.replace(/\(/g, "%28")
.replace(/\)/g, "%29")
.replace(/\*/g, "%2A");
}
function signAliyunRpcParams(method, params, accessKeySecret) {
const canonicalQuery = Object.keys(params)
.sort()
.map((key) => `${percentEncodeRpc(key)}=${percentEncodeRpc(params[key])}`)
.join("&");
const stringToSign = `${method.toUpperCase()}&${percentEncodeRpc("/")}&${percentEncodeRpc(canonicalQuery)}`;
return crypto.createHmac("sha1", `${accessKeySecret}&`).update(stringToSign).digest("base64");
}
function getAliyunVideoEnhanCredentials() {
const accessKeyId =
process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_ID ||
process.env.ALIYUN_ACCESS_KEY_ID ||
process.env.STS_ACCESS_KEY_ID ||
"";
const accessKeySecret =
process.env.ALIYUN_VIDEOENHAN_ACCESS_KEY_SECRET ||
process.env.ALIYUN_ACCESS_KEY_SECRET ||
process.env.STS_ACCESS_KEY_SECRET ||
"";
return { accessKeyId, accessKeySecret };
}
function buildAliyunRpcRequest(action, actionParams = {}, method = "GET") {
const { accessKeyId, accessKeySecret } = getAliyunVideoEnhanCredentials();
if (!accessKeyId || !accessKeySecret) {
const error = new Error("Aliyun video super-resolution is not configured");
error.status = 501;
throw error;
}
const params = {
Action: action,
Version: ALIYUN_VIDEOENHAN_VERSION,
Format: "JSON",
AccessKeyId: accessKeyId,
SignatureMethod: "HMAC-SHA1",
SignatureVersion: "1.0",
SignatureNonce: crypto.randomUUID(),
Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
...actionParams,
};
params.Signature = signAliyunRpcParams(method.toUpperCase(), params, accessKeySecret);
const encoded = Object.entries(params)
.map(([key, value]) => `${percentEncodeRpc(key)}=${percentEncodeRpc(value)}`)
.join("&");
if (method.toUpperCase() === "POST") {
return { url: ALIYUN_VIDEOENHAN_ENDPOINT, body: encoded, method: "POST" };
}
return { url: `${ALIYUN_VIDEOENHAN_ENDPOINT}?${encoded}`, method: "GET" };
}
function buildAliyunRpcUrl(action, actionParams = {}) {
const { url } = buildAliyunRpcRequest(action, actionParams, "GET");
return url;
}
function parseAliyunJsonResult(value) {
if (!value) return null;
if (typeof value === "object") return value;
if (typeof value !== "string") return null;
try {
return JSON.parse(value);
} catch {
return null;
}
}
async function callAliyunRpc(action, params, method = "GET") {
const request = buildAliyunRpcRequest(action, params, method);
const fetchOptions = { method: request.method };
if (request.body) {
fetchOptions.headers = { "Content-Type": "application/x-www-form-urlencoded" };
fetchOptions.body = request.body;
}
const response = await fetch(request.url, fetchOptions);
const text = await response.text().catch(() => "");
let json = {};
try {
json = text ? JSON.parse(text) : {};
} catch {
throw new Error(`Aliyun ${action} returned non-JSON response (${response.status})`);
}
if (!response.ok || json.Code || json.code) {
throw new Error(json.Message || json.message || `Aliyun ${action} returned ${response.status}`);
}
return json;
}
function normalizeSuperResolveBitRate(value) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) return 10;
return Math.max(1, Math.min(20, Math.round(numeric)));
}
function normalizeAliyunJobStatus(value) {
return String(value || "").trim().toUpperCase();
}
async function ensureDefaultProject(userId) {
const projectId = `web-default-${userId}`;
const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [projectId, userId]);
if (rows.length === 0) {
const safeUserId = String(userId).replace(/[^a-zA-Z0-9_-]/g, "");
await pool.query(
`INSERT INTO projects (
id,
user_id,
name,
description,
oss_key,
storyboard_count,
image_count,
video_count,
file_size,
current_revision,
updated_by_device_id,
created_at,
updated_at
)
VALUES ($1, $2, $3, $4, $5, 0, 0, 0, 0, 1, 'web', NOW(), NOW())
ON CONFLICT (id) DO NOTHING`,
[
projectId,
userId,
"Default workbench",
"Web fallback project for legacy generation requests",
`users/${safeUserId}/projects/${projectId}/current/project.json`,
],
);
}
return projectId;
}
async function resolveTaskProject(userId, requestedProjectId) {
const projectId = String(requestedProjectId || "").trim().slice(0, 64);
if (!projectId) {
return ensureDefaultProject(userId);
}
const { rows } = await pool.query("SELECT id FROM projects WHERE id = $1 AND user_id = $2", [
projectId,
userId,
]);
if (rows.length === 0) {
const error = new Error("Project not found");
error.status = 404;
throw error;
}
return projectId;
}
async function insertTask(userId, projectId, type, params, conversationId = null, client = null) {
if (!client) {
return withTransaction((tx) => insertTask(userId, projectId, type, params, conversationId, tx));
}
await assertUserGenerationConcurrencyLimit(userId, client);
const clientQueueId = `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const { rows: [row] } = await client.query(
`INSERT INTO generation_tasks (user_id, project_id, conversation_id, client_queue_id, type, status, params_json, progress, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, 'pending', $6, 0, NOW(), NOW()) RETURNING *`,
[userId, projectId, conversationId, clientQueueId, type, JSON.stringify(params)],
);
return row;
}
async function assertUserGenerationConcurrencyLimit(userId, client = pool) {
await client.query("SELECT pg_advisory_xact_lock(hashtext($1))", [`generation-tasks:${userId}`]);
// Expire stale tasks using heartbeat-aware detection.
// A task is considered stale if neither the server nor the client has touched it
// in the last 10 minutes. This is much faster than the old 30/60-minute window
// because: if a client is actively polling, `last_poll_at` keeps the task alive;
// if the client navigated away (or crashed), `last_poll_at` stops updating and
// the task is freed after 10 minutes.
await client.query(
`UPDATE generation_tasks
SET status = 'failed', error = '任务超时自动释放', updated_at = NOW()
WHERE user_id = $1
AND status IN ('pending', 'running')
AND GREATEST(updated_at, COALESCE(last_poll_at, created_at)) < NOW() - INTERVAL '10 minutes'`,
[userId],
);
const { rows } = await client.query(
"SELECT COUNT(*)::int AS active_count FROM generation_tasks WHERE user_id = $1 AND status IN ('pending', 'running')",
[userId],
);
const activeCount = Number(rows[0]?.active_count ?? rows[0]?.count ?? 0);
if (activeCount < MAX_USER_ACTIVE_GENERATION_TASKS) return;
const error = new Error(GENERATION_CONCURRENCY_LIMIT_MESSAGE);
error.status = 429;
error.code = "GENERATION_CONCURRENCY_LIMIT";
error.activeCount = activeCount;
error.maxActiveTasks = MAX_USER_ACTIVE_GENERATION_TASKS;
throw error;
}
async function providerPoolExists(provider) {
if (!provider) return false;
const { rows } = await pool.query(
"SELECT 1 FROM api_keys WHERE provider = $1 AND enabled = 1 LIMIT 1",
[provider],
);
return rows.length > 0;
}
function releaseLease(slotResult) {
if (slotResult?.leaseToken) keyManager.releaseKey(slotResult.leaseToken).catch(() => {});
}
function sendAiRouteError(res, err) {
res.status(err.status || 500).json({
error: err.message,
code: err.code,
activeCount: err.activeCount,
maxActiveTasks: err.maxActiveTasks,
});
}
async function fetchWithTimeout(url, options = {}, timeoutMs = IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...options, signal: controller.signal });
} catch (err) {
if (err?.name === "AbortError") {
throw new Error(`Provider request timed out after ${Math.round(timeoutMs / 1000)}s`);
}
throw err;
} finally {
clearTimeout(timer);
}
}
function sanitizeUpstreamError(value, fallback = "上游服务暂时不可用,请稍后重试") {
const raw = String(value || "").trim();
if (!raw) return fallback;
let message = raw;
try {
const parsed = JSON.parse(raw);
message =
parsed?.error?.message ||
parsed?.error_description ||
parsed?.message ||
parsed?.error ||
raw;
} catch {}
const compact = String(message).replace(/\s+/g, " ").trim();
const looksLikeMarkup =
/^<!doctype\s/i.test(compact) ||
/^<html[\s>]/i.test(compact) ||
/^<\?xml/i.test(compact) ||
/<\/?[a-z][^>]*>/i.test(compact);
if (looksLikeMarkup) return fallback;
return compact.slice(0, 320);
}
function parseTaskParams(value) {
if (!value || typeof value !== "string") return {};
try {
return JSON.parse(value);
} catch {
return {};
}
}
function formatAiTaskRow(row) {
return {
taskId: String(row.id),
projectId: row.project_id,
conversationId: row.conversation_id,
clientQueueId: row.client_queue_id || null,
type: row.type,
status: row.status,
progress: Number(row.progress || 0),
resultUrl: row.result_url || null,
error: row.error || null,
params: parseTaskParams(row.params_json),
createdAt: row.created_at,
updatedAt: row.updated_at,
completedAt: row.completed_at || null,
};
}
function extensionFromContentType(contentType, fallbackType) {
const mime = String(contentType || "").split(";")[0].trim().toLowerCase();
if (mime === "image/jpeg") return "jpg";
if (mime === "image/png") return "png";
if (mime === "image/webp") return "webp";
if (mime === "image/gif") return "gif";
if (mime === "video/webm") return "webm";
if (mime === "video/quicktime") return "mov";
if (mime === "video/mp4") return "mp4";
return fallbackType === "video" ? "mp4" : "png";
}
function contentDispositionFilename(value) {
return String(value || "generated")
.replace(/[\\/:*?"<>|]+/g, "-")
.replace(/[^\x20-\x7e]/g, "")
.trim()
.slice(0, 120) || "generated";
}
function isErrorContentType(contentType) {
return /(?:application|text)\/(?:json|xml|html|plain)|\+xml/i.test(String(contentType || ""));
}
function buildDashscopeImageBody(params) {
const content = [];
for (const url of params.referenceUrls || []) {
if (url) content.push({ image: url });
}
content.push({ text: params.prompt });
const quality = clampImageQualityForModel(params.model, params.quality);
return {
model: params.model,
input: {
messages: [{ role: "user", content }],
},
parameters: {
size: mapAspectRatioToDashscopeSize(params.ratio, quality),
n: params.gridMode === "grid-4" ? 4 : params.gridMode === "grid-9" ? 9 : 1,
watermark: false,
},
};
}
function buildGrsaiImageBody(params) {
const isGptImage = String(params.model || "").startsWith("gpt-image");
const modelKey = String(params.model || "").toLowerCase();
const quality = GRSAI_IMAGE_QUALITY_MODEL_OVERRIDES.get(modelKey) || clampGrsaiImageQualityForModel(params.model, params.quality);
return isGptImage
? {
model: params.model,
prompt: params.prompt,
images: params.referenceUrls || [],
aspectRatio: mapAspectRatioToPixels(params.ratio, quality),
replyType: "json",
}
: {
model: params.model,
prompt: params.prompt,
images: params.referenceUrls || [],
aspectRatio: params.ratio || "auto",
imageSize: quality,
replyType: "json",
};
}
function buildRightcodeImageBody(providerConfig, params) {
const referenceUrls = Array.isArray(params.referenceUrls) ? params.referenceUrls.filter(Boolean) : [];
const quality = normalizeQuality(params.quality, "1K");
return {
model: providerConfig.model || params.model,
prompt: params.prompt,
image: referenceUrls,
size: mapAspectRatioToPixels(params.ratio, quality),
response_format: "url",
};
}
function getGridCount(gridMode) {
if (gridMode === "grid-4") return 4;
if (gridMode === "grid-9") return 9;
if (gridMode === "grid-25") return 25;
return 1;
}
function buildGeminiImageBody(params) {
const parts = [{ text: String(params.prompt || "").trim() }];
const refs = (params.referenceUrls || []).filter(Boolean);
for (const url of refs) {
parts.push({
fileData: { fileUri: url, mimeType: "image/png" },
});
}
const generationConfig = { responseModalities: ["IMAGE", "TEXT"] };
const count = getGridCount(params.gridMode);
if (count > 1) generationConfig.candidateCount = count;
return {
contents: [{ parts }],
generationConfig,
};
}
function buildOpenAIImageBody(providerConfig, params) {
const userContent = [];
const prompt = String(params.prompt || "").trim();
if (prompt) userContent.push({ type: "text", text: prompt });
const refs = (params.referenceUrls || []).filter(Boolean);
for (const url of refs) {
userContent.push({ type: "image_url", image_url: { url } });
}
const body = {
model: providerConfig.model || params.model,
messages: [{ role: "user", content: userContent.length > 1 ? userContent : (prompt || "generate an image") }],
};
const count = getGridCount(params.gridMode);
if (count > 1) body.n = count;
return body;
}
function buildImageRequest(providerConfig, params, apiKey) {
const effectiveParams = providerConfig.model ? { ...params, model: providerConfig.model } : params;
if (providerConfig.transport === "dashscope-image") {
return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildDashscopeImageBody(effectiveParams) };
}
if (providerConfig.transport === "rightcode-image") {
return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildRightcodeImageBody(providerConfig, effectiveParams) };
}
if (providerConfig.transport === "gemini-image") {
return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGeminiImageBody(effectiveParams) };
}
if (providerConfig.transport === "openai-image") {
return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildOpenAIImageBody(providerConfig, effectiveParams) };
}
return { headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` }, body: buildGrsaiImageBody(effectiveParams) };
}
function buildSeedVideoBody(params) {
const resolution = normalizeVideoResolution(params.quality, ["480p", "720p"]);
const metadata = {
generate_audio: true,
watermark: false,
ratio: normalizeRatio(params.ratio),
duration: normalizeDuration(params.duration, 4, 15, 5),
resolution,
};
const body = {
model: params.model,
prompt: params.prompt,
metadata,
};
const refs = params.referenceUrls || [];
if (params.frameMode === "start-end" && refs.length >= 2) {
metadata.first_frame_image = refs[0];
metadata.last_frame_image = refs[refs.length - 1];
} else if (refs.length === 1) {
body.image = refs[0];
} else if (refs.length > 1) {
metadata.reference_images = refs;
}
return body;
}
function buildArkSeedVideoBody(params) {
const content = [];
if (params.prompt) content.push({ type: "text", text: params.prompt });
const refs = params.referenceUrls || [];
if (params.frameMode === "start-end" && refs.length >= 2) {
content.push({ type: "image_url", image_url: { url: refs[0] }, role: "first_frame" });
content.push({ type: "image_url", image_url: { url: refs[refs.length - 1] }, role: "last_frame" });
} else {
refs.forEach((url, index) => {
content.push({
type: "image_url",
image_url: { url },
role: index === 0 ? "first_frame" : "reference_image",
});
});
}
const body = {
model: params.model,
content,
ratio: normalizeRatio(params.ratio),
duration: normalizeDuration(params.duration, 4, 15, 5),
generate_audio: true,
watermark: false,
};
body.resolution = normalizeVideoResolution(params.quality, ["480p", "720p", "1080p"]);
return body;
}
function buildWanT2vBody(params) {
const requestedResolution = String(params.quality || "").toUpperCase();
const parameters = {
resolution: requestedResolution === "720P" ? "720P" : "1080P",
ratio: normalizeRatio(params.ratio),
duration: normalizeDuration(params.duration, 3, 15, 5),
watermark: false,
prompt_extend: true,
};
return {
model: params.model,
input: { prompt: params.prompt },
parameters,
};
}
function buildWanI2vBody(params) {
const refs = params.referenceUrls || [];
const media = [];
if (params.frameMode === "start-end" && refs.length >= 2) {
media.push({ type: "first_frame", url: refs[0] });
media.push({ type: "last_frame", url: refs[refs.length - 1] });
} else if (refs[0]) {
media.push({ type: "first_frame", url: refs[0] });
}
if (!media.length) {
throw createMissingReferenceError("wan2.7-i2v 需要提供至少一张参考图片作为首帧");
}
const input = { prompt: params.prompt, media };
const requestedResolution = String(params.quality || "").toUpperCase();
const parameters = {
resolution: requestedResolution === "720P" ? "720P" : "1080P",
ratio: normalizeRatio(params.ratio),
duration: normalizeDuration(params.duration, 3, 15, 5),
watermark: false,
};
parameters.prompt_extend = true;
return {
model: params.model,
input,
parameters,
};
}
function normalizeHappyHorseResolution(value) {
return String(value || "").toUpperCase() === "720P" ? "720P" : "1080P";
}
function getReferenceImageUrls(params, limit = 9) {
return (Array.isArray(params.referenceUrls) ? params.referenceUrls : [])
.map((url) => normalizePublicHttpUrl(url))
.filter(Boolean)
.slice(0, limit);
}
function buildHappyHorseBaseParameters(params, { includeRatio }) {
const parameters = {
resolution: normalizeHappyHorseResolution(params.quality),
duration: normalizeDuration(params.duration, 3, 15, 5),
watermark: false,
};
if (includeRatio) parameters.ratio = normalizeRatio(params.ratio);
return parameters;
}
function createMissingReferenceError(message) {
const error = new Error(message);
error.status = 400;
return error;
}
function buildHappyHorseT2vBody(params) {
return {
model: params.model,
input: {
prompt: params.prompt,
},
parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }),
};
}
function buildHappyHorseI2vBody(params) {
const [firstFrame] = getReferenceImageUrls(params, 1);
if (!firstFrame) {
throw createMissingReferenceError("HappyHorse I2V requires one first-frame image.");
}
return {
model: params.model,
input: {
prompt: params.prompt,
media: [{ type: "first_frame", url: firstFrame }],
},
parameters: buildHappyHorseBaseParameters(params, { includeRatio: false }),
};
}
function buildHappyHorseR2vBody(params) {
const refs = getReferenceImageUrls(params, 9);
if (!refs.length) {
throw createMissingReferenceError("HappyHorse R2V requires 1 to 9 reference images.");
}
return {
model: params.model,
input: {
prompt: params.prompt,
media: refs.map((url) => ({ type: "reference_image", url })),
},
parameters: buildHappyHorseBaseParameters(params, { includeRatio: true }),
};
}
function getHappyHorseReferenceError(protocol, referenceUrls) {
if (protocol === "happyhorse-i2v" && !getReferenceImageUrls({ referenceUrls }, 1).length) {
return "HappyHorse I2V requires one first-frame image.";
}
if (protocol === "happyhorse-r2v" && !getReferenceImageUrls({ referenceUrls }, 9).length) {
return "HappyHorse R2V requires 1 to 9 reference images.";
}
return "";
}
async function assertWanS2vImageDetected(providerConfig, params, apiKey) {
const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]);
if (!imageUrl) {
const error = new Error("Missing imageUrl");
error.status = 400;
throw error;
}
const response = await fetch(`${providerConfig.baseUrl}${providerConfig.detectEndpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: providerConfig.detectModel || "wan2.2-s2v-detect",
input: { image_url: imageUrl },
}),
});
const text = await response.text();
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {}
if (!response.ok) {
throw new Error(sanitizeUpstreamError(text, `数字人人像检测返回 HTTP ${response.status}`));
}
const output = json && typeof json === "object" ? json.output || json.data || json : {};
const pass =
output.check_pass === true ||
output.checkPass === true ||
output.passed === true ||
output.pass === true ||
String(output.code || "").toLowerCase() === "success";
if (!pass) {
const message = extractProviderDetectMessage(output) || "人像检测未通过,请换一张清晰、单人、正面的人物图。";
const error = new Error(message);
error.status = 400;
throw error;
}
}
function extractProviderDetectMessage(output) {
if (!output || typeof output !== "object") return "";
return String(
output.message ||
output.reason ||
output.failure_reason ||
output.description ||
output.error ||
"",
).trim();
}
function buildWanS2vBody(params) {
const imageUrl = normalizePublicHttpUrl(params.imageUrl || (params.referenceUrls || [])[0]);
const audioUrl = normalizePublicHttpUrl(params.audioUrl);
if (!imageUrl) {
const error = new Error("Missing imageUrl");
error.status = 400;
throw error;
}
if (!audioUrl) {
const error = new Error("Missing audioUrl");
error.status = 400;
throw error;
}
const parameters = {
resolution: normalizeS2vResolution(params.quality),
style: normalizeS2vStyle(params.style),
};
return {
model: params.model,
input: {
image_url: imageUrl,
audio_url: audioUrl,
},
parameters,
};
}
function buildWanAnimateMixBody(params) {
const imageUrl = normalizePublicHttpUrl(params.imageUrl);
const videoUrl = normalizePublicHttpUrl((params.referenceUrls || [])[0]);
if (!imageUrl) {
const error = new Error("Missing imageUrl");
error.status = 400;
throw error;
}
if (!videoUrl) {
const error = new Error("Missing videoUrl");
error.status = 400;
throw error;
}
const mode = "wan-pro";
const watermark = params.muted === false;
return {
model: params.model,
input: {
image_url: imageUrl,
video_url: videoUrl,
watermark,
},
parameters: {
mode,
},
};
}
function buildDashscopeKlingBody(params) {
const refs = params.referenceUrls || [];
const media = [];
if (params.frameMode === "start-end" && refs.length >= 2) {
media.push({ type: "first_frame", url: refs[0] });
media.push({ type: "last_frame", url: refs[refs.length - 1] });
} else if (refs[0]) {
media.push({ type: "first_frame", url: refs[0] });
}
const input = { prompt: params.prompt };
if (media.length) input.media = media;
const parameters = {
mode: params.quality === "std" ? "std" : "pro",
duration: normalizeDuration(params.duration, 5, 10, 5),
audio: false,
watermark: false,
};
if (!media.length) parameters.aspect_ratio = normalizeRatio(params.ratio);
return { model: params.model, input, parameters };
}
function buildKlingOmniBody(params) {
const refs = params.referenceUrls || [];
const imageList = [];
if (params.frameMode === "start-end" && refs.length >= 2) {
imageList.push({ image_url: refs[0], type: "first_frame" });
imageList.push({ image_url: refs[refs.length - 1], type: "end_frame" });
} else if (refs[0]) {
imageList.push({ image_url: refs[0], type: "first_frame" });
}
const body = {
model_name: "kling-v3-omni",
mode: params.quality === "std" ? "std" : "pro",
sound: "off",
duration: String(normalizeDuration(params.duration, 3, 15, 5)),
watermark_info: { enabled: false },
prompt: params.prompt,
};
if (imageList.length) body.image_list = imageList;
else body.aspect_ratio = normalizeRatio(params.ratio);
return body;
}
function buildViduT2vBody(params) {
const requestedRes = String(params.quality || "").toUpperCase();
const resolution = requestedRes === "720P" ? "720P" : "1080P";
const sizeMap = { "720P": "1280*720", "1080P": "1920*1080" };
return { model: params.model, input: { prompt: params.prompt }, parameters: { resolution, size: sizeMap[resolution], duration: normalizeDuration(params.duration, 1, 16, 5), watermark: false } };
}
function buildViduI2vBody(params) {
const [img] = getReferenceImageUrls(params, 1);
if (!img) throw createMissingReferenceError("Vidu I2V 需要提供一张参考图片");
const requestedRes = String(params.quality || "").toUpperCase();
const resolution = requestedRes === "720P" ? "720P" : "1080P";
return { model: params.model, input: { prompt: params.prompt || "", media: [{ type: "image", url: img }] }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 16, 5), watermark: false } };
}
function buildPixverseT2vBody(params) {
const requestedRes = String(params.quality || "").toUpperCase();
const sizeMap = { "720P": "1280*720", "1080P": "1920*1080" };
const size = sizeMap[requestedRes] || "1280*720";
return { model: params.model, input: { prompt: params.prompt }, parameters: { size, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } };
}
function buildPixverseI2vBody(params) {
const [img] = getReferenceImageUrls(params, 1);
if (!img) throw createMissingReferenceError("PixVerse I2V 需要提供一张参考图片");
const requestedRes = String(params.quality || "").toUpperCase();
const resolution = (requestedRes === "720P" || requestedRes === "1080P") ? requestedRes : "720P";
return { model: params.model, input: { prompt: params.prompt || "", media: [{ type: "image_url", url: img }] }, parameters: { resolution, duration: normalizeDuration(params.duration, 1, 15, 5), watermark: false, audio: false } };
}
function buildVideoRequest(providerConfig, params, apiKey) {
const headers = { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` };
let body;
if (providerConfig.protocol === "seed-video-ark") {
body = buildArkSeedVideoBody(params);
} else if (providerConfig.protocol === "happyhorse-t2v") {
body = buildHappyHorseT2vBody(params);
headers["X-DashScope-Async"] = "enable";
} else if (providerConfig.protocol === "happyhorse-i2v") {
body = buildHappyHorseI2vBody(params);
headers["X-DashScope-Async"] = "enable";
} else if (providerConfig.protocol === "happyhorse-r2v") {
body = buildHappyHorseR2vBody(params);
headers["X-DashScope-Async"] = "enable";
} else if (providerConfig.protocol === "wan-i2v") {
body = buildWanI2vBody(params);
headers["X-DashScope-Async"] = "enable";
} else if (providerConfig.protocol === "wan-t2v") {
body = buildWanT2vBody(params);
headers["X-DashScope-Async"] = "enable";
} else if (providerConfig.protocol === "wan-s2v") {
body = buildWanS2vBody(params);
headers["X-DashScope-Async"] = "enable";
} else if (providerConfig.protocol === "wan-animate-mix") {
body = buildWanAnimateMixBody(params);
headers["X-DashScope-Async"] = "enable";
} else if (providerConfig.protocol === "kling-dashscope") {
body = buildDashscopeKlingBody(params);
headers["X-DashScope-Async"] = "enable";
} else if (providerConfig.protocol === "vidu-t2v") {
body = buildViduT2vBody(params);
headers["X-DashScope-Async"] = "enable";
} else if (providerConfig.protocol === "vidu-i2v") {
body = buildViduI2vBody(params);
headers["X-DashScope-Async"] = "enable";
} else if (providerConfig.protocol === "pixverse-t2v") {
body = buildPixverseT2vBody(params);
headers["X-DashScope-Async"] = "enable";
} else if (providerConfig.protocol === "pixverse-i2v") {
body = buildPixverseI2vBody(params);
headers["X-DashScope-Async"] = "enable";
} else if (providerConfig.protocol === "kling-omni") {
body = buildKlingOmniBody(params);
const credential = parseKlingCredential(apiKey);
if (credential) {
headers.Authorization = `Bearer ${createKlingJwt(credential.accessKey, credential.secretKey)}`;
}
} else {
body = buildSeedVideoBody(params);
}
return { headers, body };
}
function registerAiRoutes(router) {
router.post("/ai/image", requireAuth, async (req, res) => {
const { model, prompt, ratio, quality, gridMode, referenceUrls, projectId: requestedProjectId, conversationId } = req.body;
if (!prompt) return res.status(400).json({ error: "Missing prompt" });
try {
const allCandidates = resolveImageProviderCandidates(model);
const providerCandidates = allCandidates.filter(c => !shouldSkipProvider(c.provider));
if (!providerCandidates.length) providerCandidates.push(...allCandidates);
const primaryProviderConfig = providerCandidates[0];
const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null;
const params = {
model: primaryProviderConfig.model,
requestedModel: primaryProviderConfig.requestedModel,
prompt,
ratio,
quality,
gridMode,
referenceUrls,
};
const { taskRow, imageBilling } = await withTransaction(async (client) => {
const nextTaskRow = await insertTask(
req.user.id,
projectId,
"image",
params,
Number.isFinite(Number(conversationId)) ? Number(conversationId) : null,
client,
);
const billingResult = await deductImageGenerationCredits(req.user.id, client, {
taskId: nextTaskRow.id,
model: params.requestedModel || params.model || model,
resolution: [ratio, quality].filter(Boolean).join(" / "),
});
if (!billingResult.success) {
const error = new Error(billingResult.message || "账户积分不足");
error.status = 402;
error.code = "INSUFFICIENT_BALANCE";
error.costCents = billingResult.costCents;
throw error;
}
return { taskRow: nextTaskRow, imageBilling: billingResult };
});
const preauth = { authorized: true, estimatedCostCents: 0, billingMode: imageBilling.deductionType };
res.status(202).json({
taskId: String(taskRow.id),
status: "pending",
imageBilling: {
costCents: imageBilling.costCents,
deductionType: imageBilling.deductionType,
balanceAfterCents: imageBilling.balanceAfterCents,
},
providerDebug: buildImageProviderDebug(model),
});
submitImageWithProviderFallback(taskRow.id, providerCandidates, req.user, preauth, params).catch((err) => {
console.error("[ai/image] submit error:", err.message);
updateTaskInDb(taskRow.id, { status: "failed", error: err.message });
});
} catch (err) {
console.error("[ai/image] error:", err.message);
sendAiRouteError(res, err);
}
});
router.post("/ai/video", requireAuth, async (req, res) => {
const {
model,
prompt,
ratio,
duration,
quality,
frameMode,
referenceUrls,
imageUrl,
audioUrl,
resolution,
muted,
hasReferenceVideo,
style,
projectId: requestedProjectId,
conversationId,
} = req.body;
let providerConfig;
try {
providerConfig = resolveVideoProvider(model);
} catch (err) {
return res.status(err.status || 400).json({ error: err.message });
}
const provider = providerConfig.provider;
const isWanS2v = providerConfig.protocol === "wan-s2v";
const isWanAnimateMix = providerConfig.protocol === "wan-animate-mix";
const happyHorseReferenceError = getHappyHorseReferenceError(providerConfig.protocol, referenceUrls);
if (!isWanS2v && !isWanAnimateMix && !prompt) return res.status(400).json({ error: "Missing prompt" });
if (happyHorseReferenceError) return res.status(400).json({ error: happyHorseReferenceError });
if (isWanS2v) {
if (!normalizePublicHttpUrl(imageUrl || (Array.isArray(referenceUrls) ? referenceUrls[0] : ""))) {
return res.status(400).json({ error: "Missing imageUrl" });
}
if (!normalizePublicHttpUrl(audioUrl)) {
return res.status(400).json({ error: "Missing audioUrl" });
}
}
if (isWanAnimateMix) {
if (!normalizePublicHttpUrl(imageUrl)) {
return res.status(400).json({ error: "Missing imageUrl" });
}
if (!normalizePublicHttpUrl((Array.isArray(referenceUrls) ? referenceUrls[0] : ""))) {
return res.status(400).json({ error: "Missing reference videoUrl" });
}
}
let slotResult = null;
try {
const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null;
const params = {
model: providerConfig.model,
requestedModel: providerConfig.requestedModel,
prompt: prompt || "数字人口播视频",
ratio,
duration,
quality: quality || resolution,
resolution: resolution || quality,
frameMode,
referenceUrls,
imageUrl,
audioUrl,
muted: Boolean(muted),
hasReferenceVideo: Boolean(hasReferenceVideo),
style,
};
let enterpriseBilling = null;
let preauth = null;
if (isEnterpriseVideoBillingUser(req.user)) {
enterpriseBilling = prepareEnterpriseVideoBilling({ user: req.user, providerConfig, params });
preauth = {
authorized: true,
estimatedCostCents: enterpriseBilling.amountCents,
billingMode: "enterprise",
};
} else {
preauth = await preauthorizeCall(req.user.id, provider);
if (!preauth.authorized) {
return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" });
}
}
await assertUserGenerationConcurrencyLimit(req.user.id);
slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 });
if (!slotResult) {
return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` });
}
const { taskRow, reservedBilling, regularBilling } = await withTransaction(async (client) => {
const nextTaskRow = await insertTask(
req.user.id,
projectId,
"video",
params,
Number.isFinite(Number(conversationId)) ? Number(conversationId) : null,
client,
);
if (enterpriseBilling) {
const nextBilling = await reserveEnterpriseVideoCredits(client, {
...enterpriseBilling,
taskId: nextTaskRow.id,
});
return { taskRow: nextTaskRow, reservedBilling: nextBilling, regularBilling: null };
}
// Regular user: deduct from personal balance
const credits = calculateEnterpriseVideoCredits({
model: params.model,
resolution: params.resolution || params.quality,
durationSeconds: params.duration,
muted: params.muted,
hasReferenceVideo: params.hasReferenceVideo,
});
const costCents = Math.ceil(credits * 100);
const { rows: [deducted] } = await client.query(
"UPDATE users SET balance_cents = balance_cents - $1, updated_at = NOW() WHERE id = $2 AND balance_cents >= $1 RETURNING balance_cents",
[costCents, req.user.id],
);
if (!deducted) {
throw Object.assign(new Error("账户积分不足,请充值"), { status: 402, code: "INSUFFICIENT_BALANCE" });
}
await client.query(
"INSERT INTO transactions (user_id, type, amount_cents, balance_after_cents, description) VALUES ($1, 'deduct', $2, $3, $4)",
[req.user.id, -costCents, deducted.balance_cents, `视频生成扣费 ${credits} 积分`],
);
return { taskRow: nextTaskRow, reservedBilling: null, regularBilling: { costCents, balanceAfterCents: deducted.balance_cents, credits } };
});
if (reservedBilling) {
params.enterpriseBilling = {
creditLedgerId: reservedBilling.creditLedgerId,
amountCents: reservedBilling.amountCents,
resolution: reservedBilling.resolution,
durationSeconds: reservedBilling.durationSeconds,
rateCentsPerSecond: reservedBilling.rateCentsPerSecond,
};
await pool.query("UPDATE generation_tasks SET params_json = $1, updated_at = NOW() WHERE id = $2", [
JSON.stringify(params),
taskRow.id,
]);
}
res.status(202).json({
taskId: String(taskRow.id),
status: "pending",
enterpriseBilling: reservedBilling
? {
creditLedgerId: reservedBilling.creditLedgerId,
amountCents: reservedBilling.amountCents,
enterpriseBalanceCents: reservedBilling.enterpriseBalanceCents,
}
: undefined,
});
const activeSlotResult = slotResult;
slotResult = null;
submitVideoToProvider(taskRow.id, providerConfig, activeSlotResult, params)
.then(async () => {
try {
await markEnterpriseVideoCreditsAccepted(pool, reservedBilling?.creditLedgerId);
} catch (settlementError) {
console.error("[ai/video] enterprise ledger settle error:", settlementError.message);
}
})
.catch(async (err) => {
console.error("[ai/video] submit error:", err.message);
await updateTaskInDb(taskRow.id, { status: "failed", error: err.message });
await refundEnterpriseVideoCredits(pool, reservedBilling, err.message);
releaseLease(activeSlotResult);
});
} catch (err) {
releaseLease(slotResult);
console.error("[ai/video] error:", err.message);
if (err.code === "INSUFFICIENT_ENTERPRISE_BALANCE") {
return res.status(err.status || 402).json({
error: err.message,
code: "INSUFFICIENT_ENTERPRISE_BALANCE",
});
}
sendAiRouteError(res, err);
}
});
router.post("/ai/image/super-resolve", requireAuth, async (req, res) => {
const imageUrl = normalizePublicHttpUrl(req.body?.imageUrl);
const scale = normalizeImageUpscaleFactor(req.body?.scale ?? req.body?.upscaleFactor);
const { projectId: requestedProjectId, conversationId } = req.body || {};
if (!imageUrl) return res.status(400).json({ error: "Missing imageUrl" });
const provider = "dashscope";
let slotResult;
try {
const preauth = await preauthorizeCall(req.user.id, provider);
if (!preauth.authorized) {
return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" });
}
await assertUserGenerationConcurrencyLimit(req.user.id);
slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 });
if (!slotResult) {
return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` });
}
const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null;
const params = {
model: "wanx2.1-imageedit",
operation: "image-super-resolution",
imageUrl,
scale,
};
const taskRow = await insertTask(
req.user.id,
projectId,
"image",
params,
Number.isFinite(Number(conversationId)) ? Number(conversationId) : null,
);
res.status(202).json({ taskId: String(taskRow.id), status: "pending" });
submitDashscopeImageSuperResolveTask(taskRow.id, slotResult, params).catch((err) => {
console.error("[ai/image/super-resolve] submit error:", err.message);
updateTaskInDb(taskRow.id, { status: "failed", error: err.message });
releaseLease(slotResult);
});
} catch (err) {
if (slotResult) releaseLease(slotResult);
console.error("[ai/image/super-resolve] error:", err.message);
sendAiRouteError(res, err);
}
});
router.post("/ai/video/super-resolve", requireAuth, async (req, res) => {
const videoUrl = String(req.body?.videoUrl || "").trim();
const bitRate = normalizeSuperResolveBitRate(req.body?.bitRate);
const providerMode = String(req.body?.provider || req.body?.model || "").trim();
const shouldUseDashscopeStyle =
providerMode === "dashscope-style-transform" || providerMode === "video-style-transform";
const { projectId: requestedProjectId, conversationId } = req.body || {};
if (!videoUrl) return res.status(400).json({ error: "Missing videoUrl" });
if (!/^https?:\/\//i.test(videoUrl)) {
return res.status(400).json({ error: "videoUrl must be an HTTP URL" });
}
let dashscopeSlotResult;
try {
if (shouldUseDashscopeStyle) {
const provider = "dashscope";
const preauth = await preauthorizeCall(req.user.id, provider);
if (!preauth.authorized) {
return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" });
}
await assertUserGenerationConcurrencyLimit(req.user.id);
dashscopeSlotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 });
if (!dashscopeSlotResult) {
return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` });
}
const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null;
const styleOptions = normalizeVideoStyleTransformOptions(req.body);
const params = {
model: "video-style-transform",
operation: "video-style-super-resolution",
videoUrl,
...styleOptions,
};
const taskRow = await insertTask(
req.user.id,
projectId,
"video",
params,
Number.isFinite(Number(conversationId)) ? Number(conversationId) : null,
);
res.status(202).json({ taskId: String(taskRow.id), status: "pending" });
submitDashscopeVideoStyleTransformTask(taskRow.id, dashscopeSlotResult, params).catch((err) => {
console.error("[ai/video/super-resolve] dashscope submit error:", err.message);
updateTaskInDb(taskRow.id, { status: "failed", error: err.message });
releaseLease(dashscopeSlotResult);
});
return;
}
const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null;
const params = { model: "aliyun-video-super-resolve", videoUrl, bitRate };
const taskRow = await insertTask(
req.user.id,
projectId,
"video",
params,
Number.isFinite(Number(conversationId)) ? Number(conversationId) : null,
);
res.status(202).json({ taskId: String(taskRow.id), status: "pending" });
submitVideoSuperResolveTask(taskRow.id, params).catch((err) => {
console.error("[ai/video/super-resolve] submit error:", err.message);
updateTaskInDb(taskRow.id, { status: "failed", error: err.message });
});
} catch (err) {
if (dashscopeSlotResult) releaseLease(dashscopeSlotResult);
console.error("[ai/video/super-resolve] error:", err.message);
sendAiRouteError(res, err);
}
});
router.post("/ai/video/erase-subtitles", requireAuth, async (req, res) => {
const videoUrl = normalizePublicHttpUrl(req.body?.videoUrl);
const { projectId: requestedProjectId, conversationId } = req.body || {};
if (!videoUrl) return res.status(400).json({ error: "Missing videoUrl" });
try {
await assertUserGenerationConcurrencyLimit(req.user.id);
const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null;
const bx = Number(req.body?.bx) || 0;
const by = Number(req.body?.by) || 0;
const bw = Number(req.body?.bw) || 0;
const bh = Number(req.body?.bh) || 0;
const params = { model: "aliyun-erase-subtitles", videoUrl, bx, by, bw, bh };
const taskRow = await insertTask(
req.user.id,
projectId,
"video",
params,
Number.isFinite(Number(conversationId)) ? Number(conversationId) : null,
);
res.status(202).json({ taskId: String(taskRow.id), status: "pending" });
submitEraseSubtitlesTask(taskRow.id, params).catch((err) => {
console.error("[ai/video/erase-subtitles] submit error:", err.message);
updateTaskInDb(taskRow.id, { status: "failed", error: err.message });
});
} catch (err) {
console.error("[ai/video/erase-subtitles] error:", err.message);
sendAiRouteError(res, err);
}
});
router.post("/ai/image/edit", requireAuth, async (req, res) => {
const imageUrl = normalizePublicHttpUrl(req.body?.imageUrl);
const editFunction = String(req.body?.function || "description_edit").trim();
const prompt = String(req.body?.prompt || "").trim();
const n = Math.max(1, Math.min(4, Number(req.body?.n) || 1));
const { projectId: requestedProjectId, conversationId } = req.body || {};
if (!imageUrl) return res.status(400).json({ error: "Missing imageUrl" });
const provider = "dashscope";
let slotResult;
try {
const preauth = await preauthorizeCall(req.user.id, provider);
if (!preauth.authorized) {
return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" });
}
await assertUserGenerationConcurrencyLimit(req.user.id);
slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 });
if (!slotResult) {
return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` });
}
const projectId = requestedProjectId ? await resolveTaskProject(req.user.id, requestedProjectId) : null;
const params = { model: "wanx2.1-imageedit", operation: "image-edit", imageUrl, function: editFunction, prompt, n };
const taskRow = await insertTask(
req.user.id,
projectId,
"image",
params,
Number.isFinite(Number(conversationId)) ? Number(conversationId) : null,
);
res.status(202).json({ taskId: String(taskRow.id), status: "pending" });
submitDashscopeImageEditTask(taskRow.id, slotResult, params).catch((err) => {
console.error("[ai/image/edit] submit error:", err.message);
updateTaskInDb(taskRow.id, { status: "failed", error: err.message });
releaseLease(slotResult);
});
} catch (err) {
if (slotResult) releaseLease(slotResult);
console.error("[ai/image/edit] error:", err.message);
sendAiRouteError(res, err);
}
});
router.post("/ai/chat", requireAuth, async (req, res) => {
const { model, messages, stream = true, temperature } = req.body;
if (!messages || !messages.length) return res.status(400).json({ error: "Missing messages" });
const providerConfig = resolveTextProvider(model);
const provider = providerConfig.provider;
let slotResult;
try {
const preauth = await preauthorizeCall(req.user.id, provider);
if (!preauth.authorized) {
return res.status(402).json({ error: preauth.message, code: "INSUFFICIENT_BALANCE" });
}
slotResult = await keyManager.acquireKey(provider, req.user, preauth, { waitTimeoutMs: 15000 });
if (!slotResult) {
return res.status(429).json({ error: `${provider} concurrency pool is full, please retry later` });
}
const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`;
const reqHeaders = {
"Content-Type": "application/json",
Authorization: `Bearer ${slotResult.apiKey}`,
};
const reqBody = JSON.stringify({
model: providerConfig.model,
messages,
stream,
temperature: temperature || 0.7,
max_tokens: 4096,
enable_thinking: false,
});
if (stream) {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders();
const abortController = new AbortController();
const streamTimer = setTimeout(() => abortController.abort(), 120000);
req.on("close", () => { clearTimeout(streamTimer); abortController.abort(); });
try {
const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody, signal: abortController.signal });
if (!upstream.ok) {
const errText = await upstream.text().catch(() => "upstream error");
res.write(
`data: ${JSON.stringify({
error: sanitizeUpstreamError(errText, `文本服务返回 HTTP ${upstream.status}`),
done: true,
})}\n\n`,
);
res.end();
releaseLease(slotResult);
return;
}
const reader = upstream.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const payload = line.slice(6).trim();
if (payload === "[DONE]") {
res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`);
continue;
}
try {
const chunk = JSON.parse(payload);
const delta = chunk.choices?.[0]?.delta?.content || "";
if (delta) res.write(`data: ${JSON.stringify({ delta, done: false })}\n\n`);
} catch {}
}
}
res.write(`data: ${JSON.stringify({ delta: "", done: true })}\n\n`);
res.end();
releaseLease(slotResult);
} catch (streamErr) {
if (streamErr.name !== "AbortError") {
res.write(
`data: ${JSON.stringify({
error: sanitizeUpstreamError(streamErr.message),
done: true,
})}\n\n`,
);
}
res.end();
releaseLease(slotResult);
}
} else {
const nonStreamAbort = new AbortController();
const nonStreamTimer = setTimeout(() => nonStreamAbort.abort(), 120000);
const upstream = await fetch(url, { method: "POST", headers: reqHeaders, body: reqBody, signal: nonStreamAbort.signal });
clearTimeout(nonStreamTimer);
const text = await upstream.text().catch(() => "");
releaseLease(slotResult);
let json = {};
try {
json = text ? JSON.parse(text) : {};
} catch {
return res.status(502).json({
error: sanitizeUpstreamError(text, `文本服务返回 HTTP ${upstream.status}`),
});
}
if (!upstream.ok || json.error) {
console.error("[ai/chat] upstream error:", upstream.status, JSON.stringify(json.error || json.message || "").slice(0, 500), "model:", providerConfig.model, "provider:", providerConfig.provider);
if (upstream.status >= 500 && providerConfig.provider && providerConfig.provider.startsWith("dashscope")) {
try {
const fallbackConfig = resolveTextProvider("gemini-3.1-pro");
const fallbackUrl = fallbackConfig.baseUrl + fallbackConfig.endpoint;
const fallbackHeaders = { "Content-Type": "application/json", Authorization: "Bearer " + slotResult.apiKey };
const fallbackBody = JSON.stringify({ model: fallbackConfig.model, messages, stream: false, temperature: temperature || 0.7, max_tokens: 4096 });
const fbAbort = new AbortController();
const fbTimer = setTimeout(() => fbAbort.abort(), 90000);
const fbUpstream = await fetch(fallbackUrl, { method: "POST", headers: fallbackHeaders, body: fallbackBody, signal: fbAbort.signal });
clearTimeout(fbTimer);
const fbText = await fbUpstream.text().catch(() => "");
if (fbUpstream.ok) {
const fbJson = fbText ? JSON.parse(fbText) : {};
const fbContent = fbJson.choices?.[0]?.message?.content || "";
if (fbContent) {
const fbUsage = fbJson.usage || {};
return res.json({ content: fbContent, usage: { promptTokens: fbUsage.prompt_tokens, completionTokens: fbUsage.completion_tokens } });
}
}
} catch (fbErr) {
console.error("[ai/chat] fallback also failed:", fbErr.message);
}
}
return res.status(502).json({
error: sanitizeUpstreamError(
json.error?.message || json.message || json.error || text,
`文本服务返回 HTTP ${upstream.status}`,
),
});
}
const content = json.choices?.[0]?.message?.content || "";
const usage = json.usage || {};
res.json({ content, usage: { promptTokens: usage.prompt_tokens, completionTokens: usage.completion_tokens } });
}
} catch (err) {
releaseLease(slotResult);
console.error("[ai/chat] error:", err.message);
res.status(err.name === "AbortError" ? 504 : 500).json({ error: err.name === "AbortError" ? "AI 上游响应超时,请重试" : err.message });
}
});
router.get("/ai/tasks", requireAuth, async (req, res) => {
try {
const limit = Math.min(Math.max(Number(req.query.limit) || 100, 1), 200);
const offset = Math.min(Math.max(Number(req.query.offset) || 0, 0), 5000);
const status = String(req.query.status || "").trim();
const type = String(req.query.type || "").trim();
const projectId = String(req.query.projectId || req.query.project_id || "").trim();
const params = [req.user.id];
const where = ["user_id = $1"];
if (["pending", "running", "completed", "failed", "cancelled"].includes(status)) {
params.push(status);
where.push(`status = $${params.length}`);
}
if (["image", "video"].includes(type)) {
params.push(type);
where.push(`type = $${params.length}`);
}
const source = String(req.query.source || "").trim();
if (source) {
params.push(source);
where.push(`params_json->>'source' = $${params.length}`);
}
if (projectId) {
params.push(projectId);
where.push(`project_id = $${params.length}`);
}
params.push(limit, offset);
const { rows } = await pool.query(
`
SELECT *
FROM generation_tasks
WHERE ${where.join(" AND ")}
ORDER BY updated_at DESC
LIMIT $${params.length - 1}
OFFSET $${params.length}
`,
params,
);
res.json({ tasks: rows.map(formatAiTaskRow) });
} catch (err) {
console.error("[ai/tasks] list failed:", err.message);
res.status(500).json({ error: "Failed to load task history" });
}
});
router.patch("/ai/tasks/:taskId/conversation", requireAuth, async (req, res) => {
const taskId = Number(req.params.taskId);
const conversationId = Number(req.body?.conversationId);
if (!Number.isFinite(taskId) || !Number.isFinite(conversationId)) {
return res.status(400).json({ error: "Invalid task or conversation id" });
}
try {
const { rows: conversationRows } = await pool.query(
"SELECT id FROM conversations WHERE id = $1 AND user_id = $2",
[conversationId, req.user.id],
);
if (conversationRows.length === 0) {
return res.status(404).json({ error: "Conversation not found" });
}
const { rows } = await pool.query(
`UPDATE generation_tasks
SET conversation_id = $1, updated_at = NOW()
WHERE id = $2 AND user_id = $3
RETURNING id, conversation_id`,
[conversationId, taskId, req.user.id],
);
if (rows.length === 0) {
return res.status(404).json({ error: "Task not found" });
}
res.json({ taskId: String(rows[0].id), conversationId: rows[0].conversation_id });
} catch (err) {
res.status(err.name === "AbortError" ? 504 : 500).json({ error: err.name === "AbortError" ? "AI 上游响应超时,请重试" : err.message });
}
});
router.get("/ai/tasks/:taskId", requireAuth, async (req, res) => {
const { taskId } = req.params;
try {
const { rows } = await pool.query(
"SELECT * FROM generation_tasks WHERE id = $1 AND user_id = $2",
[taskId, req.user.id],
);
if (rows.length === 0) return res.status(404).json({ error: "Task not found" });
// Heartbeat: track that the client is still polling this task.
// Only update for active tasks to avoid unnecessary writes on completed/failed rows.
if (rows[0].status === "pending" || rows[0].status === "running") {
pool.query(
"UPDATE generation_tasks SET last_poll_at = NOW() WHERE id = $1",
[taskId],
).catch(() => {});
}
res.json(formatAiTaskRow(rows[0]));
} catch (err) {
res.status(err.name === "AbortError" ? 504 : 500).json({ error: err.name === "AbortError" ? "AI 上游响应超时,请重试" : err.message });
}
});
router.get("/ai/tasks/:taskId/stream", requireAuth, async (req, res) => {
const { taskId } = req.params;
try {
const { rows } = await pool.query(
"SELECT * FROM generation_tasks WHERE id = $1 AND user_id = $2",
[taskId, req.user.id],
);
if (rows.length === 0) return res.status(404).json({ error: "Task not found" });
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
const row = rows[0];
const initial = {
taskId: row.id,
status: row.status,
progress: row.progress,
resultUrl: row.result_url || null,
error: row.error || null,
};
res.write(`data: ${JSON.stringify(initial)}\n\n`);
if (["completed", "failed", "cancelled"].includes(row.status)) {
res.end();
return;
}
const onUpdate = (evt) => {
res.write(`data: ${JSON.stringify(evt)}\n\n`);
if (["completed", "failed", "cancelled"].includes(evt.status)) {
res.end();
}
};
taskEvents.on(`task:${taskId}`, onUpdate);
req.on("close", () => {
taskEvents.off(`task:${taskId}`, onUpdate);
});
} catch (err) {
if (!res.headersSent) res.status(err.name === "AbortError" ? 504 : 500).json({ error: err.name === "AbortError" ? "AI 上游响应超时,请重试" : err.message });
}
});
router.patch("/ai/tasks/:taskId/cancel", requireAuth, async (req, res) => {
const taskId = Number(req.params.taskId);
if (!Number.isFinite(taskId)) return res.status(400).json({ error: "Invalid task id" });
try {
const { rows } = await pool.query(
"UPDATE generation_tasks SET status = 'cancelled', updated_at = NOW() WHERE id = $1 AND user_id = $2 AND status IN ('pending', 'running') RETURNING id, status",
[taskId, req.user.id],
);
if (rows.length === 0) return res.status(404).json({ error: "Task not found or not in active state" });
res.json({ id: rows[0].id, status: rows[0].status });
} catch (err) {
console.error("[ai/task-cancel] error:", err.message);
res.status(500).json({ error: "取消任务失败" });
}
});
router.get("/ai/tasks/:taskId/download", requireAuth, async (req, res) => {
const { taskId } = req.params;
try {
const { rows } = await pool.query(
"SELECT id, type, result_url FROM generation_tasks WHERE id = $1 AND user_id = $2",
[taskId, req.user.id],
);
if (rows.length === 0) return res.status(404).json({ error: "Task not found" });
const task = rows[0];
const resultUrl = String(task.result_url || "").trim();
if (!/^https?:\/\//i.test(resultUrl)) {
return res.status(400).json({ error: "Task result is not downloadable" });
}
const upstream = await fetch(resultUrl, { method: "GET" });
if (!upstream.ok || !upstream.body) {
return res.status(upstream.status || 502).json({ error: `Result download failed (${upstream.status})` });
}
const contentType = upstream.headers.get("content-type") || (task.type === "video" ? "video/mp4" : "image/png");
if (isErrorContentType(contentType)) {
const text = await upstream.text().catch(() => "");
return res.status(502).json({
error: text.includes("Expired") || text.includes("AccessDenied")
? "结果链接已过期,请重新生成后再下载"
: "结果链接返回了错误内容,请重新生成后再下载",
});
}
const buffer = Buffer.from(await upstream.arrayBuffer());
if (!buffer.length) {
return res.status(502).json({ error: "Result download returned empty content" });
}
const extension = extensionFromContentType(contentType, task.type);
const filename = contentDispositionFilename(`generated-${task.type}-${task.id}.${extension}`);
res.setHeader("Content-Type", contentType);
res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
res.setHeader("Content-Length", String(buffer.length));
res.setHeader("Cache-Control", "no-store");
res.end(buffer);
} catch (err) {
console.error("[ai/tasks/download] failed:", err.message);
if (!res.headersSent) res.status(err.name === "AbortError" ? 504 : 500).json({ error: err.name === "AbortError" ? "AI 上游响应超时,请重试" : err.message });
}
});
router.get("/ai/proxy-download", requireAuth, async (req, res) => {
const url = String(req.query.url || "").trim();
if (!url || !/^https?:\/\//i.test(url)) {
return res.status(400).json({ error: "Invalid URL" });
}
if (!/aliyuncs\.com/i.test(url)) {
return res.status(403).json({ error: "Only OSS URLs can be proxied" });
}
try {
const upstream = await fetch(url, { method: "GET" });
if (!upstream.ok || !upstream.body) {
return res.status(upstream.status || 502).json({ error: `Proxy download failed (${upstream.status})` });
}
const contentType = upstream.headers.get("content-type") || "application/octet-stream";
const buffer = Buffer.from(await upstream.arrayBuffer());
if (!buffer.length) {
return res.status(502).json({ error: "Proxy download returned empty content" });
}
res.setHeader("Content-Type", contentType);
res.setHeader("Content-Length", String(buffer.length));
res.setHeader("Cache-Control", "no-store");
res.end(buffer);
} catch (err) {
console.error("[ai/proxy-download] failed:", err.message);
if (!res.headersSent) res.status(err.name === "AbortError" ? 504 : 500).json({ error: err.name === "AbortError" ? "AI 上游响应超时,请重试" : err.message });
}
});
}
async function submitImageWithProviderFallback(taskDbId, providerCandidates, user, preauth, params, previousErrors = []) {
const errors = [...previousErrors];
const candidates = Array.isArray(providerCandidates) ? providerCandidates : [];
for (let index = 0; index < candidates.length; index += 1) {
const providerConfig = candidates[index];
const provider = providerConfig?.provider;
let slotResult = null;
if (!provider) continue;
try {
if (index > 0 && !(await providerPoolExists(provider))) {
throw new Error(`${provider} provider pool is not configured`);
}
slotResult = await keyManager.acquireKey(provider, user, preauth, { waitTimeoutMs: 15000 });
if (!slotResult) {
throw new Error(`${provider} concurrency pool is full`);
}
await submitImageToProvider(taskDbId, providerConfig, slotResult, params, {
onTaskFailed: async (failureMessage) => {
recordProviderFailure(provider);
const providerError = `${provider}: ${failureMessage}`;
const remainingCandidates = candidates.slice(index + 1);
if (remainingCandidates.length === 0) {
await updateTaskInDb(taskDbId, {
status: "failed",
error: `All image providers failed: ${[...errors, providerError].join(" | ")}`,
});
return true;
}
console.warn(`[ai/image] provider ${provider} failed during polling for task ${taskDbId}: ${failureMessage}`);
await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null });
try {
await submitImageWithProviderFallback(taskDbId, remainingCandidates, user, preauth, params, [
...errors,
providerError,
]);
return true;
} catch (fallbackErr) {
await updateTaskInDb(taskDbId, { status: "failed", error: fallbackErr.message });
return true;
}
},
});
recordProviderSuccess(provider, 0);
if (index > 0) {
console.info(`[ai/image] task ${taskDbId} switched provider to ${provider}`);
}
return;
} catch (err) {
const message = err?.message || String(err);
errors.push(`${provider}: ${message}`);
console.warn(`[ai/image] provider ${provider} failed for task ${taskDbId}: ${message}`);
recordProviderFailure(provider);
releaseLease(slotResult);
if (index < candidates.length - 1) {
await updateTaskInDb(taskDbId, { status: "pending", progress: 5, providerTaskId: null, error: null });
}
}
}
throw new Error(errors.length ? `All image providers failed: ${errors.join(" | ")}` : "No image provider available");
}
async function submitImageToProvider(taskDbId, providerConfig, slotResult, params, options = {}) {
const url = getPostUrl(providerConfig);
const { headers, body } = buildImageRequest(providerConfig, params, slotResult.apiKey);
await updateTaskInDb(taskDbId, { status: "running", progress: 10 });
const submitTimeout = providerConfig.transport === "gemini-image" ? GEMINI_IMAGE_SUBMIT_TIMEOUT_MS : IMAGE_PROVIDER_SUBMIT_TIMEOUT_MS;
const response = await fetchWithTimeout(url, { method: "POST", headers, body: JSON.stringify(body) }, submitTimeout);
if (!response.ok) {
const errText = await response.text().catch(() => "provider error");
throw new Error(sanitizeUpstreamError(errText, `图片服务返回 HTTP ${response.status}`));
}
const json = await response.json();
// Synchronous transports — extract image URL directly, no polling
if (providerConfig.transport === "rightcode-image" || providerConfig.transport === "gemini-image" || providerConfig.transport === "openai-image") {
let directUrl = extractImageUrl(json) || extractGeminiImageUrl(json);
const tag = providerConfig.transport === "rightcode-image" ? "rightcode" : "kuaikuai";
console.info(
`[ai/image/${tag}] task ${taskDbId} direct result ${directUrl ? "parsed" : "missing"} for model ${providerConfig.model || params.model}`,
);
if (!directUrl) throw new Error(`${tag} did not return an image url`);
// Gemini may return base64 data URL — too large for DB, upload to OSS first
if (directUrl.startsWith("data:") && isOssConfigured()) {
const match = directUrl.match(/^data:([^;,]+);base64,(.+)$/);
if (match) {
const mimeType = match[1];
const buffer = Buffer.from(match[2], "base64");
const ext = mimeType.split("/")[1] || "png";
const ossKey = `tmp/${String(params.userId || "gen").replace(/[^a-zA-Z0-9_-]/g, "")}/generation-results/${Date.now()}_${crypto.randomUUID()}.${ext}`;
await putObject(ossKey, buffer, mimeType, { "x-oss-object-acl": "public-read" });
const bucket = process.env.OSS_BUCKET || "";
const region = (process.env.OSS_REGION || "").replace(/^oss-/, "");
directUrl = process.env.OSS_PUBLIC_BASE_URL
? `${process.env.OSS_PUBLIC_BASE_URL.replace(/\/+$/, "")}/${ossKey}`
: `https://${bucket}.oss-${region}.aliyuncs.com/${ossKey}`;
console.info(`[ai/image/${tag}] task ${taskDbId} base64 result uploaded to OSS: ${ossKey}`);
}
}
await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl });
console.info(`[ai/image/${tag}] task ${taskDbId} completed with direct image result`);
releaseLease(slotResult);
return;
}
const directUrl = extractImageUrl(json);
const providerTaskId = extractProviderTaskId(json);
if (directUrl) {
console.info(`[ai/image/grsai] task ${taskDbId} completed with direct result from submit response`);
await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl });
releaseLease(slotResult);
return;
}
if (!providerTaskId) throw new Error("Provider did not return taskId");
await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 });
startPolling(taskDbId, {
providerTaskId,
apiKey: slotResult.apiKey,
type: "image",
providerConfig,
leaseToken: slotResult.leaseToken,
keyManager,
onTaskFailed: options.onTaskFailed,
});
}
async function submitVideoToProvider(taskDbId, providerConfig, slotResult, params) {
const url = `${providerConfig.baseUrl}${providerConfig.endpoint}`;
const { headers, body } = buildVideoRequest(providerConfig, params, slotResult.apiKey);
await updateTaskInDb(taskDbId, { status: "running", progress: 10 });
if (providerConfig.protocol === "wan-s2v") {
await assertWanS2vImageDetected(providerConfig, params, slotResult.apiKey);
await updateTaskInDb(taskDbId, { status: "running", progress: 16 });
}
const response = await fetch(url, { method: "POST", headers, body: JSON.stringify(body) });
if (!response.ok) {
const errText = await response.text().catch(() => "provider error");
throw new Error(sanitizeUpstreamError(errText, `视频服务返回 HTTP ${response.status}`));
}
const json = await response.json();
const directUrl = extractVideoUrl(json);
const providerTaskId = extractProviderTaskId(json);
if (directUrl && !providerTaskId) {
await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl });
releaseLease(slotResult);
return;
}
if (!providerTaskId) throw new Error("Video provider did not return taskId");
await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 });
startPolling(taskDbId, {
providerTaskId,
apiKey: slotResult.apiKey,
type: "video",
providerConfig,
leaseToken: slotResult.leaseToken,
keyManager,
});
}
async function submitDashscopeImageSuperResolveTask(taskDbId, slotResult, params) {
await updateTaskInDb(taskDbId, { status: "running", progress: 10 });
const body = buildDashscopeImageSuperResolveBody(params);
const response = await fetch(DASHSCOPE_IMAGE_EDIT_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
Authorization: `Bearer ${slotResult.apiKey}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errText = await response.text().catch(() => "provider error");
throw new Error(sanitizeUpstreamError(errText, `图片超分服务返回 HTTP ${response.status}`));
}
const json = await response.json();
const directUrl = extractImageUrl(json);
const providerTaskId = extractProviderTaskId(json);
if (directUrl && !providerTaskId) {
await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl });
releaseLease(slotResult);
return;
}
if (!providerTaskId) throw new Error("DashScope image super-resolution did not return taskId");
await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 });
startPolling(taskDbId, {
providerTaskId,
apiKey: slotResult.apiKey,
type: "image",
providerConfig: { transport: "dashscope-image" },
leaseToken: slotResult.leaseToken,
keyManager,
});
}
async function submitDashscopeVideoStyleTransformTask(taskDbId, slotResult, params) {
await updateTaskInDb(taskDbId, { status: "running", progress: 10 });
const body = buildDashscopeVideoStyleTransformBody(params);
const response = await fetch(DASHSCOPE_VIDEO_STYLE_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
Authorization: `Bearer ${slotResult.apiKey}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errText = await response.text().catch(() => "provider error");
throw new Error(sanitizeUpstreamError(errText, `视频风格重绘超分服务返回 HTTP ${response.status}`));
}
const json = await response.json();
const directUrl = extractVideoUrl(json);
const providerTaskId = extractProviderTaskId(json);
if (directUrl && !providerTaskId) {
await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl });
releaseLease(slotResult);
return;
}
if (!providerTaskId) throw new Error("DashScope video style transform did not return taskId");
await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 });
startPolling(taskDbId, {
providerTaskId,
apiKey: slotResult.apiKey,
type: "video",
providerConfig: {
protocol: "wan-i2v",
baseUrl: "https://dashscope.aliyuncs.com",
},
leaseToken: slotResult.leaseToken,
keyManager,
});
}
async function submitVideoSuperResolveTask(taskDbId, params) {
await updateTaskInDb(taskDbId, { status: "running", progress: 8 });
const submitResult = await callAliyunRpc("SuperResolveVideo", {
VideoUrl: toViapiAccessibleUrl(params.videoUrl),
BitRate: String(params.bitRate || 10),
});
const jobId = submitResult.RequestId || submitResult.requestId || submitResult.JobId || submitResult.jobId;
if (!jobId) {
throw new Error("Aliyun SuperResolveVideo did not return a job id");
}
await updateTaskInDb(taskDbId, { providerTaskId: jobId, status: "running", progress: 18 });
for (let attempt = 0; attempt < SUPER_RESOLVE_MAX_POLL_ATTEMPTS; attempt += 1) {
if (attempt > 0) {
await new Promise((resolve) => setTimeout(resolve, SUPER_RESOLVE_POLL_INTERVAL_MS));
}
const result = await callAliyunRpc("GetAsyncJobResult", { JobId: jobId });
const data = result.Data || result.data || {};
const status = normalizeAliyunJobStatus(data.Status || data.status);
const progress = Math.min(96, 18 + Math.round((attempt / SUPER_RESOLVE_MAX_POLL_ATTEMPTS) * 76));
if (status === "PROCESS_SUCCESS" || status === "SUCCESS" || status === "SUCCEEDED") {
const resultPayload = parseAliyunJsonResult(data.Result || data.result) || data;
const videoUrl = resultPayload.VideoUrl || resultPayload.videoUrl || resultPayload.video_url;
if (!videoUrl) {
throw new Error("Aliyun super-resolution completed without a video url");
}
await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: videoUrl });
return;
}
if (
status === "PROCESS_FAILED" ||
status === "FAIL" ||
status === "FAILED" ||
status === "TIMEOUT_FAILED" ||
status === "LIMIT_RETRY_FAILED"
) {
throw new Error(data.Message || data.MessageDetail || data.ErrorMessage || "Aliyun video super-resolution failed");
}
await updateTaskInDb(taskDbId, { status: "running", progress });
}
throw new Error("Aliyun video super-resolution timed out");
}
async function submitEraseSubtitlesTask(taskDbId, params) {
await updateTaskInDb(taskDbId, { status: "running", progress: 8 });
const rpcParams = { VideoUrl: toViapiAccessibleUrl(params.videoUrl) };
if (params.bx || params.by || params.bw || params.bh) {
rpcParams.BX = String(params.bx || 0);
rpcParams.BY = String(params.by || 0);
rpcParams.BW = String(params.bw || 0);
rpcParams.BH = String(params.bh || 0);
}
const submitResult = await callAliyunRpc("EraseVideoSubtitles", rpcParams);
const jobId = submitResult.RequestId || submitResult.requestId || submitResult.JobId || submitResult.jobId;
if (!jobId) {
throw new Error("Aliyun EraseVideoSubtitles did not return a job id");
}
await updateTaskInDb(taskDbId, { providerTaskId: jobId, status: "running", progress: 18 });
for (let attempt = 0; attempt < SUPER_RESOLVE_MAX_POLL_ATTEMPTS; attempt += 1) {
if (attempt > 0) {
await new Promise((resolve) => setTimeout(resolve, SUPER_RESOLVE_POLL_INTERVAL_MS));
}
const result = await callAliyunRpc("GetAsyncJobResult", { JobId: jobId });
const data = result.Data || result.data || {};
const status = normalizeAliyunJobStatus(data.Status || data.status);
const progress = Math.min(96, 18 + Math.round((attempt / SUPER_RESOLVE_MAX_POLL_ATTEMPTS) * 76));
if (status === "PROCESS_SUCCESS" || status === "SUCCESS" || status === "SUCCEEDED") {
const resultPayload = parseAliyunJsonResult(data.Result || data.result) || data;
const videoUrl = resultPayload.VideoUrl || resultPayload.videoUrl || resultPayload.video_url;
if (!videoUrl) {
throw new Error("Aliyun subtitle erasure completed without a video url");
}
await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: videoUrl });
return;
}
if (
status === "PROCESS_FAILED" ||
status === "FAIL" ||
status === "FAILED" ||
status === "TIMEOUT_FAILED" ||
status === "LIMIT_RETRY_FAILED"
) {
throw new Error(data.Message || data.MessageDetail || data.ErrorMessage || "字幕去除失败");
}
await updateTaskInDb(taskDbId, { status: "running", progress });
}
throw new Error("字幕去除超时");
}
async function submitDashscopeImageEditTask(taskDbId, slotResult, params) {
await updateTaskInDb(taskDbId, { status: "running", progress: 10 });
const WAN27_IMAGE_EDIT_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1/services/aigc/image-generation/generation";
const body = {
model: "wan2.7-image-pro",
input: {
messages: [{
role: "user",
content: [
{ image: params.imageUrl },
{ text: params.prompt || "去除图像中的水印和文字" },
],
}],
},
parameters: {
size: "2K",
n: params.n || 1,
watermark: false,
},
};
const response = await fetch(WAN27_IMAGE_EDIT_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-DashScope-Async": "enable",
Authorization: `Bearer ${slotResult.apiKey}`,
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errText = await response.text().catch(() => "provider error");
throw new Error(sanitizeUpstreamError(errText, `图片编辑服务返回 HTTP ${response.status}`));
}
const json = await response.json();
const directUrl = extractImageUrl(json);
const providerTaskId = extractProviderTaskId(json);
if (directUrl && !providerTaskId) {
await updateTaskInDb(taskDbId, { status: "completed", progress: 100, resultUrl: directUrl });
releaseLease(slotResult);
return;
}
if (!providerTaskId) throw new Error("DashScope image edit did not return taskId");
await updateTaskInDb(taskDbId, { providerTaskId, status: "running", progress: 20 });
startPolling(taskDbId, {
providerTaskId,
apiKey: slotResult.apiKey,
type: "image",
providerConfig: { transport: "dashscope-image" },
leaseToken: slotResult.leaseToken,
keyManager,
});
}
module.exports = { registerAiRoutes };