903 lines
24 KiB
TypeScript
903 lines
24 KiB
TypeScript
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||
import http, {
|
||
type IncomingMessage,
|
||
type RequestOptions,
|
||
type ServerResponse,
|
||
} from 'node:http';
|
||
import https from 'node:https';
|
||
import path from 'node:path';
|
||
|
||
import { loadEnv, type Plugin } from 'vite';
|
||
|
||
const QWEN_SPRITE_MASTER_GENERATE_PATH = '/api/qwen-sprite/master';
|
||
const QWEN_SPRITE_SHEET_GENERATE_PATH = '/api/qwen-sprite/sheet';
|
||
const QWEN_SPRITE_FRAME_REPAIR_PATH = '/api/qwen-sprite/frame-repair';
|
||
const QWEN_SPRITE_SAVE_PATH = '/api/qwen-sprite/save';
|
||
const DEFAULT_DASHSCOPE_BASE_URL = 'https://dashscope.aliyuncs.com/api/v1';
|
||
const DEFAULT_QWEN_IMAGE_MODEL = 'qwen-image-2.0';
|
||
|
||
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')
|
||
.replace(/^\uFEFF/u, '') || '{}';
|
||
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 resolveRuntimeEnv(
|
||
rootDir: string,
|
||
mode: string,
|
||
env: Record<string, string>,
|
||
) {
|
||
return {
|
||
...env,
|
||
...loadEnv(mode, rootDir, ''),
|
||
};
|
||
}
|
||
|
||
function normalizeDashScopeBaseUrl(value: string) {
|
||
return value.replace(/\/$/u, '');
|
||
}
|
||
|
||
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 raw text.
|
||
}
|
||
|
||
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 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 proxyJsonRequest(
|
||
urlString: string,
|
||
apiKey: string,
|
||
body: Record<string, unknown>,
|
||
) {
|
||
return requestTextResponse(urlString, {
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${apiKey}`,
|
||
'Content-Type': 'application/json',
|
||
},
|
||
bodyText: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
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 extractImageUrls(payload: Record<string, unknown>) {
|
||
const results: string[] = [];
|
||
collectStringsByKey(payload.output, 'image', results);
|
||
collectStringsByKey(payload.output, 'url', results);
|
||
return [...new Set(results)];
|
||
}
|
||
|
||
function parseDataUrl(source: string) {
|
||
const matched = /^data:(image\/[^;]+);base64,(.+)$/u.exec(source);
|
||
if (!matched) {
|
||
return null;
|
||
}
|
||
|
||
const mimeType = matched[1];
|
||
const base64Payload = matched[2];
|
||
const extension = (() => {
|
||
switch (mimeType) {
|
||
case 'image/jpeg':
|
||
return 'jpg';
|
||
case 'image/webp':
|
||
return 'webp';
|
||
default:
|
||
return 'png';
|
||
}
|
||
})();
|
||
|
||
return {
|
||
buffer: Buffer.from(base64Payload, 'base64'),
|
||
extension,
|
||
};
|
||
}
|
||
|
||
async function resolveImageSourcePayload(rootDir: string, source: string) {
|
||
const parsedDataUrl = parseDataUrl(source);
|
||
if (parsedDataUrl) {
|
||
return parsedDataUrl;
|
||
}
|
||
|
||
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, '') || 'png';
|
||
|
||
return {
|
||
buffer,
|
||
extension,
|
||
};
|
||
}
|
||
|
||
async function resolveImageSourceAsDataUrl(rootDir: string, source: string) {
|
||
if (/^data:image\/[^;]+;base64,/u.test(source)) {
|
||
return source;
|
||
}
|
||
|
||
const payload = await resolveImageSourcePayload(rootDir, source);
|
||
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')}`;
|
||
}
|
||
|
||
async function writeDraftImageFile(
|
||
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 generateQwenImages(
|
||
rootDir: string,
|
||
mode: string,
|
||
env: Record<string, string>,
|
||
input: {
|
||
kind: 'master' | 'sheet' | 'repair';
|
||
promptText: string;
|
||
negativePrompt: string;
|
||
model: string;
|
||
size: string;
|
||
promptExtend: boolean;
|
||
seed?: number;
|
||
candidateCount: number;
|
||
referenceImages: string[];
|
||
},
|
||
) {
|
||
const runtimeEnv = resolveRuntimeEnv(rootDir, mode, env);
|
||
const baseUrl = normalizeDashScopeBaseUrl(
|
||
runtimeEnv.DASHSCOPE_BASE_URL || DEFAULT_DASHSCOPE_BASE_URL,
|
||
);
|
||
const apiKey = runtimeEnv.DASHSCOPE_API_KEY || '';
|
||
|
||
if (!apiKey) {
|
||
throw new Error('服务端缺少 DASHSCOPE_API_KEY,无法调用 Qwen-Image。');
|
||
}
|
||
|
||
const content = [
|
||
...(await Promise.all(
|
||
input.referenceImages
|
||
.slice(0, 3)
|
||
.map(async (image) => ({ image: await resolveImageSourceAsDataUrl(rootDir, image) })),
|
||
)),
|
||
{ text: input.promptText },
|
||
];
|
||
|
||
const requestPayload: Record<string, unknown> = {
|
||
model: input.model || DEFAULT_QWEN_IMAGE_MODEL,
|
||
input: {
|
||
messages: [
|
||
{
|
||
role: 'user',
|
||
content,
|
||
},
|
||
],
|
||
},
|
||
parameters: {
|
||
n: Math.max(1, Math.min(6, input.candidateCount)),
|
||
negative_prompt: input.negativePrompt,
|
||
prompt_extend: input.promptExtend,
|
||
watermark: false,
|
||
size: input.size,
|
||
...(typeof input.seed === 'number' && Number.isFinite(input.seed)
|
||
? { seed: input.seed }
|
||
: {}),
|
||
},
|
||
};
|
||
|
||
const response = await proxyJsonRequest(
|
||
`${baseUrl}/services/aigc/multimodal-generation/generation`,
|
||
apiKey,
|
||
requestPayload,
|
||
);
|
||
|
||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||
throw new Error(
|
||
extractApiErrorMessage(response.bodyText, 'Qwen-Image 生成失败。'),
|
||
);
|
||
}
|
||
|
||
const parsed = JSON.parse(response.bodyText) as Record<string, unknown>;
|
||
const imageUrls = extractImageUrls(parsed);
|
||
|
||
if (imageUrls.length === 0) {
|
||
throw new Error('Qwen-Image 未返回可下载的图片结果。');
|
||
}
|
||
|
||
const draftId = createTimestampId(`qwen-${input.kind}`);
|
||
const relativeDir = path.posix.join(
|
||
'generated-qwen-sprites',
|
||
'_drafts',
|
||
input.kind,
|
||
draftId,
|
||
);
|
||
|
||
const drafts = await Promise.all(
|
||
imageUrls.map(async (imageUrl, index) => {
|
||
const binaryResponse = await requestBinaryResponse(imageUrl);
|
||
if (
|
||
binaryResponse.statusCode < 200 ||
|
||
binaryResponse.statusCode >= 300
|
||
) {
|
||
throw new Error(`下载生成图片失败(${binaryResponse.statusCode})。`);
|
||
}
|
||
|
||
const imageSrc = await writeDraftImageFile(
|
||
rootDir,
|
||
path.posix.join(relativeDir, `candidate-${String(index + 1).padStart(2, '0')}.png`),
|
||
binaryResponse.body,
|
||
);
|
||
|
||
return {
|
||
id: `${draftId}-${index + 1}`,
|
||
label: `${input.kind === 'master' ? '主图' : input.kind === 'sheet' ? '精灵表' : '修帧'} ${index + 1}`,
|
||
imageSrc,
|
||
remoteUrl: imageUrl,
|
||
};
|
||
}),
|
||
);
|
||
|
||
await writeFile(
|
||
path.resolve(rootDir, 'public', ...relativeDir.split('/'), 'job.json'),
|
||
JSON.stringify(
|
||
{
|
||
draftId,
|
||
kind: input.kind,
|
||
model: input.model,
|
||
size: input.size,
|
||
promptText: input.promptText,
|
||
negativePrompt: input.negativePrompt,
|
||
promptExtend: input.promptExtend,
|
||
seed: input.seed,
|
||
candidateCount: input.candidateCount,
|
||
referenceImageCount: input.referenceImages.length,
|
||
drafts,
|
||
createdAt: new Date().toISOString(),
|
||
},
|
||
null,
|
||
2,
|
||
) + '\n',
|
||
'utf8',
|
||
);
|
||
|
||
return {
|
||
draftId,
|
||
drafts,
|
||
model: input.model,
|
||
size: input.size,
|
||
promptText: input.promptText,
|
||
negativePrompt: input.negativePrompt,
|
||
};
|
||
}
|
||
|
||
async function handleGenerateMaster(
|
||
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;
|
||
}
|
||
|
||
let body: Record<string, unknown>;
|
||
try {
|
||
body = await readJsonBody(req);
|
||
} catch {
|
||
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
|
||
return;
|
||
}
|
||
|
||
const promptText =
|
||
typeof body.promptText === 'string' ? body.promptText.trim() : '';
|
||
const negativePrompt =
|
||
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
|
||
const model =
|
||
typeof body.model === 'string' && body.model.trim()
|
||
? body.model.trim()
|
||
: DEFAULT_QWEN_IMAGE_MODEL;
|
||
const size =
|
||
typeof body.size === 'string' && body.size.trim()
|
||
? body.size.trim()
|
||
: '1024*1024';
|
||
const promptExtend = body.promptExtend !== false;
|
||
const candidateCount =
|
||
typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount)
|
||
? body.candidateCount
|
||
: 1;
|
||
const seed =
|
||
typeof body.seed === 'number' && Number.isFinite(body.seed)
|
||
? body.seed
|
||
: undefined;
|
||
const referenceImages = isStringArray(body.referenceImages)
|
||
? body.referenceImages
|
||
: [];
|
||
|
||
if (!promptText) {
|
||
sendJson(res, 400, { error: { message: 'promptText is required.' } });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const result = await generateQwenImages(rootDir, mode, env, {
|
||
kind: 'master',
|
||
promptText,
|
||
negativePrompt,
|
||
model,
|
||
size,
|
||
promptExtend,
|
||
seed,
|
||
candidateCount,
|
||
referenceImages,
|
||
});
|
||
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
...result,
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 500, {
|
||
error: {
|
||
message: error instanceof Error ? error.message : '生成主图失败。',
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
async function handleGenerateSheet(
|
||
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;
|
||
}
|
||
|
||
let body: Record<string, unknown>;
|
||
try {
|
||
body = await readJsonBody(req);
|
||
} catch {
|
||
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
|
||
return;
|
||
}
|
||
|
||
const promptText =
|
||
typeof body.promptText === 'string' ? body.promptText.trim() : '';
|
||
const negativePrompt =
|
||
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
|
||
const model =
|
||
typeof body.model === 'string' && body.model.trim()
|
||
? body.model.trim()
|
||
: DEFAULT_QWEN_IMAGE_MODEL;
|
||
const size =
|
||
typeof body.size === 'string' && body.size.trim()
|
||
? body.size.trim()
|
||
: '1024*1024';
|
||
const promptExtend = body.promptExtend !== false;
|
||
const candidateCount =
|
||
typeof body.candidateCount === 'number' && Number.isFinite(body.candidateCount)
|
||
? body.candidateCount
|
||
: 1;
|
||
const seed =
|
||
typeof body.seed === 'number' && Number.isFinite(body.seed)
|
||
? body.seed
|
||
: undefined;
|
||
const referenceImages = isStringArray(body.referenceImages)
|
||
? body.referenceImages
|
||
: [];
|
||
|
||
if (!promptText) {
|
||
sendJson(res, 400, { error: { message: 'promptText is required.' } });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const result = await generateQwenImages(rootDir, mode, env, {
|
||
kind: 'sheet',
|
||
promptText,
|
||
negativePrompt,
|
||
model,
|
||
size,
|
||
promptExtend,
|
||
seed,
|
||
candidateCount,
|
||
referenceImages,
|
||
});
|
||
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
...result,
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 500, {
|
||
error: {
|
||
message: error instanceof Error ? error.message : '生成精灵表失败。',
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
async function handleRepairFrame(
|
||
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;
|
||
}
|
||
|
||
let body: Record<string, unknown>;
|
||
try {
|
||
body = await readJsonBody(req);
|
||
} catch {
|
||
sendJson(res, 400, { error: { message: 'Invalid JSON body' } });
|
||
return;
|
||
}
|
||
|
||
const promptText =
|
||
typeof body.promptText === 'string' ? body.promptText.trim() : '';
|
||
const negativePrompt =
|
||
typeof body.negativePrompt === 'string' ? body.negativePrompt.trim() : '';
|
||
const model =
|
||
typeof body.model === 'string' && body.model.trim()
|
||
? body.model.trim()
|
||
: DEFAULT_QWEN_IMAGE_MODEL;
|
||
const size =
|
||
typeof body.size === 'string' && body.size.trim()
|
||
? body.size.trim()
|
||
: '512*512';
|
||
const promptExtend = body.promptExtend !== false;
|
||
const seed =
|
||
typeof body.seed === 'number' && Number.isFinite(body.seed)
|
||
? body.seed
|
||
: undefined;
|
||
const referenceImages = isStringArray(body.referenceImages)
|
||
? body.referenceImages
|
||
: [];
|
||
|
||
if (!promptText) {
|
||
sendJson(res, 400, { error: { message: 'promptText is required.' } });
|
||
return;
|
||
}
|
||
|
||
if (referenceImages.length === 0) {
|
||
sendJson(res, 400, {
|
||
error: { message: '至少需要一张参考图来修复帧。' },
|
||
});
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const result = await generateQwenImages(rootDir, mode, env, {
|
||
kind: 'repair',
|
||
promptText,
|
||
negativePrompt,
|
||
model,
|
||
size,
|
||
promptExtend,
|
||
seed,
|
||
candidateCount: 1,
|
||
referenceImages,
|
||
});
|
||
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
...result,
|
||
repairedFrame: result.drafts[0] ?? null,
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 500, {
|
||
error: {
|
||
message: error instanceof Error ? error.message : '修帧失败。',
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
async function handleSaveAsset(
|
||
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 assetKey =
|
||
typeof body.assetKey === 'string' ? sanitizePathSegment(body.assetKey) : '';
|
||
const actionKey =
|
||
typeof body.actionKey === 'string' ? sanitizePathSegment(body.actionKey) : '';
|
||
const masterSource =
|
||
typeof body.masterSource === 'string' ? body.masterSource.trim() : '';
|
||
const sheetSource =
|
||
typeof body.sheetSource === 'string' ? body.sheetSource.trim() : '';
|
||
const framesDataUrls = isStringArray(body.framesDataUrls)
|
||
? body.framesDataUrls
|
||
: [];
|
||
const metadata = isRecordValue(body.metadata) ? body.metadata : {};
|
||
const prompts = isRecordValue(body.prompts) ? body.prompts : {};
|
||
|
||
if (!assetKey) {
|
||
sendJson(res, 400, { error: { message: 'assetKey is required.' } });
|
||
return;
|
||
}
|
||
|
||
if (!actionKey) {
|
||
sendJson(res, 400, { error: { message: 'actionKey is required.' } });
|
||
return;
|
||
}
|
||
|
||
if (!sheetSource) {
|
||
sendJson(res, 400, { error: { message: 'sheetSource is required.' } });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const assetId = createTimestampId('qwen-sprite');
|
||
const relativeDir = path.posix.join(
|
||
'generated-qwen-sprites',
|
||
assetKey,
|
||
actionKey,
|
||
assetId,
|
||
);
|
||
const absoluteDir = path.resolve(rootDir, 'public', ...relativeDir.split('/'));
|
||
await mkdir(path.join(absoluteDir, 'frames'), { recursive: true });
|
||
|
||
let masterImagePath: string | null = null;
|
||
if (masterSource) {
|
||
const payload = await resolveImageSourcePayload(rootDir, masterSource);
|
||
masterImagePath = await writeDraftImageFile(
|
||
rootDir,
|
||
path.posix.join(relativeDir, `master.${payload.extension}`),
|
||
payload.buffer,
|
||
);
|
||
}
|
||
|
||
const sheetPayload = await resolveImageSourcePayload(rootDir, sheetSource);
|
||
const sheetImagePath = await writeDraftImageFile(
|
||
rootDir,
|
||
path.posix.join(relativeDir, `sheet.${sheetPayload.extension}`),
|
||
sheetPayload.buffer,
|
||
);
|
||
|
||
const framePaths: string[] = [];
|
||
for (let index = 0; index < framesDataUrls.length; index += 1) {
|
||
const framePayload = await resolveImageSourcePayload(
|
||
rootDir,
|
||
framesDataUrls[index] ?? '',
|
||
);
|
||
const framePath = await writeDraftImageFile(
|
||
rootDir,
|
||
path.posix.join(
|
||
relativeDir,
|
||
'frames',
|
||
`frame-${String(index + 1).padStart(2, '0')}.${framePayload.extension}`,
|
||
),
|
||
framePayload.buffer,
|
||
);
|
||
framePaths.push(framePath);
|
||
}
|
||
|
||
await writeFile(
|
||
path.join(absoluteDir, 'metadata.json'),
|
||
JSON.stringify(
|
||
{
|
||
assetId,
|
||
assetKey,
|
||
actionKey,
|
||
masterImagePath,
|
||
sheetImagePath,
|
||
framePaths,
|
||
metadata,
|
||
prompts,
|
||
createdAt: new Date().toISOString(),
|
||
},
|
||
null,
|
||
2,
|
||
) + '\n',
|
||
'utf8',
|
||
);
|
||
|
||
sendJson(res, 200, {
|
||
ok: true,
|
||
assetId,
|
||
assetDir: `/${relativeDir}`,
|
||
masterImagePath,
|
||
sheetImagePath,
|
||
framePaths,
|
||
saveMessage: '已保存到 public/generated-qwen-sprites。',
|
||
});
|
||
} catch (error) {
|
||
sendJson(res, 500, {
|
||
error: {
|
||
message: error instanceof Error ? error.message : '保存精灵表资产失败。',
|
||
},
|
||
});
|
||
}
|
||
}
|
||
|
||
export function createQwenSpriteSheetToolPlugins(
|
||
rootDir: string,
|
||
mode: string,
|
||
env: Record<string, string>,
|
||
): Plugin[] {
|
||
const masterHandler = (req: IncomingMessage, res: ServerResponse) =>
|
||
void handleGenerateMaster(rootDir, mode, env, req, res);
|
||
const sheetHandler = (req: IncomingMessage, res: ServerResponse) =>
|
||
void handleGenerateSheet(rootDir, mode, env, req, res);
|
||
const repairHandler = (req: IncomingMessage, res: ServerResponse) =>
|
||
void handleRepairFrame(rootDir, mode, env, req, res);
|
||
const saveHandler = (req: IncomingMessage, res: ServerResponse) =>
|
||
void handleSaveAsset(rootDir, req, res);
|
||
|
||
return [
|
||
{
|
||
name: 'qwen-sprite-master-generate',
|
||
configureServer(server) {
|
||
server.middlewares.use(QWEN_SPRITE_MASTER_GENERATE_PATH, masterHandler);
|
||
},
|
||
configurePreviewServer(server) {
|
||
server.middlewares.use(QWEN_SPRITE_MASTER_GENERATE_PATH, masterHandler);
|
||
},
|
||
},
|
||
{
|
||
name: 'qwen-sprite-sheet-generate',
|
||
configureServer(server) {
|
||
server.middlewares.use(QWEN_SPRITE_SHEET_GENERATE_PATH, sheetHandler);
|
||
},
|
||
configurePreviewServer(server) {
|
||
server.middlewares.use(QWEN_SPRITE_SHEET_GENERATE_PATH, sheetHandler);
|
||
},
|
||
},
|
||
{
|
||
name: 'qwen-sprite-frame-repair',
|
||
configureServer(server) {
|
||
server.middlewares.use(QWEN_SPRITE_FRAME_REPAIR_PATH, repairHandler);
|
||
},
|
||
configurePreviewServer(server) {
|
||
server.middlewares.use(QWEN_SPRITE_FRAME_REPAIR_PATH, repairHandler);
|
||
},
|
||
},
|
||
{
|
||
name: 'qwen-sprite-save',
|
||
configureServer(server) {
|
||
server.middlewares.use(QWEN_SPRITE_SAVE_PATH, saveHandler);
|
||
},
|
||
configurePreviewServer(server) {
|
||
server.middlewares.use(QWEN_SPRITE_SAVE_PATH, saveHandler);
|
||
},
|
||
},
|
||
];
|
||
}
|