Files
Genarrative/scripts/dev-server/characterAssetStudioPlugins.ts
高物 bd9fdcbe31
Some checks failed
CI / verify (push) Has been cancelled
Implement scene-based chapter quest progression
2026-04-08 11:58:47 +08:00

2090 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
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.7-i2v';
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',
};
}
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) {
const trimmed = promptText.trim();
return [
'单人 NPC 角色形象,全身,侧身朝右,站姿稳定,武器与手完整可见。',
'画面简洁,背景干净,角色轮廓清楚,适合后续做动作与裁切。',
'不要多人,不要复杂场景,不要夸张透视,不要截断脚底。',
trimmed || '江湖风格角色,服装完整,姿态自然。',
].join(' ');
}
function buildImageSequencePrompt(
animation: string,
promptText: string,
frameCount: number,
useChromaKey: boolean,
) {
return [
`同一角色连续 ${frameCount} 帧动作序列,动作主题是 ${animation}`,
'固定机位,单人,全身,侧身朝右,保持同一套服装、发型、武器和体型。',
'帧间动作连续,姿态逐步推进,不要换人,不要跳变,不要多余物体。',
useChromaKey
? '纯绿色背景,无地面装饰,方便后期抠像。'
: '背景尽量纯净,避免复杂场景。',
promptText.trim(),
]
.filter(Boolean)
.join(' ');
}
function buildNpcAnimationPrompt(
animation: string,
promptText: string,
useChromaKey: boolean,
) {
return [
`单人 NPC 全身动作视频,动作主题是 ${animation}`,
'角色固定为同一人,侧身朝右,镜头稳定,轮廓清晰,武器不可丢失。',
'动作连贯,避免服装、发型、面部、武器随机漂移。',
useChromaKey
? '背景为纯绿色绿幕,无其他人物和场景元素,方便后期抠像。'
: '背景简洁纯净,无复杂场景。',
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 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 && sourceMode === 'text-to-image') {
sendJson(res, 400, {
error: { message: '文生主形象需要填写角色设定。' },
});
return;
}
let activeTaskId = '';
let activePrompt = '';
try {
const finalPrompt = buildNpcVisualPrompt(promptText);
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 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 durationSeconds =
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 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;
}
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 === 'image-to-video') {
const finalPrompt = buildNpcAnimationPrompt(
animation,
promptText,
useChromaKey,
);
activePrompt = finalPrompt;
activeModel = videoModel;
const media = [
{ type: 'image', url: visualUrl, role: 'first_frame' },
...(lastFrameImageDataUrl
? [
{
type: 'image',
url: await uploadFileToDashScope(
baseUrl,
apiKey,
videoModel,
`${characterId}-${animation}-last-frame`,
await resolveMediaSourcePayload(
rootDir,
lastFrameImageDataUrl,
),
),
role: 'last_frame',
},
]
: []),
];
const createTaskResponse = await proxyJsonRequest(
`${baseUrl}/services/aigc/video-generation/video-synthesis`,
apiKey,
{
model: videoModel,
input: {
prompt: finalPrompt,
media,
},
parameters: {
duration: durationSeconds,
resolution,
},
},
{
'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;
}
if (strategy === 'motion-transfer') {
if (referenceVideoDataUrls.length === 0) {
sendJson(res, 400, {
error: { message: '动作模板驱动至少需要一段参考视频。' },
});
return;
}
const finalPrompt = buildNpcAnimationPrompt(
animation,
promptText,
useChromaKey,
);
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,
);
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,
);
},
},
];
}