import {Buffer} from 'node:buffer'; import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs'; import path from 'node:path'; import {spawnSync} from 'node:child_process'; import {mergeApiServerEnv} from './dev-utils.mjs'; const repoRoot = process.cwd(); const defaultTimeoutMs = 1_000_000; const defaultTheme = '海底糖果集市'; const uiLabels = ['back', 'settings', 'tile', 'remove', 'match', 'shuffle']; function parseArgs(argv) { const args = { live: false, theme: defaultTheme, outDir: '', }; for (let index = 2; index < argv.length; index += 1) { const raw = argv[index]; if (raw === '--live') { args.live = true; continue; } if (raw === '--theme') { args.theme = String(argv[index + 1] ?? defaultTheme); index += 1; continue; } if (raw === '--out-dir') { args.outDir = String(argv[index + 1] ?? ''); index += 1; } } return args; } function timestamp() { const now = new Date(); const pad = (value) => String(value).padStart(2, '0'); return [ now.getFullYear(), pad(now.getMonth() + 1), pad(now.getDate()), '-', pad(now.getHours()), pad(now.getMinutes()), pad(now.getSeconds()), ].join(''); } function resolveEnv() { const env = mergeApiServerEnv(repoRoot, process.env); return { baseUrl: String(env.VECTOR_ENGINE_BASE_URL || '').trim().replace(/\/+$/u, ''), apiKey: String(env.VECTOR_ENGINE_API_KEY || '').trim(), timeoutMs: Number.parseInt( String(env.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), 10, ), }; } function generationUrl(baseUrl) { return baseUrl.endsWith('/v1') ? `${baseUrl}/images/generations` : `${baseUrl}/v1/images/generations`; } function editUrl(baseUrl) { return baseUrl.endsWith('/v1') ? `${baseUrl}/images/edits` : `${baseUrl}/v1/images/edits`; } function promptWithNegative(prompt, negative) { const normalizedPrompt = prompt.trim(); const normalizedNegative = String(negative ?? '').trim(); return normalizedNegative ? `${normalizedPrompt}\n避免:${normalizedNegative}` : normalizedPrompt; } function buildLevelScenePrompt(theme) { const normalizedTheme = String(theme || defaultTheme).trim() || defaultTheme; return [ '生成抓大鹅游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质', '', '抓大鹅主题描述:', normalizedTheme, '', '画面元素:', '返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 重庆火锅”和倒计时时间,右上角显示设置按钮', '画面中间是一个和主题匹配的容器,宽度与画面宽度同宽,紧贴画面横向边缘', '底部还有三个道具按钮分别为“移出”、“凑齐”、“打乱”', ].join('\n'); } function buildUiSpritesheetPrompt() { return '提取画面中的UI元素,将返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮的顺序从上到下从左到右整理成纯绿色绿幕背景spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。UI 素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。'; } function buildBackgroundPrompt() { return '移除画面中的所有UI组件和容器中的内含物,完整保留容器和背景,补全被UI覆盖的背景内容'; } function buildItemSpritesheetPrompt() { return '固定生成10行*10列spritesheet图,统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布,任意两个素材间距相同,物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品,每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品'; } function collectStringsByKey(value, targetKey, output) { if (Array.isArray(value)) { value.forEach((entry) => collectStringsByKey(entry, targetKey, output)); return; } if (!value || typeof value !== 'object') { return; } for (const [key, nested] of Object.entries(value)) { if (key === targetKey) { if (typeof nested === 'string' && nested.trim()) { output.push(nested.trim()); } else if (Array.isArray(nested)) { nested.forEach((entry) => { if (typeof entry === 'string' && entry.trim()) { output.push(entry.trim()); } }); } } collectStringsByKey(nested, targetKey, output); } } function inferExtensionFromBytes(bytes) { if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) { return 'png'; } if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) { return 'jpg'; } if ( bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP' ) { return 'webp'; } return 'png'; } async function fetchJson(url, options, timeoutMs) { const abortController = new AbortController(); const timer = setTimeout(() => abortController.abort(), timeoutMs); try { const response = await fetch(url, { ...options, signal: abortController.signal, }); const text = await response.text(); if (!response.ok) { throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`); } return JSON.parse(text); } catch (error) { if (error?.name === 'AbortError') { throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); } throw error; } finally { clearTimeout(timer); } } async function downloadUrl(url, timeoutMs) { const abortController = new AbortController(); const timer = setTimeout(() => abortController.abort(), timeoutMs); try { const response = await fetch(url, {signal: abortController.signal}); if (!response.ok) { throw new Error(`download ${response.status}`); } return Buffer.from(await response.arrayBuffer()); } finally { clearTimeout(timer); } } async function imageBytesFromPayload(payload, env) { const urls = []; const b64Images = []; collectStringsByKey(payload, 'url', urls); collectStringsByKey(payload, 'image', urls); collectStringsByKey(payload, 'image_url', urls); collectStringsByKey(payload, 'b64_json', b64Images); const imageUrl = [...new Set(urls)].find((url) => /^https?:\/\//u.test(url)); if (imageUrl) { return downloadUrl(imageUrl, env.timeoutMs); } if (b64Images[0]) { return Buffer.from(b64Images[0], 'base64'); } throw new Error('VectorEngine returned no image'); } async function generateImage(env, {prompt, negativePrompt, size, outPath}) { const body = { model: 'gpt-image-2', prompt: promptWithNegative(prompt, negativePrompt), n: 1, size, }; const payload = await fetchJson( generationUrl(env.baseUrl), { method: 'POST', headers: { Authorization: `Bearer ${env.apiKey}`, Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify(body), }, env.timeoutMs, ); const bytes = await imageBytesFromPayload(payload, env); writeFileSync(outPath, bytes); return { outPath, extension: inferExtensionFromBytes(bytes), bytes: bytes.length, }; } async function editImage(env, {prompt, negativePrompt, size, referencePath, outPath}) { const referenceBytes = readFileSync(referencePath); const form = new FormData(); form.append('model', 'gpt-image-2'); form.append('prompt', promptWithNegative(prompt, negativePrompt)); form.append('n', '1'); form.append('size', size); form.append( 'image', new Blob([referenceBytes], {type: 'image/png'}), path.basename(referencePath), ); const payload = await fetchJson( editUrl(env.baseUrl), { method: 'POST', headers: { Authorization: `Bearer ${env.apiKey}`, Accept: 'application/json', }, body: form, }, env.timeoutMs, ); const bytes = await imageBytesFromPayload(payload, env); writeFileSync(outPath, bytes); return { outPath, extension: inferExtensionFromBytes(bytes), bytes: bytes.length, }; } function runPostprocess(outDir) { const postprocessPath = path.join(repoRoot, 'scripts', 'export-match3d-resource-pipeline-postprocess.py'); const result = spawnSync('python', [postprocessPath, '--out-dir', outDir], { cwd: repoRoot, encoding: 'utf8', }); if (result.stdout) { process.stdout.write(result.stdout); } if (result.stderr) { process.stderr.write(result.stderr); } if (result.status !== 0) { throw new Error(`postprocess failed with code ${result.status}`); } } async function main() { const args = parseArgs(process.argv); const outDir = path.resolve( repoRoot, args.outDir || path.join('output', `match3d-resource-pipeline-${timestamp()}`), ); mkdirSync(outDir, {recursive: true}); const prompts = { theme: args.theme, levelScenePrompt: buildLevelScenePrompt(args.theme), uiSpritesheetPrompt: buildUiSpritesheetPrompt(), backgroundPrompt: buildBackgroundPrompt(), itemSpritesheetPrompt: buildItemSpritesheetPrompt(), uiLabels, }; writeFileSync( path.join(outDir, '00-prompts.json'), `${JSON.stringify(prompts, null, 2)}\n`, 'utf8', ); if (!args.live) { console.log( JSON.stringify( { mode: 'dry-run', outDir, message: '加 --live 才会真实调用 VectorEngine。', prompts, }, null, 2, ), ); return; } const env = resolveEnv(); if (!env.baseUrl || !env.apiKey) { throw new Error('Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY'); } console.log(`[match3d-export] 1/4 生成关卡整图 -> ${outDir}`); const levelScene = await generateImage(env, { prompt: prompts.levelScenePrompt, negativePrompt: '水印、教程浮层、菜单、广告、真实手机外框、浏览器 UI', size: '1024x1536', outPath: path.join(outDir, '01-level-scene.raw.png'), }); console.log('[match3d-export] 2/4 并发生成 UI 图集、背景图、物品图集'); const [ui, background, items] = await Promise.all([ editImage(env, { prompt: prompts.uiSpritesheetPrompt, negativePrompt: '整页背景、中心物品、容器内物品、重复按钮、文字说明、白底、纯色底、网格线', size: '1024x1024', referencePath: levelScene.outPath, outPath: path.join(outDir, '02-ui-spritesheet.raw.png'), }), editImage(env, { prompt: prompts.backgroundPrompt, negativePrompt: '返回按钮、设置按钮、倒计时、标题文字、道具按钮、物品、容器内含物、菜单、教程浮层', size: '1024x1536', referencePath: levelScene.outPath, outPath: path.join(outDir, '04-background.raw.png'), }), editImage(env, { prompt: prompts.itemSpritesheetPrompt, negativePrompt: '文字、水印、UI、边框、网格线、标签、人物手部、复杂背景、非绿幕背景、白色背景、灰色背景、渐变背景、纹理背景', // 中文注释:这里按当前后端 normalize_image_size("2k") 的实际请求尺寸复现。 size: '1536x1024', referencePath: levelScene.outPath, outPath: path.join(outDir, '06-item-spritesheet.raw.png'), }), ]); writeFileSync( path.join(outDir, '00-generation-results.json'), `${JSON.stringify({levelScene, ui, background, items}, null, 2)}\n`, 'utf8', ); console.log('[match3d-export] 3/4 执行绿幕透明化、背景不透明化和连通域切片'); runPostprocess(outDir); console.log('[match3d-export] 4/4 完成'); console.log(JSON.stringify({ok: true, outDir}, null, 2)); } main().catch((error) => { console.error(`[match3d-export] failed: ${error?.stack || error}`); process.exit(1); });