Files
Genarrative/scripts/dev-server/qwenSpriteSheetToolPlugins.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

903 lines
24 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 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);
},
},
];
}