2166 lines
59 KiB
TypeScript
2166 lines
59 KiB
TypeScript
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<string, string | string[] | undefined>;
|
||
body: Buffer;
|
||
};
|
||
|
||
type DecodedMediaPayload = {
|
||
buffer: Buffer;
|
||
mimeType: string;
|
||
extension: string;
|
||
};
|
||
|
||
function readJsonBody(req: IncomingMessage) {
|
||
return new Promise<Record<string, unknown>>((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<string, unknown> {
|
||
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<string, string>,
|
||
) {
|
||
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<string, unknown>,
|
||
) {
|
||
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<string, unknown>;
|
||
}
|
||
|
||
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<DecodedMediaPayload> {
|
||
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<string, string>;
|
||
body?: Buffer | string;
|
||
} = {},
|
||
) {
|
||
return new Promise<RequestResponse>((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<string, string>;
|
||
body?: Buffer | string;
|
||
} = {},
|
||
) {
|
||
return requestResponse(urlString, options).then((response) => ({
|
||
...response,
|
||
bodyText: response.body.toString('utf8'),
|
||
}));
|
||
}
|
||
|
||
function requestBinaryResponse(
|
||
urlString: string,
|
||
options: {
|
||
method?: string;
|
||
headers?: Record<string, string>;
|
||
} = {},
|
||
) {
|
||
return requestResponse(urlString, options);
|
||
}
|
||
|
||
function proxyJsonRequest(
|
||
urlString: string,
|
||
apiKey: string,
|
||
body: Record<string, unknown>,
|
||
extraHeaders: Record<string, string> = {},
|
||
) {
|
||
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<string, unknown>;
|
||
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<string, unknown>) {
|
||
return findFirstStringByKey(payload, 'task_id') ?? '';
|
||
}
|
||
|
||
function extractVideoUrl(payload: Record<string, unknown>) {
|
||
return (
|
||
findFirstStringByKey(payload, 'video_url') ??
|
||
findFirstStringByKey(payload, 'url') ??
|
||
''
|
||
);
|
||
}
|
||
|
||
function extractImageUrls(payload: Record<string, unknown>) {
|
||
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<typeof getActionTemplateById>[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<string, string>,
|
||
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<string, unknown>;
|
||
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<string, string>,
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string, string>,
|
||
): 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,
|
||
);
|
||
},
|
||
},
|
||
];
|
||
}
|