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 defaultOut = path.join( repoRoot, 'public', 'child-motion-demo', 'picture-book-grass-stage.webp', ); const defaultSize = '1536x1024'; const defaultTimeoutMs = 180000; const prompt = [ '请生成一张横版儿童动作互动游戏舞台背景图,卡通绘本风格,温暖明亮。', '画面下半部分必须是开阔柔软的草地地面,适合叠加半透明角色轮廓和地面圆圈指示环。', '远处有柔和小山坡、树木、天空和浅色云朵,中心和下方前景保持干净开阔。', '构图需要适配 16:9 横屏游戏舞台,左右和上下边缘可安全裁切,主体信息不要贴边。', '风格像儿童绘本插画,柔和笔触,清新色彩,轻微纸张纹理,细节适中,不杂乱。', '不要出现人物、动物、文字、按钮、UI、边框、水印、摄像头画面、真实照片质感。', ].join(''); 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 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); } } const size = String(args.get('--size') || defaultSize); const outPath = path.resolve(String(args.get('--out') || defaultOut)); const requestBody = { model: 'gpt-image-2-all', prompt, n: 1, size, }; if (args.has('--dry-run') || !args.has('--live')) { console.log( JSON.stringify( { mode: 'dry-run', outPath, body: requestBody, }, 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 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'); } mkdirSync(path.dirname(outPath), { recursive: true }); writeFileSync(outPath, imageBytes); console.log( JSON.stringify( { ok: true, file: outPath, size, source: urls[0] ? 'url' : 'b64_json', }, null, 2, ), );