import { Blob, Buffer } from 'node:buffer'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '..'); const timeoutMsDefault = 180000; const sourcePath = path.join( repoRoot, 'public', 'branding', 'taonier-logo-hand-spirit-concepts', 'taonier-hand-spirit-01-gentle-hand-spirit.png', ); const outputDir = path.dirname(sourcePath); const chromaPath = path.join( outputDir, 'taonier-hand-spirit-01-gentle-hand-spirit-transparent-source.png', ); const manifestPath = path.join( outputDir, 'taonier-hand-spirit-01-gentle-hand-spirit-transparent-manifest.json', ); 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); } } const prompt = [ 'Use the uploaded image as the exact edit target.', 'Preserve the logo subject exactly: same abstract hand shape, same clay spirit shape, same proportions, same placement, same scale, same colors, same soft vector style.', 'Do not redesign, simplify, recolor, crop, rotate, add details, remove highlights, change the hand, or change the clay spirit.', 'Replace only the white/off-white background with a perfectly flat solid #00ff00 chroma-key background.', 'The background must be one uniform #00ff00 color with no shadows, gradients, texture, reflections, floor plane, border, or lighting variation.', 'Do not use #00ff00 anywhere inside the logo subject.', 'No text, no watermark, no UI, no extra marks, no border.', 'Keep crisp clean edges and generous safe area so the result can be converted into a transparent PNG.', ].join('\n'); 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 || timeoutMsDefault), 10, ), }; } function buildEditUrl(baseUrl) { return baseUrl.endsWith('/v1') ? `${baseUrl}/images/edits` : `${baseUrl}/v1/images/edits`; } function buildDryRunFields() { return { model: 'gpt-image-2', prompt, n: '1', size: '1024x1024', image: sourcePath, }; } 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; } 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()); } catch (error) { if (error?.name === 'AbortError') { throw new Error(`Generated image download timed out after ${timeoutMs}ms`); } throw error; } finally { clearTimeout(timer); } } function createEditFormData() { const form = new FormData(); const imageBytes = readFileSync(sourcePath); form.append('model', 'gpt-image-2'); form.append('prompt', prompt); form.append('n', '1'); form.append('size', '1024x1024'); form.append( 'image', new Blob([imageBytes], { type: 'image/png' }), path.basename(sourcePath), ); return form; } async function generateChromaSource() { const env = resolveEnv(); if (!env.baseUrl || !env.apiKey) { throw new Error( JSON.stringify({ ok: false, error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY', hasBaseUrl: Boolean(env.baseUrl), hasApiKey: Boolean(env.apiKey), }), ); } const payload = await fetchJson( buildEditUrl(env.baseUrl), { method: 'POST', headers: { Authorization: `Bearer ${env.apiKey}`, Accept: 'application/json', }, body: createEditFormData(), }, env.timeoutMs, ); const urls = extractImageUrls(payload); const b64Images = extractBase64Images(payload); let bytes; if (urls[0]) { bytes = await downloadUrl(urls[0], env.timeoutMs); } else if (b64Images[0]) { bytes = Buffer.from(b64Images[0], 'base64'); } else { throw new Error('VectorEngine returned no image'); } mkdirSync(outputDir, { recursive: true }); writeFileSync(chromaPath, bytes); const extension = inferExtensionFromBytes(bytes); writeFileSync( manifestPath, `${JSON.stringify( { model: 'gpt-image-2', endpoint: '/v1/images/edits', size: '1024x1024', source: path.relative(repoRoot, sourcePath), chromaSource: path.relative(repoRoot, chromaPath), finalOutput: 'public/branding/taonier-logo-hand-spirit-concepts/taonier-hand-spirit-01-gentle-hand-spirit-transparent.png', generatedAt: new Date().toISOString(), prompt, }, null, 2, )}\n`, 'utf8', ); return { chromaPath, manifestPath, extension, bytes: bytes.length }; } const dryRun = args.has('--dry-run') || !args.has('--live'); if (dryRun) { console.log( JSON.stringify( { mode: 'dry-run', sourcePath, chromaPath, manifestPath, fields: buildDryRunFields(), }, null, 2, ), ); process.exit(0); } if (!existsSync(sourcePath)) { console.error( JSON.stringify({ ok: false, error: 'Source image does not exist', sourcePath }), ); process.exit(1); } console.log(JSON.stringify({ ok: true, ...(await generateChromaSource()) }, null, 2));