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>((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 { 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, ) { 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; bodyText?: string; } = {}, ) { return new Promise<{ statusCode: number; headers: Record; 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; } = {}, ) { return new Promise<{ statusCode: number; headers: Record; 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, ) { 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) { 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, 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 = { 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; 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, req: IncomingMessage, res: ServerResponse, ) { if (req.method !== 'POST') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; } let body: Record; 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, req: IncomingMessage, res: ServerResponse, ) { if (req.method !== 'POST') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; } let body: Record; 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, req: IncomingMessage, res: ServerResponse, ) { if (req.method !== 'POST') { sendJson(res, 405, { error: { message: 'Method Not Allowed' } }); return; } let body: Record; 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; 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, ): 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); }, }, ]; }