1664 lines
48 KiB
TypeScript
1664 lines
48 KiB
TypeScript
import { webcrypto } from 'node:crypto';
|
||
import { mkdir, readdir, 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 { createCharacterAssetStudioPlugins } from './characterAssetStudioPlugins';
|
||
import { createQwenSpriteSheetToolPlugins } from './qwenSpriteSheetToolPlugins';
|
||
|
||
const LLM_PROXY_PATH = '/api/llm/chat/completions';
|
||
const ITEM_CATALOG_PATH = '/api/item-catalog';
|
||
const ITEM_OVERRIDES_PATH = '/api/item-overrides';
|
||
const NPC_VISUAL_OVERRIDES_PATH = '/api/npc-visual-overrides';
|
||
const NPC_LAYOUT_CONFIG_PATH = '/api/npc-layout-config';
|
||
const CHARACTER_OVERRIDES_PATH = '/api/character-overrides';
|
||
const MONSTER_OVERRIDES_PATH = '/api/monster-overrides';
|
||
const SCENE_OVERRIDES_PATH = '/api/scene-overrides';
|
||
const SCENE_NPC_OVERRIDES_PATH = '/api/scene-npc-overrides';
|
||
const STATE_FUNCTION_OVERRIDES_PATH = '/api/state-function-overrides';
|
||
const CHARACTER_VISUAL_PUBLISH_PATH = '/api/character-visual/publish';
|
||
const CHARACTER_ANIMATION_PUBLISH_PATH = '/api/animation/publish';
|
||
const CUSTOM_WORLD_SCENE_IMAGE_PATH = '/api/custom-world/scene-image';
|
||
const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
|
||
const DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL = 'wan2.7-image';
|
||
const DASHSCOPE_TASK_POLL_INTERVAL_MS = 2000;
|
||
const DASHSCOPE_TASK_TIMEOUT_MS = 150000;
|
||
|
||
if (
|
||
!globalThis.crypto ||
|
||
typeof globalThis.crypto.getRandomValues !== 'function'
|
||
) {
|
||
Object.defineProperty(globalThis, 'crypto', {
|
||
value: webcrypto,
|
||
configurable: true,
|
||
});
|
||
}
|
||
|
||
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 sleep(ms: number) {
|
||
return new Promise((resolve) => {
|
||
setTimeout(resolve, ms);
|
||
});
|
||
}
|
||
|
||
function hashText(value: string) {
|
||
let hash = 0;
|
||
for (let index = 0; index < value.length; index += 1) {
|
||
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
|
||
}
|
||
return hash >>> 0;
|
||
}
|
||
|
||
function buildAssetPathSegment(value: string, fallback: string) {
|
||
const sanitized = sanitizePathSegment(value);
|
||
const suffix = hashText(value || fallback)
|
||
.toString(36)
|
||
.slice(0, 6);
|
||
return `${sanitized || fallback}-${suffix}`;
|
||
}
|
||
|
||
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 to the raw response text below.
|
||
}
|
||
|
||
return responseText;
|
||
}
|
||
|
||
function requestTextResponse(
|
||
urlString: string,
|
||
options: {
|
||
method?: string;
|
||
headers?: Record<string, string>;
|
||
bodyText?: string;
|
||
} = {},
|
||
) {
|
||
return new Promise<{
|
||
statusCode: number;
|
||
headers: Record<string, string | string[] | undefined>;
|
||
bodyText: string;
|
||
}>((resolve, reject) => {
|
||
const url = new URL(urlString);
|
||
const transport = url.protocol === 'https:' ? https : http;
|
||
const payload = options.bodyText;
|
||
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': Buffer.byteLength(payload) } : {}),
|
||
},
|
||
};
|
||
|
||
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,
|
||
bodyText: Buffer.concat(chunks).toString('utf8'),
|
||
});
|
||
});
|
||
upstreamRes.on('error', reject);
|
||
});
|
||
|
||
request.on('error', reject);
|
||
if (payload) {
|
||
request.write(payload);
|
||
}
|
||
request.end();
|
||
});
|
||
}
|
||
|
||
function requestBinaryResponse(
|
||
urlString: string,
|
||
options: {
|
||
method?: string;
|
||
headers?: Record<string, string>;
|
||
} = {},
|
||
) {
|
||
return new Promise<{
|
||
statusCode: number;
|
||
headers: Record<string, string | string[] | undefined>;
|
||
body: Buffer;
|
||
}>((resolve, reject) => {
|
||
const url = new URL(urlString);
|
||
const transport = url.protocol === 'https:' ? https : http;
|
||
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 ?? {},
|
||
};
|
||
|
||
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);
|
||
request.end();
|
||
});
|
||
}
|
||
|
||
function normalizeUpstreamBaseUrl(value: string) {
|
||
return value.replace(/\/chat\/completions\/?$/u, '').replace(/\/$/u, '');
|
||
}
|
||
|
||
function proxyJsonRequest(
|
||
urlString: string,
|
||
apiKey: string,
|
||
body: Record<string, unknown>,
|
||
extraHeaders: Record<string, string> = {},
|
||
) {
|
||
return new Promise<{
|
||
statusCode: number;
|
||
headers: Record<string, string | string[] | undefined>;
|
||
bodyText: string;
|
||
}>((resolve, reject) => {
|
||
const url = new URL(urlString);
|
||
const transport = url.protocol === 'https:' ? https : http;
|
||
const payload = JSON.stringify(body);
|
||
const options: RequestOptions = {
|
||
protocol: url.protocol,
|
||
hostname: url.hostname,
|
||
port: url.port ? Number(url.port) : undefined,
|
||
path: `${url.pathname}${url.search}`,
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${apiKey}`,
|
||
'Content-Type': 'application/json',
|
||
'Content-Length': Buffer.byteLength(payload),
|
||
...extraHeaders,
|
||
},
|
||
};
|
||
|
||
const upstreamReq = transport.request(options, (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,
|
||
bodyText: Buffer.concat(chunks).toString('utf8'),
|
||
});
|
||
});
|
||
});
|
||
|
||
upstreamReq.on('error', reject);
|
||
upstreamReq.write(payload);
|
||
upstreamReq.end();
|
||
});
|
||
}
|
||
|
||
function proxyStreamingRequest(
|
||
urlString: string,
|
||
apiKey: string,
|
||
body: Record<string, unknown>,
|
||
res: ServerResponse,
|
||
) {
|
||
return new Promise<void>((resolve, reject) => {
|
||
const url = new URL(urlString);
|
||
const transport = url.protocol === 'https:' ? https : http;
|
||
const payload = JSON.stringify(body);
|
||
const options: RequestOptions = {
|
||
protocol: url.protocol,
|
||
hostname: url.hostname,
|
||
port: url.port ? Number(url.port) : undefined,
|
||
path: `${url.pathname}${url.search}`,
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${apiKey}`,
|
||
'Content-Type': 'application/json',
|
||
'Content-Length': Buffer.byteLength(payload),
|
||
},
|
||
};
|
||
|
||
const upstreamReq = transport.request(options, (upstreamRes) => {
|
||
res.statusCode = upstreamRes.statusCode ?? 502;
|
||
res.setHeader(
|
||
'Content-Type',
|
||
String(
|
||
upstreamRes.headers['content-type'] ||
|
||
'text/event-stream; charset=utf-8',
|
||
),
|
||
);
|
||
res.setHeader('Cache-Control', 'no-cache');
|
||
res.setHeader('Connection', 'keep-alive');
|
||
|
||
upstreamRes.on('data', (chunk) => {
|
||
res.write(chunk);
|
||
});
|
||
upstreamRes.on('end', () => {
|
||
res.end();
|
||
resolve();
|
||
});
|
||
upstreamRes.on('error', (error) => {
|
||
if (!res.writableEnded) {
|
||
res.end();
|
||
}
|
||
reject(error);
|
||
});
|
||
});
|
||
|
||
upstreamReq.on('error', reject);
|
||
res.on('close', () => {
|
||
upstreamReq.destroy();
|
||
});
|
||
upstreamReq.write(payload);
|
||
upstreamReq.end();
|
||
});
|
||
}
|
||
|
||
function createLlmProxyPlugin(
|
||
rootDir: string,
|
||
mode: string,
|
||
env: Record<string, string>,
|
||
): Plugin {
|
||
const handler = async (req: IncomingMessage, res: ServerResponse) => {
|
||
const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env);
|
||
const upstreamBaseUrl = normalizeUpstreamBaseUrl(
|
||
runtimeEnv.VITE_LLM_BASE_URL ||
|
||
runtimeEnv.LLM_BASE_URL ||
|
||
'https://ark.cn-beijing.volces.com/api/v3',
|
||
);
|
||
const apiKey =
|
||
runtimeEnv.LLM_API_KEY ||
|
||
runtimeEnv.ARK_API_KEY ||
|
||
runtimeEnv.VITE_LLM_API_KEY ||
|
||
'';
|
||
|
||
if (req.method !== 'POST') {
|
||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||
return;
|
||
}
|
||
|
||
if (!apiKey) {
|
||
sendJson(res, 500, {
|
||
error: { message: 'Missing LLM API key on server' },
|
||
});
|
||
return;
|
||
}
|
||
|
||
let body: Record<string, unknown>;
|
||
try {
|
||
body = await readJsonBody(req);
|
||
} catch {
|
||
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (body.stream === true) {
|
||
await proxyStreamingRequest(
|
||
`${upstreamBaseUrl}/chat/completions`,
|
||
apiKey,
|
||
body,
|
||
res,
|
||
);
|
||
return;
|
||
}
|
||
|
||
const upstreamResponse = await proxyJsonRequest(
|
||
`${upstreamBaseUrl}/chat/completions`,
|
||
apiKey,
|
||
body,
|
||
);
|
||
res.statusCode = upstreamResponse.statusCode;
|
||
res.setHeader(
|
||
'Content-Type',
|
||
String(
|
||
upstreamResponse.headers['content-type'] ||
|
||
'application/json; charset=utf-8',
|
||
),
|
||
);
|
||
res.end(upstreamResponse.bodyText);
|
||
} catch (error) {
|
||
sendJson(res, 502, {
|
||
error: {
|
||
message:
|
||
error instanceof Error ? error.message : 'LLM proxy request failed',
|
||
},
|
||
});
|
||
}
|
||
};
|
||
|
||
return {
|
||
name: 'local-llm-proxy',
|
||
configureServer(server) {
|
||
server.middlewares.use(LLM_PROXY_PATH, handler);
|
||
},
|
||
configurePreviewServer(server) {
|
||
server.middlewares.use(LLM_PROXY_PATH, handler);
|
||
},
|
||
};
|
||
}
|
||
|
||
async function handleJsonFileRead(
|
||
filePath: string,
|
||
res: ServerResponse,
|
||
readErrorMessage: string,
|
||
) {
|
||
try {
|
||
const content = await readFile(filePath, 'utf8');
|
||
res.statusCode = 200;
|
||
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
||
res.end(content);
|
||
} catch (error) {
|
||
sendJson(res, 500, {
|
||
error: {
|
||
message: error instanceof Error ? error.message : readErrorMessage,
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
async function readJsonObjectFile(filePath: string) {
|
||
try {
|
||
const content = await readFile(filePath, 'utf8');
|
||
const parsed = JSON.parse(content);
|
||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
||
? (parsed as Record<string, unknown>)
|
||
: {};
|
||
} catch (error) {
|
||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||
return {};
|
||
}
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async function writeJsonObjectFile(
|
||
filePath: string,
|
||
payload: Record<string, unknown>,
|
||
) {
|
||
await mkdir(path.dirname(filePath), { recursive: true });
|
||
await writeFile(filePath, JSON.stringify(payload, null, 2) + '\n', 'utf8');
|
||
}
|
||
|
||
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 isStringArray(value: unknown): value is string[] {
|
||
return (
|
||
Array.isArray(value) &&
|
||
value.every((item) => typeof item === 'string' && item.trim().length > 0)
|
||
);
|
||
}
|
||
|
||
async function resolveAssetSourcePayload(
|
||
rootDir: string,
|
||
source: string,
|
||
fallbackMessage: string,
|
||
) {
|
||
const dataUrlMatch =
|
||
/^data:(image\/png|image\/jpeg|image\/webp);base64,(.+)$/u.exec(source);
|
||
if (dataUrlMatch) {
|
||
const mimeType = dataUrlMatch[1];
|
||
const base64Payload = dataUrlMatch[2];
|
||
return {
|
||
buffer: Buffer.from(base64Payload, 'base64'),
|
||
extension:
|
||
mimeType === 'image/jpeg'
|
||
? 'jpg'
|
||
: mimeType === 'image/webp'
|
||
? 'webp'
|
||
: 'png',
|
||
};
|
||
}
|
||
|
||
if (!source.startsWith('/')) {
|
||
throw new Error(fallbackMessage);
|
||
}
|
||
|
||
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('Asset source points outside the public directory.');
|
||
}
|
||
|
||
const buffer = await readFile(absolutePath);
|
||
const extension = path
|
||
.extname(absolutePath)
|
||
.replace(/^\./u, '')
|
||
.toLowerCase();
|
||
if (!extension) {
|
||
throw new Error(fallbackMessage);
|
||
}
|
||
|
||
return {
|
||
buffer,
|
||
extension,
|
||
};
|
||
}
|
||
|
||
async function resolveAssetSourceAsDataUrl(
|
||
rootDir: string,
|
||
source: string,
|
||
fallbackMessage: string,
|
||
) {
|
||
if (/^data:image\/[^;]+;base64,/u.test(source)) {
|
||
return source;
|
||
}
|
||
|
||
const payload = await resolveAssetSourcePayload(
|
||
rootDir,
|
||
source,
|
||
fallbackMessage,
|
||
);
|
||
const mimeType = (() => {
|
||
switch (payload.extension) {
|
||
case 'jpg':
|
||
case 'jpeg':
|
||
return 'image/jpeg';
|
||
case 'webp':
|
||
return 'image/webp';
|
||
default:
|
||
return 'image/png';
|
||
}
|
||
})();
|
||
|
||
return `data:${mimeType};base64,${payload.buffer.toString('base64')}`;
|
||
}
|
||
|
||
function resolveDashScopeSceneImageModel(model: string) {
|
||
if (/^wan2\.7-image(?:-pro)?$/u.test(model)) {
|
||
return model;
|
||
}
|
||
|
||
return DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL;
|
||
}
|
||
|
||
function resolveImageExtension(
|
||
contentTypeHeader: string | string[] | undefined,
|
||
sourceUrl: string,
|
||
) {
|
||
const contentType = Array.isArray(contentTypeHeader)
|
||
? (contentTypeHeader[0] ?? '')
|
||
: (contentTypeHeader ?? '');
|
||
if (/image\/jpeg/u.test(contentType)) {
|
||
return 'jpg';
|
||
}
|
||
if (/image\/png/u.test(contentType)) {
|
||
return 'png';
|
||
}
|
||
|
||
try {
|
||
const extension = path.posix
|
||
.extname(new URL(sourceUrl).pathname)
|
||
.toLowerCase();
|
||
if (extension === '.jpg' || extension === '.jpeg') {
|
||
return 'jpg';
|
||
}
|
||
if (extension === '.png') {
|
||
return 'png';
|
||
}
|
||
} catch {
|
||
// Ignore malformed URLs and fall back to png below.
|
||
}
|
||
|
||
return 'png';
|
||
}
|
||
|
||
async function waitForDashScopeTask(
|
||
baseUrl: string,
|
||
apiKey: string,
|
||
taskId: string,
|
||
timeoutMs = DASHSCOPE_TASK_TIMEOUT_MS,
|
||
) {
|
||
const deadline = Date.now() + 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})。`,
|
||
),
|
||
);
|
||
}
|
||
|
||
let parsed: Record<string, unknown> | null = null;
|
||
try {
|
||
const candidate = JSON.parse(response.bodyText);
|
||
parsed = isRecordValue(candidate) ? candidate : null;
|
||
} catch {
|
||
parsed = null;
|
||
}
|
||
|
||
if (!parsed) {
|
||
throw new Error('场景图片生成任务返回了无法解析的结果。');
|
||
}
|
||
|
||
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') {
|
||
throw new Error(
|
||
extractApiErrorMessage(response.bodyText, '场景图片生成任务失败。'),
|
||
);
|
||
}
|
||
|
||
if (taskStatus === 'UNKNOWN') {
|
||
throw new Error('场景图片生成任务状态未知,请重新发起生成。');
|
||
}
|
||
|
||
await sleep(DASHSCOPE_TASK_POLL_INTERVAL_MS);
|
||
}
|
||
|
||
throw new Error('场景图片生成超时,请稍后重试。');
|
||
}
|
||
|
||
function getDashScopeImageUrl(taskResponse: Record<string, unknown>) {
|
||
const output = isRecordValue(taskResponse.output)
|
||
? taskResponse.output
|
||
: null;
|
||
const results = output && Array.isArray(output.results) ? output.results : [];
|
||
|
||
for (const entry of results) {
|
||
if (!isRecordValue(entry)) {
|
||
continue;
|
||
}
|
||
|
||
if (typeof entry.url === 'string' && entry.url.trim()) {
|
||
return {
|
||
url: entry.url.trim(),
|
||
actualPrompt:
|
||
typeof entry.actual_prompt === 'string' && entry.actual_prompt.trim()
|
||
? entry.actual_prompt.trim()
|
||
: undefined,
|
||
};
|
||
}
|
||
}
|
||
|
||
const choices = output && Array.isArray(output.choices) ? output.choices : [];
|
||
for (const choice of choices) {
|
||
if (!isRecordValue(choice)) {
|
||
continue;
|
||
}
|
||
|
||
const message = isRecordValue(choice.message) ? choice.message : null;
|
||
const content =
|
||
message && Array.isArray(message.content) ? message.content : [];
|
||
|
||
for (const entry of content) {
|
||
if (!isRecordValue(entry)) {
|
||
continue;
|
||
}
|
||
|
||
const imageUrl =
|
||
typeof entry.image === 'string' && entry.image.trim()
|
||
? entry.image.trim()
|
||
: typeof entry.url === 'string' && entry.url.trim()
|
||
? entry.url.trim()
|
||
: '';
|
||
|
||
if (imageUrl) {
|
||
return {
|
||
url: imageUrl,
|
||
actualPrompt:
|
||
typeof entry.actual_prompt === 'string' &&
|
||
entry.actual_prompt.trim()
|
||
? entry.actual_prompt.trim()
|
||
: typeof entry.revised_prompt === 'string' &&
|
||
entry.revised_prompt.trim()
|
||
? entry.revised_prompt.trim()
|
||
: undefined,
|
||
};
|
||
}
|
||
}
|
||
}
|
||
|
||
throw new Error('场景图片生成成功,但没有返回可下载的图片地址。');
|
||
}
|
||
|
||
type PublishedVisualManifest = {
|
||
id: string;
|
||
characterId: string;
|
||
sourceMode: string;
|
||
promptText?: string;
|
||
masterImagePath: string;
|
||
previewImagePaths: string[];
|
||
width: number;
|
||
height: number;
|
||
facing: 'right';
|
||
locked: boolean;
|
||
};
|
||
|
||
type PublishedAnimationManifest = {
|
||
id: string;
|
||
animationSetId: string;
|
||
characterId: string;
|
||
visualAssetId: string;
|
||
action: string;
|
||
frameCount: number;
|
||
fps: number;
|
||
loop: boolean;
|
||
frameWidth: number;
|
||
frameHeight: number;
|
||
previewVideoPath?: string;
|
||
framePaths: string[];
|
||
};
|
||
|
||
function createJsonFileEditorPlugin({
|
||
name,
|
||
routePath,
|
||
filePath,
|
||
invalidPayloadMessage,
|
||
readErrorMessage,
|
||
saveErrorMessage,
|
||
}: {
|
||
name: string;
|
||
routePath: string;
|
||
filePath: string;
|
||
invalidPayloadMessage: string;
|
||
readErrorMessage: string;
|
||
saveErrorMessage: string;
|
||
}): Plugin {
|
||
const readOnlyHandler = async (req: IncomingMessage, res: ServerResponse) => {
|
||
if (req.method !== 'GET') {
|
||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||
return;
|
||
}
|
||
|
||
await handleJsonFileRead(filePath, res, readErrorMessage);
|
||
};
|
||
|
||
const readWriteHandler = async (
|
||
req: IncomingMessage,
|
||
res: ServerResponse,
|
||
) => {
|
||
if (req.method === 'GET') {
|
||
await handleJsonFileRead(filePath, res, readErrorMessage);
|
||
return;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
||
sendJson(res, 400, { error: { message: invalidPayloadMessage } });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
await writeFile(filePath, JSON.stringify(body, null, 2) + '\n', 'utf8');
|
||
sendJson(res, 200, { ok: true });
|
||
} catch (error) {
|
||
sendJson(res, 500, {
|
||
error: {
|
||
message: error instanceof Error ? error.message : saveErrorMessage,
|
||
},
|
||
});
|
||
}
|
||
};
|
||
|
||
return {
|
||
name,
|
||
configureServer(server) {
|
||
server.middlewares.use(routePath, readWriteHandler);
|
||
},
|
||
configurePreviewServer(server) {
|
||
server.middlewares.use(routePath, readOnlyHandler);
|
||
},
|
||
};
|
||
}
|
||
|
||
function createNpcVisualOverridePlugin(rootDir: string): Plugin {
|
||
return createJsonFileEditorPlugin({
|
||
name: 'npc-visual-overrides',
|
||
routePath: NPC_VISUAL_OVERRIDES_PATH,
|
||
filePath: path.resolve(rootDir, 'src/data/npcVisualOverrides.json'),
|
||
invalidPayloadMessage: 'Override payload must be an object map',
|
||
readErrorMessage: 'Failed to read override file',
|
||
saveErrorMessage: 'Failed to save override file',
|
||
});
|
||
}
|
||
|
||
function createNpcLayoutConfigPlugin(rootDir: string): Plugin {
|
||
return createJsonFileEditorPlugin({
|
||
name: 'npc-layout-config',
|
||
routePath: NPC_LAYOUT_CONFIG_PATH,
|
||
filePath: path.resolve(rootDir, 'src/data/npcLayoutConfig.json'),
|
||
invalidPayloadMessage: 'Layout payload must be an object',
|
||
readErrorMessage: 'Failed to read layout file',
|
||
saveErrorMessage: 'Failed to save layout file',
|
||
});
|
||
}
|
||
|
||
function createCharacterOverridesPlugin(rootDir: string): Plugin {
|
||
return createJsonFileEditorPlugin({
|
||
name: 'character-overrides',
|
||
routePath: CHARACTER_OVERRIDES_PATH,
|
||
filePath: path.resolve(rootDir, 'src/data/characterOverrides.json'),
|
||
invalidPayloadMessage: 'Character override payload must be an object map',
|
||
readErrorMessage: 'Failed to read character override file',
|
||
saveErrorMessage: 'Failed to save character override file',
|
||
});
|
||
}
|
||
|
||
function createMonsterOverridesPlugin(rootDir: string): Plugin {
|
||
return createJsonFileEditorPlugin({
|
||
name: 'monster-overrides',
|
||
routePath: MONSTER_OVERRIDES_PATH,
|
||
filePath: path.resolve(rootDir, 'src/data/monsterOverrides.json'),
|
||
invalidPayloadMessage: 'Monster override payload must be an object map',
|
||
readErrorMessage: 'Failed to read monster override file',
|
||
saveErrorMessage: 'Failed to save monster override file',
|
||
});
|
||
}
|
||
|
||
function createSceneOverridesPlugin(rootDir: string): Plugin {
|
||
return createJsonFileEditorPlugin({
|
||
name: 'scene-overrides',
|
||
routePath: SCENE_OVERRIDES_PATH,
|
||
filePath: path.resolve(rootDir, 'src/data/sceneOverrides.json'),
|
||
invalidPayloadMessage: 'Scene override payload must be an object map',
|
||
readErrorMessage: 'Failed to read scene override file',
|
||
saveErrorMessage: 'Failed to save scene override file',
|
||
});
|
||
}
|
||
|
||
function createSceneNpcOverridesPlugin(rootDir: string): Plugin {
|
||
return createJsonFileEditorPlugin({
|
||
name: 'scene-npc-overrides',
|
||
routePath: SCENE_NPC_OVERRIDES_PATH,
|
||
filePath: path.resolve(rootDir, 'src/data/sceneNpcOverrides.json'),
|
||
invalidPayloadMessage: 'Scene NPC override payload must be an object map',
|
||
readErrorMessage: 'Failed to read scene NPC override file',
|
||
saveErrorMessage: 'Failed to save scene NPC override file',
|
||
});
|
||
}
|
||
|
||
function createStateFunctionOverridesPlugin(rootDir: string): Plugin {
|
||
return createJsonFileEditorPlugin({
|
||
name: 'state-function-overrides',
|
||
routePath: STATE_FUNCTION_OVERRIDES_PATH,
|
||
filePath: path.resolve(rootDir, 'src/data/stateFunctionOverrides.json'),
|
||
invalidPayloadMessage:
|
||
'State function override payload must be an object map',
|
||
readErrorMessage: 'Failed to read state function override file',
|
||
saveErrorMessage: 'Failed to save state function override file',
|
||
});
|
||
}
|
||
|
||
function createCustomWorldSceneImagePlugin(
|
||
rootDir: string,
|
||
mode: string,
|
||
env: Record<string, string>,
|
||
): Plugin {
|
||
const handler = async (req: IncomingMessage, res: ServerResponse) => {
|
||
const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env);
|
||
const baseUrl = normalizeDashScopeBaseUrl(
|
||
runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL,
|
||
);
|
||
const apiKey = runtimeEnv.DASHSCOPE_API_KEY || '';
|
||
const defaultModel =
|
||
runtimeEnv.DASHSCOPE_IMAGE_MODEL || DEFAULT_DASHSCOPE_SCENE_IMAGE_MODEL;
|
||
const taskTimeoutMs = Number(
|
||
runtimeEnv.DASHSCOPE_IMAGE_REQUEST_TIMEOUT_MS ||
|
||
runtimeEnv.VITE_SCENE_IMAGE_REQUEST_TIMEOUT_MS ||
|
||
DASHSCOPE_TASK_TIMEOUT_MS,
|
||
);
|
||
|
||
if (req.method !== 'POST') {
|
||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||
return;
|
||
}
|
||
|
||
if (!apiKey) {
|
||
sendJson(res, 500, {
|
||
error: { message: 'Missing DASHSCOPE_API_KEY on server' },
|
||
});
|
||
return;
|
||
}
|
||
|
||
let body: Record<string, unknown>;
|
||
try {
|
||
body = await readJsonBody(req);
|
||
} catch {
|
||
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
|
||
return;
|
||
}
|
||
|
||
const prompt = typeof body.prompt === 'string' ? body.prompt.trim() : '';
|
||
const negativePrompt =
|
||
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
|
||
const size =
|
||
typeof body.size === 'string' && body.size.trim()
|
||
? body.size.trim()
|
||
: '1280*720';
|
||
const requestedModel =
|
||
typeof body.model === 'string' && body.model.trim()
|
||
? body.model.trim()
|
||
: defaultModel;
|
||
const model = resolveDashScopeSceneImageModel(requestedModel);
|
||
const worldName =
|
||
typeof body.worldName === 'string' ? body.worldName.trim() : '';
|
||
const profileId =
|
||
typeof body.profileId === 'string' ? body.profileId.trim() : '';
|
||
const landmarkName =
|
||
typeof body.landmarkName === 'string' ? body.landmarkName.trim() : '';
|
||
const landmarkId =
|
||
typeof body.landmarkId === 'string' ? body.landmarkId.trim() : '';
|
||
const referenceImageSrc =
|
||
typeof body.referenceImageSrc === 'string'
|
||
? body.referenceImageSrc.trim()
|
||
: '';
|
||
|
||
if (!prompt) {
|
||
sendJson(res, 400, { error: { message: 'prompt is required.' } });
|
||
return;
|
||
}
|
||
|
||
if (!landmarkName && !landmarkId) {
|
||
sendJson(res, 400, {
|
||
error: { message: 'landmarkName or landmarkId is required.' },
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const messageContent: Array<{ image: string } | { text: string }> = [];
|
||
if (referenceImageSrc) {
|
||
messageContent.push({
|
||
image: await resolveAssetSourceAsDataUrl(
|
||
rootDir,
|
||
referenceImageSrc,
|
||
'参考图必须来自 public 目录或使用 Data URL。',
|
||
),
|
||
});
|
||
}
|
||
messageContent.push({ text: prompt });
|
||
|
||
const createTaskResponse = await proxyJsonRequest(
|
||
`${baseUrl}/services/aigc/image-generation/generation`,
|
||
apiKey,
|
||
{
|
||
model,
|
||
input: {
|
||
messages: [
|
||
{
|
||
role: 'user',
|
||
content: messageContent,
|
||
},
|
||
],
|
||
},
|
||
parameters: {
|
||
n: 1,
|
||
size,
|
||
prompt_extend: true,
|
||
watermark: false,
|
||
...(negativePrompt ? { negative_prompt: negativePrompt } : {}),
|
||
},
|
||
},
|
||
{
|
||
'X-DashScope-Async': 'enable',
|
||
},
|
||
);
|
||
|
||
if (
|
||
createTaskResponse.statusCode < 200 ||
|
||
createTaskResponse.statusCode >= 300
|
||
) {
|
||
sendJson(res, createTaskResponse.statusCode, {
|
||
error: {
|
||
message: extractApiErrorMessage(
|
||
createTaskResponse.bodyText,
|
||
'创建场景图片生成任务失败。',
|
||
),
|
||
},
|
||
});
|
||
return;
|
||
}
|
||
|
||
let taskPayload: Record<string, unknown> | null = null;
|
||
try {
|
||
const candidate = JSON.parse(createTaskResponse.bodyText);
|
||
taskPayload = isRecordValue(candidate) ? candidate : null;
|
||
} catch {
|
||
taskPayload = null;
|
||
}
|
||
|
||
const output =
|
||
taskPayload && isRecordValue(taskPayload.output)
|
||
? taskPayload.output
|
||
: null;
|
||
const taskId =
|
||
output && typeof output.task_id === 'string'
|
||
? output.task_id.trim()
|
||
: '';
|
||
|
||
if (!taskId) {
|
||
throw new Error('场景图片生成任务未返回 task_id。');
|
||
}
|
||
|
||
const taskResponse = await waitForDashScopeTask(
|
||
baseUrl,
|
||
apiKey,
|
||
taskId,
|
||
Number.isFinite(taskTimeoutMs) && taskTimeoutMs > 0
|
||
? taskTimeoutMs
|
||
: DASHSCOPE_TASK_TIMEOUT_MS,
|
||
);
|
||
const imageResult = getDashScopeImageUrl(taskResponse);
|
||
const imageResponse = await requestBinaryResponse(imageResult.url);
|
||
|
||
if (imageResponse.statusCode < 200 || imageResponse.statusCode >= 300) {
|
||
throw new Error(`下载生成图片失败(${imageResponse.statusCode})。`);
|
||
}
|
||
|
||
const assetId = createTimestampId('custom-scene');
|
||
const worldSegment = buildAssetPathSegment(
|
||
profileId || worldName || 'custom-world',
|
||
'world',
|
||
);
|
||
const landmarkSegment = buildAssetPathSegment(
|
||
landmarkId || landmarkName || 'landmark',
|
||
'landmark',
|
||
);
|
||
const relativeDir = path.posix.join(
|
||
'generated-custom-world-scenes',
|
||
worldSegment,
|
||
landmarkSegment,
|
||
assetId,
|
||
);
|
||
const outputDir = path.resolve(
|
||
rootDir,
|
||
'public',
|
||
...relativeDir.split('/'),
|
||
);
|
||
const extension = resolveImageExtension(
|
||
imageResponse.headers['content-type'],
|
||
imageResult.url,
|
||
);
|
||
const fileName = `scene.${extension}`;
|
||
const imageSrc = `/${path.posix.join(relativeDir, fileName)}`;
|
||
|
||
await mkdir(outputDir, { recursive: true });
|
||
await writeFile(path.join(outputDir, fileName), imageResponse.body);
|
||
await writeFile(
|
||
path.join(outputDir, 'manifest.json'),
|
||
JSON.stringify(
|
||
{
|
||
assetId,
|
||
taskId,
|
||
model,
|
||
size,
|
||
prompt,
|
||
negativePrompt,
|
||
referenceImageSrc: referenceImageSrc || undefined,
|
||
actualPrompt: imageResult.actualPrompt,
|
||
remoteUrl: imageResult.url,
|
||
imageSrc,
|
||
worldName,
|
||
landmarkName,
|
||
createdAt: new Date().toISOString(),
|
||
},
|
||
null,
|
||
2,
|
||
) + '\n',
|
||
'utf8',
|
||
);
|
||
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
imageSrc,
|
||
assetId,
|
||
taskId,
|
||
model,
|
||
size,
|
||
prompt,
|
||
actualPrompt: imageResult.actualPrompt,
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 500, {
|
||
error: {
|
||
message:
|
||
error instanceof Error ? error.message : '场景图片生成失败。',
|
||
},
|
||
});
|
||
}
|
||
};
|
||
|
||
return {
|
||
name: 'custom-world-scene-image',
|
||
configureServer(server) {
|
||
server.middlewares.use(CUSTOM_WORLD_SCENE_IMAGE_PATH, handler);
|
||
},
|
||
configurePreviewServer(server) {
|
||
server.middlewares.use(CUSTOM_WORLD_SCENE_IMAGE_PATH, handler);
|
||
},
|
||
};
|
||
}
|
||
|
||
async function collectPngAssetPaths(
|
||
rootDir: string,
|
||
relativeDir = 'Icons',
|
||
): Promise<string[]> {
|
||
const entries = await readdir(rootDir, { withFileTypes: true });
|
||
const collected: string[] = [];
|
||
|
||
for (const entry of entries) {
|
||
const absolutePath = path.join(rootDir, entry.name);
|
||
const relativePath = `${relativeDir}/${entry.name}`.replace(/\\/g, '/');
|
||
|
||
if (entry.isDirectory()) {
|
||
collected.push(
|
||
...(await collectPngAssetPaths(absolutePath, relativePath)),
|
||
);
|
||
continue;
|
||
}
|
||
|
||
if (entry.isFile() && entry.name.toLowerCase().endsWith('.png')) {
|
||
collected.push(relativePath);
|
||
}
|
||
}
|
||
|
||
return collected.sort((left, right) => left.localeCompare(right));
|
||
}
|
||
|
||
function createItemCatalogPlugin(rootDir: string): Plugin {
|
||
let cachedAssetPaths: string[] | null = null;
|
||
|
||
const handler = async (req: IncomingMessage, res: ServerResponse) => {
|
||
if (req.method !== 'GET') {
|
||
sendJson(res, 405, { error: { message: 'Method Not Allowed' } });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
if (!cachedAssetPaths) {
|
||
cachedAssetPaths = await collectPngAssetPaths(
|
||
path.resolve(rootDir, 'public/Icons'),
|
||
);
|
||
}
|
||
|
||
sendJson(res, 200, {
|
||
assetPaths: cachedAssetPaths,
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 500, {
|
||
error: {
|
||
message:
|
||
error instanceof Error
|
||
? error.message
|
||
: 'Failed to read item catalog assets',
|
||
},
|
||
});
|
||
}
|
||
};
|
||
|
||
return {
|
||
name: 'item-catalog',
|
||
configureServer(server) {
|
||
server.middlewares.use(ITEM_CATALOG_PATH, handler);
|
||
},
|
||
configurePreviewServer(server) {
|
||
server.middlewares.use(ITEM_CATALOG_PATH, handler);
|
||
},
|
||
};
|
||
}
|
||
|
||
function createItemOverridesPlugin(rootDir: string): Plugin {
|
||
return createJsonFileEditorPlugin({
|
||
name: 'item-overrides',
|
||
routePath: ITEM_OVERRIDES_PATH,
|
||
filePath: path.resolve(rootDir, 'src/data/itemOverrides.json'),
|
||
invalidPayloadMessage: 'Item override payload must be an object map',
|
||
readErrorMessage: 'Failed to read item override file',
|
||
saveErrorMessage: 'Failed to save item override file',
|
||
});
|
||
}
|
||
|
||
function createCharacterVisualPublishPlugin(rootDir: string): Plugin {
|
||
const characterOverridesFilePath = path.resolve(
|
||
rootDir,
|
||
'src/data/characterOverrides.json',
|
||
);
|
||
|
||
const handler = async (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() : '';
|
||
const sourceMode =
|
||
typeof body.sourceMode === 'string' ? body.sourceMode.trim() : 'upload';
|
||
const promptText =
|
||
typeof body.promptText === 'string' && body.promptText.trim()
|
||
? body.promptText.trim()
|
||
: undefined;
|
||
const selectedPreviewSource =
|
||
typeof body.selectedPreviewSource === 'string'
|
||
? body.selectedPreviewSource
|
||
: '';
|
||
const previewSources = isStringArray(body.previewSources)
|
||
? body.previewSources
|
||
: [];
|
||
const width =
|
||
typeof body.width === 'number' && Number.isFinite(body.width)
|
||
? body.width
|
||
: 1024;
|
||
const height =
|
||
typeof body.height === 'number' && Number.isFinite(body.height)
|
||
? body.height
|
||
: 1536;
|
||
const updateCharacterOverride = body.updateCharacterOverride !== false;
|
||
|
||
if (!characterId) {
|
||
sendJson(res, 400, { error: { message: 'characterId is required.' } });
|
||
return;
|
||
}
|
||
|
||
if (!selectedPreviewSource) {
|
||
sendJson(res, 400, {
|
||
error: { message: 'selectedPreviewSource is required.' },
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const assetId = createTimestampId('visual');
|
||
const visualDir = path.resolve(
|
||
rootDir,
|
||
'public/generated-characters',
|
||
sanitizePathSegment(characterId),
|
||
'visual',
|
||
assetId,
|
||
);
|
||
await mkdir(visualDir, { recursive: true });
|
||
|
||
const masterPayload = await resolveAssetSourcePayload(
|
||
rootDir,
|
||
selectedPreviewSource,
|
||
'Unsupported image payload. Expected PNG/JPEG/WEBP data URL or public asset path.',
|
||
);
|
||
const masterFileName = `master.${masterPayload.extension}`;
|
||
const masterFilePath = path.join(visualDir, masterFileName);
|
||
await writeFile(masterFilePath, masterPayload.buffer);
|
||
|
||
const previewImagePaths: string[] = [];
|
||
for (let index = 0; index < previewSources.length; index += 1) {
|
||
const previewPayload = await resolveAssetSourcePayload(
|
||
rootDir,
|
||
previewSources[index] ?? '',
|
||
'Unsupported image payload. Expected PNG/JPEG/WEBP data URL or public asset path.',
|
||
);
|
||
const previewFileName = `preview-${index + 1}.${previewPayload.extension}`;
|
||
await writeFile(
|
||
path.join(visualDir, previewFileName),
|
||
previewPayload.buffer,
|
||
);
|
||
previewImagePaths.push(
|
||
`/generated-characters/${sanitizePathSegment(characterId)}/visual/${assetId}/${previewFileName}`,
|
||
);
|
||
}
|
||
|
||
const masterImagePath = `/generated-characters/${sanitizePathSegment(characterId)}/visual/${assetId}/${masterFileName}`;
|
||
const manifest: PublishedVisualManifest = {
|
||
id: assetId,
|
||
characterId,
|
||
sourceMode,
|
||
promptText,
|
||
masterImagePath,
|
||
previewImagePaths,
|
||
width,
|
||
height,
|
||
facing: 'right',
|
||
locked: true,
|
||
};
|
||
|
||
await writeFile(
|
||
path.join(visualDir, 'visual-manifest.json'),
|
||
JSON.stringify(manifest, null, 2) + '\n',
|
||
'utf8',
|
||
);
|
||
|
||
let overrideMap: Record<string, unknown> = {};
|
||
if (updateCharacterOverride) {
|
||
overrideMap = await readJsonObjectFile(characterOverridesFilePath);
|
||
const existingOverride = overrideMap[characterId];
|
||
const nextOverride =
|
||
existingOverride &&
|
||
typeof existingOverride === 'object' &&
|
||
!Array.isArray(existingOverride)
|
||
? { ...(existingOverride as Record<string, unknown>) }
|
||
: {};
|
||
nextOverride.generatedVisualAssetId = assetId;
|
||
nextOverride.portrait = masterImagePath;
|
||
overrideMap[characterId] = nextOverride;
|
||
await writeJsonObjectFile(characterOverridesFilePath, overrideMap);
|
||
}
|
||
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
assetId,
|
||
portraitPath: masterImagePath,
|
||
overrideMap,
|
||
saveMessage: updateCharacterOverride
|
||
? '主形象已发布到 public/generated-characters,并更新角色覆盖。'
|
||
: '主形象已保存到 public/generated-characters,可直接写回当前自定义世界角色。',
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 500, {
|
||
error: {
|
||
message:
|
||
error instanceof Error
|
||
? error.message
|
||
: 'Failed to publish character visual asset',
|
||
},
|
||
});
|
||
}
|
||
};
|
||
|
||
return {
|
||
name: 'character-visual-publish',
|
||
configureServer(server) {
|
||
server.middlewares.use(CHARACTER_VISUAL_PUBLISH_PATH, handler);
|
||
},
|
||
configurePreviewServer(server) {
|
||
server.middlewares.use(CHARACTER_VISUAL_PUBLISH_PATH, handler);
|
||
},
|
||
};
|
||
}
|
||
|
||
function createCharacterAnimationPublishPlugin(rootDir: string): Plugin {
|
||
const characterOverridesFilePath = path.resolve(
|
||
rootDir,
|
||
'src/data/characterOverrides.json',
|
||
);
|
||
|
||
const handler = async (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() : '';
|
||
const visualAssetId =
|
||
typeof body.visualAssetId === 'string' ? body.visualAssetId.trim() : '';
|
||
const animations =
|
||
body.animations &&
|
||
typeof body.animations === 'object' &&
|
||
!Array.isArray(body.animations)
|
||
? (body.animations as Record<string, unknown>)
|
||
: null;
|
||
const updateCharacterOverride = body.updateCharacterOverride !== false;
|
||
|
||
if (!characterId) {
|
||
sendJson(res, 400, { error: { message: 'characterId is required.' } });
|
||
return;
|
||
}
|
||
|
||
if (!visualAssetId) {
|
||
sendJson(res, 400, { error: { message: 'visualAssetId is required.' } });
|
||
return;
|
||
}
|
||
|
||
if (!animations || Object.keys(animations).length === 0) {
|
||
sendJson(res, 400, { error: { message: 'animations is required.' } });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const animationSetId = createTimestampId('animation-set');
|
||
const baseAnimationDir = path.resolve(
|
||
rootDir,
|
||
'public/generated-animations',
|
||
sanitizePathSegment(characterId),
|
||
animationSetId,
|
||
);
|
||
await mkdir(baseAnimationDir, { recursive: true });
|
||
|
||
const actionManifests: PublishedAnimationManifest[] = [];
|
||
const nextAnimationMap: Record<string, Record<string, unknown>> = {};
|
||
|
||
for (const [action, rawAnimation] of Object.entries(animations)) {
|
||
if (
|
||
!rawAnimation ||
|
||
typeof rawAnimation !== 'object' ||
|
||
Array.isArray(rawAnimation)
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
const typedAnimation = rawAnimation as {
|
||
framesDataUrls?: unknown;
|
||
fps?: unknown;
|
||
loop?: unknown;
|
||
frameWidth?: unknown;
|
||
frameHeight?: unknown;
|
||
previewVideoPath?: unknown;
|
||
};
|
||
const framesDataUrls = isStringArray(typedAnimation.framesDataUrls)
|
||
? typedAnimation.framesDataUrls
|
||
: [];
|
||
if (framesDataUrls.length === 0) {
|
||
continue;
|
||
}
|
||
|
||
const fps =
|
||
typeof typedAnimation.fps === 'number' &&
|
||
Number.isFinite(typedAnimation.fps)
|
||
? typedAnimation.fps
|
||
: 8;
|
||
const loop = typedAnimation.loop === true;
|
||
const frameWidth =
|
||
typeof typedAnimation.frameWidth === 'number' &&
|
||
Number.isFinite(typedAnimation.frameWidth)
|
||
? typedAnimation.frameWidth
|
||
: 192;
|
||
const frameHeight =
|
||
typeof typedAnimation.frameHeight === 'number' &&
|
||
Number.isFinite(typedAnimation.frameHeight)
|
||
? typedAnimation.frameHeight
|
||
: 256;
|
||
const actionKey = sanitizePathSegment(action);
|
||
const actionDir = path.join(baseAnimationDir, actionKey);
|
||
await mkdir(actionDir, { recursive: true });
|
||
|
||
const framePaths: string[] = [];
|
||
for (let index = 0; index < framesDataUrls.length; index += 1) {
|
||
const framePayload = await resolveAssetSourcePayload(
|
||
rootDir,
|
||
framesDataUrls[index] ?? '',
|
||
'Unsupported frame payload. Expected PNG/JPEG/WEBP data URL or public asset path.',
|
||
);
|
||
const frameFileName = `frame${String(index + 1).padStart(2, '0')}.${framePayload.extension}`;
|
||
await writeFile(
|
||
path.join(actionDir, frameFileName),
|
||
framePayload.buffer,
|
||
);
|
||
framePaths.push(
|
||
`/generated-animations/${sanitizePathSegment(characterId)}/${animationSetId}/${actionKey}/${frameFileName}`,
|
||
);
|
||
}
|
||
|
||
const basePath = `/generated-animations/${sanitizePathSegment(characterId)}/${animationSetId}/${actionKey}`;
|
||
const previewVideoPath =
|
||
typeof typedAnimation.previewVideoPath === 'string' &&
|
||
typedAnimation.previewVideoPath.trim()
|
||
? typedAnimation.previewVideoPath.trim()
|
||
: undefined;
|
||
const manifest: PublishedAnimationManifest = {
|
||
id: `${animationSetId}-${actionKey}`,
|
||
animationSetId,
|
||
characterId,
|
||
visualAssetId,
|
||
action,
|
||
frameCount: framePaths.length,
|
||
fps,
|
||
loop,
|
||
frameWidth,
|
||
frameHeight,
|
||
previewVideoPath,
|
||
framePaths,
|
||
};
|
||
|
||
await writeFile(
|
||
path.join(actionDir, 'manifest.json'),
|
||
JSON.stringify(manifest, null, 2) + '\n',
|
||
'utf8',
|
||
);
|
||
|
||
actionManifests.push(manifest);
|
||
nextAnimationMap[action] = {
|
||
folder: action,
|
||
prefix: 'frame',
|
||
frames: framePaths.length,
|
||
startFrame: 1,
|
||
extension: 'png',
|
||
basePath,
|
||
};
|
||
}
|
||
|
||
await writeFile(
|
||
path.join(baseAnimationDir, 'manifest.json'),
|
||
JSON.stringify(
|
||
{
|
||
animationSetId,
|
||
characterId,
|
||
visualAssetId,
|
||
actions: actionManifests,
|
||
},
|
||
null,
|
||
2,
|
||
) + '\n',
|
||
'utf8',
|
||
);
|
||
|
||
let overrideMap: Record<string, unknown> = {};
|
||
if (updateCharacterOverride) {
|
||
overrideMap = await readJsonObjectFile(characterOverridesFilePath);
|
||
const existingOverride = overrideMap[characterId];
|
||
const nextOverride =
|
||
existingOverride &&
|
||
typeof existingOverride === 'object' &&
|
||
!Array.isArray(existingOverride)
|
||
? { ...(existingOverride as Record<string, unknown>) }
|
||
: {};
|
||
const existingAnimationMap =
|
||
nextOverride.animationMap &&
|
||
typeof nextOverride.animationMap === 'object' &&
|
||
!Array.isArray(nextOverride.animationMap)
|
||
? (nextOverride.animationMap as Record<string, unknown>)
|
||
: {};
|
||
nextOverride.generatedAnimationSetId = animationSetId;
|
||
nextOverride.generatedVisualAssetId = visualAssetId;
|
||
nextOverride.animationMap = {
|
||
...existingAnimationMap,
|
||
...nextAnimationMap,
|
||
};
|
||
overrideMap[characterId] = nextOverride;
|
||
await writeJsonObjectFile(characterOverridesFilePath, overrideMap);
|
||
}
|
||
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
animationSetId,
|
||
overrideMap,
|
||
animationMap: nextAnimationMap,
|
||
saveMessage: updateCharacterOverride
|
||
? '基础动作资源已发布到 public/generated-animations,并更新角色覆盖。'
|
||
: '基础动作资源已保存到 public/generated-animations,可直接写回当前自定义世界角色。',
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 500, {
|
||
error: {
|
||
message:
|
||
error instanceof Error
|
||
? error.message
|
||
: 'Failed to publish character animation asset',
|
||
},
|
||
});
|
||
}
|
||
};
|
||
|
||
return {
|
||
name: 'character-animation-publish',
|
||
configureServer(server) {
|
||
server.middlewares.use(CHARACTER_ANIMATION_PUBLISH_PATH, handler);
|
||
},
|
||
configurePreviewServer(server) {
|
||
server.middlewares.use(CHARACTER_ANIMATION_PUBLISH_PATH, handler);
|
||
},
|
||
};
|
||
}
|
||
|
||
export function createLocalApiPlugins(
|
||
rootDir: string,
|
||
mode: string,
|
||
env: Record<string, string>,
|
||
): Plugin[] {
|
||
return [
|
||
...createCharacterAssetStudioPlugins(rootDir, mode, env),
|
||
...createQwenSpriteSheetToolPlugins(rootDir, mode, env),
|
||
createLlmProxyPlugin(rootDir, mode, env),
|
||
createCustomWorldSceneImagePlugin(rootDir, mode, env),
|
||
createItemCatalogPlugin(rootDir),
|
||
createItemOverridesPlugin(rootDir),
|
||
createNpcVisualOverridePlugin(rootDir),
|
||
createNpcLayoutConfigPlugin(rootDir),
|
||
createCharacterOverridesPlugin(rootDir),
|
||
createMonsterOverridesPlugin(rootDir),
|
||
createSceneOverridesPlugin(rootDir),
|
||
createSceneNpcOverridesPlugin(rootDir),
|
||
createStateFunctionOverridesPlugin(rootDir),
|
||
createCharacterVisualPublishPlugin(rootDir),
|
||
createCharacterAnimationPublishPlugin(rootDir),
|
||
];
|
||
}
|