"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 = /^]/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 };