import { Buffer } from 'node:buffer'; import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from 'node:fs'; import path from 'node:path'; const repoRoot = process.cwd(); const outputDir = path.join( repoRoot, 'public', 'branding', 'taonier-logo-spiral-reference-concepts', ); const defaultTimeoutMs = 420000; const defaultReferenceImagePath = path.join( outputDir, 'taonier-spiral-reference.jpg', ); const concepts = [ { id: 'taonier-spiral-soft-squish', title: '软泥旋合', prompt: '参考输入图的粗圆头螺旋动势,但不要照抄黑白图,也不要使用黑底白线。为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo。结合此前认可的“软泥合拍”造型:上下两团抽象软泥被旋转吸入中心,像把脑洞轻轻揉成作品。上方使用糖果莓粉 / 珊瑚粉,下方使用薄荷青 / 青绿,中央一颗暖黄色小星点。整体主流、亲和、Q 弹、可爱但不幼稚,适合作为 App icon。禁止真实手、手指、播放键、聊天气泡、笑脸、眼睛、花朵、褐色陶土、文字、字母、水印、3D、厚阴影、复杂碎元素。', }, { id: 'taonier-spiral-candy-roll', title: '糖果泥卷', prompt: '以参考输入图的单笔圆头旋涡为结构灵感,为“陶泥儿”设计一个无文字扁平矢量 Logo。图形像一条柔软陶泥带被卷成可爱的糖果泥卷,但需要保留上下双色软泥合拍的感觉:外侧莓粉,内侧薄荷青,中心有小小暖黄星核。造型要圆润、干净、强记忆点,小尺寸清晰,像年轻创作娱乐 App 的主标。不要直接做黑白旋涡,不要做催眠、棒棒糖、浏览器加载、循环箭头或太极图。禁止文字、字母、水印、3D、真实陶艺、聊天气泡、播放三角、笑脸、眼睛。', }, { id: 'taonier-spiral-star-core', title: '星核涡标', prompt: '使用参考输入图的向心旋转和包裹感,设计“陶泥儿”无文字扁平矢量 Logo。两块软泥形沿螺旋方向轻轻包住中央作品星核,像 AI 把灵感旋成小游戏。整体比普通旋涡更像品牌符号:线条粗、端点圆、负形干净,不能像手或眼睛。配色沿用陶泥儿前序方向:莓粉 / 珊瑚粉、薄荷青 / 青绿、奶油白、暖黄色星点。风格主流、亲和、可爱、现代、清晰。禁止黑白原图复刻、聊天气泡、播放键、笑脸、花朵、真实手指、褐色主色、文字、水印。', }, { id: 'taonier-spiral-bouncy-clay', title: 'Q弹泥涡', prompt: '参考输入图的圆润螺旋,但把它转化成“陶泥儿”的 Q 弹软泥 Logo:两条短而厚的软泥弧线从上下错位旋入中心,中间不是黑洞,而是一颗小星 / 小作品核。颜色更可爱:粉桃、薄荷、奶油白、暖黄。图形要比参考更轻、更甜、更品牌化,保留一点“软泥合拍”的上下关系。适合 App icon 和启动页。不要像棒棒糖、蚊香、加载图标、太极、旋风、眼睛、聊天气泡、播放按钮;禁止文字、字母、水印、3D。', }, { id: 'taonier-spiral-creation-whirl', title: '创作星涡', prompt: '结合参考输入图的螺旋势能和陶泥儿此前“软泥合拍”的粉绿配色,设计无文字扁平矢量 Logo。主形是一个开放式旋涡,像软泥被轻轻揉动,中心生成暖黄色四角星。整体要活泼、生动、主流、容易记住,不要太抽象成通用旋涡。形体应保留圆头粗线和柔软手感,但不能出现具体手。颜色:亮莓粉、清爽薄荷青、奶白、暖黄,最多四色。禁止黑白照搬、褐色陶土、播放三角、聊天气泡、笑脸、眼睛、花朵、文字、水印、复杂碎点。', }, { id: 'taonier-spiral-soft-token', title: '旋合软标', prompt: '以参考输入图的粗线螺旋为参考,为“陶泥儿”做更成熟的 App icon 主标。把螺旋收敛成一个完整圆润的软泥符号:上半莓粉,下半青绿,中间用奶白负形形成自然旋转缝隙,中心保留一枚小暖黄星点。它需要兼顾精品 AI 创作、UGC、小游戏和传播感,不能太儿童、不能太像加载图。风格:flat vector logo, clean, friendly, cute, memorable, scalable, solid colors。禁止文字、字母、水印、3D、厚阴影、真实手、播放键、聊天气泡、笑脸、眼睛、太极、棒棒糖。', }, ]; 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); } } 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 || defaultTimeoutMs), 10, ), }; } function buildUrl(baseUrl) { return baseUrl.endsWith('/v1') ? `${baseUrl}/images/generations` : `${baseUrl}/v1/images/generations`; } function getMimeType(filePath) { const extension = path.extname(filePath).toLowerCase(); if (extension === '.jpg' || extension === '.jpeg') { return 'image/jpeg'; } if (extension === '.webp') { return 'image/webp'; } return 'image/png'; } function readReferenceDataUrl(filePath) { const bytes = readFileSync(filePath); return `data:${getMimeType(filePath)};base64,${bytes.toString('base64')}`; } 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 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}`); } return Buffer.from(await response.arrayBuffer()); } catch (error) { if (error?.name === 'AbortError') { throw new Error(`Generated image download timed out after ${timeoutMs}ms`); } throw error; } finally { clearTimeout(timer); } } async function generateConcept(env, concept, referenceDataUrl) { const requestBody = { model: 'gpt-image-2-all', quality: String(args.get('--quality') || 'low'), prompt: concept.prompt, image: [referenceDataUrl], n: 1, size: '1024x1024', }; const payload = await fetchJson( buildUrl(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 bytes; if (urls[0]) { bytes = await downloadUrl(urls[0], env.timeoutMs); } else if (b64Images[0]) { bytes = Buffer.from(b64Images[0], 'base64'); } else { throw new Error(`VectorEngine returned no image for ${concept.id}`); } mkdirSync(outputDir, { recursive: true }); const extension = inferExtensionFromBytes(bytes); const outputPath = path.join(outputDir, `${concept.id}.${extension}`); writeFileSync(outputPath, bytes); return outputPath; } const dryRun = args.has('--dry-run') || !args.has('--live'); const referenceImagePath = String( args.get('--reference') || defaultReferenceImagePath, ); const onlyIds = String(args.get('--only') || '') .split(',') .map((value) => value.trim()) .filter(Boolean); const limit = Number.parseInt(String(args.get('--limit') || '0'), 10); const selected = concepts .filter((concept) => !onlyIds.length || onlyIds.includes(concept.id)) .slice(0, limit > 0 ? limit : concepts.length); if (!existsSync(referenceImagePath)) { console.error( JSON.stringify({ ok: false, error: 'Reference image not found', referenceImagePath, }), ); process.exit(1); } const referenceDataUrl = readReferenceDataUrl(referenceImagePath); if (dryRun) { console.log( JSON.stringify( { mode: 'dry-run', outputDir, referenceImagePath, referenceImage: { mimeType: getMimeType(referenceImagePath), dataUrlLength: referenceDataUrl.length, }, count: selected.length, requests: selected.map((concept) => ({ id: concept.id, title: concept.title, body: { model: 'gpt-image-2-all', quality: String(args.get('--quality') || 'low'), prompt: concept.prompt, image: [''], n: 1, size: '1024x1024', }, })), }, 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 concept of selected) { console.log(`Generating ${concept.id}...`); generated.push(await generateConcept(env, concept, referenceDataUrl)); } console.log( JSON.stringify( { ok: true, count: generated.length, files: generated, verifiedFiles: readdirSync(outputDir).sort(), }, null, 2, ), );