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-short-foot-creature-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: '女性用户友好、全年龄向、年轻明亮但不低幼', shapeRules: [ '主体是坐在地上的闭合泥团生物,像一个稳定的软陶泥胚', '底部有 3-5 个短短的圆脚或脚趾状支点,但不能变成爪子', '头顶可以有弯角、小尖、软芽、卷曲或捏起的造型,作为记忆点', '整体必须是 logo 符号级别,不是完整角色插画', '32px 下仍能看出低重心泥团、短脚和头顶造型', ], 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.', 'The reference idea is only shape language: a squat soft clay lump creature sitting on the ground, with several very short rounded feet and a distinctive top tuft or curved horn. Do not copy realistic ceramic rendering.', 'Brand core: fun creation. The mark should feel like a tiny clay-born creative spirit: soft, friendly, shareable, and able to turn ideas into playful AI/UGC casual game 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 silhouette: low center of gravity, closed mound body, slightly irregular but clean outline, broad base, 3 to 5 stubby rounded feet at the bottom. Feet must be short and cute, never claws, toes, paws, or realistic animal legs.', 'Top silhouette: one memorable soft sculpted feature on the head, such as a curled clay tip, small pinched sprout, rounded horn, or soft wave tuft. The top feature should be part of the same clay body, not a separate hat or accessory.', 'Face policy: face is optional; if used, only two tiny simple eye dots. No mouth, no blush, no emoji expression, no detailed face. The silhouette should work even without the face.', 'Style: modern minimalist vector mascot logo, flat brand-color shapes, clean curves, premium but warm. Slight clay warmth through color and form only; no realistic texture.', 'Color direction: earthy but fresh, using warm clay beige, terracotta, cream white, soft coral, and a very small teal or mint accent if helpful. Avoid dirty mud, brick red dominance, candy gradients, glossy dessert rendering.', 'Food avoidance is critical: do not make it look like mochi, dumpling, bun, bread, cake, cookie, candy, pudding, chocolate, jelly, or any edible mascot.', 'Avoid pottery class object, ceramic figurine, handmade toy, rough mud texture, archaeology stamp, realistic glaze, 3D clay render, or shiny ceramic highlight.', 'Avoid scary monster, teeth, claws, spikes, realistic animal, pet logo, chibi over-detailing, generic blob, star, spark, halo, magic wand, hand, pottery tool, UI, border, or watermark.', 'Composition: centered on clean light background, generous safe area, strong readable silhouette first. It should look recognizable at 32px and still work in black and white.', ]; const variants = [ { id: '01-curled-tip', title: '弯角泥团', prompt: [ ...basePrompt, 'Variant focus: a squat clay lump creature with one soft curled tip leaning gently forward, four tiny rounded feet, calm premium silhouette.', ], }, { id: '02-soft-sprout', title: '软芽泥团', prompt: [ ...basePrompt, 'Variant focus: a low mound creature with a pinched sprout-like top made from the same clay body, three short feet, fresh and memorable.', ], }, { id: '03-wave-tuft', title: '波浪小怪', prompt: [ ...basePrompt, 'Variant focus: a playful clay creature with a single wave-shaped top tuft, broad sitting base, 4 tiny feet, more dynamic but still logo-simple.', ], }, { id: '04-round-horn', title: '圆角小怪', prompt: [ ...basePrompt, 'Variant focus: a friendly abstract little monster with one rounded horn-like bump and a second smaller bump, stubby feet, no scary details.', ], }, { id: '05-low-squat', title: '低趴泥团', prompt: [ ...basePrompt, 'Variant focus: extra low and stable clay mound, wide base, five tiny rounded feet, top feature is a subtle pinched crest, very favicon-readable.', ], }, { id: '06-asymmetric-charm', title: '偏心灵体', prompt: [ ...basePrompt, 'Variant focus: asymmetrical friendly spirit mark, body leans slightly to one side, curled top balances the shape, short feet stay grounded.', ], }, { id: '07-avatar-bold', title: '头像强识别', prompt: [ ...basePrompt, 'Variant focus: bold social avatar readability, thick simple silhouette, two tiny eye dots allowed, top tuft and feet readable at 32px.', ], }, { id: '08-vector-outline', title: '商标轮廓', prompt: [ ...basePrompt, 'Variant focus: designer-ready vector mark. Use 2-3 flat shapes, crisp boundaries, very strong black-and-white silhouette, minimal inner detail.', ], }, ]; 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-short-foot-creature-${variant.id}.${image.extension}`, ); writeFileSync(outputPath, image.bytes); return outputPath; } function writeManifest(files) { const manifestPath = path.join( outputDir, 'taonier-logo-short-foot-creature-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, ), );