import { Buffer } from 'node:buffer'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(scriptDir, '..'); const defaultOutDir = path.join(repoRoot, 'public', 'match3d-style-references'); const defaultTimeoutMs = 180000; const styleTemplates = [ { id: 'flat-icon', title: '扁平图标', prompt: '扁平矢量游戏道具图标风格,干净色块,正面视角,深色清晰轮廓,移动端休闲游戏素材,可读性很强。', }, { id: 'cel-cartoon', title: '赛璐璐卡通', prompt: '赛璐璐卡通游戏道具风格,明亮配色,清晰线稿,硬边阴影,边缘干净,像轻松休闲手游里的 2D 素材。', }, { id: 'pixel-retro', title: '像素复古', prompt: '复古像素游戏道具素材风格,有限色板,清晰像素边缘,主体轮廓稳定,像 32-bit 休闲游戏图标。', }, { id: 'watercolor', title: '手绘水彩', prompt: '手绘水彩游戏道具风格,柔和纸张纹理,透明叠色,边缘轻微晕染,但主体剪影仍然清楚。', }, { id: 'sticker-outline', title: '贴纸描边', prompt: '贴纸描边游戏道具素材风格,粗白边,深色外轮廓,柔和投影,色彩活泼,适合休闲消除游戏。', }, { id: 'painterly-icon', title: '厚涂图标', prompt: '厚涂游戏道具图标风格,细腻笔触,明确体积光影,中心构图,清晰剪影,适合高品质 2D 道具素材。', }, ]; const args = new Map(); for (let index = 2; index < process.argv.length; index += 1) { const raw = process.argv[index]; if (!raw.startsWith('--')) { continue; } const next = process.argv[index + 1]; if (next && !next.startsWith('--')) { args.set(raw, next); index += 1; } else { args.set(raw, true); } } function readDotenv(fileName) { const filePath = path.join(repoRoot, fileName); if (!existsSync(filePath)) { return {}; } const values = {}; for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/u)) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('#')) { continue; } const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed); if (!match) { continue; } let value = match[2].trim(); if ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) ) { value = value.slice(1, -1); } values[match[1]] = value; } return values; } function resolveEnv() { const loaded = { ...readDotenv('.env.example'), ...readDotenv('.env.local'), ...readDotenv('.env.secrets.local'), ...process.env, }; return { baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '') .trim() .replace(/\/+$/u, ''), apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(), timeoutMs: Number.parseInt( String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs), 10, ), }; } function buildVectorEngineImagesGenerationUrl(baseUrl) { return baseUrl.endsWith('/v1') ? `${baseUrl}/images/generations` : `${baseUrl}/v1/images/generations`; } function buildPrompt(template) { return [ '请生成一张 1:1 方形抓大鹅入口 2D 素材风格参考图。', '画面是一组 5 个小型游戏道具样张,题材统一为水果、甜点、玩具和宝石的混合展示。', `整体风格:${template.prompt}`, '要求:每个道具都是独立 2D 素材示例,主体集中,轮廓清晰,适合被切成抓大鹅局内物品素材。', '构图:浅色干净背景,散点排列,留有呼吸感,不要九宫格边框,不要 UI 面板,不要按钮。', '避免:文字、水印、logo、教程标注、真实照片、复杂场景、人物、动物、3D 模型视口、明显透视地面、厚重阴影。', ].join(''); } 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()); } if (Array.isArray(nested)) { nested.forEach((entry) => { if (typeof entry === 'string' && entry.trim()) { output.push(entry.trim()); } }); } } collectStringsByKey(nested, targetKey, output); } } function extractImageUrls(payload) { const urls = []; collectStringsByKey(payload, 'url', urls); collectStringsByKey(payload, 'image', urls); collectStringsByKey(payload, 'image_url', urls); return [...new Set(urls)].filter((url) => /^https?:\/\//u.test(url)); } function extractBase64Images(payload) { const values = []; collectStringsByKey(payload, 'b64_json', values); return values; } async function fetchWithTimeout(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 text; } catch (error) { if (error?.name === 'AbortError') { throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`); } throw error; } finally { clearTimeout(timer); } } async function downloadImage(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()); } catch (error) { if (error?.name === 'AbortError') { throw new Error( `Generated image download timed out after ${timeoutMs}ms`, ); } throw error; } finally { clearTimeout(timer); } } async function generateOne(env, template, outDir, size) { const requestBody = { model: 'gpt-image-2-all', prompt: buildPrompt(template), n: 1, size, }; const payloadText = await fetchWithTimeout( buildVectorEngineImagesGenerationUrl(env.baseUrl), { method: 'POST', headers: { Authorization: `Bearer ${env.apiKey}`, Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), }, env.timeoutMs, ); const payload = JSON.parse(payloadText); const urls = extractImageUrls(payload); const base64Images = extractBase64Images(payload); const imageBytes = urls[0] ? await downloadImage(urls[0], env.timeoutMs) : base64Images[0] ? Buffer.from(base64Images[0], 'base64') : null; if (!imageBytes) { throw new Error(`VectorEngine returned no image for ${template.id}`); } mkdirSync(outDir, { recursive: true }); const outPath = path.join(outDir, `${template.id}.png`); writeFileSync(outPath, imageBytes); return { file: outPath, source: urls[0] ? 'url' : 'b64_json', }; } const dryRun = args.has('--dry-run') || !args.has('--live'); const outDir = path.resolve(String(args.get('--out-dir') || defaultOutDir)); const size = String(args.get('--size') || '1024x1024'); const onlyIds = String(args.get('--only') || '') .split(',') .map((value) => value.trim()) .filter(Boolean); const templates = styleTemplates.filter( (template) => !onlyIds.length || onlyIds.includes(template.id), ); if (dryRun) { console.log( JSON.stringify( { mode: 'dry-run', outDir, count: templates.length, requests: templates.map((template) => ({ id: template.id, title: template.title, body: { model: 'gpt-image-2-all', prompt: buildPrompt(template), n: 1, size, }, })), }, null, 2, ), ); process.exit(0); } const env = resolveEnv(); if (!env.baseUrl || !env.apiKey) { console.error( JSON.stringify({ ok: false, error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY', hasBaseUrl: Boolean(env.baseUrl), hasApiKey: Boolean(env.apiKey), }), ); process.exit(1); } const generated = []; for (const template of templates) { console.log(`Generating ${template.id}...`); generated.push({ id: template.id, ...(await generateOne(env, template, outDir, size)), }); } console.log( JSON.stringify( { ok: true, count: generated.length, files: generated, }, null, 2, ), );