2026-06-02 13:14:10 +08:00
"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 ( ) ;
2026-06-04 18:58:45 +08:00
const streamTimer = setTimeout ( ( ) => abortController . abort ( ) , 120000 ) ;
2026-06-02 13:14:10 +08:00
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 ( ) ;
2026-06-04 18:58:45 +08:00
const nonStreamTimer = setTimeout ( ( ) => nonStreamAbort . abort ( ) , 120000 ) ;
2026-06-02 13:14:10 +08:00
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 ( ) ;
2026-06-04 18:58:45 +08:00
const fbTimer = setTimeout ( ( ) => fbAbort . abort ( ) , 90000 ) ;
2026-06-02 13:14:10 +08:00
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 ) ;
2026-06-04 18:58:45 +08:00
res . status ( err . name === "AbortError" ? 504 : 500 ) . json ( { error : err . name === "AbortError" ? "AI 上游响应超时,请重试" : err . message } ) ;
2026-06-02 13:14:10 +08:00
}
} ) ;
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 ) {
2026-06-04 18:58:45 +08:00
res . status ( err . name === "AbortError" ? 504 : 500 ) . json ( { error : err . name === "AbortError" ? "AI 上游响应超时,请重试" : err . message } ) ;
2026-06-02 13:14:10 +08:00
}
} ) ;
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 ) {
2026-06-04 18:58:45 +08:00
res . status ( err . name === "AbortError" ? 504 : 500 ) . json ( { error : err . name === "AbortError" ? "AI 上游响应超时,请重试" : err . message } ) ;
2026-06-02 13:14:10 +08:00
}
} ) ;
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 ) {
2026-06-04 18:58:45 +08:00
if ( ! res . headersSent ) res . status ( err . name === "AbortError" ? 504 : 500 ) . json ( { error : err . name === "AbortError" ? "AI 上游响应超时,请重试" : err . message } ) ;
2026-06-02 13:14:10 +08:00
}
} ) ;
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 ) ;
2026-06-04 18:58:45 +08:00
if ( ! res . headersSent ) res . status ( err . name === "AbortError" ? 504 : 500 ) . json ( { error : err . name === "AbortError" ? "AI 上游响应超时,请重试" : err . message } ) ;
2026-06-02 13:14:10 +08:00
}
} ) ;
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 ) ;
2026-06-04 18:58:45 +08:00
if ( ! res . headersSent ) res . status ( err . name === "AbortError" ? 504 : 500 ) . json ( { error : err . name === "AbortError" ? "AI 上游响应超时,请重试" : err . message } ) ;
2026-06-02 13:14:10 +08:00
}
} ) ;
}
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 } ;