2277 lines
84 KiB
JavaScript
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 };
|