import { mkdir, readFile, writeFile } from 'node:fs/promises'; import http, { type IncomingMessage, type RequestOptions, type ServerResponse, } from 'node:http'; import https from 'node:https'; import path from 'node:path'; import { loadEnv, type Plugin } from 'vite'; import { buildMasterPrompt, buildVideoActionPrompt, getActionTemplateById, } from '../../src/tools/qwenSpriteSheetToolModel'; const CHARACTER_VISUAL_GENERATE_PATH = '/api/character-visual/generate'; const CHARACTER_VISUAL_JOBS_PATH = '/api/character-visual/jobs/'; const CHARACTER_ANIMATION_GENERATE_PATH = '/api/animation/generate'; const CHARACTER_ANIMATION_JOBS_PATH = '/api/animation/jobs/'; const CHARACTER_ANIMATION_IMPORT_VIDEO_PATH = '/api/animation/import-video'; const CHARACTER_ANIMATION_TEMPLATES_PATH = '/api/animation/templates'; const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1'; const DEFAULT_CHARACTER_VISUAL_MODEL = 'wan2.7-image-pro'; const DEFAULT_CHARACTER_VIDEO_MODEL = 'wan2.2-kf2v-flash'; const DEFAULT_CHARACTER_REFERENCE_VIDEO_MODEL = 'wan2.7-r2v'; const DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL = 'wan2.2-animate-move'; const DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS = 2500; const DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS = 15000; const DASHSCOPE_IMAGE_TASK_TIMEOUT_MS = 180000; const DASHSCOPE_VIDEO_TASK_TIMEOUT_MS = 420000; const BUILT_IN_MOTION_TEMPLATES = [ { id: 'idle_loop', label: '待机循环', animation: 'idle', promptSuffix: '保持呼吸感和轻微重心起伏。', notes: '适合方案三的默认待机模板。', }, { id: 'run_side', label: '奔跑侧移', animation: 'run', promptSuffix: '保持平稳横向移动,脚步连续。', notes: '适合横版角色的标准奔跑模板。', }, { id: 'attack_slash', label: '横斩攻击', animation: 'attack', promptSuffix: '短促前踏后横斩,收招干净。', notes: '适合近战角色的基础攻击模板。', }, { id: 'hurt_back', label: '受击后仰', animation: 'hurt', promptSuffix: '身体后仰,重心短暂失衡后稳住。', notes: '适合方案三的受击模板。', }, { id: 'die_fall', label: '倒地死亡', animation: 'die', promptSuffix: '失衡倒地,动作完整结束。', notes: '适合终结动作模板。', }, ] as const; type RequestResponse = { statusCode: number; headers: Record; body: Buffer; }; type DecodedMediaPayload = { buffer: Buffer; mimeType: string; extension: string; }; function readJsonBody(req: IncomingMessage) { return new Promise>((resolve, reject) => { const chunks: Buffer[] = []; req.on('data', (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); req.on('end', () => { try { const raw = Buffer.concat(chunks).toString('utf8') || '{}'; resolve(JSON.parse(raw)); } catch (error) { reject(error); } }); req.on('error', reject); }); } function sendJson(res: ServerResponse, statusCode: number, payload: unknown) { res.statusCode = statusCode; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(payload)); } function isRecordValue(value: unknown): value is Record { return Boolean(value) && typeof value === 'object' && !Array.isArray(value); } function isStringArray(value: unknown): value is string[] { return ( Array.isArray(value) && value.every((item) => typeof item === 'string' && item.trim().length > 0) ); } function sleep(ms: number) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } function normalizeDashScopeBaseUrl(value: string) { return value.replace(/\/$/u, ''); } function resolveRuntimeEnv( rootDir: string, mode: string, env: Record, ) { return { ...env, ...loadEnv(mode, rootDir, ''), }; } function extractApiErrorMessage(responseText: string, fallbackMessage: string) { if (!responseText.trim()) { return fallbackMessage; } try { const parsed = JSON.parse(responseText) as { code?: string; message?: string; error?: { message?: string }; }; if ( typeof parsed.error?.message === 'string' && parsed.error.message.trim() ) { return parsed.error.message; } if (typeof parsed.message === 'string' && parsed.message.trim()) { return parsed.message; } if (typeof parsed.code === 'string' && parsed.code.trim()) { return `${fallbackMessage} (${parsed.code})`; } } catch { // Fall through. } return responseText; } function sanitizePathSegment(value: string) { const normalized = value .trim() .toLowerCase() .replace(/[^a-z0-9-_]+/gu, '-') .replace(/-+/gu, '-') .replace(/^-|-$/gu, ''); return normalized || 'asset'; } function createTimestampId(prefix: string) { return `${prefix}-${Date.now()}`; } function getJobRecordPath( rootDir: string, kind: 'visual' | 'animation', taskId: string, ) { return path.resolve( rootDir, 'public', 'generated-character-drafts', '_jobs', kind, `${sanitizePathSegment(taskId)}.json`, ); } async function writeJobRecord( rootDir: string, kind: 'visual' | 'animation', taskId: string, payload: Record, ) { const filePath = getJobRecordPath(rootDir, kind, taskId); await mkdir(path.dirname(filePath), { recursive: true }); await writeFile(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8'); } async function readJobRecord( rootDir: string, kind: 'visual' | 'animation', taskId: string, ) { const filePath = getJobRecordPath(rootDir, kind, taskId); const raw = await readFile(filePath, 'utf8'); return JSON.parse(raw) as Record; } function decodeMediaDataUrl(dataUrl: string): DecodedMediaPayload { const matched = /^data:([^;]+);base64,(.+)$/u.exec(dataUrl); if (!matched) { throw new Error('不支持的媒体数据,要求使用 Base64 Data URL。'); } const mimeType = matched[1]; const base64Payload = matched[2]; const extension = (() => { switch (mimeType) { case 'image/jpeg': return 'jpg'; case 'image/png': return 'png'; case 'image/webp': return 'webp'; case 'video/mp4': return 'mp4'; case 'video/quicktime': return 'mov'; case 'video/x-msvideo': return 'avi'; default: return mimeType.split('/')[1] ?? 'bin'; } })(); return { buffer: Buffer.from(base64Payload, 'base64'), mimeType, extension, }; } async function resolveMediaSourcePayload( rootDir: string, source: string, ): Promise { const dataUrlMatch = /^data:/u.test(source); if (dataUrlMatch) { return decodeMediaDataUrl(source); } if (!source.startsWith('/')) { throw new Error('媒体来源必须是 Data URL 或 public 目录下的 URL。'); } const normalizedSource = path.posix.normalize(source).replace(/^\/+/u, ''); const absolutePath = path.resolve( rootDir, 'public', ...normalizedSource.split('/'), ); const publicRoot = path.resolve(rootDir, 'public'); if (!absolutePath.startsWith(publicRoot)) { throw new Error('媒体来源路径越界。'); } const buffer = await readFile(absolutePath); const extension = path .extname(absolutePath) .replace(/^\./u, '') .toLowerCase(); const mimeType = (() => { switch (extension) { case 'jpg': case 'jpeg': return 'image/jpeg'; case 'png': return 'image/png'; case 'webp': return 'image/webp'; case 'mp4': return 'video/mp4'; case 'mov': return 'video/quicktime'; case 'avi': return 'video/x-msvideo'; default: return 'application/octet-stream'; } })(); return { buffer, mimeType, extension: extension || 'bin', }; } async function resolveMediaSourceAsDataUrl( rootDir: string, source: string, ) { if (/^data:/u.test(source)) { return source; } const payload = await resolveMediaSourcePayload(rootDir, source); return `data:${payload.mimeType};base64,${payload.buffer.toString('base64')}`; } function requestResponse( urlString: string, options: { method?: string; headers?: Record; body?: Buffer | string; } = {}, ) { return new Promise((resolve, reject) => { const url = new URL(urlString); const transport = url.protocol === 'https:' ? https : http; const payload = typeof options.body === 'string' ? Buffer.from(options.body) : options.body; const requestOptions: RequestOptions = { protocol: url.protocol, hostname: url.hostname, port: url.port ? Number(url.port) : undefined, path: `${url.pathname}${url.search}`, method: options.method ?? 'GET', headers: { ...(options.headers ?? {}), ...(payload ? { 'Content-Length': String(payload.byteLength) } : {}), }, }; const request = transport.request(requestOptions, (upstreamRes) => { const chunks: Buffer[] = []; upstreamRes.on('data', (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); }); upstreamRes.on('end', () => { resolve({ statusCode: upstreamRes.statusCode ?? 502, headers: upstreamRes.headers, body: Buffer.concat(chunks), }); }); upstreamRes.on('error', reject); }); request.on('error', reject); if (payload) { request.write(payload); } request.end(); }); } function getRequestPathname(req: IncomingMessage) { return new URL(req.url || '/', 'http://localhost').pathname; } function requestTextResponse( urlString: string, options: { method?: string; headers?: Record; body?: Buffer | string; } = {}, ) { return requestResponse(urlString, options).then((response) => ({ ...response, bodyText: response.body.toString('utf8'), })); } function requestBinaryResponse( urlString: string, options: { method?: string; headers?: Record; } = {}, ) { return requestResponse(urlString, options); } function proxyJsonRequest( urlString: string, apiKey: string, body: Record, extraHeaders: Record = {}, ) { return requestTextResponse(urlString, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', ...extraHeaders, }, body: JSON.stringify(body), }); } function buildMultipartBody( fields: Array<{ name: string; value: string }>, file: { fieldName: string; fileName: string; contentType: string; buffer: Buffer; }, ) { const boundary = `----GenarrativeBoundary${Date.now().toString(16)}`; const chunks: Buffer[] = []; fields.forEach((field) => { chunks.push( Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="${field.name}"\r\n\r\n${field.value}\r\n`, ), ); }); chunks.push( Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="${file.fieldName}"; filename="${file.fileName}"\r\nContent-Type: ${file.contentType}\r\n\r\n`, ), ); chunks.push(file.buffer); chunks.push(Buffer.from(`\r\n--${boundary}--\r\n`)); return { boundary, body: Buffer.concat(chunks), }; } async function uploadFileToDashScope( baseUrl: string, apiKey: string, model: string, fileName: string, payload: DecodedMediaPayload, ) { const policyResponse = await requestTextResponse( `${baseUrl}/uploads?action=getPolicy&model=${encodeURIComponent(model)}`, { method: 'GET', headers: { Authorization: `Bearer ${apiKey}`, }, }, ); if (policyResponse.statusCode < 200 || policyResponse.statusCode >= 300) { throw new Error( extractApiErrorMessage( policyResponse.bodyText, '获取阿里云临时上传策略失败。', ), ); } const policyResponsePayload = JSON.parse(policyResponse.bodyText) as { data?: { upload_host?: string; upload_dir?: string; policy?: string; signature?: string; oss_access_key_id?: string; x_oss_object_acl?: string; x_oss_content_type?: string; x_oss_forbid_overwrite?: string; 'x-oss-object-acl'?: string; 'x-oss-content-type'?: string; 'x-oss-forbid-overwrite'?: string; }; }; const policyPayload = policyResponsePayload.data ?? {}; if ( !policyPayload.upload_host || !policyPayload.upload_dir || !policyPayload.policy || !policyPayload.signature || !policyPayload.oss_access_key_id ) { throw new Error('阿里云临时上传策略返回不完整。'); } const objectKey = `${policyPayload.upload_dir.replace(/\/+$/u, '')}/${sanitizePathSegment(fileName)}.${payload.extension}`; const multipart = buildMultipartBody( [ { name: 'key', value: objectKey }, { name: 'OSSAccessKeyId', value: policyPayload.oss_access_key_id }, { name: 'policy', value: policyPayload.policy }, { name: 'Signature', value: policyPayload.signature }, { name: 'success_action_status', value: '200' }, ...(policyPayload.x_oss_object_acl || policyPayload['x-oss-object-acl'] ? [ { name: 'x-oss-object-acl', value: policyPayload.x_oss_object_acl || policyPayload['x-oss-object-acl'] || '', }, ] : []), ...(policyPayload.x_oss_forbid_overwrite || policyPayload['x-oss-forbid-overwrite'] ? [ { name: 'x-oss-forbid-overwrite', value: policyPayload.x_oss_forbid_overwrite || policyPayload['x-oss-forbid-overwrite'] || '', }, ] : []), ...(policyPayload.x_oss_content_type || policyPayload['x-oss-content-type'] ? [ { name: 'x-oss-content-type', value: policyPayload.x_oss_content_type || policyPayload['x-oss-content-type'] || '', }, ] : []), ], { fieldName: 'file', fileName, contentType: payload.mimeType, buffer: payload.buffer, }, ); const uploadResponse = await requestTextResponse(policyPayload.upload_host, { method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=${multipart.boundary}`, }, body: multipart.body, }); if (uploadResponse.statusCode < 200 || uploadResponse.statusCode >= 300) { throw new Error( extractApiErrorMessage(uploadResponse.bodyText, '上传媒体文件失败。'), ); } return `oss://${objectKey}`; } async function waitForDashScopeTask( baseUrl: string, apiKey: string, taskId: string, options: { timeoutMs: number; intervalMs: number; }, ) { const deadline = Date.now() + options.timeoutMs; while (Date.now() < deadline) { const response = await requestTextResponse(`${baseUrl}/tasks/${taskId}`, { method: 'GET', headers: { Authorization: `Bearer ${apiKey}`, }, }); if (response.statusCode < 200 || response.statusCode >= 300) { throw new Error( extractApiErrorMessage( response.bodyText, `查询任务失败(${response.statusCode})。`, ), ); } const parsed = JSON.parse(response.bodyText) as Record; const output = isRecordValue(parsed.output) ? parsed.output : null; const taskStatus = output && typeof output.task_status === 'string' ? output.task_status : ''; if (taskStatus === 'SUCCEEDED') { return parsed; } if (taskStatus === 'FAILED' || taskStatus === 'CANCELED') { throw new Error( extractApiErrorMessage(response.bodyText, '任务执行失败。'), ); } if (taskStatus === 'UNKNOWN') { throw new Error('任务状态未知,可能已过期。'); } await sleep(options.intervalMs); } throw new Error('任务执行超时,请稍后重试。'); } function findFirstStringByKey( value: unknown, targetKey: string, ): string | null { if (Array.isArray(value)) { for (const item of value) { const candidate = findFirstStringByKey(item, targetKey); if (candidate) { return candidate; } } return null; } if (!isRecordValue(value)) { return null; } const directValue = value[targetKey]; if (typeof directValue === 'string' && directValue.trim()) { return directValue.trim(); } for (const nestedValue of Object.values(value)) { const candidate = findFirstStringByKey(nestedValue, targetKey); if (candidate) { return candidate; } } return null; } function collectStringsByKey( value: unknown, targetKey: string, results: string[], ) { if (Array.isArray(value)) { value.forEach((item) => collectStringsByKey(item, targetKey, results)); return; } if (!isRecordValue(value)) { return; } const directValue = value[targetKey]; if (typeof directValue === 'string' && directValue.trim()) { results.push(directValue.trim()); } Object.values(value).forEach((nestedValue) => collectStringsByKey(nestedValue, targetKey, results), ); } function extractTaskId(payload: Record) { return findFirstStringByKey(payload, 'task_id') ?? ''; } function extractVideoUrl(payload: Record) { return ( findFirstStringByKey(payload, 'video_url') ?? findFirstStringByKey(payload, 'url') ?? '' ); } function extractImageUrls(payload: Record) { const urls: string[] = []; collectStringsByKey(payload, 'image', urls); collectStringsByKey(payload, 'url', urls); return [...new Set(urls)]; } function buildNpcVisualPrompt( promptText: string, characterBriefText = '', ) { const mergedBrief = [characterBriefText.trim(), promptText.trim()] .filter(Boolean) .join('\n'); return buildMasterPrompt(mergedBrief || '自定义世界角色,服装完整,姿态自然。'); } function buildImageSequencePrompt( animation: string, promptText: string, frameCount: number, useChromaKey: boolean, ) { return [ `同一角色连续 ${frameCount} 帧动作序列,动作主题是 ${animation}。`, '固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。', '帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。', useChromaKey ? '纯绿色背景,无地面装饰,方便后期抠像。' : '背景尽量纯净,避免复杂场景。', promptText.trim(), ] .filter(Boolean) .join(' '); } function buildNpcAnimationPrompt(options: { animation: string; promptText: string; useChromaKey: boolean; characterBriefText?: string; actionTemplateId?: string; }) { if (options.actionTemplateId) { return buildVideoActionPrompt({ actionTemplate: getActionTemplateById( options.actionTemplateId as Parameters[0], ), actionDetailText: options.promptText, useChromaKey: options.useChromaKey, characterBrief: options.characterBriefText?.trim() || `${options.animation} 动作角色`, }); } return [ `单人 NPC 全身动作视频,动作主题是 ${options.animation}。`, '角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。', '动作连贯,避免服装、发型、面部、武器随机漂移。', options.useChromaKey ? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。' : '背景简洁纯净,无复杂场景。', options.characterBriefText?.trim() ? `角色设定:${options.characterBriefText.trim()}` : '', options.promptText.trim(), ] .filter(Boolean) .join(' '); } async function writeDraftBinaryFile( rootDir: string, relativePath: string, buffer: Buffer, ) { const absolutePath = path.resolve( rootDir, 'public', ...relativePath.split('/'), ); await mkdir(path.dirname(absolutePath), { recursive: true }); await writeFile(absolutePath, buffer); return `/${relativePath}`; } async function handleGenerateCharacterVisuals( rootDir: string, mode: string, env: Record, req: IncomingMessage, res: ServerResponse, ) { if (req.method !== 'POST') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; } const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env); const baseUrl = normalizeDashScopeBaseUrl( runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, ); const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; const timeoutMs = Number( runtimeEnv.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS || DASHSCOPE_IMAGE_TASK_TIMEOUT_MS, ); if (!apiKey) { sendJson(res, 500, { error: { message: '缺少 DASHSCOPE_API_KEY,无法生成角色主形象。' }, }); return; } let body: Record; try { body = await readJsonBody(req); } catch { sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); return; } const characterId = typeof body.characterId === 'string' ? body.characterId.trim() : 'character'; const sourceMode = typeof body.sourceMode === 'string' ? body.sourceMode.trim() : ''; const promptText = typeof body.promptText === 'string' ? body.promptText.trim() : ''; const characterBriefText = typeof body.characterBriefText === 'string' ? body.characterBriefText.trim() : ''; const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls) ? body.referenceImageDataUrls.slice(0, 4) : []; const candidateCountRaw = typeof body.candidateCount === 'number' ? body.candidateCount : 3; const candidateCount = Math.max( 1, Math.min(4, Math.round(candidateCountRaw)), ); const model = typeof body.imageModel === 'string' && body.imageModel.trim() ? body.imageModel.trim() : runtimeEnv.DASHSCOPE_CHARACTER_VISUAL_MODEL || runtimeEnv.DASHSCOPE_IMAGE_MODEL || DEFAULT_CHARACTER_VISUAL_MODEL; const size = typeof body.size === 'string' && body.size.trim() ? body.size.trim() : '1024*1536'; if (sourceMode === 'image-to-image' && referenceImageDataUrls.length === 0) { sendJson(res, 400, { error: { message: '图生主形象至少需要一张参考图。' }, }); return; } if (!promptText && !characterBriefText && sourceMode === 'text-to-image') { sendJson(res, 400, { error: { message: '文生主形象需要填写角色设定。' }, }); return; } let activeTaskId = ''; let activePrompt = ''; try { const finalPrompt = buildNpcVisualPrompt(promptText, characterBriefText); activePrompt = finalPrompt; const content = [ { text: finalPrompt }, ...referenceImageDataUrls.map((image) => ({ image })), ]; const createTaskResponse = await proxyJsonRequest( `${baseUrl}/services/aigc/image-generation/generation`, apiKey, { model, input: { messages: [ { role: 'user', content, }, ], }, parameters: { n: candidateCount, size, prompt_extend: true, watermark: false, }, }, { 'X-DashScope-Async': 'enable', }, ); if ( createTaskResponse.statusCode < 200 || createTaskResponse.statusCode >= 300 ) { sendJson(res, createTaskResponse.statusCode, { error: { message: extractApiErrorMessage( createTaskResponse.bodyText, '创建角色主形象任务失败。', ), }, }); return; } const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< string, unknown >; const taskId = extractTaskId(taskPayload); activeTaskId = taskId; if (!taskId) { throw new Error('角色主形象任务未返回 task_id。'); } const createdAt = new Date().toISOString(); await writeJobRecord(rootDir, 'visual', taskId, { taskId, kind: 'visual', status: 'running', characterId, model, prompt: finalPrompt, createdAt, updatedAt: createdAt, }); const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DASHSCOPE_IMAGE_TASK_TIMEOUT_MS, intervalMs: DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS, }); const imageUrls = extractImageUrls(taskResult).slice(0, candidateCount); if (imageUrls.length === 0) { throw new Error('角色主形象生成成功,但没有返回可下载图片。'); } const jobId = createTimestampId('visual-draft'); const draftRelativeDir = path.posix.join( 'generated-character-drafts', sanitizePathSegment(characterId), 'visual', jobId, ); const drafts = await Promise.all( imageUrls.map(async (imageUrl, index) => { const imageResponse = await requestBinaryResponse(imageUrl); if (imageResponse.statusCode < 200 || imageResponse.statusCode >= 300) { throw new Error( `下载主形象候选失败(${imageResponse.statusCode})。`, ); } const fileName = `candidate-${String(index + 1).padStart(2, '0')}.png`; const imageSrc = await writeDraftBinaryFile( rootDir, path.posix.join(draftRelativeDir, fileName), imageResponse.body, ); return { id: `candidate-${index + 1}`, label: `候选 ${index + 1}`, imageSrc, width: 1024, height: 1536, }; }), ); await writeFile( path.resolve( rootDir, 'public', ...draftRelativeDir.split('/'), 'job.json', ), JSON.stringify( { taskId, model, prompt: finalPrompt, sourceMode, createdAt: new Date().toISOString(), imageUrls, }, null, 2, ) + '\n', 'utf8', ); await writeJobRecord(rootDir, 'visual', taskId, { taskId, kind: 'visual', status: 'completed', characterId, model, prompt: finalPrompt, createdAt, updatedAt: new Date().toISOString(), result: { drafts, draftRelativeDir, }, }); sendJson(res, 200, { ok: true, taskId, model, prompt: finalPrompt, drafts, }); } catch (error) { if (activeTaskId) { await writeJobRecord(rootDir, 'visual', activeTaskId, { taskId: activeTaskId, kind: 'visual', status: 'failed', characterId, model, prompt: activePrompt, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), errorMessage: error instanceof Error ? error.message : '生成角色主形象失败。', }); } sendJson(res, 500, { error: { message: error instanceof Error ? error.message : '生成角色主形象候选失败。', }, }); } } async function handleGenerateCharacterAnimation( rootDir: string, mode: string, env: Record, req: IncomingMessage, res: ServerResponse, ) { if (req.method !== 'POST') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; } const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env); const baseUrl = normalizeDashScopeBaseUrl( runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL, ); const apiKey = runtimeEnv.DASHSCOPE_API_KEY || ''; const timeoutMs = Number( runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_REQUEST_TIMEOUT_MS || runtimeEnv.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS || DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, ); if (!apiKey) { sendJson(res, 500, { error: { message: '缺少 DASHSCOPE_API_KEY,无法生成角色动作。' }, }); return; } let body: Record; try { body = await readJsonBody(req); } catch { sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); return; } const characterId = typeof body.characterId === 'string' ? body.characterId.trim() : 'character'; const strategy = typeof body.strategy === 'string' ? body.strategy.trim() : ''; const animation = typeof body.animation === 'string' ? body.animation.trim() : 'idle'; const promptText = typeof body.promptText === 'string' ? body.promptText.trim() : ''; const characterBriefText = typeof body.characterBriefText === 'string' ? body.characterBriefText.trim() : ''; const actionTemplateId = typeof body.actionTemplateId === 'string' ? body.actionTemplateId.trim() : ''; const visualSource = typeof body.visualSource === 'string' ? body.visualSource.trim() : ''; const referenceImageDataUrls = isStringArray(body.referenceImageDataUrls) ? body.referenceImageDataUrls.slice(0, 6) : []; const referenceVideoDataUrls = isStringArray(body.referenceVideoDataUrls) ? body.referenceVideoDataUrls.slice(0, 2) : []; const lastFrameImageDataUrl = typeof body.lastFrameImageDataUrl === 'string' && body.lastFrameImageDataUrl.trim() ? body.lastFrameImageDataUrl.trim() : ''; const frameCount = typeof body.frameCount === 'number' && Number.isFinite(body.frameCount) ? Math.max(2, Math.min(16, Math.round(body.frameCount))) : 8; const requestedDurationSeconds = typeof body.durationSeconds === 'number' && Number.isFinite(body.durationSeconds) ? Math.max(1, Math.min(8, Math.round(body.durationSeconds))) : 4; const useChromaKey = body.useChromaKey !== false; const resolution = typeof body.resolution === 'string' && body.resolution.trim() ? body.resolution.trim() : '720P'; const imageSequenceModel = typeof body.imageSequenceModel === 'string' && body.imageSequenceModel.trim() ? body.imageSequenceModel.trim() : runtimeEnv.DASHSCOPE_CHARACTER_IMAGE_SEQUENCE_MODEL || runtimeEnv.DASHSCOPE_CHARACTER_VISUAL_MODEL || DEFAULT_CHARACTER_VISUAL_MODEL; const videoModel = typeof body.videoModel === 'string' && body.videoModel.trim() ? body.videoModel.trim() : runtimeEnv.DASHSCOPE_CHARACTER_VIDEO_MODEL || DEFAULT_CHARACTER_VIDEO_MODEL; const durationSeconds = videoModel === 'wan2.2-kf2v-flash' ? 5 : requestedDurationSeconds; const normalizedResolution = videoModel === 'wan2.2-kf2v-flash' ? '480P' : resolution; const referenceVideoModel = typeof body.referenceVideoModel === 'string' && body.referenceVideoModel.trim() ? body.referenceVideoModel.trim() : runtimeEnv.DASHSCOPE_CHARACTER_REFERENCE_VIDEO_MODEL || DEFAULT_CHARACTER_REFERENCE_VIDEO_MODEL; const motionTransferModel = typeof body.motionTransferModel === 'string' && body.motionTransferModel.trim() ? body.motionTransferModel.trim() : runtimeEnv.DASHSCOPE_CHARACTER_MOTION_TRANSFER_MODEL || DEFAULT_CHARACTER_MOTION_TRANSFER_MODEL; if (!visualSource) { sendJson(res, 400, { error: { message: '请先准备主形象,再生成动作。' }, }); return; } let activeTaskId = ''; let activePrompt = ''; let activeModel = ''; try { if (strategy === 'image-sequence') { const finalPrompt = buildImageSequencePrompt( animation, promptText, frameCount, useChromaKey, ); activePrompt = finalPrompt; activeModel = imageSequenceModel; const createTaskResponse = await proxyJsonRequest( `${baseUrl}/services/aigc/image-generation/generation`, apiKey, { model: imageSequenceModel, input: { messages: [ { role: 'user', content: [ { text: finalPrompt }, { image: visualSource }, ...referenceImageDataUrls.map((image) => ({ image })), ], }, ], }, parameters: { n: frameCount, size: '768*1024', enable_sequential: true, prompt_extend: true, watermark: false, }, }, { 'X-DashScope-Async': 'enable', }, ); if ( createTaskResponse.statusCode < 200 || createTaskResponse.statusCode >= 300 ) { sendJson(res, createTaskResponse.statusCode, { error: { message: extractApiErrorMessage( createTaskResponse.bodyText, '创建动作序列帧任务失败。', ), }, }); return; } const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< string, unknown >; const taskId = extractTaskId(taskPayload); activeTaskId = taskId; if (!taskId) { throw new Error('动作序列帧任务未返回 task_id。'); } const createdAt = new Date().toISOString(); await writeJobRecord(rootDir, 'animation', taskId, { taskId, kind: 'animation', status: 'running', characterId, animation, strategy, model: imageSequenceModel, prompt: finalPrompt, createdAt, updatedAt: createdAt, }); const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DASHSCOPE_IMAGE_TASK_TIMEOUT_MS, intervalMs: DASHSCOPE_IMAGE_TASK_POLL_INTERVAL_MS, }); const imageUrls = extractImageUrls(taskResult).slice(0, frameCount); if (imageUrls.length === 0) { throw new Error('动作序列帧生成成功,但没有返回图片。'); } const jobId = createTimestampId('animation-seq'); const draftRelativeDir = path.posix.join( 'generated-character-drafts', sanitizePathSegment(characterId), 'animation', sanitizePathSegment(animation), jobId, ); const imageSources = await Promise.all( imageUrls.map(async (imageUrl, index) => { const imageResponse = await requestBinaryResponse(imageUrl); if ( imageResponse.statusCode < 200 || imageResponse.statusCode >= 300 ) { throw new Error(`下载动作帧失败(${imageResponse.statusCode})。`); } return writeDraftBinaryFile( rootDir, path.posix.join( draftRelativeDir, `frame-${String(index + 1).padStart(2, '0')}.png`, ), imageResponse.body, ); }), ); await writeFile( path.resolve( rootDir, 'public', ...draftRelativeDir.split('/'), 'job.json', ), JSON.stringify( { taskId, model: imageSequenceModel, strategy, animation, prompt: finalPrompt, createdAt: new Date().toISOString(), imageUrls, }, null, 2, ) + '\n', 'utf8', ); await writeJobRecord(rootDir, 'animation', taskId, { taskId, kind: 'animation', status: 'completed', characterId, animation, strategy, model: imageSequenceModel, prompt: finalPrompt, createdAt, updatedAt: new Date().toISOString(), result: { imageSources, draftRelativeDir, }, }); sendJson(res, 200, { ok: true, taskId, strategy: 'image-sequence', model: imageSequenceModel, prompt: finalPrompt, imageSources, }); return; } if (strategy === 'image-to-video') { const finalPrompt = buildNpcAnimationPrompt({ animation, promptText, useChromaKey, characterBriefText, actionTemplateId, }); activePrompt = finalPrompt; activeModel = videoModel; const isKf2vFlash = videoModel === 'wan2.2-kf2v-flash'; const visualInputRef = isKf2vFlash ? await resolveMediaSourceAsDataUrl(rootDir, visualSource) : await uploadFileToDashScope( baseUrl, apiKey, videoModel, `${characterId}-${animation}-visual`, await resolveMediaSourcePayload(rootDir, visualSource), ); const lastFrameRef = lastFrameImageDataUrl ? isKf2vFlash ? await resolveMediaSourceAsDataUrl(rootDir, lastFrameImageDataUrl) : await uploadFileToDashScope( baseUrl, apiKey, videoModel, `${characterId}-${animation}-last-frame`, await resolveMediaSourcePayload( rootDir, lastFrameImageDataUrl, ), ) : ''; const inputPayload = isKf2vFlash ? { prompt: finalPrompt, first_frame_url: visualInputRef, ...(lastFrameRef ? { last_frame_url: lastFrameRef } : {}), } : { prompt: finalPrompt, media: [ { type: 'first_frame', url: visualInputRef }, ...(lastFrameRef ? [{ type: 'last_frame', url: lastFrameRef }] : []), ], }; const videoSynthesisEndpoint = isKf2vFlash ? `${baseUrl}/services/aigc/image2video/video-synthesis` : `${baseUrl}/services/aigc/video-generation/video-synthesis`; const createTaskResponse = await proxyJsonRequest( videoSynthesisEndpoint, apiKey, { model: videoModel, input: inputPayload, parameters: { duration: durationSeconds, resolution: normalizedResolution, ...(isKf2vFlash ? { prompt_extend: true, watermark: false } : {}), }, }, { 'X-DashScope-Async': 'enable', 'X-DashScope-OssResourceResolve': 'enable', }, ); if ( createTaskResponse.statusCode < 200 || createTaskResponse.statusCode >= 300 ) { sendJson(res, createTaskResponse.statusCode, { error: { message: extractApiErrorMessage( createTaskResponse.bodyText, '创建图生视频任务失败。', ), }, }); return; } const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< string, unknown >; const taskId = extractTaskId(taskPayload); activeTaskId = taskId; if (!taskId) { throw new Error('图生视频任务未返回 task_id。'); } const createdAt = new Date().toISOString(); await writeJobRecord(rootDir, 'animation', taskId, { taskId, kind: 'animation', status: 'running', characterId, animation, strategy, model: videoModel, prompt: finalPrompt, createdAt, updatedAt: createdAt, }); const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, }); const videoUrl = extractVideoUrl(taskResult); if (!videoUrl) { throw new Error('图生视频成功,但没有返回视频链接。'); } const videoResponse = await requestBinaryResponse(videoUrl); if (videoResponse.statusCode < 200 || videoResponse.statusCode >= 300) { throw new Error(`下载图生视频失败(${videoResponse.statusCode})。`); } const jobId = createTimestampId('animation-video'); const draftRelativeDir = path.posix.join( 'generated-character-drafts', sanitizePathSegment(characterId), 'animation', sanitizePathSegment(animation), jobId, ); const previewVideoPath = await writeDraftBinaryFile( rootDir, path.posix.join(draftRelativeDir, 'preview.mp4'), videoResponse.body, ); await writeFile( path.resolve( rootDir, 'public', ...draftRelativeDir.split('/'), 'job.json', ), JSON.stringify( { taskId, model: videoModel, strategy, animation, prompt: finalPrompt, createdAt: new Date().toISOString(), videoUrl, }, null, 2, ) + '\n', 'utf8', ); await writeJobRecord(rootDir, 'animation', taskId, { taskId, kind: 'animation', status: 'completed', characterId, animation, strategy, model: videoModel, prompt: finalPrompt, createdAt, updatedAt: new Date().toISOString(), result: { previewVideoPath, draftRelativeDir, }, }); sendJson(res, 200, { ok: true, taskId, strategy: 'image-to-video', model: videoModel, prompt: finalPrompt, previewVideoPath, }); return; } const modelForVisualUpload = strategy === 'reference-to-video' ? referenceVideoModel : strategy === 'motion-transfer' ? motionTransferModel : videoModel; const visualUrl = await uploadFileToDashScope( baseUrl, apiKey, modelForVisualUpload, `${characterId}-${animation}-visual`, await resolveMediaSourcePayload(rootDir, visualSource), ); if (strategy === 'motion-transfer') { if (referenceVideoDataUrls.length === 0) { sendJson(res, 400, { error: { message: '动作模板驱动至少需要一段参考视频。' }, }); return; } const finalPrompt = buildNpcAnimationPrompt({ animation, promptText, useChromaKey, characterBriefText, }); activePrompt = finalPrompt; activeModel = motionTransferModel; const referenceVideoUrl = await uploadFileToDashScope( baseUrl, apiKey, motionTransferModel, `${characterId}-${animation}-reference-video`, await resolveMediaSourcePayload(rootDir, referenceVideoDataUrls[0]), ); const createTaskResponse = await proxyJsonRequest( `${baseUrl}/services/aigc/image2video/video-synthesis`, apiKey, { model: motionTransferModel, input: { prompt: finalPrompt, image_url: visualUrl, video_url: referenceVideoUrl, watermark: false, }, parameters: { mode: 'wan-std', }, }, { 'X-DashScope-Async': 'enable', 'X-DashScope-OssResourceResolve': 'enable', }, ); if ( createTaskResponse.statusCode < 200 || createTaskResponse.statusCode >= 300 ) { sendJson(res, createTaskResponse.statusCode, { error: { message: extractApiErrorMessage( createTaskResponse.bodyText, '创建动作模板迁移任务失败。', ), }, }); return; } const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< string, unknown >; const taskId = extractTaskId(taskPayload); activeTaskId = taskId; if (!taskId) { throw new Error('动作模板迁移任务未返回 task_id。'); } const createdAt = new Date().toISOString(); await writeJobRecord(rootDir, 'animation', taskId, { taskId, kind: 'animation', status: 'running', characterId, animation, strategy, model: motionTransferModel, prompt: finalPrompt, createdAt, updatedAt: createdAt, }); const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, }); const videoUrl = extractVideoUrl(taskResult); if (!videoUrl) { throw new Error('动作模板迁移成功,但没有返回视频链接。'); } const videoResponse = await requestBinaryResponse(videoUrl); if (videoResponse.statusCode < 200 || videoResponse.statusCode >= 300) { throw new Error( `下载动作模板视频失败(${videoResponse.statusCode})。`, ); } const jobId = createTimestampId('animation-motion'); const draftRelativeDir = path.posix.join( 'generated-character-drafts', sanitizePathSegment(characterId), 'animation', sanitizePathSegment(animation), jobId, ); const previewVideoPath = await writeDraftBinaryFile( rootDir, path.posix.join(draftRelativeDir, 'preview.mp4'), videoResponse.body, ); await writeFile( path.resolve( rootDir, 'public', ...draftRelativeDir.split('/'), 'job.json', ), JSON.stringify( { taskId, model: motionTransferModel, strategy, animation, prompt: finalPrompt, createdAt: new Date().toISOString(), videoUrl, }, null, 2, ) + '\n', 'utf8', ); await writeJobRecord(rootDir, 'animation', taskId, { taskId, kind: 'animation', status: 'completed', characterId, animation, strategy, model: motionTransferModel, prompt: finalPrompt, createdAt, updatedAt: new Date().toISOString(), result: { previewVideoPath, draftRelativeDir, }, }); sendJson(res, 200, { ok: true, taskId, strategy: 'motion-transfer', model: motionTransferModel, prompt: finalPrompt, previewVideoPath, }); return; } if (strategy === 'reference-to-video') { const uploadedReferenceUrls = await Promise.all([ ...referenceImageDataUrls.map(async (source, index) => uploadFileToDashScope( baseUrl, apiKey, referenceVideoModel, `${characterId}-${animation}-reference-image-${index + 1}`, await resolveMediaSourcePayload(rootDir, source), ), ), ...referenceVideoDataUrls.map(async (source, index) => uploadFileToDashScope( baseUrl, apiKey, referenceVideoModel, `${characterId}-${animation}-reference-video-${index + 1}`, await resolveMediaSourcePayload(rootDir, source), ), ), ]); if (uploadedReferenceUrls.length === 0) { sendJson(res, 400, { error: { message: '参考生视频至少需要一张参考图或一段参考视频。' }, }); return; } const finalPrompt = buildNpcAnimationPrompt({ animation, promptText, useChromaKey, characterBriefText, }); activePrompt = finalPrompt; activeModel = referenceVideoModel; const createTaskResponse = await proxyJsonRequest( `${baseUrl}/services/aigc/video-generation/video-synthesis`, apiKey, { model: referenceVideoModel, input: { prompt: finalPrompt, reference_urls: [visualUrl, ...uploadedReferenceUrls], }, parameters: { duration: durationSeconds, resolution, prompt_optimizer: true, }, }, { 'X-DashScope-Async': 'enable', 'X-DashScope-OssResourceResolve': 'enable', }, ); if ( createTaskResponse.statusCode < 200 || createTaskResponse.statusCode >= 300 ) { sendJson(res, createTaskResponse.statusCode, { error: { message: extractApiErrorMessage( createTaskResponse.bodyText, '创建参考生视频任务失败。', ), }, }); return; } const taskPayload = JSON.parse(createTaskResponse.bodyText) as Record< string, unknown >; const taskId = extractTaskId(taskPayload); activeTaskId = taskId; if (!taskId) { throw new Error('参考生视频任务未返回 task_id。'); } const createdAt = new Date().toISOString(); await writeJobRecord(rootDir, 'animation', taskId, { taskId, kind: 'animation', status: 'running', characterId, animation, strategy, model: referenceVideoModel, prompt: finalPrompt, createdAt, updatedAt: createdAt, }); const taskResult = await waitForDashScopeTask(baseUrl, apiKey, taskId, { timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DASHSCOPE_VIDEO_TASK_TIMEOUT_MS, intervalMs: DASHSCOPE_VIDEO_TASK_POLL_INTERVAL_MS, }); const videoUrl = extractVideoUrl(taskResult); if (!videoUrl) { throw new Error('参考生视频成功,但没有返回视频链接。'); } const videoResponse = await requestBinaryResponse(videoUrl); if (videoResponse.statusCode < 200 || videoResponse.statusCode >= 300) { throw new Error(`下载参考生视频失败(${videoResponse.statusCode})。`); } const jobId = createTimestampId('animation-reference'); const draftRelativeDir = path.posix.join( 'generated-character-drafts', sanitizePathSegment(characterId), 'animation', sanitizePathSegment(animation), jobId, ); const previewVideoPath = await writeDraftBinaryFile( rootDir, path.posix.join(draftRelativeDir, 'preview.mp4'), videoResponse.body, ); await writeFile( path.resolve( rootDir, 'public', ...draftRelativeDir.split('/'), 'job.json', ), JSON.stringify( { taskId, model: referenceVideoModel, strategy, animation, prompt: finalPrompt, createdAt: new Date().toISOString(), videoUrl, }, null, 2, ) + '\n', 'utf8', ); await writeJobRecord(rootDir, 'animation', taskId, { taskId, kind: 'animation', status: 'completed', characterId, animation, strategy, model: referenceVideoModel, prompt: finalPrompt, createdAt, updatedAt: new Date().toISOString(), result: { previewVideoPath, draftRelativeDir, }, }); sendJson(res, 200, { ok: true, taskId, strategy: 'reference-to-video', model: referenceVideoModel, prompt: finalPrompt, previewVideoPath, }); return; } sendJson(res, 400, { error: { message: `不支持的动作生成策略:${strategy || 'unknown'}` }, }); } catch (error) { if (activeTaskId) { await writeJobRecord(rootDir, 'animation', activeTaskId, { taskId: activeTaskId, kind: 'animation', status: 'failed', characterId, animation, strategy, model: activeModel, prompt: activePrompt, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), errorMessage: error instanceof Error ? error.message : '生成角色动作失败。', }); } sendJson(res, 500, { error: { message: error instanceof Error ? error.message : '生成角色动作失败。', }, }); } } async function handleReadCharacterJobStatus( rootDir: string, req: IncomingMessage, res: ServerResponse, kind: 'visual' | 'animation', ) { if (req.method !== 'GET') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; } const pathname = getRequestPathname(req); const prefix = kind === 'visual' ? CHARACTER_VISUAL_JOBS_PATH : CHARACTER_ANIMATION_JOBS_PATH; const taskId = decodeURIComponent(pathname.slice(prefix.length)).trim(); if (!taskId) { sendJson(res, 400, { error: { message: 'taskId is required.' } }); return; } try { const record = await readJobRecord(rootDir, kind, taskId); sendJson(res, 200, record); } catch (error) { sendJson(res, 404, { error: { message: error instanceof Error ? error.message : '未找到对应的任务记录。', }, }); } } async function handleImportCharacterAnimationVideo( rootDir: string, req: IncomingMessage, res: ServerResponse, ) { if (req.method !== 'POST') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; } let body: Record; try { body = await readJsonBody(req); } catch { sendJson(res, 400, { error: { message: 'Invalid JSON body' } }); return; } const characterId = typeof body.characterId === 'string' ? body.characterId.trim() : 'character'; const animation = typeof body.animation === 'string' ? body.animation.trim() : 'clip'; const videoSource = typeof body.videoSource === 'string' ? body.videoSource.trim() : ''; const sourceLabel = typeof body.sourceLabel === 'string' && body.sourceLabel.trim() ? body.sourceLabel.trim() : 'imported-video'; if (!videoSource) { sendJson(res, 400, { error: { message: 'videoSource is required.' } }); return; } try { const payload = await resolveMediaSourcePayload(rootDir, videoSource); const draftId = createTimestampId('animation-import'); const relativeDir = path.posix.join( 'generated-character-drafts', sanitizePathSegment(characterId), 'animation', sanitizePathSegment(animation), draftId, ); const fileName = `${sanitizePathSegment(sourceLabel)}.${payload.extension}`; const importedVideoPath = await writeDraftBinaryFile( rootDir, path.posix.join(relativeDir, fileName), payload.buffer, ); await writeFile( path.resolve(rootDir, 'public', ...relativeDir.split('/'), 'import.json'), JSON.stringify( { characterId, animation, sourceLabel, importedVideoPath, createdAt: new Date().toISOString(), }, null, 2, ) + '\n', 'utf8', ); sendJson(res, 200, { ok: true, importedVideoPath, draftId, saveMessage: '参考视频已导入到本地草稿目录。', }); } catch (error) { sendJson(res, 500, { error: { message: error instanceof Error ? error.message : '导入动作视频失败。', }, }); } } function handleListAnimationTemplates( _rootDir: string, req: IncomingMessage, res: ServerResponse, ) { if (req.method !== 'GET') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; } sendJson(res, 200, { ok: true, templates: BUILT_IN_MOTION_TEMPLATES, }); } export function createCharacterAssetStudioPlugins( rootDir: string, mode: string, env: Record, ): Plugin[] { const visualHandler = (req: IncomingMessage, res: ServerResponse) => void handleGenerateCharacterVisuals(rootDir, mode, env, req, res); const animationHandler = (req: IncomingMessage, res: ServerResponse) => void handleGenerateCharacterAnimation(rootDir, mode, env, req, res); const visualJobHandler = (req: IncomingMessage, res: ServerResponse) => void handleReadCharacterJobStatus(rootDir, req, res, 'visual'); const animationJobHandler = (req: IncomingMessage, res: ServerResponse) => void handleReadCharacterJobStatus(rootDir, req, res, 'animation'); const importVideoHandler = (req: IncomingMessage, res: ServerResponse) => void handleImportCharacterAnimationVideo(rootDir, req, res); const templateListHandler = (req: IncomingMessage, res: ServerResponse) => void handleListAnimationTemplates(rootDir, req, res); return [ { name: 'character-visual-generate', configureServer(server) { server.middlewares.use(CHARACTER_VISUAL_GENERATE_PATH, visualHandler); }, configurePreviewServer(server) { server.middlewares.use(CHARACTER_VISUAL_GENERATE_PATH, visualHandler); }, }, { name: 'character-visual-job-status', configureServer(server) { server.middlewares.use(CHARACTER_VISUAL_JOBS_PATH, visualJobHandler); }, configurePreviewServer(server) { server.middlewares.use(CHARACTER_VISUAL_JOBS_PATH, visualJobHandler); }, }, { name: 'character-animation-generate', configureServer(server) { server.middlewares.use( CHARACTER_ANIMATION_GENERATE_PATH, animationHandler, ); }, configurePreviewServer(server) { server.middlewares.use( CHARACTER_ANIMATION_GENERATE_PATH, animationHandler, ); }, }, { name: 'character-animation-job-status', configureServer(server) { server.middlewares.use(CHARACTER_ANIMATION_JOBS_PATH, animationJobHandler); }, configurePreviewServer(server) { server.middlewares.use(CHARACTER_ANIMATION_JOBS_PATH, animationJobHandler); }, }, { name: 'character-animation-import-video', configureServer(server) { server.middlewares.use( CHARACTER_ANIMATION_IMPORT_VIDEO_PATH, importVideoHandler, ); }, configurePreviewServer(server) { server.middlewares.use( CHARACTER_ANIMATION_IMPORT_VIDEO_PATH, importVideoHandler, ); }, }, { name: 'character-animation-templates', configureServer(server) { server.middlewares.use( CHARACTER_ANIMATION_TEMPLATES_PATH, templateListHandler, ); }, configurePreviewServer(server) { server.middlewares.use( CHARACTER_ANIMATION_TEMPLATES_PATH, templateListHandler, ); }, }, ]; }