import { 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-concepts', ); const timeoutMsDefault = 180000; 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 logoBrief = { brand: '陶泥儿', coreBelief: '好玩会创造', logoType: 'symbol/icon-only mark, no wordmark', product: 'AI UGC 轻休闲小游戏创作与传播平台,用户像捏陶泥一样把脑洞、梗和灵感塑造成可分享的作品', metaphor: '抽象化的手托举/递出一个软萌陶泥灵体', intent: ['托举', '分享', '传递', '创作被捏成一个有生命感的小作品'], spiritShape: '不规则半球形陶泥灵体,参考黑底白色半圆拱形轮廓,但不照抄', audience: '女性用户友好、全年龄向、年轻明亮但不低幼', material: '只保留陶泥温度,不追求泥土质感', mustHave: [ '手必须高度抽象,像托举曲线或掌形基座', '陶泥灵体必须是主角,软萌但不出现脸', '画面传达分享/传递,而不是供奉/宗教/医疗', '32px 可识别', '黑白化仍成立', ], }; const basePrompt = [ 'Create an icon-only brand logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.', 'Brand core belief: fun creation. Users can turn imagination, memes, and ideas into playable AI/UGC casual game works and share them with others.', 'Main metaphor: an abstract hand gently holding up and offering a cute soft clay spirit. The logo should communicate creation, sharing, passing forward, and a small idea becoming a lovable playable object.', 'Logo type: abstract symbol/icon only. Not a mascot illustration, not an app-icon rounded-square background, not an emblem with text.', 'Composition: one highly simplified hand-like support curve or palm base below, holding one soft clay spirit above. The hand must be abstract, smooth, and logo-like, not realistic fingers.', 'Clay spirit shape: a cute irregular semi-dome / half-blob / rounded arch form, inspired by a simple white arched half-circle silhouette on dark background, but transformed into a friendly original brand symbol.', 'The spirit should be soft and lovable without a face. No eyes, no mouth, no expression, no limbs, no character body, no halo, no star, no spark.', 'The hand gesture should feel like sharing or gently presenting, not worship, religion, medical care, begging, or holding a food item.', 'Style: modern minimalist vector logo. Clean broad shapes, clear silhouette, fresh bright colors, very light clay warmth only. It must look good and recognizable at 32px favicon size.', 'Color direction: women-friendly and all-age fresh palette: coral orange, peach pink, cream white, clear soft teal or mint green. Use flat brand-color shapes, not candy rendering.', 'Avoid muted mud colors as the dominant palette. Avoid realistic clay texture, dirty clay, brick, pottery shard, mud pie, rough craft class object, and archaeology stamp feeling.', 'Food avoidance is critical: do not make the spirit look like bread, mochi, dumpling, cake, cookie, candy, jelly, donut, cream filling, sauce, dessert, or food packaging.', 'Shape avoidance: no square base, no rounded-square app background, no free ribbon, no swirl, no S or G letter feeling, no center star.', 'Composition: centered on a clean light background, generous safe area, no border, no UI, no watermark. Use simple readable silhouette first.', 'Validation targets: black-and-white version should still read clearly; the hand support and semi-dome clay spirit should be recognizable at 32px.', ]; const variants = [ { id: '01-gentle-hand-spirit', title: '温柔托举灵体', prompt: [ ...basePrompt, 'Variant focus: the clearest version. A simple cream abstract palm curve holds a coral-peach semi-dome clay spirit. Friendly, iconic, and readable.', ], }, { id: '02-sharing-palm', title: '分享掌形', prompt: [ ...basePrompt, 'Variant focus: sharing intention. The abstract hand is slightly forward-facing, like offering the clay spirit outward, but still very simplified and not realistic.', ], }, { id: '03-teal-support', title: '青绿托线', prompt: [ ...basePrompt, 'Variant focus: use a clear soft teal support curve as the hand and a warm peach clay spirit above. Strong color memory, no food look.', ], }, { id: '04-arched-spirit', title: '拱形泥灵', prompt: [ ...basePrompt, 'Variant focus: emphasize the irregular semi-dome clay spirit shape from the reference: simple arched top, flatter base, slightly organic, no face.', ], }, { id: '05-playful-offer', title: '轻玩递出', prompt: [ ...basePrompt, 'Variant focus: more playful and lively. The hand support suggests passing the spirit forward, with one broad curve only. Avoid decorative tiny details.', ], }, { id: '06-monochrome-first', title: '黑白优先', prompt: [ ...basePrompt, 'Variant focus: design for black-and-white survival first. Use strong positive and negative shapes so the hand and spirit remain readable without color.', ], }, { id: '07-avatar-readable', title: '头像可读', prompt: [ ...basePrompt, 'Variant focus: social avatar and favicon readability. Compact, bold silhouette, thicker hand curve, larger semi-dome spirit, no small parts.', ], }, { id: '08-vector-ready', title: '矢量定稿感', prompt: [ ...basePrompt, 'Variant focus: designer-ready vector concept. 2-3 flat shapes, crisp boundaries, distinctive hand-support and clay-spirit silhouette, minimal material cue.', ], }, ]; 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 buildVectorEngineImagesGenerationUrl(baseUrl) { return baseUrl.endsWith('/v1') ? `${baseUrl}/images/generations` : `${baseUrl}/v1/images/generations`; } function buildRequestBody(variant) { return { model: 'gpt-image-2-all', prompt: variant.prompt.join('\n'), n: 1, size: '1024x1024', }; } 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); } } async function generateOne(env, variant) { const requestBody = buildRequestBody(variant); const payload = await fetchJson( buildVectorEngineImagesGenerationUrl(env.baseUrl), { method: 'POST', headers: { Authorization: `Bearer ${env.apiKey}`, Accept: 'application/json', 'Content-Type': 'application/json', }, body: JSON.stringify(requestBody), }, 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-${variant.id}.${image.extension}`); writeFileSync(outputPath, image.bytes); return outputPath; } function writeManifest(files) { const manifestPath = path.join(outputDir, 'taonier-logo-hand-spirit-manifest.json'); writeFileSync( manifestPath, `${JSON.stringify( { model: 'gpt-image-2-all', size: '1024x1024', generatedAt: new Date().toISOString(), logoSkillSummary: { requiredReview: 'visual inspection, 32px readability, black-white viability', outputStatus: 'AI concept only; final logo needs vector cleanup', }, brief: logoBrief, 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, count: selectedVariants.length, brief: logoBrief, requests: selectedVariants.map((variant) => ({ id: variant.id, title: variant.title, body: buildRequestBody(variant), })), }, 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 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, ), );