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-pair-ears-jar-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: [ '主形体是一个陶罐或陶罐容器,强调器皿感和包裹感', '罐中只露出耳朵,不露完整脸部,不露完整身体', '耳朵可以是兔、猫、狐狸、熊、狗等动物耳朵,但只能露耳朵', '罐子可以带短手短脚,但不是必须;若有,也要极简抽象', '整体必须是 logo 符号级别,不是完整插画角色', ], 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 only animal ears peeking out from inside. The jar should feel mysterious and cute at the same time. Do not show a full face or full body, only ears.', '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.', 'Hidden animal presence: only the ears emerge from the jar opening or upper body. The ears may be rabbit, cat, fox, bear, or dog ears depending on the variant, but no face, no eyes, no nose, no mouth, no full head. The ears must feel playful and alive, but still simple and logo-friendly.', 'Optional tiny feet or short hand nubs are allowed only if they help the silhouette; keep them extremely abstract and minimal.', '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, or smoky cream. Ear color direction: use natural animal colors that fit the variant, but keep them soft and not neon.', '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 any jar face or expression. No eyes, no smile, no blush, no emoji marks on the jar.', '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-rabbit-jar', title: '兔耳陶罐', prompt: [ ...basePrompt, 'Variant focus: rabbit ears. Long soft rabbit ears rise from the jar opening with a gentle curve, while the jar remains compact and premium.', ], }, { id: '02-cat-jar', title: '猫耳陶罐', prompt: [ ...basePrompt, 'Variant focus: cat ears. Small pointed cat ears peek from the jar opening, giving a slightly sly but still very cute feeling.', ], }, { id: '03-fox-jar', title: '狐耳陶罐', prompt: [ ...basePrompt, 'Variant focus: fox ears. Slender fox-like ears with a warm orange accent, a little more clever and playful than the rabbit version.', ], }, { id: '04-bear-jar', title: '熊耳陶罐', prompt: [ ...basePrompt, 'Variant focus: bear ears. Two small rounded bear ears emerging from the top, very soft and sleepy, with a sturdy jar silhouette.', ], }, { id: '05-dog-jar', title: '狗耳陶罐', prompt: [ ...basePrompt, 'Variant focus: dog ears. Slightly floppy dog ears peeking from the vessel, friendly and lively, but still only ears, no face.', ], }, { id: '06-dual-ears', title: '双耳组合', prompt: [ ...basePrompt, 'Variant focus: two different ear shapes on one jar, such as one rabbit ear and one cat ear, but still harmonized into a single mascot symbol.', ], }, { id: '07-tall-jar', title: '高罐长耳', prompt: [ ...basePrompt, 'Variant focus: taller jar silhouette with more vertical ears, so the ear read is clearer at favicon size and the vessel feels more iconic.', ], }, { id: '08-jar-mark', title: '商标定稿感', prompt: [ ...basePrompt, 'Variant focus: strongest trademark readability. Use a compact jar silhouette, very simple ears, minimal details, excellent black-and-white legibility.', ], }, ]; 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-pair-ears-jar-${variant.id}.${image.extension}`); writeFileSync(outputPath, image.bytes); return outputPath; } function writeManifest(files) { const manifestPath = path.join(outputDir, 'taonier-logo-pair-ears-jar-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, ), );