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-peeking-head-jar-new-animals-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 mascot mark, no wordmark', product: 'AI UGC 轻休闲小游戏创作与传播平台,用户把脑洞、梗和灵感塑造成可分享的作品', direction: '保持当前“半头探出”的状态,但把动物类型真正拓宽到新物种', audience: '女性用户友好、全年龄向、年轻明亮但不低幼', structureRules: [ '主形体仍然是陶罐容器,罐子负责陶器和包裹感', '动物只露出耳朵、上半个脑袋和两只黑点眼睛', '眼睛不能有高光、不能有白点反光、不能有玻璃感', '不露鼻子、嘴巴、身体、爪子或完整动物脸', '罐子绝对不能有表情元素', ], avoid: [ '中文或英文字', '罐子表情', '动物嘴巴或鼻子', '眼睛高光', '白眼球高光', '星星或闪光', '手托举元素', '写实陶瓷高光', '脏泥土或砖块', '面团、汤圆、甜点、面包、巧克力、糖果、布丁', '完整动物身体', '恐怖怪物、牙齿、爪子', ], }; const basePrompt = [ 'Create an icon-only mascot logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.', 'Core idea: a ceramic jar container with a small animal peeking out only to eye level. Show the animal ears, the upper half of the head, and two simple black-dot eyes. Do not show nose, mouth, lower face, paws, body, or full head.', 'The jar must remain the main brand shape. The animal head should feel hidden inside the jar, as if a playful idea is peeking out.', 'Brand core: fun creation. The logo should feel like a tiny clay-born creative spirit that can hold playful ideas, memes, and shareable works.', 'Logo type: simple mascot symbol only. It must be a brand mark, not a full character illustration, not a scene, not a sticker, not a rounded-square app icon background.', 'Main structure: a closed or mostly closed ceramic jar, pot, or vessel with a clear rim and body silhouette. The jar should read as a container first, not as a cup, vase, bowl, bottle, or food package.', 'Animal visibility: show only ears, top half of head, and two solid black eye dots above or behind the jar rim. The rim may partially cover the lower half of the animal head. No animal mouth, no nose, no cheek blush, no paws, no body.', 'Eye policy: the eyes must be pure matte black dots. No highlights, no glossy sparkles, no white reflective spots, no glassy pupils, no shiny toy eyes.', 'Jar policy: the jar itself has no face or expression. No eyes, no smile, no blush, no emoji marks on the jar.', 'Style: modern minimalist vector mascot logo, clean curves, flat brand-color shapes, premium but warm. Slight clay warmth through color and form only; no realistic texture.', 'Jar color direction: low-saturation ceramic tones such as beige, sand, warm gray, muted terracotta, pale taupe, clay pink, smoky cream, cool ash clay, or muted olive clay. Each variant should shift the jar silhouette and color slightly.', 'Animal color direction: use soft natural animal colors that fit the species, but keep them gentle and logo-friendly. The animal head should be more alive and colorful than the jar.', 'Avoid realistic ceramic glaze, muddy pottery, rough handmade class object, 3D render, ceramic shine, brick, food-like container, toy feeling, chibi over-detailing, or generic blob.', 'Food avoidance is critical: do not make it look like bread, mochi, dumpling, bun, cake, cookie, candy, pudding, chocolate, jelly, or dessert packaging.', 'Avoid star, spark, halo, magic wand, hand, pottery tool, UI, border, or watermark.', 'Composition: centered on a clean light background with generous safe area. Strong readable silhouette first. Must still read at 32px and in black and white.', ]; const variants = [ { id: '01-capybara', title: '水豚头', prompt: [ ...basePrompt, 'Variant focus: capybara. Use a broad calm jar with a warm beige body and a capybara head peeking out. The capybara has simple rounded ears and a very gentle expression made only from black-dot eyes.', ], }, { id: '02-hamster', title: '仓鼠头', prompt: [ ...basePrompt, 'Variant focus: hamster. Use a squat round jar with a pale sand body and a hamster head. Slightly fuller cheeks are allowed only as shape, but no mouth or nose; eyes are black dots only.', ], }, { id: '03-koala', title: '考拉头', prompt: [ ...basePrompt, 'Variant focus: koala. Use a muted eucalyptus-gray jar and a gray-white koala head with round fuzzy ears. Keep the head soft and calm, eyes are black dots only.', ], }, { id: '04-otter', title: '水獭头', prompt: [ ...basePrompt, 'Variant focus: otter. Use a smooth river-stone jar and a warm brown otter head. The ears can be tiny and round, the head is compact and playful, eyes are black dots only.', ], }, { id: '05-squirrel', title: '松鼠头', prompt: [ ...basePrompt, 'Variant focus: squirrel. Use a light clay jar and a reddish-brown squirrel head with small upright ears. The head should feel energetic but still only half exposed, eyes are black dots only.', ], }, { id: '06-raccoon', title: '浣熊头', prompt: [ ...basePrompt, 'Variant focus: raccoon. Use a muted taupe jar and a gray raccoon head with a darker mask shape implied by color, but no nose or mouth; eyes are black dots only.', ], }, { id: '07-lamb', title: '小羊头', prompt: [ ...basePrompt, 'Variant focus: lamb. Use a soft cream jar and a fluffy off-white lamb head with small curled ears. Keep the silhouette gentle and soft, eyes are black dots only.', ], }, { id: '08-hedgehog', title: '刺猬头', prompt: [ ...basePrompt, 'Variant focus: hedgehog. Use a compact jar with a warm sand body and a hedgehog head hinted by a rounded spiky silhouette, but keep the spikes soft and logo-simple, eyes are black dots only.', ], }, ]; 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-peeking-head-jar-new-animals-${variant.id}.${image.extension}`, ); writeFileSync(outputPath, image.bytes); return outputPath; } function writeManifest(files) { const manifestPath = path.join( outputDir, 'taonier-logo-peeking-head-jar-new-animals-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, ), );