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 outputDir = path.join( repoRoot, 'public', 'branding', 'taonier-logo-hand-spirit-outline-eye-concepts', ); const referenceImagePath = path.join( repoRoot, 'public', 'branding', 'taonier-logo-hand-spirit-ref01-logo-refine-concepts', 'taonier-hand-spirit-ref01-logo-refine-01-flat-coral-cream.png', ); const timeoutMsDefault = 420000; 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 basePrompt = [ 'Use the uploaded logo as the exact structural reference. Keep the same icon-only composition: a soft semi-dome spirit above a smooth abstract hand-like support curve.', 'Create a cuter, more logo-like refinement. Add a clean outline around the whole symbol, and add two pure matte black dot eyes to the upper semi-dome part only.', 'The eyes must belong to the upper half-circle spirit, not the lower support curve. No nose, no mouth, no eyebrows, no blush, no limbs, no character body, no star, no spark, no text.', 'Do not redesign the overall structure. Keep the same proportions, centered layout, and soft brand silhouette. Only make it more cute, more memorable, and more trademark-like.', 'The outline should feel clean, friendly, and brand-ready, not sticker-like, not cartoonish, not too thick unless the variant asks for it.', 'Use the same soft warm palette family as the reference, but allow subtle low-saturation tweaks to improve charm and contrast. Keep it elegant and youthful, not loud.', 'Keep 32px readability and black-white viability. It must still work as an app icon, social avatar, and trademark symbol.', 'Clean light background, generous safe area. Image-only logo concept.', ]; const variants = [ { id: '01-thin-outline-small-eyes', title: '细描边小眼', prompt: [ ...basePrompt, 'Variant focus: the most restrained cute version. Use a thin warm outline and small matte black dot eyes with calm spacing.', ], }, { id: '02-medium-outline-round-eyes', title: '中描边圆眼', prompt: [ ...basePrompt, 'Variant focus: medium outline thickness and slightly rounder dot eyes. Make the face read a touch more openly cute, but still minimal.', ], }, { id: '03-bold-outline-higher-eyes', title: '粗描边高眼', prompt: [ ...basePrompt, 'Variant focus: stronger bold outline and eyes placed a little higher on the upper dome, creating a sweeter peeking expression.', ], }, { id: '04-warm-cocoa-outline', title: '暖可可描边', prompt: [ ...basePrompt, 'Variant focus: use a warm cocoa or deep beige outline that makes the logo feel softer and more plush, with small centered black eyes.', ], }, { id: '05-compact-avatar-cute', title: '头像可爱款', prompt: [ ...basePrompt, 'Variant focus: compact avatar readability. Enlarge the upper dome slightly, keep the hand support bold, and make the eyes more visible without adding any mouth.', ], }, { id: '06-black-white-first', title: '黑白优先', prompt: [ ...basePrompt, 'Variant focus: black-and-white survival first. Make the outline and the eyes work clearly even if all color is removed. Very strong logo readability.', ], }, { id: '07-soft-feminine-cute', title: '柔和少女感', prompt: [ ...basePrompt, 'Variant focus: a softer feminine-cute version. Keep the outline elegant and the eyes gentle; the whole mark should feel like a friendly brand mascot symbol.', ], }, { id: '08-vector-ready-cute', title: '矢量定稿感', prompt: [ ...basePrompt, 'Variant focus: designer-ready vector concept. Clean crisp outline, balanced eye spacing, no decorative detail, very easy to trace into an SVG mark.', ], }, ]; 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 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 inferExtensionFromContentType(contentType) { const normalized = contentType.split(';')[0]?.trim().toLowerCase(); if (normalized === 'image/png') return 'png'; if (normalized === 'image/webp') return 'webp'; if (normalized === 'image/gif') return 'gif'; return 'jpg'; } 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}`); const bytes = Buffer.from(await response.arrayBuffer()); return { bytes, extension: inferExtensionFromContentType( response.headers.get('content-type') || 'image/jpeg', ), }; } catch (error) { if (error?.name === 'AbortError') { throw new Error(`Generated image download timed out after ${timeoutMs}ms`); } throw error; } finally { clearTimeout(timer); } } function createEditFormData(variant) { const form = new FormData(); const imageBytes = readFileSync(referenceImagePath); form.append('model', 'gpt-image-2'); form.append('prompt', variant.prompt.join('\n')); form.append('n', '1'); form.append('size', '1024x1024'); form.append( 'image', new Blob([imageBytes], { type: 'image/png' }), path.basename(referenceImagePath), ); return form; } function buildDryRunFields(variant) { return { model: 'gpt-image-2', prompt: variant.prompt.join('\n'), n: '1', size: '1024x1024', image: referenceImagePath, }; } async function generateOne(env, variant) { const payload = await fetchJson( buildEditUrl(env.baseUrl), { method: 'POST', headers: { Authorization: `Bearer ${env.apiKey}`, Accept: 'application/json', }, body: createEditFormData(variant), }, env.timeoutMs, ); const urls = extractImageUrls(payload); const b64Images = extractBase64Images(payload); let image; if (urls[0]) { image = await downloadUrl(urls[0], env.timeoutMs); } else if (b64Images[0]) { const bytes = Buffer.from(b64Images[0], 'base64'); image = { bytes, extension: inferExtensionFromBytes(bytes) }; } else { throw new Error(`VectorEngine returned no image for ${variant.id}`); } mkdirSync(outputDir, { recursive: true }); const outputPath = path.join( outputDir, `taonier-hand-spirit-outline-eye-${variant.id}.${image.extension}`, ); writeFileSync(outputPath, image.bytes); return outputPath; } function writeManifest(files) { const manifestPath = path.join( outputDir, 'taonier-hand-spirit-outline-eye-manifest.json', ); writeFileSync( manifestPath, `${JSON.stringify( { model: 'gpt-image-2', endpoint: '/v1/images/edits', size: '1024x1024', referenceImage: path.relative(repoRoot, referenceImagePath), generatedAt: new Date().toISOString(), brief: { brand: '陶泥儿', goal: '在上轮 01 的基础上加入描边和黑点眼睛,让标志更可爱', keep: '保留托举曲线与半球灵体结构,不加文字、不加星星、不改骨架', }, variants: variants.map((variant) => { const file = files.find((item) => path.basename(item).includes(variant.id)); return { id: variant.id, title: variant.title, file: file ? path.basename(file) : null, prompt: variant.prompt.join('\n'), }; }), }, null, 2, )}\n`, 'utf8', ); return manifestPath; } const dryRun = args.has('--dry-run') || !args.has('--live'); const limit = Number.parseInt(String(args.get('--limit') || variants.length), 10); const selectedVariants = variants.slice(0, limit); if (dryRun) { console.log( JSON.stringify( { mode: 'dry-run', outputDir, referenceImagePath, count: selectedVariants.length, requests: selectedVariants.map((variant) => ({ id: variant.id, title: variant.title, fields: buildDryRunFields(variant), })), }, null, 2, ), ); process.exit(0); } if (!existsSync(referenceImagePath)) { console.error( JSON.stringify({ ok: false, error: 'Reference image does not exist', referenceImagePath, }), ); process.exit(1); } 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 variant of selectedVariants) { console.log(`Generating ${variant.id} ${variant.title}...`); generated.push(await generateOne(env, variant)); } const manifestPath = writeManifest(generated); console.log( JSON.stringify( { ok: true, count: generated.length, files: generated, manifest: manifestPath, }, null, 2, ), );