From 2ba4691bc04918e9f4955f8ee1c17dcf3520c7fe Mon Sep 17 00:00:00 2001
From: kdletters <61648117+kdletters@users.noreply.github.com>
Date: Fri, 22 May 2026 16:35:35 +0800
Subject: [PATCH] chore: remove redundant asset generation scripts
---
scripts/generate-bark-battle-assets.mjs | 145 --
scripts/generate-child-motion-demo-assets.mjs | 1848 -----------------
.../generate-edutainment-tv-map-concepts.mjs | 394 ----
scripts/generate-match3d-style-references.mjs | 327 ---
scripts/generate-spacetime-bindings.mjs | 321 ---
...er-abstract-mascot-image2-contact-sheet.py | 79 -
...r-abstract-mascot-image2-logo-concepts.mjs | 330 ---
...e-taonier-abstract-mascot-logo-concepts.py | 295 ---
...r-abstract-mascot-minimal-contact-sheet.py | 77 -
...-abstract-mascot-minimal-logo-concepts.mjs | 318 ---
...aonier-abstract-mascot-v2-logo-concepts.py | 297 ---
.../generate-taonier-anchor-logo-concepts.py | 323 ---
...nerate-taonier-anti-candy-contact-sheet.py | 87 -
...erate-taonier-anti-candy-logo-concepts.mjs | 420 ----
...enerate-taonier-braincore-contact-sheet.py | 87 -
...nerate-taonier-braincore-logo-concepts.mjs | 415 ----
...apybara-jar-ref01-logo-refine-concepts.mjs | 493 -----
...ara-jar-ref01-logo-refine-contact-sheet.py | 144 --
...erate-taonier-clay-mascot-contact-sheet.py | 71 -
...rate-taonier-clay-mascot-logo-concepts.mjs | 330 ---
...nerate-taonier-closed-geo-contact-sheet.py | 87 -
...erate-taonier-closed-geo-logo-concepts.mjs | 419 ----
...erate-taonier-distinctive-contact-sheet.py | 87 -
...rate-taonier-distinctive-logo-concepts.mjs | 422 ----
.../generate-taonier-flow-contact-sheet.py | 87 -
.../generate-taonier-flow-logo-concepts.mjs | 418 ----
...enerate-taonier-geometric-logo-concepts.py | 300 ---
...aonier-hand-spirit-bold-color-concepts.mjs | 405 ----
...er-hand-spirit-bold-color-contact-sheet.py | 134 --
...erate-taonier-hand-spirit-contact-sheet.py | 107 -
...rate-taonier-hand-spirit-logo-concepts.mjs | 442 ----
...onier-hand-spirit-muted-color-concepts.mjs | 404 ----
...r-hand-spirit-muted-color-contact-sheet.py | 134 --
...onier-hand-spirit-outline-eye-concepts.mjs | 424 ----
...r-hand-spirit-outline-eye-contact-sheet.py | 134 --
...hand-spirit-ref01-logo-refine-concepts.mjs | 491 -----
...-spirit-ref01-logo-refine-contact-sheet.py | 147 --
.../generate-taonier-hands-logo-concepts.mjs | 315 ---
.../generate-taonier-logo-brief-concepts.mjs | 444 ----
...nerate-taonier-logo-brief-contact-sheet.py | 107 -
scripts/generate-taonier-logo-concepts.mjs | 1196 -----------
...ate-taonier-mascot-symbol-contact-sheet.py | 107 -
...te-taonier-mascot-symbol-logo-concepts.mjs | 448 ----
...ate-taonier-pair-ears-jar-contact-sheet.py | 107 -
...te-taonier-pair-ears-jar-logo-concepts.mjs | 450 ----
...ing-head-jar-blackdot-eye-contact-sheet.py | 112 -
...ng-head-jar-blackdot-eye-logo-concepts.mjs | 464 -----
...er-peeking-head-jar-broad-contact-sheet.py | 107 -
...r-peeking-head-jar-broad-logo-concepts.mjs | 464 -----
...-taonier-peeking-head-jar-contact-sheet.py | 107 -
...peeking-head-jar-expanded-contact-sheet.py | 112 -
...eeking-head-jar-expanded-logo-concepts.mjs | 461 ----
...taonier-peeking-head-jar-logo-concepts.mjs | 452 ----
...king-head-jar-new-animals-contact-sheet.py | 112 -
...ing-head-jar-new-animals-logo-concepts.mjs | 464 -----
...rate-taonier-playful-bean-contact-sheet.py | 107 -
...ate-taonier-playful-bean-logo-concepts.mjs | 444 ----
...ate-taonier-ref04-locked-color-variants.py | 544 -----
...onier-short-foot-creature-contact-sheet.py | 112 -
...nier-short-foot-creature-logo-concepts.mjs | 457 ----
.../generate-taonier-spiral-contact-sheet.py | 74 -
.../generate-taonier-spiral-logo-concepts.mjs | 373 ----
.../generate-taonier-squish-logo-concepts.mjs | 315 ---
.../generate-taonier-squish-logo-variants.mjs | 183 --
...enerate-taonier-squish-recolor-variants.py | 272 ---
65 files changed, 20353 deletions(-)
delete mode 100644 scripts/generate-bark-battle-assets.mjs
delete mode 100644 scripts/generate-child-motion-demo-assets.mjs
delete mode 100644 scripts/generate-edutainment-tv-map-concepts.mjs
delete mode 100644 scripts/generate-match3d-style-references.mjs
delete mode 100644 scripts/generate-spacetime-bindings.mjs
delete mode 100644 scripts/generate-taonier-abstract-mascot-image2-contact-sheet.py
delete mode 100644 scripts/generate-taonier-abstract-mascot-image2-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-abstract-mascot-logo-concepts.py
delete mode 100644 scripts/generate-taonier-abstract-mascot-minimal-contact-sheet.py
delete mode 100644 scripts/generate-taonier-abstract-mascot-minimal-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-abstract-mascot-v2-logo-concepts.py
delete mode 100644 scripts/generate-taonier-anchor-logo-concepts.py
delete mode 100644 scripts/generate-taonier-anti-candy-contact-sheet.py
delete mode 100644 scripts/generate-taonier-anti-candy-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-braincore-contact-sheet.py
delete mode 100644 scripts/generate-taonier-braincore-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-capybara-jar-ref01-logo-refine-concepts.mjs
delete mode 100644 scripts/generate-taonier-capybara-jar-ref01-logo-refine-contact-sheet.py
delete mode 100644 scripts/generate-taonier-clay-mascot-contact-sheet.py
delete mode 100644 scripts/generate-taonier-clay-mascot-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-closed-geo-contact-sheet.py
delete mode 100644 scripts/generate-taonier-closed-geo-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-distinctive-contact-sheet.py
delete mode 100644 scripts/generate-taonier-distinctive-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-flow-contact-sheet.py
delete mode 100644 scripts/generate-taonier-flow-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-geometric-logo-concepts.py
delete mode 100644 scripts/generate-taonier-hand-spirit-bold-color-concepts.mjs
delete mode 100644 scripts/generate-taonier-hand-spirit-bold-color-contact-sheet.py
delete mode 100644 scripts/generate-taonier-hand-spirit-contact-sheet.py
delete mode 100644 scripts/generate-taonier-hand-spirit-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-hand-spirit-muted-color-concepts.mjs
delete mode 100644 scripts/generate-taonier-hand-spirit-muted-color-contact-sheet.py
delete mode 100644 scripts/generate-taonier-hand-spirit-outline-eye-concepts.mjs
delete mode 100644 scripts/generate-taonier-hand-spirit-outline-eye-contact-sheet.py
delete mode 100644 scripts/generate-taonier-hand-spirit-ref01-logo-refine-concepts.mjs
delete mode 100644 scripts/generate-taonier-hand-spirit-ref01-logo-refine-contact-sheet.py
delete mode 100644 scripts/generate-taonier-hands-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-logo-brief-concepts.mjs
delete mode 100644 scripts/generate-taonier-logo-brief-contact-sheet.py
delete mode 100644 scripts/generate-taonier-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-mascot-symbol-contact-sheet.py
delete mode 100644 scripts/generate-taonier-mascot-symbol-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-pair-ears-jar-contact-sheet.py
delete mode 100644 scripts/generate-taonier-pair-ears-jar-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-peeking-head-jar-blackdot-eye-contact-sheet.py
delete mode 100644 scripts/generate-taonier-peeking-head-jar-blackdot-eye-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-peeking-head-jar-broad-contact-sheet.py
delete mode 100644 scripts/generate-taonier-peeking-head-jar-broad-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-peeking-head-jar-contact-sheet.py
delete mode 100644 scripts/generate-taonier-peeking-head-jar-expanded-contact-sheet.py
delete mode 100644 scripts/generate-taonier-peeking-head-jar-expanded-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-peeking-head-jar-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-peeking-head-jar-new-animals-contact-sheet.py
delete mode 100644 scripts/generate-taonier-peeking-head-jar-new-animals-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-playful-bean-contact-sheet.py
delete mode 100644 scripts/generate-taonier-playful-bean-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-ref04-locked-color-variants.py
delete mode 100644 scripts/generate-taonier-short-foot-creature-contact-sheet.py
delete mode 100644 scripts/generate-taonier-short-foot-creature-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-spiral-contact-sheet.py
delete mode 100644 scripts/generate-taonier-spiral-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-squish-logo-concepts.mjs
delete mode 100644 scripts/generate-taonier-squish-logo-variants.mjs
delete mode 100644 scripts/generate-taonier-squish-recolor-variants.py
diff --git a/scripts/generate-bark-battle-assets.mjs b/scripts/generate-bark-battle-assets.mjs
deleted file mode 100644
index f7e01211..00000000
--- a/scripts/generate-bark-battle-assets.mjs
+++ /dev/null
@@ -1,145 +0,0 @@
-import { Buffer } from 'node:buffer';
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
-import path from 'node:path';
-
-const repoRoot = process.cwd();
-const promptsPath = path.join(repoRoot, 'public', 'bark-battle-assets', 'bark-battle-image-prompts.json');
-const outDir = path.join(repoRoot, 'public', 'bark-battle-assets', 'generated');
-const args = new Set(process.argv.slice(2));
-
-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 || 1000000), 10),
- };
-}
-
-function generationUrl(baseUrl) {
- return baseUrl.endsWith('/v1') ? `${baseUrl}/images/generations` : `${baseUrl}/v1/images/generations`;
-}
-
-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) => typeof entry === 'string' && entry.trim() && output.push(entry.trim()));
- }
- collectStringsByKey(nested, targetKey, output);
- }
-}
-
-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, 300)}`);
- return JSON.parse(text);
- } 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());
- const type = response.headers.get('content-type') || '';
- const extension = type.includes('webp') ? 'webp' : type.includes('jpeg') ? 'jpg' : 'png';
- return { bytes, extension };
- } finally {
- clearTimeout(timer);
- }
-}
-
-const rawTemplates = JSON.parse(readFileSync(promptsPath, 'utf8'));
-const onlyIds = process.argv
- .slice(2)
- .flatMap((arg, index, values) => (arg === '--only' ? String(values[index + 1] || '').split(',') : []))
- .map((value) => value.trim())
- .filter(Boolean);
-const templates = rawTemplates.filter((template) => !onlyIds.length || onlyIds.includes(template.id));
-const dryRun = args.has('--dry-run') || !args.has('--live');
-const requests = templates.map((template) => ({ id: template.id, title: template.title, body: { model: 'gpt-image-2', prompt: template.prompt, n: 1, size: '1024x1024' } }));
-if (dryRun) {
- console.log(JSON.stringify({ mode: 'dry-run', outDir, count: requests.length, requests }, 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);
-}
-
-mkdirSync(outDir, { recursive: true });
-const files = [];
-for (const request of requests) {
- console.log(`Generating ${request.id}...`);
- const payload = await fetchJson(generationUrl(env.baseUrl), {
- method: 'POST',
- headers: { Authorization: `Bearer ${env.apiKey}`, Accept: 'application/json', 'Content-Type': 'application/json' },
- body: JSON.stringify(request.body),
- }, env.timeoutMs);
- const urls = [];
- const b64 = [];
- collectStringsByKey(payload, 'url', urls);
- collectStringsByKey(payload, 'image', urls);
- collectStringsByKey(payload, 'image_url', urls);
- collectStringsByKey(payload, 'b64_json', b64);
- let image;
- const url = [...new Set(urls)].find((item) => /^https?:\/\//u.test(item));
- if (url) {
- image = await downloadUrl(url, env.timeoutMs);
- } else if (b64[0]) {
- const bytes = Buffer.from(b64[0], 'base64');
- image = { bytes, extension: inferExtensionFromBytes(bytes) };
- } else {
- throw new Error(`VectorEngine returned no image for ${request.id}`);
- }
- const outputPath = path.join(outDir, `${request.id}.${image.extension}`);
- writeFileSync(outputPath, image.bytes);
- files.push(outputPath);
-}
-console.log(JSON.stringify({ ok: true, count: files.length, files }, null, 2));
diff --git a/scripts/generate-child-motion-demo-assets.mjs b/scripts/generate-child-motion-demo-assets.mjs
deleted file mode 100644
index f39f6928..00000000
--- a/scripts/generate-child-motion-demo-assets.mjs
+++ /dev/null
@@ -1,1848 +0,0 @@
-import { Buffer } from 'node:buffer';
-import { spawnSync } from 'node:child_process';
-import {
- existsSync,
- mkdirSync,
- readFileSync,
- writeFileSync,
-} from 'node:fs';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-const scriptDir = path.dirname(fileURLToPath(import.meta.url));
-const repoRoot = path.resolve(scriptDir, '..');
-const assetDir = path.join(repoRoot, 'public', 'child-motion-demo');
-const intermediateDir = path.join(repoRoot, 'tmp', 'child-motion-demo-assets');
-const defaultTimeoutMs = 1000000;
-const chromaKeyColor = '#ff00ff';
-const layoutReferenceOutput = 'picture-book-stage-layout-v2.png';
-
-const backgroundPrompt = [
- '请生成一张横版儿童动作互动游戏舞台背景图,卡通绘本风格,温暖明亮。',
- '画面下半部分必须是开阔柔软的草地地面,适合叠加半透明角色轮廓和地面圆圈指示环。',
- '远处有柔和小山坡、树木、天空和浅色云朵,中心和下方前景保持干净开阔。',
- '构图需要适配 16:9 横屏游戏舞台,左右和上下边缘可安全裁切,主体信息不要贴边。',
- '风格像儿童绘本插画,柔和笔触,清新色彩,轻微纸张纹理,细节适中,不杂乱。',
- '不要出现人物、动物、文字、按钮、UI、边框、水印、摄像头画面、真实照片质感。',
-].join('');
-
-const styleReferenceNote = [
- '参考图仅用于统一卡通绘本草地舞台的色彩、笔触、纸张纹理和明亮童趣气质。',
- '不要复制参考图构图,不要出现真实照片质感。',
-].join('');
-
-const layoutReferencePrompt = [
- '请基于参考背景重新设计一张 16:9 儿童动作互动游戏热身关版式参考图,卡通绘本草地风格保持统一。',
- '背景品质和明亮草地绘本质感沿用参考图,不要把背景做暗或做成科技风。',
- '画面中心到下方中部保持开阔,留给半透明角色轮廓和地面椭圆指示环。',
- '底部只放一条自然的前景草坪边缘,占舞台高度约 18% 到 22%,草叶比例真实可爱,不要拉伸成扁平色块。',
- '顶部居中放一个小型横向 HUD 软纸条,占舞台宽度约 45% 到 52%,高度约 9% 到 12%,不要做成整屏顶部栏。',
- '右下角放一个小型五格状态条,占舞台宽度约 28% 到 34%,高度约 6% 到 8%,不要压住角色脚下区域。',
- '开始按钮占位使用小型胶囊按钮和轻盈托盘,整体不要超过舞台宽度 26%。',
- '所有 UI 都是无文字、无图标的空白资源占位,边缘带少量草叶、水彩纸张纹理和浅蓝高光。',
- '不要出现人物、动物、文字、数字、水印、摄像头画面、真实照片质感。',
-].join('');
-
-const chromaKeyNote = [
- `背景必须是完全纯色、均匀一致的 ${chromaKeyColor} 品红色,用于后续去背。`,
- '背景不能有阴影、渐变、纹理、地面、反光或光照变化。',
- `主体中不要使用 ${chromaKeyColor} 或接近品红的颜色。`,
- '主体边缘保持清晰,四周留出充足空白。',
- '不要出现文字、水印、真实照片质感。',
-].join('');
-
-const noStretchNote = [
- '资源自身必须按最终用途设计比例绘制,不要画成方形卡片再留大面积空白。',
- '网页端会按资源原始比例等比缩放使用,不会把资源横向或纵向强行拉伸。',
- '不要出现文字、数字、按钮文案、水印、真实照片质感。',
-].join('');
-
-const assetDefinitions = [
- {
- id: 'background',
- output: 'picture-book-grass-stage.png',
- size: '1536x1024',
- prompt: backgroundPrompt,
- transparent: false,
- useBackgroundReference: false,
- },
- {
- id: 'layout-reference-v2',
- output: layoutReferenceOutput,
- outputDirectory: 'intermediate',
- size: '2048x1152',
- prompt: layoutReferencePrompt,
- transparent: false,
- useBackgroundReference: true,
- },
- {
- id: 'floor',
- output: 'picture-book-foreground-grass-v2.png',
- sourceOutput: 'picture-book-foreground-grass-v2-source.png',
- size: '2048x768',
- transparent: true,
- useBackgroundReference: true,
- useLayoutReference: true,
- layoutNormalization: {
- canvasWidth: 2048,
- canvasHeight: 640,
- fit: 'cover-width',
- fillWidth: 1.04,
- anchorY: 'bottom',
- padding: 18,
- },
- prompt: [
- '请生成儿童动作互动游戏的底部前景草坪资源,不是完整背景。',
- '主体是一条横向自然草地边缘,用于覆盖 16:9 舞台最下方约五分之一高度。',
- '草坪顶部边缘有松散手绘草叶和少量浅色小花,底部更厚实,中心不要出现硬平台、椭圆地毯或 UI 栏。',
- '整体应像绘本背景自然延伸出来的草地前景,比例宽而舒展,草叶不能被压扁或横向拉伸。',
- '不要天空、远山、人物、角色、按钮、面板、边框。',
- '风格必须和参考背景一致:明亮、温暖、卡通绘本、水彩笔触、轻微纸张纹理。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'ground-ring',
- output: 'picture-book-ground-ring-v2.png',
- sourceOutput: 'picture-book-ground-ring-v2-source.png',
- size: '1536x512',
- transparent: true,
- useBackgroundReference: true,
- useLayoutReference: true,
- layoutNormalization: {
- canvasWidth: 1200,
- canvasHeight: 520,
- fit: 'contain',
- fillWidth: 0.92,
- fillHeight: 0.78,
- anchorY: 'center',
- padding: 24,
- },
- prompt: [
- '请生成一个儿童动作互动游戏地面椭圆指示环资产。',
- '主体是单个透视椭圆环,直接设计成贴在草地地面上的椭圆,不要依赖网页后期压扁。',
- '圆环由柔软草叶、水彩绿色描边和浅色高光组成,中心留空,边缘带轻微绘本手绘不规则感。',
- '整体清爽、明亮、儿童绘本风,不要科技感,不要霓虹,不要金属材质。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'character-outline',
- output: 'picture-book-character-outline-v2.png',
- sourceOutput: 'picture-book-character-outline-v2-source.png',
- size: '1024x1536',
- transparent: true,
- transparencyCleanup: 'character-outline',
- useBackgroundReference: true,
- useLayoutReference: true,
- layoutNormalization: {
- canvasWidth: 1024,
- canvasHeight: 1536,
- fit: 'contain',
- fillWidth: 0.78,
- fillHeight: 0.9,
- anchorY: 'bottom',
- padding: 28,
- },
- prompt: [
- '请生成一个儿童动作互动游戏的半透明角色轮廓指示器资产。',
- '主体是正面站立的人形轮廓,儿童友好比例,无五官、无衣服细节、无性别特征,双臂自然微微张开。',
- '视觉上像浅蓝绿色水彩发光描边加半透明白色填充,用于表示真实用户的位置剪影。',
- '轮廓需要简洁清晰,适合缩放到游戏舞台中使用。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'character-outline-only-v3',
- output: 'picture-book-character-outline-v3.png',
- sourceOutput: 'picture-book-character-outline-v2.png',
- sourceDirectory: 'asset',
- transparent: true,
- localPostprocess: 'character-outline-only',
- prompt:
- '本地后处理资源:基于 character-outline-v2 提取用户角色外轮廓,只保留浅青白描边,中间完全透明,不保留原有半透明材质、填充和明暗变化。',
- },
- {
- id: 'character-outline-white-v4',
- output: 'picture-book-character-outline-v4.png',
- sourceOutput: 'picture-book-character-outline-v2.png',
- sourceDirectory: 'asset',
- transparent: true,
- localPostprocess: 'character-outline-white-thin',
- prompt:
- '本地后处理资源:基于 character-outline-v2 提取用户角色外轮廓,先弱化耳朵、手指、脚趾等细碎凸起,再输出更细的白色描边,中间完全透明。',
- },
- {
- id: 'hud-strip',
- output: 'picture-book-hud-strip-v2.png',
- sourceOutput: 'picture-book-hud-strip-v2-source.png',
- size: '1536x512',
- transparent: true,
- transparencyCleanup: 'soft-panel',
- useBackgroundReference: true,
- useLayoutReference: true,
- layoutNormalization: {
- canvasWidth: 2200,
- canvasHeight: 420,
- fit: 'contain',
- fillWidth: 0.96,
- fillHeight: 0.92,
- anchorY: 'center',
- padding: 18,
- },
- prompt: [
- '请生成儿童动作互动游戏顶部 HUD 软纸条资产,不是方形面板。',
- '主体是一条细长横向顶部信息条,目标宽高比约 5:1,像轻盈软纸丝带,不要做成圆形徽章、方形卡片或厚重弹窗。',
- '中间为浅米白到淡浅绿色水彩软纸区域,左右边缘可以有少量草叶装饰,但不能扩大成大圆端。',
- '边缘有少量草叶、浅蓝高光和绘本纸张纹理,中心必须干净空白,方便网页叠加标题和进度。',
- '形状轻盈,适合放在 16:9 舞台顶部居中,占画面宽度约一半,不要做成全宽导航栏或后台系统面板。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'calibration-strip',
- output: 'picture-book-calibration-strip-v2.png',
- sourceOutput: 'picture-book-calibration-strip-v2-source.png',
- size: '1536x512',
- transparent: true,
- transparencyCleanup: 'soft-panel',
- useBackgroundReference: true,
- useLayoutReference: true,
- layoutNormalization: {
- canvasWidth: 1800,
- canvasHeight: 360,
- fit: 'contain',
- fillWidth: 0.96,
- fillHeight: 0.9,
- anchorY: 'center',
- padding: 16,
- },
- prompt: [
- '请生成儿童动作互动游戏右下角五格状态条资产,不是方形面板。',
- '主体是横向小型状态条,内部有五个柔和小胶囊或五个浅色分隔留白区域,但不要写任何文字或数字。',
- '整体用于舞台右下角,轻薄、不厚重,不压住角色脚下区域。',
- '米白、淡浅绿和浅蓝水彩高光为主,边缘可以有少量草叶和纸张纹理,风格必须和参考背景一致。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'start-panel',
- output: 'picture-book-start-panel-v2.png',
- sourceOutput: 'picture-book-start-panel-v2-source.png',
- size: '1024x512',
- transparent: true,
- transparencyCleanup: 'soft-panel',
- useBackgroundReference: true,
- useLayoutReference: true,
- layoutNormalization: {
- canvasWidth: 1280,
- canvasHeight: 520,
- fit: 'contain',
- fillWidth: 0.88,
- fillHeight: 0.88,
- anchorY: 'center',
- padding: 18,
- },
- prompt: [
- '请生成儿童动作互动游戏开始按钮背后的轻盈托盘资产,不是完整弹窗。',
- '主体是一个小型横向圆角软纸托盘,中心空白,适合只承载一个开始按钮。',
- '边缘可以有少量草叶、浅蓝高光和淡绿色纸张纹理,整体要比 HUD 更小、更轻,不要做成大卡片。',
- '不要文字、数字、图标或按钮文案。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'ui-button',
- output: 'picture-book-ui-button-v2.png',
- sourceOutput: 'picture-book-ui-button-v2-source.png',
- size: '1024x512',
- transparent: true,
- useBackgroundReference: true,
- useLayoutReference: true,
- layoutNormalization: {
- canvasWidth: 1300,
- canvasHeight: 520,
- fit: 'contain',
- fillWidth: 0.86,
- fillHeight: 0.76,
- anchorY: 'center',
- padding: 18,
- },
- prompt: [
- '请生成一个儿童动作互动游戏主按钮背景资产。',
- '主体是横向胶囊形按钮,无文字,绿色草地色为主,带浅蓝天空高光和柔和水彩纸张质感。',
- '按钮中心保持干净,适合网页叠加“开始游戏”等文字。',
- '整体要圆润、明亮、童趣、绘本感,不要科技感、金属感、真实照片质感。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'wave-cat-head-guide',
- output: 'picture-book-wave-cat-head-guide-v1.png',
- sourceOutput: 'picture-book-wave-cat-head-guide-v1-source.png',
- size: '1024x1024',
- transparent: true,
- useBackgroundReference: true,
- useLayoutReference: true,
- layoutNormalization: {
- canvasWidth: 1024,
- canvasHeight: 1024,
- fit: 'contain',
- fillWidth: 0.76,
- fillHeight: 0.76,
- anchorY: 'center',
- padding: 22,
- },
- prompt: [
- '请生成儿童动作互动游戏招手提示中央使用的卡通猫猫头资产,只画猫猫头,不要身体和爪子。',
- '主体是一只原创绘本卡通猫猫头,圆润、亲切、表情开心,适合夹在左右两只猫爪中间作为挥手引导。',
- '猫头可以是浅米白、淡橘、柔和浅棕和浅蓝绿色高光,轮廓清晰,五官简洁可爱,不能像真实照片或具体 IP 角色。',
- '资产需要轻盈半透明、水彩纸张质感,缩小后仍能清楚看出猫脸和耳朵,边缘不要有复杂毛发。',
- '整体风格必须和参考背景一致:明亮、温暖、卡通绘本、草地游戏舞台气质。',
- '不要文字、数字、按钮、面板、人物、全身动物、品牌符号、水印、真实照片质感、厚重阴影或科技感。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'wave-cat-paw-guide',
- output: 'picture-book-wave-cat-paw-guide-v1.png',
- sourceOutput: 'picture-book-wave-cat-paw-guide-v1-source.png',
- size: '1024x1024',
- transparent: true,
- useBackgroundReference: true,
- useLayoutReference: true,
- layoutNormalization: {
- canvasWidth: 1024,
- canvasHeight: 1024,
- fit: 'contain',
- fillWidth: 0.82,
- fillHeight: 0.82,
- anchorY: 'center',
- padding: 22,
- },
- prompt: [
- '请生成儿童动作互动游戏的挥手引导猫爪资产,只画一只猫爪和一小段前臂,用于网页左右镜像复用。',
- '主体是一段从画面下方斜向上伸出的柔软卡通猫前臂,末端是圆润猫爪,不展示手指细节,爪垫可以用几个浅色圆形简化表达。',
- '猫爪需要像儿童绘本里的玩偶圆爪,简洁可爱,适合放在猫猫头左右两侧做左右摆动动画。',
- '资产需要半透明、轻盈,轮廓清晰,缩小后仍能看出前臂和猫爪;边缘不要复杂毛发,不要尖爪。',
- '颜色使用浅米白、淡橘、柔和草绿色和浅蓝绿色水彩高光,风格和参考背景一致,明亮、温暖、卡通绘本、轻微纸张纹理。',
- '不要文字、数字、按钮、面板、人物全身、完整动物、真实照片质感、厚重阴影或科技感。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'wave-cat-head-guide-v2',
- output: 'picture-book-wave-cat-head-guide-v2.png',
- sourceOutput: 'picture-book-wave-cat-head-guide-v2-source.png',
- size: '1024x1024',
- transparent: true,
- useBackgroundReference: true,
- useLayoutReference: true,
- layoutNormalization: {
- canvasWidth: 1024,
- canvasHeight: 1024,
- fit: 'contain',
- fillWidth: 0.72,
- fillHeight: 0.72,
- anchorY: 'center',
- padding: 24,
- },
- prompt: [
- '请重新设计一版儿童动作互动游戏招手提示中央使用的原创绘本卡通猫猫头资产,只画猫猫头,不要身体和爪子。',
- '主体是一只圆润的小猫头,像贴在游戏舞台中央的柔软绘本贴纸,轮廓大而简洁,表情开心、友好、轻轻张嘴微笑。',
- '五官必须更简化:大眼睛、短鼻子、小嘴巴、短胡须即可;不要长胡须伸出太远,不要复杂毛发,不要真实猫毛细节。',
- '色彩使用浅奶油白、淡橘和少量浅草绿或天空蓝高光,整体更轻、更通透,适合叠在明亮草地舞台上。',
- '边缘是柔和水彩描边和轻微纸张纹理,缩小到舞台中央后仍能一眼看出是可爱的猫猫头。',
- '不要文字、数字、按钮、面板、人物、全身动物、品牌符号、水印、真实照片质感、厚重阴影或科技感。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'wave-cat-paw-guide-v2',
- output: 'picture-book-wave-cat-paw-guide-v2.png',
- sourceOutput: 'picture-book-wave-cat-paw-guide-v2-source.png',
- size: '1024x1024',
- transparent: true,
- useBackgroundReference: true,
- useLayoutReference: true,
- layoutNormalization: {
- canvasWidth: 1024,
- canvasHeight: 1024,
- fit: 'contain',
- fillWidth: 0.74,
- fillHeight: 0.78,
- anchorY: 'center',
- padding: 24,
- },
- prompt: [
- '请重新设计一版儿童动作互动游戏挥手引导猫爪资产,只画一只圆润猫爪和很短一段前臂,用于网页左右镜像复用。',
- '主体是大号圆猫爪,爪面朝向观众,爪垫用一个浅粉色大圆垫和几个浅粉色小圆垫简化表达,前臂只保留短短一截,不要画成长手臂。',
- '猫爪要像儿童绘本贴纸或软玩具爪子,轮廓饱满、简洁、可爱,适合放在猫猫头左右两侧做挥动动画。',
- '色彩与猫猫头统一:浅奶油白、淡橘、柔和浅粉或淡桃色爪垫和少量浅草绿或天空蓝高光;整体半透明、轻盈、无厚重阴影。',
- '爪垫必须保持明亮柔和,禁止黑色、灰色、深棕色、深色阴影或高反差硬边。',
- '缩小后必须清楚看出猫爪轮廓和爪垫;不要尖爪、不要手指细节、不要真实皮肤或真实毛发质感。',
- '不要文字、数字、按钮、面板、人物全身、完整动物、真实照片质感、厚重阴影或科技感。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'ground-ring-v3',
- output: 'picture-book-ground-ring-v3.png',
- sourceOutput: 'picture-book-ground-ring-v3-source.png',
- size: '1536x512',
- transparent: true,
- useBackgroundReference: true,
- useLayoutReference: true,
- layoutNormalization: {
- canvasWidth: 1200,
- canvasHeight: 520,
- fit: 'contain',
- fillWidth: 0.92,
- fillHeight: 0.78,
- anchorY: 'center',
- padding: 24,
- },
- prompt: [
- '请重新设计儿童动作互动游戏地面位置指示环资产,用于放在绿色草地上,必须和草皮明显区分。',
- '主体是单个贴在地面上的透视椭圆指示环,不是完整背景,不要依赖网页后期压扁。',
- '样式像浅蓝天空色和暖黄色软垫组成的绘本地贴:外圈为浅蓝白水彩描边,内圈有柔和暖黄色或奶油色高光,中心留空透明。',
- '圆环边缘可以有少量星星光点、短虚线或纸贴边,但不要用大面积绿色草叶作为主体,避免和草地混在一起。',
- '禁止使用粉紫色、品红色、紫色外圈、玫红光晕或任何接近 #ff00ff 的颜色;这些颜色会被当成透明背景删除。',
- '除纯色品红背景外,主体只能使用浅蓝、白色、奶油黄、暖黄色、浅橙和极少量浅草绿。',
- '整体要明亮、温暖、儿童绘本风,和草地舞台统一但有清楚视觉对比;不要科技感,不要霓虹,不要金属材质。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'wave-cat-torso-guide-v3',
- output: 'picture-book-wave-cat-torso-guide-v3.png',
- sourceOutput: 'picture-book-wave-cat-torso-guide-v3-source.png',
- size: '1024x1024',
- transparent: true,
- useBackgroundReference: true,
- useLayoutReference: true,
- layoutNormalization: {
- canvasWidth: 1024,
- canvasHeight: 1024,
- fit: 'contain',
- fillWidth: 0.62,
- fillHeight: 0.58,
- anchorY: 'bottom',
- padding: 24,
- },
- prompt: [
- '请为输入图中的橘白绘本猫猫头补充一个可单独叠放在头部下方的猫猫上半身胸口资产。',
- '只画短短的上半身胸口、脖子下沿、圆润肩膀和一点点短前肢根部;不要画头、耳朵、眼睛、嘴巴、胡须、完整爪子、腿或脚。',
- '主体必须是橘白小猫身体,色彩和输入图一致:浅奶油白为主,淡橘色斑纹点缀,柔和浅棕描边,少量浅草绿或天空蓝高光。',
- '形状像儿童绘本贴纸里的圆润上半身,底部自然截断,适合网页叠在猫头下面形成半身猫猫。',
- '两侧不要伸出长手臂,左右猫爪会由网页单独叠加。',
- '禁止黑色、黑白猫、大面积深色毛、真实毛发、尖锐漫画黑线、高反差阴影。',
- '不要文字、数字、按钮、面板、人物、完整动物、品牌符号、水印、真实照片质感、厚重阴影或科技感。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'wave-cat-body-guide-v4',
- output: 'picture-book-wave-cat-body-guide-v4.png',
- sourceOutput: 'picture-book-wave-cat-body-guide-v4-source.png',
- size: '1024x1024',
- transparent: true,
- useBackgroundReference: true,
- useLayoutReference: true,
- useWaveCatHeadReference: true,
- layoutNormalization: {
- canvasWidth: 1024,
- canvasHeight: 1024,
- fit: 'contain',
- fillWidth: 0.78,
- fillHeight: 0.88,
- anchorY: 'bottom',
- padding: 24,
- },
- prompt: [
- '请按参考结构重新绘制儿童动作互动游戏中央招手提示的猫咪身体主体资源。',
- '主体结构参考用户草图:正面半身猫咪,圆猫头在上方,两个三角耳朵,头下方接一个简单圆润躯干,躯干到胸口和腰部一半为止。',
- '本资源只包含猫头、耳朵、脖子、躯干和肩部连接点,不要画任何手臂、前臂、手掌、猫爪、腿或脚;左右肩膀两侧要留出手臂接入空间。',
- '角色必须是橘白猫:主体毛色 80% 为浅奶油白和温暖淡橘色,少量浅棕描边;只能有小面积深棕眼睛和细线五官。',
- '五官简洁可爱:大眼睛、短鼻子、小嘴巴、短胡须;躯干为浅奶油白和淡橘色斑纹,边缘柔和水彩描边。',
- '整体像儿童绘本贴纸,半透明、轻盈,缩小到舞台中央后仍能看清猫头和半身结构。',
- '禁止画手臂或猫爪,禁止黑色、灰色、黑白猫、奶牛猫、虎斑深色块、大面积深棕毛、真实毛发、尖锐漫画黑线、高反差阴影、文字、数字、按钮、面板、水印和真实照片质感。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'wave-cat-arm-guide-v4',
- output: 'picture-book-wave-cat-arm-guide-v4.png',
- sourceOutput: 'picture-book-wave-cat-arm-guide-v4-source.png',
- size: '1024x1024',
- transparent: true,
- useBackgroundReference: true,
- useLayoutReference: true,
- layoutNormalization: {
- canvasWidth: 1024,
- canvasHeight: 1024,
- fit: 'contain',
- fillWidth: 0.7,
- fillHeight: 0.82,
- anchorY: 'bottom',
- padding: 24,
- },
- prompt: [
- '请按参考结构重新绘制儿童动作互动游戏猫咪挥手动画用的单侧手臂资源。',
- '只画一条橘白猫咪手臂:从肩膀连接处开始,弯曲向上,包含上臂、前臂和末端圆猫爪,整体像用户草图中单侧向上挥动的弯曲手臂。',
- '资源需要适合网页左右镜像复用:默认绘制一条从画面下方肩部连接点向上弯到画面左上方的手臂,肩部连接点在资源下方内侧,方便 CSS 设置旋转轴。',
- '猫爪末端是圆润猫爪,爪垫浅粉或淡桃色,不要尖爪;手臂粗细均匀、短而可爱,不要画成长人类手臂。',
- '角色必须是橘白猫手臂:主体毛色 80% 为浅奶油白和温暖淡橘色,淡橘斑纹点缀,柔和浅棕描边,爪垫浅粉或淡桃色。',
- '整体像儿童绘本贴纸,半透明、轻盈,边缘清晰,缩小后仍能看出弯曲手臂和圆猫爪。',
- '不要画猫头、躯干、另一只手臂、完整动物、腿、脚、文字、数字、按钮、面板、水印、真实照片质感、黑色、灰色、黑白毛、黑灰重阴影或深色大面积毛。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'wave-cat-body-guide-v5',
- output: 'picture-book-wave-cat-body-guide-v5.png',
- sourceOutput: 'picture-book-wave-cat-body-guide-v5-source.png',
- size: '1024x1024',
- transparent: true,
- transparencyCleanup: 'cat-guide',
- useBackgroundReference: true,
- useLayoutReference: true,
- useWaveCatHeadReference: true,
- layoutNormalization: {
- canvasWidth: 1024,
- canvasHeight: 1024,
- fit: 'contain',
- fillWidth: 0.72,
- fillHeight: 0.88,
- anchorY: 'bottom',
- padding: 22,
- },
- prompt: [
- '请按用户参考结构重新绘制儿童动作互动游戏中央招手提示的猫咪身体主体资源,用作动画底座。主体必须是正面纸偶结构:一个大圆猫头、两个三角耳朵、头下方连接短脖子和圆润半身躯干,画到上半身和腰部一半即可。',
- '本资源只包含猫头、耳朵、五官、脖子、躯干、圆润肩膀和两侧肩部连接点;绝对不要画任何手臂、前臂、手掌、猫爪、小手、小脚、腿、脚或尾巴。左右肩膀外侧需要留出干净的手臂接入空间,方便网页单独叠加手臂动画。',
- '请保持输入猫猫头的暖橘白绘本风格:头顶和耳朵外侧有淡橘色块,脸和肚子为浅奶油白,少量浅橘斑纹,五官只用小面积深棕眼睛和暖棕细线。',
- '所有描边必须是柔和暖棕或浅橘棕,不要使用纯黑描边;资源自身保持清晰不透明,网页会统一设置半透明效果,不要在图片里主动降低主体透明度。',
- '整体像儿童绘本贴纸或可动纸偶底座,结构简单、比例可爱,缩小到舞台中央后仍能看清大猫头、小身体和肩部挂点。',
- '禁止画手臂或猫爪,禁止黑色、灰色、黑白猫、奶牛猫、虎斑深色块、大面积深棕毛、真实毛发、尖锐漫画黑线、高反差阴影、文字、数字、按钮、面板、水印和真实照片质感。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'wave-cat-arm-guide-v5',
- output: 'picture-book-wave-cat-arm-guide-v5.png',
- sourceOutput: 'picture-book-wave-cat-arm-guide-v5-source.png',
- size: '1024x1024',
- transparent: true,
- transparencyCleanup: 'cat-guide',
- useBackgroundReference: true,
- useLayoutReference: true,
- useWaveCatHeadReference: true,
- layoutNormalization: {
- canvasWidth: 1024,
- canvasHeight: 1024,
- fit: 'contain',
- fillWidth: 0.58,
- fillHeight: 0.86,
- anchorY: 'bottom',
- padding: 20,
- },
- prompt: [
- '请按用户参考结构重新绘制儿童动作互动游戏猫咪挥手动画用的单侧手臂手部资源。只画一条猫咪手臂:从底部肩膀连接点开始,包含短上臂、弯曲前臂和末端圆猫爪,像可动纸偶的一条独立手臂。',
- '默认绘制一条向左上方举起的手臂,肩膀连接点在画面底部偏内侧,圆猫爪在画面上方;资源需要适合网页左右镜像复用和围绕肩膀连接点旋转摆动。',
- '猫爪用类似多啦A梦圆手的圆润简化形状,不展示手指细节,不要尖爪;爪面可以有浅粉或淡桃色圆形爪垫。手臂短而可爱,比例像小猫上肢,不要画成人类长手臂。',
- '请保持输入猫猫头的暖橘白绘本风格:手臂主体为浅奶油白和淡橘色,少量浅橘斑纹,爪垫浅粉或淡桃色,柔和暖棕描边。',
- '所有描边必须是柔和暖棕或浅橘棕,不要使用纯黑描边;资源自身保持清晰不透明,网页会统一设置半透明效果,不要在图片里主动降低主体透明度。',
- '不要画猫头、躯干、另一只手臂、完整动物、腿、脚、文字、数字、按钮、面板、水印、真实照片质感、黑色、灰色、黑白毛、黑灰重阴影或深色大面积毛。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'wave-cat-body-guide-v6',
- output: 'picture-book-wave-cat-body-guide-v6.png',
- sourceOutput: 'picture-book-wave-cat-body-guide-v6-source.png',
- size: '1024x1024',
- transparent: true,
- transparencyCleanup: 'cat-guide',
- useBackgroundReference: true,
- useLayoutReference: true,
- useWaveCatHeadReference: true,
- layoutNormalization: {
- canvasWidth: 1024,
- canvasHeight: 1024,
- fit: 'contain',
- fillWidth: 0.68,
- fillHeight: 0.86,
- anchorY: 'bottom',
- padding: 22,
- },
- prompt: [
- '请重新绘制儿童动作互动游戏中央招手提示的猫咪身体主体资源,严格按可动纸偶拆件结构生成。主体只有一只正面橘白猫:大圆猫头、两个三角耳朵、短脖子、梨形半身躯干,底部自然截断。',
- '身体两侧只允许出现圆润肩膀轮廓和一个很小的肩部连接圆点或肩窝标记;绝对不要画伸出的手臂、前臂、手掌、猫爪、小手、小脚、腿、脚或尾巴。肩膀外侧必须留空,后续网页会单独叠加两条手臂。',
- '猫咪造型参考输入猫猫头的暖橘白配色:头顶、耳朵外侧和身体侧边为淡橘色,脸和肚子为浅奶油白,少量浅橘斑纹;五官使用暖棕细线和小面积深棕眼睛。',
- '请避免粉色大背景、避免主体外侧彩色光晕,主体贴纸外轮廓之外必须直接是纯色背景;线条为柔和暖棕或浅橘棕,不要纯黑粗描边。',
- '资源自身保持清晰不透明,半透明效果由网页 CSS 控制;整体像儿童绘本可动纸偶底座,缩小后仍能看清大猫头、短身体、肩部连接点。',
- '禁止手臂、爪子、小手、脚、尾巴;禁止黑色、灰色、黑白猫、奶牛猫、虎斑深色块、大面积深棕毛、真实毛发、尖锐漫画黑线、高反差阴影、文字、数字、按钮、面板、水印和真实照片质感。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'wave-cat-body-guide-v7',
- output: 'picture-book-wave-cat-body-guide-v7.png',
- sourceOutput: 'picture-book-wave-cat-body-guide-v6.png',
- sourceDirectory: 'asset',
- transparent: true,
- localPostprocess: 'remove-cat-body-shoulder-dots',
- prompt:
- '本地后处理资源:基于 wave-cat-body-guide-v6 去除身体左右两侧不协调的小圆点,保留猫头、身体、透明边界和整体水彩风格。',
- },
- {
- id: 'wave-cat-arm-guide-v6',
- output: 'picture-book-wave-cat-arm-guide-v6.png',
- sourceOutput: 'picture-book-wave-cat-arm-guide-v6-source.png',
- size: '1024x1024',
- transparent: true,
- transparencyCleanup: 'cat-guide',
- useBackgroundReference: true,
- useLayoutReference: true,
- useWaveCatHeadReference: true,
- layoutNormalization: {
- canvasWidth: 1024,
- canvasHeight: 1024,
- fit: 'contain',
- fillWidth: 0.74,
- fillHeight: 0.88,
- anchorY: 'bottom',
- padding: 20,
- },
- prompt: [
- '请重新绘制儿童动作互动游戏猫咪挥手动画用的单侧手臂资源,严格作为可动纸偶拆件。只画一条橘白猫手臂:底部是肩膀连接端,向左上方弯曲,末端是一只简化圆猫手。',
- '猫手必须像多啦A梦式圆手或软玩具圆爪:一个完整圆润手掌,不画手指,不画黑色或深色爪垫,不画粉色爪垫点,不画尖爪。手臂短而厚实,像小猫上肢,不要成人类长手臂。',
- '资源必须适合网页左右镜像复用和围绕肩部连接点旋转:肩膀连接端在画面底部偏内侧,圆手在画面上方,四周留透明空白。',
- '颜色参考输入猫猫头:浅奶油白和淡橘色为主体,少量浅橘斑纹,柔和暖棕或浅橘棕描边;不要纯黑粗描边。',
- '请避免粉色大背景、避免主体外侧彩色光晕,主体贴纸外轮廓之外必须直接是纯色背景。资源自身保持清晰不透明,半透明效果由网页 CSS 控制。',
- '不要画猫头、躯干、另一只手臂、完整动物、腿、脚、文字、数字、按钮、面板、水印、真实照片质感、黑色、灰色、黑白毛、黑灰重阴影或深色大面积毛。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
- {
- id: 'wave-cat-arm-guide-v7',
- output: 'picture-book-wave-cat-arm-guide-v7.png',
- sourceOutput: 'picture-book-wave-cat-arm-guide-v7-source.png',
- size: '1024x1024',
- transparent: true,
- transparencyCleanup: 'cat-guide',
- useWaveCatHeadReference: true,
- useWaveCatArmReference: true,
- layoutNormalization: {
- canvasWidth: 1024,
- canvasHeight: 1024,
- fit: 'contain',
- fillWidth: 0.74,
- fillHeight: 0.88,
- anchorY: 'bottom',
- padding: 20,
- },
- prompt: [
- '请在参考手臂资源的基础上重新绘制儿童动作互动游戏猫咪挥手动画用的单侧手臂资源,严格作为可动纸偶拆件。只画一条橘白猫手臂:底部是肩膀连接端,向左上方弯曲,末端是一只简化圆猫手。',
- '关键修改:末端圆猫爪必须正面对镜头,像在对观众挥手。圆爪正面轮廓要清楚可见,不要转成侧面,不要转向画面内侧或角色中心,不要画成握拳或背面。可以用浅奶油白圆形爪面、柔和高光和非常淡的短弧线表现正面对镜头。',
- '猫手必须像多啦A梦式圆手或软玩具圆爪:一个完整圆润圆爪,不画分开的手指,不画尖爪,不画黑色或深色爪垫。若需要爪面细节,只允许非常浅的桃色小圆面或柔和弧线,不能变成真实动物爪垫。',
- '手臂短而厚实,像小猫上肢,不要成人类长手臂。资源必须适合网页左右镜像复用和围绕肩部连接点旋转:肩膀连接端在画面底部偏内侧,圆手在画面上方,四周留透明空白。',
- '颜色参考输入猫猫头和参考手臂:浅奶油白和淡橘色为主体,少量浅橘斑纹,柔和暖棕或浅橘棕描边;不要纯黑粗描边。',
- '请避免粉色大背景、避免主体外侧彩色光晕,主体贴纸外轮廓之外必须直接是纯色背景。资源自身保持清晰不透明,半透明效果由网页 CSS 控制。',
- '不要画猫头、躯干、另一只手臂、完整动物、腿、脚、文字、数字、按钮、面板、水印、真实照片质感、黑色、灰色、黑白毛、黑灰重阴影或深色大面积毛。',
- styleReferenceNote,
- noStretchNote,
- chromaKeyNote,
- ].join(''),
- },
-];
-
-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('--')) {
- const existing = args.get(raw);
- if (Array.isArray(existing)) {
- existing.push(next);
- } else if (existing) {
- args.set(raw, [existing, next]);
- } else {
- 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 buildVectorEngineImagesGenerationUrl(baseUrl) {
- return baseUrl.endsWith('/v1')
- ? `${baseUrl}/images/generations`
- : `${baseUrl}/v1/images/generations`;
-}
-
-function buildVectorEngineImagesEditUrl(baseUrl) {
- return baseUrl.endsWith('/v1')
- ? `${baseUrl}/images/edits`
- : `${baseUrl}/v1/images/edits`;
-}
-
-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, preferredPath) {
- 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 path.extname(preferredPath).replace(/^\./u, '') || 'png';
-}
-
-function mimeFromExtension(extension) {
- if (extension === 'jpg' || extension === 'jpeg') {
- return 'image/jpeg';
- }
- if (extension === 'webp') {
- return 'image/webp';
- }
- return 'image/png';
-}
-
-function readReferenceImage(filePath) {
- if (!existsSync(filePath)) {
- return null;
- }
- const bytes = readFileSync(filePath);
- const extension = inferExtensionFromBytes(bytes, filePath);
- return {
- fileName: path.basename(filePath).replace(/"/gu, '_'),
- mimeType: mimeFromExtension(extension),
- bytes,
- };
-}
-
-function pushReferenceImage(body, filePath) {
- const reference = readReferenceImage(filePath);
- if (!reference) {
- return false;
- }
- body.referenceImages = [...(body.referenceImages || []), reference];
- return true;
-}
-
-function buildRequestBody(asset, size) {
- const body = {
- model: 'gpt-image-2',
- prompt: asset.prompt,
- n: 1,
- size: size || asset.size,
- };
- if (asset.useBackgroundReference) {
- pushReferenceImage(
- body,
- path.join(assetDir, 'picture-book-grass-stage.png'),
- );
- }
- if (asset.useLayoutReference) {
- pushReferenceImage(
- body,
- path.join(intermediateDir, layoutReferenceOutput),
- );
- }
- if (asset.useWaveCatHeadReference) {
- pushReferenceImage(
- body,
- path.join(assetDir, 'picture-book-wave-cat-head-guide-v2.png'),
- );
- }
- if (asset.useWaveCatArmReference) {
- pushReferenceImage(
- body,
- path.join(assetDir, 'picture-book-wave-cat-arm-guide-v6.png'),
- );
- }
- return body;
-}
-
-async function fetchWithTimeout(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 text;
- } catch (error) {
- if (error?.name === 'AbortError') {
- throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
- }
- throw error;
- } finally {
- clearTimeout(timer);
- }
-}
-
-async function downloadImage(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);
- }
-}
-
-function outputPathFor(asset) {
- if (asset.outputDirectory === 'intermediate') {
- return path.join(intermediateDir, asset.output);
- }
- return path.join(assetDir, asset.output);
-}
-
-function sourceOutputPathFor(asset) {
- if (asset.sourceDirectory === 'asset') {
- return path.join(assetDir, asset.sourceOutput || asset.output);
- }
- return path.join(intermediateDir, asset.sourceOutput || asset.output);
-}
-
-function opaqueSourceOutputPathFor(asset) {
- return path.join(
- intermediateDir,
- `${path.basename(asset.sourceOutput || asset.output, path.extname(asset.sourceOutput || asset.output))}-rgb.png`,
- );
-}
-
-function normalizeOutputPath(preferredPath, imageBytes) {
- const actualExtension = inferExtensionFromBytes(imageBytes, preferredPath);
- const outputPath =
- path.extname(preferredPath).toLowerCase() === `.${actualExtension}`
- ? preferredPath
- : path.join(
- path.dirname(preferredPath),
- `${path.basename(preferredPath, path.extname(preferredPath))}.${actualExtension}`,
- );
- return { actualExtension, outputPath };
-}
-
-function resolveCodexHome() {
- if (process.env.CODEX_HOME) {
- return process.env.CODEX_HOME;
- }
- if (process.env.USERPROFILE) {
- return path.join(process.env.USERPROFILE, '.codex');
- }
- if (process.env.HOME) {
- return path.join(process.env.HOME, '.codex');
- }
- return null;
-}
-
-function findChromaKeyHelper() {
- const codexHome = resolveCodexHome();
- if (!codexHome) {
- return null;
- }
- const helper = path.join(
- codexHome,
- 'skills',
- '.system',
- 'imagegen',
- 'scripts',
- 'remove_chroma_key.py',
- );
- return existsSync(helper) ? helper : null;
-}
-
-function removeChromaKey(sourcePath, finalPath) {
- const helper = findChromaKeyHelper();
- if (!helper) {
- throw new Error(
- 'Missing Codex imagegen remove_chroma_key.py helper for transparent assets',
- );
- }
-
- const result = spawnSync(
- 'python',
- [
- helper,
- '--input',
- sourcePath,
- '--out',
- finalPath,
- '--key-color',
- chromaKeyColor,
- '--auto-key',
- 'border',
- '--soft-matte',
- '--transparent-threshold',
- '12',
- '--opaque-threshold',
- '220',
- '--despill',
- '--force',
- ],
- {
- cwd: repoRoot,
- encoding: 'utf8',
- },
- );
-
- if (result.status !== 0) {
- throw new Error(
- `remove_chroma_key.py failed: ${(result.stderr || result.stdout).trim()}`,
- );
- }
-}
-
-function removeUiPanelChromaKey(sourcePath, finalPath) {
- const script = [
- 'from PIL import Image, ImageFilter',
- 'import sys',
- 'source, out = sys.argv[1], sys.argv[2]',
- 'im = Image.open(source).convert("RGBA")',
- 'px = im.load()',
- 'w, h = im.size',
- 'corner = im.getpixel((0, 0))',
- 'key = corner[:3]',
- 'for y in range(h):',
- ' for x in range(w):',
- ' r, g, b, _ = px[x, y]',
- ' brightness = (r + g + b) / 3',
- ' dist = ((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2) ** 0.5',
- ' magenta_bias = r + b - 1.85 * g',
- ' if brightness < 42 or dist < 155 or (r > 185 and b > 150 and g < 190 and magenta_bias > 235):',
- ' alpha = 0',
- ' elif dist < 225:',
- ' alpha = int(max(0, min(255, (dist - 155) / 70 * 255)))',
- ' else:',
- ' alpha = 255',
- ' if alpha > 0 and r > g + 28 and b > g + 20:',
- ' r = min(r, g + 18)',
- ' b = min(b, g + 14)',
- ' px[x, y] = (r, g, b, alpha)',
- 'alpha = im.getchannel("A").filter(ImageFilter.GaussianBlur(0.45))',
- 'im.putalpha(alpha)',
- 'im.save(out)',
- ].join('\n');
-
- const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
- cwd: repoRoot,
- encoding: 'utf8',
- });
-
- if (result.status !== 0) {
- throw new Error(
- `Failed to clean UI panel transparency: ${(result.stderr || result.stdout).trim()}`,
- );
- }
-}
-
-function removeCharacterOutlineChromaKey(sourcePath, finalPath) {
- const script = [
- 'from PIL import Image, ImageFilter',
- 'import sys',
- 'source, out = sys.argv[1], sys.argv[2]',
- 'im = Image.open(source).convert("RGBA")',
- 'px = im.load()',
- 'w, h = im.size',
- 'for y in range(h):',
- ' for x in range(w):',
- ' r, g, b, _ = px[x, y]',
- ' magenta_strength = min(r, b) - g',
- ' magenta_bg = r > 180 and b > 170 and g < 145 and magenta_strength > 70',
- ' hot_bg = r > 225 and b > 205 and g < 190 and magenta_strength > 55',
- ' if magenta_bg or hot_bg:',
- ' alpha = 0',
- ' else:',
- ' alpha = 255',
- ' if alpha > 0 and r > g + 35 and b > g + 22:',
- ' r = min(r, g + 24)',
- ' b = min(b, g + 20)',
- ' px[x, y] = (r, g, b, alpha)',
- 'alpha = im.getchannel("A").filter(ImageFilter.GaussianBlur(0.35))',
- 'im.putalpha(alpha)',
- 'im.save(out)',
- ].join('\n');
-
- const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
- cwd: repoRoot,
- encoding: 'utf8',
- });
-
- if (result.status !== 0) {
- throw new Error(
- `Failed to clean character outline transparency: ${(result.stderr || result.stdout).trim()}`,
- );
- }
-}
-
-function createCharacterOutlineOnlyIndicator(sourcePath, finalPath) {
- const script = [
- 'from PIL import Image, ImageChops, ImageFilter',
- 'import sys',
- 'source, out = sys.argv[1], sys.argv[2]',
- 'im = Image.open(source).convert("RGBA")',
- 'alpha = im.getchannel("A")',
- 'mask = alpha.point(lambda v: 255 if v > 24 else 0)',
- 'mask = mask.filter(ImageFilter.MaxFilter(5)).filter(ImageFilter.MinFilter(5))',
- 'outer = mask.filter(ImageFilter.MaxFilter(47))',
- 'inner = mask.filter(ImageFilter.MinFilter(47))',
- 'stroke = ImageChops.subtract(outer, inner)',
- 'stroke = stroke.filter(ImageFilter.GaussianBlur(0.45))',
- 'glow = stroke.filter(ImageFilter.GaussianBlur(3.0)).point(lambda v: int(v * 0.34))',
- 'result = Image.new("RGBA", im.size, (0, 0, 0, 0))',
- 'glow_layer = Image.new("RGBA", im.size, (91, 205, 197, 0))',
- 'glow_layer.putalpha(glow)',
- 'line_layer = Image.new("RGBA", im.size, (224, 255, 247, 0))',
- 'line_layer.putalpha(stroke.point(lambda v: min(235, int(v * 0.92))))',
- 'result.alpha_composite(glow_layer)',
- 'result.alpha_composite(line_layer)',
- 'result.save(out)',
- ].join('\n');
-
- const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
- cwd: repoRoot,
- encoding: 'utf8',
- });
-
- if (result.status !== 0) {
- throw new Error(
- `Failed to create outline-only character indicator: ${(result.stderr || result.stdout).trim()}`,
- );
- }
-}
-
-function createWhiteCharacterOutlineIndicator(sourcePath, finalPath) {
- const script = [
- 'from pathlib import Path',
- 'import cv2',
- 'import numpy as np',
- 'from PIL import Image',
- 'import sys',
- 'source, out = Path(sys.argv[1]), Path(sys.argv[2])',
- 'rgba = np.array(Image.open(source).convert("RGBA"))',
- 'alpha = rgba[:, :, 3]',
- 'mask = (alpha > 24).astype(np.uint8) * 255',
- 'contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)',
- 'body = np.zeros_like(mask)',
- 'if contours:',
- ' largest = max(contours, key=cv2.contourArea)',
- ' cv2.drawContours(body, [largest], -1, 255, thickness=cv2.FILLED)',
- 'open_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (25, 25))',
- 'close_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (35, 35))',
- 'body = cv2.morphologyEx(body, cv2.MORPH_OPEN, open_kernel, iterations=1)',
- 'body = cv2.morphologyEx(body, cv2.MORPH_CLOSE, close_kernel, iterations=1)',
- 'body = cv2.GaussianBlur(body, (0, 0), 7.0)',
- '_, body = cv2.threshold(body, 92, 255, cv2.THRESH_BINARY)',
- 'body = cv2.GaussianBlur(body, (0, 0), 1.4)',
- '_, body = cv2.threshold(body, 64, 255, cv2.THRESH_BINARY)',
- 'line_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))',
- 'outer = cv2.dilate(body, line_kernel, iterations=1)',
- 'inner = cv2.erode(body, line_kernel, iterations=1)',
- 'stroke = cv2.subtract(outer, inner)',
- 'stroke = cv2.GaussianBlur(stroke, (0, 0), 0.55)',
- 'glow = cv2.GaussianBlur(stroke, (0, 0), 2.2)',
- 'result = np.zeros((mask.shape[0], mask.shape[1], 4), dtype=np.uint8)',
- 'glow_alpha = np.clip(glow.astype(np.float32) * 0.22, 0, 70).astype(np.uint8)',
- 'line_alpha = np.clip(stroke.astype(np.float32) * 0.78, 0, 205).astype(np.uint8)',
- 'result[:, :, 0:3] = 255',
- 'result[:, :, 3] = np.maximum(glow_alpha, line_alpha)',
- 'Image.fromarray(result, "RGBA").save(out)',
- ].join('\n');
-
- const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
- cwd: repoRoot,
- encoding: 'utf8',
- });
-
- if (result.status !== 0) {
- throw new Error(
- `Failed to create thin white character indicator: ${(result.stderr || result.stdout).trim()}`,
- );
- }
-}
-
-function removeCatGuideChromaKey(sourcePath, finalPath) {
- const script = [
- 'from collections import deque',
- 'from PIL import Image',
- 'import sys',
- 'source, out = sys.argv[1], sys.argv[2]',
- 'im = Image.open(source).convert("RGBA")',
- 'px = im.load()',
- 'w, h = im.size',
- 'corner_samples = [im.getpixel((0, 0)), im.getpixel((w - 1, 0)), im.getpixel((0, h - 1)), im.getpixel((w - 1, h - 1))]',
- 'key = tuple(sorted([p[i] for p in corner_samples])[len(corner_samples) // 2] for i in range(3))',
- 'def is_magenta_bg(r, g, b):',
- ' if r > 170 and b > 145 and g < 185 and min(r, b) - g > 36:',
- ' return True',
- ' return r > 140 and b > 90 and r > g + 35 and b > g + 10',
- 'def is_bg_candidate(x, y):',
- ' r, g, b, a = px[x, y]',
- ' if a <= 10:',
- ' return True',
- ' dist = ((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2) ** 0.5',
- ' if is_magenta_bg(r, g, b):',
- ' return True',
- ' if key[0] < 32 and key[1] < 32 and key[2] < 32:',
- ' return dist < 34 and max(r, g, b) < 55',
- ' if key[0] > 225 and key[1] > 225 and key[2] > 225:',
- ' return dist < 34 and min(r, g, b) > 210',
- ' return dist < 72',
- 'visited = bytearray(w * h)',
- 'queue = deque()',
- 'def push(x, y):',
- ' if x < 0 or y < 0 or x >= w or y >= h:',
- ' return',
- ' index = y * w + x',
- ' if visited[index] or not is_bg_candidate(x, y):',
- ' return',
- ' visited[index] = 1',
- ' queue.append((x, y))',
- 'for x in range(w):',
- ' push(x, 0)',
- ' push(x, h - 1)',
- 'for y in range(h):',
- ' push(0, y)',
- ' push(w - 1, y)',
- 'while queue:',
- ' x, y = queue.popleft()',
- ' push(x + 1, y)',
- ' push(x - 1, y)',
- ' push(x, y + 1)',
- ' push(x, y - 1)',
- 'for _ in range(3):',
- ' extra = []',
- ' for y in range(1, h - 1):',
- ' for x in range(1, w - 1):',
- ' index = y * w + x',
- ' if visited[index] or not is_bg_candidate(x, y):',
- ' continue',
- ' touches_bg = any(visited[(y + dy) * w + x + dx] for dy in (-1, 0, 1) for dx in (-1, 0, 1) if dx or dy)',
- ' if touches_bg:',
- ' extra.append(index)',
- ' if not extra:',
- ' break',
- ' for index in extra:',
- ' visited[index] = 1',
- 'for y in range(h):',
- ' for x in range(w):',
- ' r, g, b, a = px[x, y]',
- ' if visited[y * w + x]:',
- ' px[x, y] = (r, g, b, 0)',
- ' else:',
- ' if a <= 10:',
- ' a = 255',
- ' px[x, y] = (r, g, b, a)',
- 'im.save(out)',
- ].join('\n');
-
- const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
- cwd: repoRoot,
- encoding: 'utf8',
- });
-
- if (result.status !== 0) {
- throw new Error(
- `Failed to clean cat guide transparency: ${(result.stderr || result.stdout).trim()}`,
- );
- }
-}
-
-function normalizeTransparentAsset(finalPath, layoutNormalization) {
- if (!layoutNormalization) {
- return;
- }
-
- const script = [
- 'from PIL import Image',
- 'import sys',
- 'source, out = sys.argv[1], sys.argv[2]',
- 'canvas_w = int(sys.argv[3])',
- 'canvas_h = int(sys.argv[4])',
- 'fit = sys.argv[5]',
- 'fill_w = float(sys.argv[6])',
- 'fill_h = float(sys.argv[7])',
- 'anchor_y = sys.argv[8]',
- 'padding = int(sys.argv[9])',
- 'im = Image.open(source).convert("RGBA")',
- 'alpha = im.getchannel("A").point(lambda a: 255 if a > 8 else 0)',
- 'bbox = alpha.getbbox()',
- 'if bbox is None:',
- ' im.save(out)',
- ' raise SystemExit(0)',
- 'left, top, right, bottom = bbox',
- 'left = max(0, left - padding)',
- 'top = max(0, top - padding)',
- 'right = min(im.width, right + padding)',
- 'bottom = min(im.height, bottom + padding)',
- 'subject = im.crop((left, top, right, bottom))',
- 'target_w = max(1, int(canvas_w * fill_w))',
- 'target_h = max(1, int(canvas_h * fill_h))',
- 'scale_w = target_w / subject.width',
- 'scale_h = target_h / subject.height',
- 'scale = max(scale_w, scale_h) if fit == "cover-width" else min(scale_w, scale_h)',
- 'new_w = max(1, int(subject.width * scale))',
- 'new_h = max(1, int(subject.height * scale))',
- 'subject = subject.resize((new_w, new_h), Image.Resampling.LANCZOS)',
- 'if new_w > canvas_w:',
- ' crop_left = max(0, (new_w - canvas_w) // 2)',
- ' subject = subject.crop((crop_left, 0, crop_left + canvas_w, new_h))',
- ' new_w = canvas_w',
- 'if new_h > canvas_h:',
- ' if anchor_y == "bottom":',
- ' crop_top = new_h - canvas_h',
- ' elif anchor_y == "top":',
- ' crop_top = 0',
- ' else:',
- ' crop_top = max(0, (new_h - canvas_h) // 2)',
- ' subject = subject.crop((0, crop_top, new_w, crop_top + canvas_h))',
- ' new_h = canvas_h',
- 'canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))',
- 'x = (canvas_w - new_w) // 2',
- 'if anchor_y == "bottom":',
- ' y = canvas_h - new_h',
- 'elif anchor_y == "top":',
- ' y = 0',
- 'else:',
- ' y = (canvas_h - new_h) // 2',
- 'canvas.alpha_composite(subject, (x, y))',
- 'canvas.save(out)',
- ].join('\n');
-
- const result = spawnSync(
- 'python',
- [
- '-c',
- script,
- finalPath,
- finalPath,
- String(layoutNormalization.canvasWidth),
- String(layoutNormalization.canvasHeight),
- layoutNormalization.fit || 'contain',
- String(layoutNormalization.fillWidth || 0.92),
- String(layoutNormalization.fillHeight || 0.92),
- layoutNormalization.anchorY || 'center',
- String(layoutNormalization.padding || 0),
- ],
- {
- cwd: repoRoot,
- encoding: 'utf8',
- },
- );
-
- if (result.status !== 0) {
- throw new Error(
- `Failed to normalize transparent asset canvas: ${(result.stderr || result.stdout).trim()}`,
- );
- }
-}
-
-function scrubChromaFringe(finalPath) {
- const script = [
- 'from PIL import Image',
- 'import sys',
- 'path = sys.argv[1]',
- 'im = Image.open(path).convert("RGBA")',
- 'px = im.load()',
- 'w, h = im.size',
- 'for y in range(h):',
- ' for x in range(w):',
- ' r, g, b, a = px[x, y]',
- ' if a == 0:',
- ' continue',
- ' magenta_bias = min(r, b) - g',
- ' is_magenta_edge = r > 135 and b > 135 and magenta_bias > 24 and abs(r - b) < 92',
- ' if is_magenta_edge and a < 90:',
- ' px[x, y] = (r, g, b, 0)',
- ' continue',
- ' if is_magenta_edge:',
- ' neutral = max(g, min(248, int((r + b + g) / 3)))',
- ' r = min(r, neutral + 18)',
- ' b = min(b, neutral + 16)',
- ' g = max(g, min(neutral, 230))',
- ' px[x, y] = (r, g, b, a)',
- 'im.save(path)',
- ].join('\n');
-
- const result = spawnSync('python', ['-c', script, finalPath], {
- cwd: repoRoot,
- encoding: 'utf8',
- });
-
- if (result.status !== 0) {
- throw new Error(
- `Failed to scrub chroma fringe: ${(result.stderr || result.stdout).trim()}`,
- );
- }
-}
-
-function removeCatBodyShoulderDots(sourcePath, finalPath) {
- const script = [
- 'from pathlib import Path',
- 'import cv2',
- 'import numpy as np',
- 'from PIL import Image',
- 'source, out = Path(__import__("sys").argv[1]), Path(__import__("sys").argv[2])',
- 'rgba = np.array(Image.open(source).convert("RGBA"))',
- 'rgb = rgba[:, :, :3].copy()',
- 'alpha = rgba[:, :, 3]',
- 'opaque = alpha > 10',
- 'known = opaque.astype(np.uint8)',
- 'unknown = (1 - known).astype(np.uint8)',
- '_, labels = cv2.distanceTransformWithLabels(unknown, cv2.DIST_L2, 5, labelType=cv2.DIST_LABEL_PIXEL)',
- 'flat_known_indices = np.flatnonzero(known.reshape(-1))',
- 'filled_rgb = rgb.copy().reshape(-1, 3)',
- 'labels_flat = labels.reshape(-1)',
- 'unknown_flat = unknown.reshape(-1).astype(bool)',
- 'if flat_known_indices.size > 0 and unknown_flat.any():',
- ' nearest_known_flat_index = flat_known_indices[np.maximum(labels_flat[unknown_flat] - 1, 0)]',
- ' filled_rgb[unknown_flat] = filled_rgb[nearest_known_flat_index]',
- 'filled_rgb = filled_rgb.reshape(rgb.shape)',
- 'bgr = cv2.cvtColor(filled_rgb, cv2.COLOR_RGB2BGR)',
- 'mask = np.zeros(alpha.shape, dtype=np.uint8)',
- 'cv2.ellipse(mask, (383, 763), (23, 26), 0, 0, 360, 255, -1)',
- 'cv2.ellipse(mask, (648, 762), (23, 26), 0, 0, 360, 255, -1)',
- 'mask = cv2.bitwise_and(mask, opaque.astype(np.uint8) * 255)',
- 'repaired = cv2.inpaint(bgr, mask, 7, cv2.INPAINT_TELEA)',
- 'repaired_rgb = cv2.cvtColor(repaired, cv2.COLOR_BGR2RGB)',
- 'Image.fromarray(np.dstack([repaired_rgb, alpha]), "RGBA").save(out)',
- ].join('\n');
-
- const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
- cwd: repoRoot,
- encoding: 'utf8',
- });
-
- if (result.status !== 0) {
- throw new Error(
- `Failed to remove cat body shoulder dots: ${(result.stderr || result.stdout).trim()}`,
- );
- }
-}
-
-function writeOpaquePng(sourcePath, outputPath) {
- const result = spawnSync(
- 'python',
- [
- '-c',
- [
- 'from PIL import Image',
- 'import sys',
- 'Image.open(sys.argv[1]).convert("RGB").save(sys.argv[2])',
- ].join('; '),
- sourcePath,
- outputPath,
- ],
- {
- cwd: repoRoot,
- encoding: 'utf8',
- },
- );
-
- if (result.status !== 0) {
- throw new Error(
- `Failed to normalize transparent source before chroma key removal: ${(result.stderr || result.stdout).trim()}`,
- );
- }
-}
-
-async function generateAsset(asset, env, size, force) {
- const finalPath = outputPathFor(asset);
- if (!force && existsSync(finalPath)) {
- return {
- id: asset.id,
- ok: true,
- skipped: true,
- file: finalPath,
- };
- }
-
- if (args.has('--postprocess-only')) {
- if (asset.localPostprocess === 'character-outline-white-thin') {
- const sourcePath = sourceOutputPathFor(asset);
- if (!existsSync(sourcePath)) {
- throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
- }
- mkdirSync(assetDir, { recursive: true });
- createWhiteCharacterOutlineIndicator(sourcePath, finalPath);
- return {
- id: asset.id,
- ok: true,
- file: finalPath,
- sourceFile: sourcePath,
- postprocessedOnly: true,
- };
- }
-
- if (asset.localPostprocess === 'character-outline-only') {
- const sourcePath = sourceOutputPathFor(asset);
- if (!existsSync(sourcePath)) {
- throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
- }
- mkdirSync(assetDir, { recursive: true });
- createCharacterOutlineOnlyIndicator(sourcePath, finalPath);
- return {
- id: asset.id,
- ok: true,
- file: finalPath,
- sourceFile: sourcePath,
- postprocessedOnly: true,
- };
- }
-
- if (asset.localPostprocess === 'remove-cat-body-shoulder-dots') {
- const sourcePath = sourceOutputPathFor(asset);
- if (!existsSync(sourcePath)) {
- throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
- }
- mkdirSync(assetDir, { recursive: true });
- removeCatBodyShoulderDots(sourcePath, finalPath);
- return {
- id: asset.id,
- ok: true,
- file: finalPath,
- sourceFile: sourcePath,
- postprocessedOnly: true,
- };
- }
-
- if (!asset.transparent) {
- return {
- id: asset.id,
- ok: true,
- skipped: true,
- file: finalPath,
- };
- }
-
- const sourcePath = sourceOutputPathFor(asset);
- if (!existsSync(sourcePath)) {
- throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
- }
- mkdirSync(assetDir, { recursive: true });
- mkdirSync(intermediateDir, { recursive: true });
- const opaqueSourcePath = opaqueSourceOutputPathFor(asset);
- writeOpaquePng(sourcePath, opaqueSourcePath);
- if (asset.transparencyCleanup === 'soft-panel') {
- removeUiPanelChromaKey(opaqueSourcePath, finalPath);
- } else if (asset.transparencyCleanup === 'character-outline') {
- removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
- } else if (asset.transparencyCleanup === 'cat-guide') {
- removeCatGuideChromaKey(opaqueSourcePath, finalPath);
- } else {
- removeChromaKey(opaqueSourcePath, finalPath);
- }
- normalizeTransparentAsset(finalPath, asset.layoutNormalization);
- scrubChromaFringe(finalPath);
- return {
- id: asset.id,
- ok: true,
- file: finalPath,
- sourceFile: sourcePath,
- postprocessedOnly: true,
- };
- }
-
- if (asset.localPostprocess === 'character-outline-white-thin') {
- const sourcePath = sourceOutputPathFor(asset);
- if (!existsSync(sourcePath)) {
- throw new Error(`Missing source image for local postprocess: ${sourcePath}`);
- }
- mkdirSync(assetDir, { recursive: true });
- createWhiteCharacterOutlineIndicator(sourcePath, finalPath);
- return {
- id: asset.id,
- ok: true,
- file: finalPath,
- sourceFile: sourcePath,
- postprocessedOnly: true,
- };
- }
-
- if (asset.localPostprocess === 'character-outline-only') {
- const sourcePath = sourceOutputPathFor(asset);
- if (!existsSync(sourcePath)) {
- throw new Error(`Missing source image for local postprocess: ${sourcePath}`);
- }
- mkdirSync(assetDir, { recursive: true });
- createCharacterOutlineOnlyIndicator(sourcePath, finalPath);
- return {
- id: asset.id,
- ok: true,
- file: finalPath,
- sourceFile: sourcePath,
- postprocessedOnly: true,
- };
- }
-
- if (asset.localPostprocess === 'remove-cat-body-shoulder-dots') {
- const sourcePath = sourceOutputPathFor(asset);
- if (!existsSync(sourcePath)) {
- throw new Error(`Missing source image for local postprocess: ${sourcePath}`);
- }
- mkdirSync(assetDir, { recursive: true });
- removeCatBodyShoulderDots(sourcePath, finalPath);
- return {
- id: asset.id,
- ok: true,
- file: finalPath,
- sourceFile: sourcePath,
- postprocessedOnly: true,
- };
- }
-
- const { referenceImages = [], ...requestBody } = buildRequestBody(asset, size);
- const hasReferenceImages = referenceImages.length > 0;
- const requestOptions = hasReferenceImages
- ? (() => {
- const formData = new FormData();
- formData.set('model', requestBody.model);
- formData.set('prompt', requestBody.prompt);
- formData.set('n', String(requestBody.n));
- formData.set('size', requestBody.size);
- for (const referenceImage of referenceImages) {
- formData.append(
- 'image',
- new Blob([referenceImage.bytes], { type: referenceImage.mimeType }),
- referenceImage.fileName,
- );
- }
- return {
- url: buildVectorEngineImagesEditUrl(env.baseUrl),
- options: {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${env.apiKey}`,
- Accept: 'application/json',
- },
- body: formData,
- },
- };
- })()
- : {
- url: buildVectorEngineImagesGenerationUrl(env.baseUrl),
- options: {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${env.apiKey}`,
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(requestBody),
- },
- };
- const payloadText = await fetchWithTimeout(
- requestOptions.url,
- requestOptions.options,
- env.timeoutMs,
- );
-
- const payload = JSON.parse(payloadText);
- const urls = extractImageUrls(payload);
- const base64Images = extractBase64Images(payload);
- const imageBytes = urls[0]
- ? await downloadImage(urls[0], env.timeoutMs)
- : base64Images[0]
- ? Buffer.from(base64Images[0], 'base64')
- : null;
-
- if (!imageBytes) {
- throw new Error(`VectorEngine returned no image for ${asset.id}`);
- }
-
- mkdirSync(assetDir, { recursive: true });
- mkdirSync(intermediateDir, { recursive: true });
- const preferredPath = asset.transparent
- ? sourceOutputPathFor(asset)
- : finalPath;
- const { actualExtension, outputPath } = normalizeOutputPath(
- preferredPath,
- imageBytes,
- );
- writeFileSync(outputPath, imageBytes);
-
- if (asset.transparent) {
- const opaqueSourcePath = opaqueSourceOutputPathFor(asset);
- writeOpaquePng(outputPath, opaqueSourcePath);
- if (asset.transparencyCleanup === 'soft-panel') {
- removeUiPanelChromaKey(opaqueSourcePath, finalPath);
- } else if (asset.transparencyCleanup === 'character-outline') {
- removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
- } else if (asset.transparencyCleanup === 'cat-guide') {
- removeCatGuideChromaKey(opaqueSourcePath, finalPath);
- } else {
- removeChromaKey(opaqueSourcePath, finalPath);
- }
- normalizeTransparentAsset(finalPath, asset.layoutNormalization);
- scrubChromaFringe(finalPath);
- }
-
- return {
- id: asset.id,
- ok: true,
- file: asset.transparent ? finalPath : outputPath,
- sourceFile: asset.transparent ? outputPath : undefined,
- size: requestBody.size,
- extension: actualExtension,
- source: urls[0] ? 'url' : 'b64_json',
- usedReferenceImage: hasReferenceImages,
- };
-}
-
-function normalizeSelection(value) {
- if (!value) {
- return [];
- }
- return Array.isArray(value) ? value : [value];
-}
-
-function selectAssets() {
- const selectedIds = new Set([
- ...normalizeSelection(args.get('--asset')),
- ...normalizeSelection(args.get('--only')),
- ]);
- if (selectedIds.size === 0) {
- return assetDefinitions;
- }
- return assetDefinitions.filter((asset) => selectedIds.has(asset.id));
-}
-
-function dryRun(selectedAssets, size) {
- console.log(
- JSON.stringify(
- {
- mode: 'dry-run',
- assets: selectedAssets.map((asset) => {
- const { referenceImages = [], ...body } = buildRequestBody(asset, size);
- return {
- id: asset.id,
- endpoint: referenceImages.length
- ? '/v1/images/edits'
- : '/v1/images/generations',
- outputPath: outputPathFor(asset),
- sourceOutputPath: asset.transparent
- ? sourceOutputPathFor(asset)
- : undefined,
- transparent: asset.transparent,
- localPostprocess: asset.localPostprocess,
- body: referenceImages.length ? undefined : body,
- form: referenceImages.length
- ? {
- ...body,
- imageParts: referenceImages.map(
- (referenceImage) => referenceImage.fileName,
- ),
- }
- : undefined,
- };
- }),
- },
- null,
- 2,
- ),
- );
-}
-
-const selectedAssets = selectAssets();
-const unknownAssetRequested =
- selectedAssets.length === 0 &&
- (args.has('--asset') || args.has('--only'));
-
-if (unknownAssetRequested) {
- console.error(
- JSON.stringify({
- ok: false,
- error: 'No matching child motion demo asset id',
- availableIds: assetDefinitions.map((asset) => asset.id),
- }),
- );
- process.exit(1);
-}
-
-const size = args.has('--size') ? String(args.get('--size')) : undefined;
-if (args.has('--dry-run') || !args.has('--live')) {
- dryRun(selectedAssets, size);
- 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 force = Boolean(args.get('--force'));
-const results = [];
-for (const asset of selectedAssets) {
- results.push(await generateAsset(asset, env, size, force));
-}
-
-console.log(
- JSON.stringify(
- {
- ok: true,
- results,
- },
- null,
- 2,
- ),
-);
diff --git a/scripts/generate-edutainment-tv-map-concepts.mjs b/scripts/generate-edutainment-tv-map-concepts.mjs
deleted file mode 100644
index 6131839d..00000000
--- a/scripts/generate-edutainment-tv-map-concepts.mjs
+++ /dev/null
@@ -1,394 +0,0 @@
-import { Buffer } from 'node:buffer';
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
-import path from 'node:path';
-
-const repoRoot = process.cwd();
-const outDir = path.join(
- repoRoot,
- 'output',
- 'imagegen',
- 'edutainment-tv-map-entry-concepts-20260518',
-);
-const styleReferencePath = path.join(
- repoRoot,
- 'public',
- 'child-motion-demo',
- 'picture-book-grass-stage.png',
-);
-const defaultTimeoutMs = 1000000;
-const commonStyle = [
- '横屏 16:9 电视端寓教于乐板块交互入口概念图。',
- '画面像儿童主题乐园地图,不是现实品牌乐园,不出现迪士尼、环球影城、城堡商标、影视 IP、真实品牌或可识别版权角色。',
- '整体保持 Genarrative 寓教于乐现有明亮卡通绘本插画风:柔和水彩笔触、轻微纸张纹理、温暖草地、浅蓝天空、圆润可爱、低噪声、儿童友好。',
- '地图分为 5 到 6 个清晰区域,每个区域像可点击玩法入口:宝贝识物、宝贝爱画、动作热身、拼图启蒙、声音节奏、自然探索;用图形和场景暗示模块,不写文字。',
- '需要有主路径、分叉小路、入口节点、空白安全区和明显的焦点层级,适合后续在网页上叠加按钮、焦点光圈和中文标题。',
- '构图为电视大屏横屏,远看是完整乐园地图,近看每个区域可作为独立交互入口;边缘留出安全裁切,不要把重要入口贴边。',
- '不要出现文字、数字、字母、按钮文案、UI 面板、教程说明、水印、logo、真实照片质感、暗色科技风、过度商业广告感。',
-].join('');
-
-const concepts = [
- {
- id: 'edutainment-tv-map-01-ring-park',
- title: '环形乐园岛',
- prompt: [
- commonStyle,
- '版式方向:俯视略带透视的环形乐园岛,中央是柔软草地广场,外圈有一条蜿蜒小路串联 6 个入口区域。',
- '区域暗示:左侧水果与小篮子区域代表宝贝识物;左下彩色画笔和画纸区域代表宝贝爱画;下方开阔草地圆环代表动作热身;右下拼图积木和图块小屋代表拼图启蒙;右侧小舞台和音符形花朵代表声音节奏;上方小树林和放大镜步道代表自然探索。',
- '每个入口用圆润小建筑、道具和地形分区表现,入口节点尺寸接近,主路清楚,中央保留可叠加推荐焦点的位置。',
- ].join(''),
- },
- {
- id: 'edutainment-tv-map-02-open-book',
- title: '展开绘本地图',
- prompt: [
- commonStyle,
- '版式方向:一本巨大的横向展开绘本变成乐园地图,左右两页自然连接,中缝是一条小河或小路。',
- '左页偏认知与绘画:果园篮子、动物剪影卡片、彩色蜡笔丘陵、画纸小屋;右页偏运动与探索:草地热身舞台、拼图桥、小音符剧场、树林观察台。',
- '入口像从纸页上立起来的立体绘本机关,边缘有轻微纸张纹理和翻页层次,整体仍是干净可交互背景,不要文字。',
- ].join(''),
- },
- {
- id: 'edutainment-tv-map-03-floating-islands',
- title: '云朵空中岛',
- prompt: [
- commonStyle,
- '版式方向:浅蓝天空中的多个漂浮小岛,岛与岛之间由彩虹桥、云朵步道和藤蔓小路连接,横向展开适合电视端选择入口。',
- '每个小岛是一个玩法模块入口:水果识物岛、画笔创作岛、草地运动岛、拼图机械小岛、声音花园岛、自然观察岛。',
- '中央主岛最大,左右分布保持平衡,背景云层干净明亮,入口岛轮廓清晰,适合后续做焦点放大和悬停动效。',
- ].join(''),
- },
- {
- id: 'edutainment-tv-map-04-stage-garden',
- title: '草地舞台地图',
- prompt: [
- commonStyle,
- '版式方向:把现有寓教于乐草地舞台扩展成横屏互动乐园,前景是开阔草地,远景是小山、树木和柔和天空。',
- '入口沿一条 S 形小路从左到右铺开:篮子果园、画画帐篷、动作圆环舞台、拼图桥、声音小剧场、探索小树林。',
- '整体更接近实际运行态背景,可直接想象成电视端页面首屏;中心下方需要留空,给遥控器焦点框、入口标题或儿童角色站位使用。',
- ].join(''),
- },
-];
-
-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 generationUrl(baseUrl) {
- return baseUrl.endsWith('/v1')
- ? `${baseUrl}/images/generations`
- : `${baseUrl}/v1/images/generations`;
-}
-
-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 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';
-}
-
-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/jpeg') {
- return 'jpg';
- }
- return 'png';
-}
-
-function toDataUrl(filePath) {
- if (!existsSync(filePath)) {
- return null;
- }
- const bytes = readFileSync(filePath);
- const extension = inferExtensionFromBytes(bytes);
- const mime = extension === 'jpg' ? 'image/jpeg' : `image/${extension}`;
- return `data:${mime};base64,${bytes.toString('base64')}`;
-}
-
-function buildRequestBody(concept, size) {
- const body = {
- model: 'gpt-image-2-all',
- prompt: concept.prompt,
- n: 1,
- size,
- };
- const styleReference = toDataUrl(styleReferencePath);
- if (styleReference) {
- body.image = [styleReference];
- }
- return body;
-}
-
-function buildDryRunRequestBody(concept, size, hasStyleReference) {
- return {
- model: 'gpt-image-2-all',
- prompt: concept.prompt,
- n: 1,
- size,
- imageReferenceCount: hasStyleReference ? 1 : 0,
- };
-}
-
-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 {
- bytes: Buffer.from(await response.arrayBuffer()),
- extension: inferExtensionFromContentType(
- response.headers.get('content-type') || '',
- ),
- };
- } 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, concept, size) {
- const payload = await fetchJson(
- generationUrl(env.baseUrl),
- {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${env.apiKey}`,
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(buildRequestBody(concept, size)),
- },
- env.timeoutMs,
- );
-
- const urls = [];
- const b64Images = [];
- collectStringsByKey(payload, 'url', urls);
- collectStringsByKey(payload, 'image', urls);
- collectStringsByKey(payload, 'image_url', urls);
- collectStringsByKey(payload, 'b64_json', b64Images);
-
- let image;
- const imageUrl = [...new Set(urls)].find((url) => /^https?:\/\//u.test(url));
- if (imageUrl) {
- image = await downloadUrl(imageUrl, 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 ${concept.id}`);
- }
-
- mkdirSync(outDir, { recursive: true });
- const outputPath = path.join(outDir, `${concept.id}.${image.extension}`);
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-const args = new Map();
-for (let index = 2; index < process.argv.length; index += 1) {
- const raw = process.argv[index];
- if (raw.startsWith('--')) {
- const next = process.argv[index + 1];
- if (next && !next.startsWith('--')) {
- args.set(raw, next);
- index += 1;
- } else {
- args.set(raw, true);
- }
- }
-}
-
-const size = String(args.get('--size') || '2048x1152');
-const dryRun = args.has('--dry-run') || !args.has('--live');
-const selectedIds = String(args.get('--only') || '')
- .split(',')
- .map((value) => value.trim())
- .filter(Boolean);
-const selectedConcepts = concepts.filter(
- (concept) => selectedIds.length === 0 || selectedIds.includes(concept.id),
-);
-
-if (dryRun) {
- console.log(
- JSON.stringify(
- {
- mode: 'dry-run',
- outDir,
- size,
- hasStyleReference: existsSync(styleReferencePath),
- count: selectedConcepts.length,
- requests: selectedConcepts.map((concept) => ({
- id: concept.id,
- title: concept.title,
- body: buildDryRunRequestBody(
- concept,
- size,
- existsSync(styleReferencePath),
- ),
- })),
- },
- 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 files = [];
-for (const concept of selectedConcepts) {
- console.log(`Generating ${concept.id}...`);
- files.push(await generateOne(env, concept, size));
-}
-
-writeFileSync(
- path.join(outDir, 'generation-metadata.json'),
- JSON.stringify(
- {
- model: 'gpt-image-2-all',
- size,
- generatedAt: new Date().toISOString(),
- styleReference: existsSync(styleReferencePath)
- ? styleReferencePath
- : null,
- files: selectedConcepts.map((concept, index) => ({
- id: concept.id,
- title: concept.title,
- file: files[index],
- prompt: concept.prompt,
- })),
- },
- null,
- 2,
- ),
-);
-
-console.log(JSON.stringify({ ok: true, count: files.length, files }, null, 2));
diff --git a/scripts/generate-match3d-style-references.mjs b/scripts/generate-match3d-style-references.mjs
deleted file mode 100644
index fe5ad729..00000000
--- a/scripts/generate-match3d-style-references.mjs
+++ /dev/null
@@ -1,327 +0,0 @@
-import { Buffer } from 'node:buffer';
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
-import path from 'node:path';
-import { fileURLToPath } from 'node:url';
-
-const scriptDir = path.dirname(fileURLToPath(import.meta.url));
-const repoRoot = path.resolve(scriptDir, '..');
-const defaultOutDir = path.join(repoRoot, 'public', 'match3d-style-references');
-const defaultTimeoutMs = 1000000;
-
-const styleTemplates = [
- {
- id: 'flat-icon',
- title: '扁平图标',
- prompt:
- '扁平矢量游戏道具图标风格,干净色块,正面视角,深色清晰轮廓,移动端休闲游戏素材,可读性很强。',
- },
- {
- id: 'cel-cartoon',
- title: '赛璐璐卡通',
- prompt:
- '赛璐璐卡通游戏道具风格,明亮配色,清晰线稿,硬边阴影,边缘干净,像轻松休闲手游里的 2D 素材。',
- },
- {
- id: 'pixel-retro',
- title: '像素复古',
- prompt:
- '真正复古像素游戏道具 sprite 风格,先以约 64x64 低分辨率像素块绘制再按整数倍放大,硬边方块像素清晰可见,有限色板 12-24 色,主体轮廓稳定,禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。',
- },
- {
- id: 'watercolor',
- title: '手绘水彩',
- prompt:
- '手绘水彩游戏道具风格,柔和纸张纹理,透明叠色,边缘轻微晕染,但主体剪影仍然清楚。',
- },
- {
- id: 'sticker-outline',
- title: '贴纸描边',
- prompt:
- '贴纸描边游戏道具素材风格,粗白边,深色外轮廓,柔和投影,色彩活泼,适合休闲消除游戏。',
- },
- {
- id: 'painterly-icon',
- title: '厚涂图标',
- prompt:
- '厚涂游戏道具图标风格,细腻笔触,明确体积光影,中心构图,清晰剪影,适合高品质 2D 道具素材。',
- },
-];
-
-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 buildVectorEngineImagesGenerationUrl(baseUrl) {
- return baseUrl.endsWith('/v1')
- ? `${baseUrl}/images/generations`
- : `${baseUrl}/v1/images/generations`;
-}
-
-// 中文注释:入口缩略图只用于比较画风,必须展示单个代表道具,避免误导为一组待切割物品。
-function buildPrompt(template) {
- return [
- '请生成一张 1:1 方形抓大鹅入口 2D 素材风格参考图。',
- '画面只允许出现 1 个完整独立的游戏道具主体,题材固定为一颗红苹果,不要出现第二个物品。',
- `整体风格:${template.prompt}`,
- '要求:这个道具是独立 2D 素材示例,主体集中,轮廓清晰,适合作为抓大鹅局内物品素材。',
- '构图:浅色干净背景,单物体居中放大,四周留少量呼吸感,不要九宫格边框,不要 UI 面板,不要按钮。',
- '避免:多个物品、5 个物品、物品组合、重复视角、散点排列、文字、水印、logo、教程标注、真实照片、复杂场景、人物、动物、3D 模型视口、明显透视地面、厚重阴影。',
- ].join('');
-}
-
-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;
-}
-
-async function fetchWithTimeout(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 text;
- } catch (error) {
- if (error?.name === 'AbortError') {
- throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
- }
- throw error;
- } finally {
- clearTimeout(timer);
- }
-}
-
-async function downloadImage(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 generateOne(env, template, outDir, size) {
- const requestBody = {
- model: 'gpt-image-2',
- prompt: buildPrompt(template),
- n: 1,
- size,
- };
- const payloadText = await fetchWithTimeout(
- buildVectorEngineImagesGenerationUrl(env.baseUrl),
- {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${env.apiKey}`,
- Accept: 'application/json',
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(requestBody),
- },
- env.timeoutMs,
- );
-
- const payload = JSON.parse(payloadText);
- const urls = extractImageUrls(payload);
- const base64Images = extractBase64Images(payload);
- const imageBytes = urls[0]
- ? await downloadImage(urls[0], env.timeoutMs)
- : base64Images[0]
- ? Buffer.from(base64Images[0], 'base64')
- : null;
-
- if (!imageBytes) {
- throw new Error(`VectorEngine returned no image for ${template.id}`);
- }
-
- mkdirSync(outDir, { recursive: true });
- const outPath = path.join(outDir, `${template.id}.png`);
- writeFileSync(outPath, imageBytes);
- return {
- file: outPath,
- source: urls[0] ? 'url' : 'b64_json',
- };
-}
-
-const dryRun = args.has('--dry-run') || !args.has('--live');
-const outDir = path.resolve(String(args.get('--out-dir') || defaultOutDir));
-const size = String(args.get('--size') || '1024x1024');
-const onlyIds = String(args.get('--only') || '')
- .split(',')
- .map((value) => value.trim())
- .filter(Boolean);
-const templates = styleTemplates.filter(
- (template) => !onlyIds.length || onlyIds.includes(template.id),
-);
-
-if (dryRun) {
- console.log(
- JSON.stringify(
- {
- mode: 'dry-run',
- outDir,
- count: templates.length,
- requests: templates.map((template) => ({
- id: template.id,
- title: template.title,
- body: {
- model: 'gpt-image-2',
- prompt: buildPrompt(template),
- n: 1,
- size,
- },
- })),
- },
- 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 template of templates) {
- console.log(`Generating ${template.id}...`);
- generated.push({
- id: template.id,
- ...(await generateOne(env, template, outDir, size)),
- });
-}
-
-console.log(
- JSON.stringify(
- {
- ok: true,
- count: generated.length,
- files: generated,
- },
- null,
- 2,
- ),
-);
diff --git a/scripts/generate-spacetime-bindings.mjs b/scripts/generate-spacetime-bindings.mjs
deleted file mode 100644
index 6aaf5cef..00000000
--- a/scripts/generate-spacetime-bindings.mjs
+++ /dev/null
@@ -1,321 +0,0 @@
-import {spawn} from 'node:child_process';
-import {existsSync} from 'node:fs';
-import {cp, mkdir, readdir, rm, stat} from 'node:fs/promises';
-import path from 'node:path';
-import {fileURLToPath} from 'node:url';
-
-const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
-const REPO_ROOT = path.resolve(SCRIPT_DIR, '..');
-const MODULE_PATH = path.join(REPO_ROOT, 'server-rs', 'crates', 'spacetime-module');
-
-const TARGETS = [
- {
- name: 'Rust',
- lang: 'rust',
- tempName: 'rs',
- outDir: path.join(
- REPO_ROOT,
- 'server-rs',
- 'crates',
- 'spacetime-client',
- 'src',
- 'module_bindings',
- ),
- entryFile: path.join(
- REPO_ROOT,
- 'server-rs',
- 'crates',
- 'spacetime-client',
- 'src',
- 'module_bindings.rs',
- ),
- },
-];
-
-const args = new Set(process.argv.slice(2));
-const KNOWN_ARGS = new Set(['--rust-only']);
-
-for (const arg of args) {
- if (!KNOWN_ARGS.has(arg)) {
- console.error(`[spacetime:generate] 未知参数: ${arg}`);
- process.exit(1);
- }
-}
-
-if (!existsSync(path.join(MODULE_PATH, 'Cargo.toml'))) {
- console.error(`[spacetime:generate] 未找到模块: ${MODULE_PATH}`);
- process.exit(1);
-}
-
-const tempRoot = resolveTempRoot();
-assertSafeTempRoot(tempRoot);
-const selectedTargets = TARGETS.filter((target) => shouldRunTarget(target.lang));
-
-if (selectedTargets.length === 0) {
- console.error('[spacetime:generate] 没有需要生成的目标。');
- process.exit(1);
-}
-
-await mkdir(tempRoot, {recursive: true});
-
-for (const target of selectedTargets) {
- const tempOutDir = path.join(tempRoot, target.tempName);
- await recreateTempDir(tempOutDir);
-
- console.log(`[spacetime:generate] 生成 ${target.name} bindings 到短路径: ${tempOutDir}`);
- await generateBindings(target, tempOutDir);
-
- const fileCount = await countFiles(tempOutDir);
- if (fileCount === 0) {
- throw new Error(`${target.name} bindings 未生成任何文件。`);
- }
-
- console.log(`[spacetime:generate] 同步 ${fileCount} 个文件到 ${target.outDir}`);
- await replaceGeneratedDir(tempOutDir, target.outDir);
- await moveGeneratedEntryFile(target);
-}
-
-await rm(tempRoot, {recursive: true, force: true});
-console.log('[spacetime:generate] bindings 生成完成。');
-
-function shouldRunTarget(lang) {
- if (args.has('--rust-only')) {
- return lang === 'rust';
- }
-
- return true;
-}
-
-function resolveTempRoot() {
- if (process.env.GENARRATIVE_BINDGEN_TEMP_ROOT) {
- return path.resolve(process.env.GENARRATIVE_BINDGEN_TEMP_ROOT);
- }
-
- // Windows 下 SpacetimeDB CLI 2.1.0 会把所有生成文件路径一次性传给 formatter;
- // Rust bindings 文件数较多,输出到仓库深目录时容易触发 CreateProcess 路径总长限制。
- if (process.platform === 'win32') {
- return path.join(path.parse(REPO_ROOT).root, '.genarrative-bindgen');
- }
-
- return path.join(REPO_ROOT, 'tmp', 'spacetime-bindgen');
-}
-
-async function recreateTempDir(dir) {
- assertInside(dir, tempRoot, '临时生成目录');
- await rm(dir, {recursive: true, force: true});
- await mkdir(dir, {recursive: true});
-}
-
-async function replaceGeneratedDir(fromDir, toDir) {
- assertInside(toDir, REPO_ROOT, '仓库生成目录');
- await rm(toDir, {recursive: true, force: true});
- await mkdir(toDir, {recursive: true});
- const entries = await readdir(fromDir, {withFileTypes: true});
-
- for (const entry of entries) {
- await cp(path.join(fromDir, entry.name), path.join(toDir, entry.name), {
- recursive: true,
- force: true,
- });
- }
-}
-
-async function moveGeneratedEntryFile(target) {
- if (!target.entryFile) {
- return;
- }
-
- assertInside(target.entryFile, REPO_ROOT, '生成入口文件');
- const generatedModFile = path.join(target.outDir, 'mod.rs');
-
- if (!existsSync(generatedModFile)) {
- throw new Error(`${target.name} bindings 缺少入口文件: ${generatedModFile}`);
- }
-
- await rm(target.entryFile, {force: true});
- await cp(generatedModFile, target.entryFile, {force: true});
- await rm(generatedModFile, {force: true});
-}
-
-function assertInside(candidate, parent, label) {
- const relative = path.relative(path.resolve(parent), path.resolve(candidate));
- if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
- throw new Error(`${label} 不在预期目录内: ${candidate}`);
- }
-}
-
-function assertSafeTempRoot(dir) {
- const resolved = path.resolve(dir);
- const parsed = path.parse(resolved);
- const basename = path.basename(resolved).toLowerCase();
-
- if (resolved === path.resolve(REPO_ROOT) || resolved === parsed.root) {
- throw new Error(`临时根目录不允许指向仓库或磁盘根目录: ${resolved}`);
- }
-
- if (!basename.includes('bindgen')) {
- throw new Error(`临时根目录必须是明确的 bindings 生成目录: ${resolved}`);
- }
-}
-
-function buildGenerateArgs(target, outDir) {
- const generateArgs = [
- 'generate',
- '--no-config',
- '--lang',
- target.lang,
- '--out-dir',
- outDir,
- '--module-path',
- MODULE_PATH,
- '--include-private',
- '--yes',
- ];
-
- return generateArgs;
-}
-
-async function generateBindings(target, outDir) {
- const result = await run('spacetime', buildGenerateArgs(target, outDir), {
- allowGeneratedFormatFailure: target.lang === 'rust',
- });
-
- if (result.generatedFormatFailed) {
- // Windows 下 SpacetimeDB CLI 2.1.0 会把所有 Rust 文件一次性传给 formatter;
- // 这里只接管“文件已生成但 CLI 格式化失败”的尾段,并仍然只同步生成目录。
- console.warn(
- `[spacetime:generate] ${target.name} bindings 已生成,但 SpacetimeDB CLI 自带格式化失败;改用短路径分批 rustfmt。`,
- );
- await formatRustBindings(outDir);
- }
-}
-
-async function formatRustBindings(outDir) {
- const rustFiles = await collectRustFiles(outDir);
- if (rustFiles.length === 0) {
- throw new Error(`Rust bindings 未生成任何 .rs 文件,无法格式化: ${outDir}`);
- }
-
- for (const chunk of chunkCommandArgs(rustFiles)) {
- await run('rustfmt', ['--edition', '2024', ...chunk]);
- }
-}
-
-async function collectRustFiles(dir) {
- const files = [];
- const entries = await readdir(dir, {withFileTypes: true});
-
- for (const entry of entries) {
- const entryPath = path.join(dir, entry.name);
-
- if (entry.isDirectory()) {
- files.push(...(await collectRustFiles(entryPath)));
- continue;
- }
-
- if (entry.isFile() && entry.name.endsWith('.rs')) {
- files.push(entryPath);
- }
- }
-
- return files;
-}
-
-function chunkCommandArgs(argsToChunk) {
- // Windows CreateProcess 受命令行长度限制;分批能避免 bindings 文件变多后再次失败。
- const maxCommandLineChars = process.platform === 'win32' ? 20_000 : 100_000;
- const chunks = [];
- let current = [];
- let currentLength = 0;
-
- for (const arg of argsToChunk) {
- const argLength = arg.length + 3;
- if (current.length > 0 && currentLength + argLength > maxCommandLineChars) {
- chunks.push(current);
- current = [];
- currentLength = 0;
- }
-
- current.push(arg);
- currentLength += argLength;
- }
-
- if (current.length > 0) {
- chunks.push(current);
- }
-
- return chunks;
-}
-
-function run(command, commandArgs, options = {}) {
- return new Promise((resolve, reject) => {
- const child = spawn(command, commandArgs, {
- cwd: REPO_ROOT,
- env: process.env,
- shell: false,
- stdio: ['ignore', 'pipe', 'pipe'],
- });
-
- let output = '';
-
- child.stdout.on('data', (chunk) => {
- const text = chunk.toString();
- output += text;
- process.stdout.write(text);
- });
-
- child.stderr.on('data', (chunk) => {
- const text = chunk.toString();
- output += text;
- process.stderr.write(text);
- });
-
- child.on('error', reject);
- child.on('exit', (code, signal) => {
- if (signal) {
- reject(new Error(`${command} 被信号中断: ${signal}`));
- return;
- }
-
- const generatedFormatFailed = output.includes('Could not format generated files');
-
- if (generatedFormatFailed && options.allowGeneratedFormatFailure) {
- console.warn(`[spacetime:generate] ${command} generated files but formatting failed; continuing with validation.`);
- resolve({generatedFormatFailed});
- return;
- }
-
- if (generatedFormatFailed) {
- reject(new Error(`${command} generated files but formatting failed.`));
- return;
- }
-
- if (code === 0) {
- resolve({generatedFormatFailed: false});
- return;
- }
-
- reject(new Error(`${command} 退出码: ${code ?? 'unknown'}`));
- });
- });
-}
-
-async function countFiles(dir) {
- let count = 0;
- const entries = await readdir(dir, {withFileTypes: true});
-
- for (const entry of entries) {
- const entryPath = path.join(dir, entry.name);
-
- if (entry.isDirectory()) {
- count += await countFiles(entryPath);
- continue;
- }
-
- if (entry.isFile() || (await stat(entryPath)).isFile()) {
- count += 1;
- }
- }
-
- return count;
-}
diff --git a/scripts/generate-taonier-abstract-mascot-image2-contact-sheet.py b/scripts/generate-taonier-abstract-mascot-image2-contact-sheet.py
deleted file mode 100644
index 64dbcd62..00000000
--- a/scripts/generate-taonier-abstract-mascot-image2-contact-sheet.py
+++ /dev/null
@@ -1,79 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-abstract-mascot-image2-concepts"
-)
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-abstract-mascot-image2-contact-sheet.png"
-
-ITEMS = [
- ("01 泥灵符号", "taonier-image2-clay-spirit-glyph.png"),
- ("02 捏胚小偶", "taonier-image2-pinched-seed-mascot.png"),
- ("03 软陶图灵", "taonier-image2-soft-totem-creature.png"),
- ("04 口袋泥符", "taonier-image2-clay-pocket-token.png"),
- ("05 作品泥偶", "taonier-image2-work-core-puppet.png"),
- ("06 模团伙伴", "taonier-image2-mold-blob-companion.png"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def main() -> None:
- cell_size = 330
- label_height = 58
- gap = 28
- columns = 3
- rows = 2
- width = columns * cell_size + (columns + 1) * gap
- height = rows * (cell_size + label_height) + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#eee9df")
- draw = ImageDraw.Draw(sheet)
- font = load_font(24)
-
- for index, (label, filename) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_size + label_height + gap)
-
- source_path = OUTPUT_DIR / filename
- if not source_path.exists():
- continue
- source = Image.open(source_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=10,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=font)
-
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-abstract-mascot-image2-logo-concepts.mjs b/scripts/generate-taonier-abstract-mascot-image2-logo-concepts.mjs
deleted file mode 100644
index ba3adb51..00000000
--- a/scripts/generate-taonier-abstract-mascot-image2-logo-concepts.mjs
+++ /dev/null
@@ -1,330 +0,0 @@
-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-abstract-mascot-image2-concepts',
-);
-const defaultTimeoutMs = 420000;
-
-const concepts = [
- {
- id: 'taonier-image2-clay-spirit-glyph',
- title: '泥灵符号',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字 Logo 图标。方向:陶泥人、陶泥手办、抽象角色 / 吉祥物,但不要做人体形象,也不要做完整角色插画。主体像一块被轻轻捏出生命感的陶泥符号:单一圆润剪影,内部一个极简星核或负形孔洞,只有很少的点状生命感。必须是扁平矢量、主流 App icon、简单几何、亲和、醒目、可记忆、小尺寸清晰。配色:奶油白主形、暖陶土橙点缀、深墨背景、少量金色星核。禁止文字、字母、汉字、水印、真实人形、手脚、复杂五官、聊天气泡、播放三角、儿童黏土课、3D、厚阴影、贴纸感。',
- },
- {
- id: 'taonier-image2-pinched-seed-mascot',
- title: '捏胚小偶',
- prompt:
- '为“陶泥儿”设计无文字品牌主标。把“陶泥小人 / 手办 / 吉祥物”的精神压缩成一个非人形的几何泥胚:像一颗被捏过的种子或软陶坯,带一点抽象生命感,但没有手、脚、头发、衣服。中心有一颗极简四角闪光星,表达 AI 把脑洞捏成作品。风格:flat vector mascot mark, modern, friendly, geometric, logo-ready, not illustration。颜色控制在 3 到 4 色:深墨、奶白、陶土橙、暖黄。禁止文字、字母、汉字、真实陶艺工具、儿童玩具、emoji 表情、聊天气泡、播放按钮、复杂装饰、立体渲染。',
- },
- {
- id: 'taonier-image2-soft-totem-creature',
- title: '软陶图灵',
- prompt:
- '为“陶泥儿”设计无文字 Logo。主体是一个抽象陶泥角色图腾,不是人,也不是动物,而是一枚软陶手办被极简化后的品牌符号:上窄下稳、边缘像手捏过,中央有一个圆形作品核和一个小泥点。要有吉祥物的亲和力,但更像成熟平台主标。风格:bold flat vector, iconic silhouette, playful premium, highly memorable, simple enough for favicon。配色:深色背景、奶油白大形、陶土橙作品核、薄荷青或金色小点。禁止中文英文、复杂脸、两只眼睛加嘴的头像感、人体、手、脚、聊天气泡、播放三角、3D 质感。',
- },
- {
- id: 'taonier-image2-clay-pocket-token',
- title: '口袋泥符',
- prompt:
- '为“陶泥儿”设计一个能做 App icon 的无文字主 Logo。方向是“抽象口袋陶泥手办”:它像一个可以被收藏的小软陶 token,但不能是具体人物。主图形由一个圆角几何泥块、一处被捏出的缺口、一枚小星核组成,轮廓要一眼能记住。气质年轻、Q、可爱但不幼稚,适合 AI UGC 轻休闲小游戏平台。扁平矢量感,少色,高对比。禁止文字、字母、水印、真实人脸、手脚、表情包、聊天气泡、播放按钮、过多小元素、3D、照片感。',
- },
- {
- id: 'taonier-image2-work-core-puppet',
- title: '作品泥偶',
- prompt:
- '为“陶泥儿”设计无文字 Logo 图标。把陶泥手办的收藏感、AI 创作的作品核、UGC 玩梗传播的轻松感融合成一个抽象泥偶符号。不要画成人体,只用几何软块和负形孔洞表现“像有生命的陶泥作品”。主体简单、厚实、圆润,中心一枚四角星或小圆核,最多两个辅助泥点。风格:minimal vector mascot logo, clean, premium cute, mainstream consumer app icon。颜色:奶白、陶土橙、深墨、暖黄,可少量青绿。禁止文字、英文、汉字、复杂背景、复杂五官、真实手办、玩具包装、儿童黏土、3D 厚重阴影。',
- },
- {
- id: 'taonier-image2-mold-blob-companion',
- title: '模团伙伴',
- prompt:
- '为“陶泥儿”做一个无文字 Logo 主标。方向:非人形抽象吉祥物,像一团被模具轻轻压出的陶泥伙伴。整体是一个简单几何软形,带一个偏心孔洞和一个小闪光星,让人感觉它能承载用户脑洞、生成小游戏作品。要求主流、亲和、可爱、扁平、矢量、识别强,不能像插画或头像。配色:深墨底、奶油白主体、陶土橙和暖黄点缀,最多 4 色。禁止文字、字母、汉字、水印、手脚、五官表情、聊天气泡、播放三角、复杂碎片、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 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) {
- const requestBody = {
- model: 'gpt-image-2-all',
- quality: String(args.get('--quality') || 'low'),
- prompt: concept.prompt,
- 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 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 (dryRun) {
- console.log(
- JSON.stringify(
- {
- mode: 'dry-run',
- outputDir,
- 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,
- 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));
-}
-
-console.log(
- JSON.stringify(
- {
- ok: true,
- count: generated.length,
- files: generated,
- verifiedFiles: readdirSync(outputDir).sort(),
- },
- null,
- 2,
- ),
-);
diff --git a/scripts/generate-taonier-abstract-mascot-logo-concepts.py b/scripts/generate-taonier-abstract-mascot-logo-concepts.py
deleted file mode 100644
index 40629582..00000000
--- a/scripts/generate-taonier-abstract-mascot-logo-concepts.py
+++ /dev/null
@@ -1,295 +0,0 @@
-from __future__ import annotations
-
-import math
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-abstract-mascot-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-abstract-mascot-contact-sheet.png"
-
-SIZE = 1024
-SCALE = 4
-
-INK = "#121212"
-INK_BLUE = "#101418"
-CREAM = "#fff5df"
-CLAY = "#d77750"
-CLAY_DARK = "#b95f3f"
-GOLD = "#ffd25d"
-MINT = "#31c7a9"
-CORAL = "#ff6a5f"
-
-VARIANTS = [
- ("taonier-abstract-mascot-clay-bean", "陶泥豆偶", "clay_bean"),
- ("taonier-abstract-mascot-mold-baby", "模胚小灵", "mold_baby"),
- ("taonier-abstract-mascot-dot-face", "泥点面偶", "dot_face"),
- ("taonier-abstract-mascot-soft-totem", "软陶图腾", "soft_totem"),
- ("taonier-abstract-mascot-clay-seed", "陶泥种子", "clay_seed"),
- ("taonier-abstract-mascot-work-puppet", "作品泥灵", "work_puppet"),
-]
-
-
-def hex_to_rgb(value: str) -> tuple[int, int, int]:
- value = value.removeprefix("#")
- return tuple(int(value[index : index + 2], 16) for index in (0, 2, 4))
-
-
-def s(value: float) -> int:
- return round(value * SCALE)
-
-
-def circle(draw: ImageDraw.ImageDraw, cx: float, cy: float, radius: float, fill: str) -> None:
- draw.ellipse((s(cx - radius), s(cy - radius), s(cx + radius), s(cy + radius)), fill=hex_to_rgb(fill))
-
-
-def rounded_rect(
- draw: ImageDraw.ImageDraw,
- box: tuple[float, float, float, float],
- radius: float,
- fill: str,
-) -> None:
- draw.rounded_rectangle(tuple(s(value) for value in box), radius=s(radius), fill=hex_to_rgb(fill))
-
-
-def polygon(cx: float, cy: float, radius: float, sides: int, rotation: float) -> list[tuple[int, int]]:
- return [
- (s(cx + math.cos(rotation + math.tau * index / sides) * radius), s(cy + math.sin(rotation + math.tau * index / sides) * radius))
- for index in range(sides)
- ]
-
-
-def star_points(cx: float, cy: float, outer: float, inner: float, count: int = 4) -> list[tuple[int, int]]:
- points = []
- for index in range(count * 2):
- radius = outer if index % 2 == 0 else inner
- angle = -math.pi / 2 + index * math.pi / count
- points.append((s(cx + math.cos(angle) * radius), s(cy + math.sin(angle) * radius)))
- return points
-
-
-def draw_clay_bean(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#101010"))
- rounded_rect(draw, (270, 214, 754, 820), 228, CREAM)
- circle(draw, 650, 330, 112, CLAY)
- circle(draw, 676, 354, 86, CREAM)
- circle(draw, 430, 470, 34, INK)
- circle(draw, 590, 470, 34, INK)
- draw.polygon(star_points(512, 618, 66, 28), fill=hex_to_rgb(GOLD))
- rounded_rect(draw, (360, 742, 664, 790), 24, CLAY)
-
-
-def draw_mold_baby(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb(INK_BLUE))
- draw.polygon(polygon(512, 498, 304, 8, math.pi / 8), fill=hex_to_rgb(CREAM))
- circle(draw, 512, 398, 126, "#101418")
- circle(draw, 512, 398, 62, GOLD)
- circle(draw, 390, 570, 30, "#101418")
- circle(draw, 634, 570, 30, "#101418")
- rounded_rect(draw, (380, 704, 644, 758), 27, MINT)
- rounded_rect(draw, (318, 268, 460, 326), 29, CORAL)
-
-
-def draw_dot_face(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#17110e"))
- rounded_rect(draw, (254, 254, 770, 770), 190, CLAY)
- rounded_rect(draw, (330, 320, 694, 706), 140, CREAM)
- circle(draw, 440, 478, 30, "#17110e")
- circle(draw, 584, 478, 30, "#17110e")
- rounded_rect(draw, (458, 594, 566, 638), 22, CLAY)
- circle(draw, 512, 254, 54, GOLD)
- circle(draw, 512, 254, 24, "#17110e")
-
-
-def draw_soft_totem(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#111111"))
- rounded_rect(draw, (340, 188, 684, 836), 172, CREAM)
- circle(draw, 512, 336, 112, CLAY)
- rounded_rect(draw, (390, 442, 634, 722), 122, "#111111")
- circle(draw, 512, 582, 58, GOLD)
- circle(draw, 432, 332, 24, INK)
- circle(draw, 592, 332, 24, INK)
- rounded_rect(draw, (404, 782, 620, 842), 30, CREAM)
-
-
-def draw_clay_seed(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#101418"))
- draw.pieslice((s(236), s(176), s(788), s(832)), start=218, end=578, fill=hex_to_rgb(CREAM))
- circle(draw, 618, 326, 72, "#101418")
- circle(draw, 618, 326, 34, GOLD)
- circle(draw, 438, 488, 30, "#101418")
- rounded_rect(draw, (506, 548, 632, 594), 23, CLAY)
- draw.polygon(star_points(512, 682, 58, 24), fill=hex_to_rgb(GOLD))
-
-
-def draw_work_puppet(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#101010"))
- rounded_rect(draw, (286, 300, 738, 756), 150, CREAM)
- circle(draw, 286, 530, 88, "#101010")
- circle(draw, 738, 530, 88, "#101010")
- circle(draw, 512, 300, 76, GOLD)
- circle(draw, 430, 474, 24, "#101010")
- circle(draw, 594, 474, 24, "#101010")
- draw.polygon(star_points(512, 604, 68, 28), fill=hex_to_rgb("#101010"))
- draw.polygon(star_points(512, 604, 34, 14), fill=hex_to_rgb(GOLD))
- rounded_rect(draw, (360, 744, 664, 798), 27, CLAY)
-
-
-DRAWERS = {
- "clay_bean": draw_clay_bean,
- "mold_baby": draw_mold_baby,
- "dot_face": draw_dot_face,
- "soft_totem": draw_soft_totem,
- "clay_seed": draw_clay_seed,
- "work_puppet": draw_work_puppet,
-}
-
-
-def render_variant(style: str) -> Image.Image:
- image = Image.new("RGB", (SIZE * SCALE, SIZE * SCALE), hex_to_rgb("#111111"))
- draw = ImageDraw.Draw(image)
- DRAWERS[style](draw)
- return image.resize((SIZE, SIZE), Image.Resampling.LANCZOS)
-
-
-def build_svg(style: str) -> str:
- # PNG 是评审主物料;SVG 保留几何结构,供后续人工矢量微调。
- if style == "clay_bean":
- body = f'''
-
-
-
-
-
-
-
- '''
- elif style == "mold_baby":
- body = f'''
-
-
-
-
-
-
-
- '''
- elif style == "dot_face":
- body = f'''
-
-
-
-
-
-
-
- '''
- elif style == "soft_totem":
- body = f'''
-
-
-
-
-
-
-
- '''
- elif style == "clay_seed":
- body = f'''
-
-
-
-
-
-
- '''
- else:
- body = f'''
-
-
-
-
-
-
-
-
-
- '''
- return f'''
-
-'''
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def build_contact_sheet(previews: list[tuple[str, str, Image.Image]]) -> Image.Image:
- cell_size = 320
- label_height = 60
- gap = 28
- columns = 3
- rows = 2
- width = columns * cell_size + (columns + 1) * gap
- height = rows * (cell_size + label_height) + (rows + 1) * gap
- sheet = Image.new("RGB", (width, height), "#eee9df")
- draw = ImageDraw.Draw(sheet)
- font = load_font(23)
-
- for index, (_, title, preview) in enumerate(previews):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_size + label_height + gap)
- thumbnail = preview.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=10,
- fill="#fffdf8",
- )
- label = f"{index + 1:02d} {title}"
- text_box = draw.textbbox((0, 0), label, font=font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=font)
- return sheet
-
-
-def main() -> None:
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- previews: list[tuple[str, str, Image.Image]] = []
-
- for asset_id, title, style in VARIANTS:
- preview = render_variant(style)
- preview.save(OUTPUT_DIR / f"{asset_id}.png")
- (OUTPUT_DIR / f"{asset_id}.svg").write_text(build_svg(style), encoding="utf-8")
- previews.append((asset_id, title, preview))
-
- contact_sheet = build_contact_sheet(previews)
- contact_sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(
- {
- "ok": True,
- "output_dir": str(OUTPUT_DIR),
- "files": [f"{asset_id}.png" for asset_id, _, _ in VARIANTS]
- + [f"{asset_id}.svg" for asset_id, _, _ in VARIANTS]
- + [CONTACT_SHEET_PATH.name],
- }
- )
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-abstract-mascot-minimal-contact-sheet.py b/scripts/generate-taonier-abstract-mascot-minimal-contact-sheet.py
deleted file mode 100644
index f2f58b48..00000000
--- a/scripts/generate-taonier-abstract-mascot-minimal-contact-sheet.py
+++ /dev/null
@@ -1,77 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-abstract-mascot-minimal-concepts"
-)
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-abstract-mascot-minimal-contact-sheet.png"
-
-ITEMS = [
- ("01 泥芯主标", "taonier-minimal-clay-core.png"),
- ("02 泥标小偶", "taonier-minimal-clay-token.png"),
- ("03 泥种图符", "taonier-minimal-seed-glyph.png"),
- ("04 模胚小芽", "taonier-minimal-mold-bud.png"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def main() -> None:
- cell_size = 330
- label_height = 58
- gap = 28
- columns = 2
- rows = 2
- width = columns * cell_size + (columns + 1) * gap
- height = rows * (cell_size + label_height) + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#eee9df")
- draw = ImageDraw.Draw(sheet)
- font = load_font(24)
-
- for index, (label, filename) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_size + label_height + gap)
-
- source_path = OUTPUT_DIR / filename
- if not source_path.exists():
- continue
- source = Image.open(source_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=10,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=font)
-
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-abstract-mascot-minimal-logo-concepts.mjs b/scripts/generate-taonier-abstract-mascot-minimal-logo-concepts.mjs
deleted file mode 100644
index d0912fb9..00000000
--- a/scripts/generate-taonier-abstract-mascot-minimal-logo-concepts.mjs
+++ /dev/null
@@ -1,318 +0,0 @@
-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-abstract-mascot-minimal-concepts',
-);
-const defaultTimeoutMs = 420000;
-
-const concepts = [
- {
- id: 'taonier-minimal-clay-core',
- title: '泥芯主标',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字 Logo。方向是抽象陶泥角色 / 吉祥物,但不要人形,不要脸,不要手脚。主体是一枚极简陶泥胚,只有一个主轮廓、一个偏心孔或作品核、一个小星点,像会呼吸的陶泥主标。必须扁平、几何、简洁、亲和、主流 App icon 风格。配色:奶油白、陶土橙、深墨底、少量金色。禁止文字、字母、汉字、水印、复杂五官、聊天气泡、播放三角、儿童玩具、3D、厚阴影、背景场景。',
- },
- {
- id: 'taonier-minimal-clay-token',
- title: '泥标小偶',
- prompt:
- '为“陶泥儿”设计无文字品牌 Logo。主体不是人物,而是一枚像被捏出来的软陶 token:圆润、稳定、边缘有手捏感,内部只有一个极简星核或孔洞,不要眼睛鼻子嘴巴。风格:flat vector mascot mark, simple, memorable, logo-ready, cute but mature. 配色限制在 3 色到 4 色:奶白、陶土橙、深墨、暖黄。禁止文字、字母、汉字、表情包、聊天气泡、播放按钮、真实陶艺工具、复杂碎片、3D、照片感。',
- },
- {
- id: 'taonier-minimal-seed-glyph',
- title: '泥种图符',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字 Logo 图标。主题是“抽象陶泥角色”,但造型不使用人体。图形像一颗被轻轻捏过的种子,或者一枚从模具里长出的泥符,轮廓简单,记忆点集中在一个偏心洞和一颗小星核。要求简洁、几何、扁平、可注册、适合 App icon。配色:奶油白主体、暖陶土点缀、深墨背景、少量金黄。禁止文字、字母、汉字、水印、五官、手脚、动物、聊天气泡、播放三角、厚阴影、3D、背景道具。',
- },
- {
- id: 'taonier-minimal-mold-bud',
- title: '模胚小芽',
- prompt:
- '为“陶泥儿”设计无文字 Logo。主体像一枚从模具里鼓起来的陶泥小芽,只有一个主形、一个缺口、一个闪光点,不要人形,不要头像,不要复杂装饰。整体要像能代表 AI 创作、UGC 造物、轻休闲平台的品牌主标。风格:minimal flat mascot logo, clean, playful, premium, scalable. 配色:深墨、奶白、陶土橙、薄荷青或暖黄。禁止文字、字母、汉字、真实脸、聊天气泡、播放键、儿童卡通、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 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) {
- const requestBody = {
- model: 'gpt-image-2-all',
- quality: String(args.get('--quality') || 'low'),
- prompt: concept.prompt,
- 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 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 (dryRun) {
- console.log(
- JSON.stringify(
- {
- mode: 'dry-run',
- outputDir,
- 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,
- 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));
-}
-
-console.log(
- JSON.stringify(
- {
- ok: true,
- count: generated.length,
- files: generated,
- verifiedFiles: readdirSync(outputDir).sort(),
- },
- null,
- 2,
- ),
-);
diff --git a/scripts/generate-taonier-abstract-mascot-v2-logo-concepts.py b/scripts/generate-taonier-abstract-mascot-v2-logo-concepts.py
deleted file mode 100644
index 39584eab..00000000
--- a/scripts/generate-taonier-abstract-mascot-v2-logo-concepts.py
+++ /dev/null
@@ -1,297 +0,0 @@
-from __future__ import annotations
-
-import math
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-abstract-mascot-v2-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-abstract-mascot-v2-contact-sheet.png"
-
-SIZE = 1024
-SCALE = 4
-
-BG = "#101010"
-BG_WARM = "#17110e"
-BG_BLUE = "#101418"
-INK = "#111111"
-CREAM = "#fff3d7"
-CLAY = "#df7650"
-CLAY_DARK = "#bd5b3d"
-GOLD = "#ffd35f"
-MINT = "#2ec5ad"
-CORAL = "#ff6b61"
-
-VARIANTS = [
- ("taonier-abstract-mascot-v2-clay-sprite", "陶泥小灵", "clay_sprite"),
- ("taonier-abstract-mascot-v2-pinch-orbit", "捏孔泥偶", "pinch_orbit"),
- ("taonier-abstract-mascot-v2-seed-totem", "星胚图腾", "seed_totem"),
- ("taonier-abstract-mascot-v2-soft-mold", "软模团子", "soft_mold"),
- ("taonier-abstract-mascot-v2-clay-orb", "泥芯圆偶", "clay_orb"),
- ("taonier-abstract-mascot-v2-work-glyph", "作品泥符", "work_glyph"),
-]
-
-
-def hex_to_rgb(value: str) -> tuple[int, int, int]:
- value = value.removeprefix("#")
- return tuple(int(value[index : index + 2], 16) for index in (0, 2, 4))
-
-
-def s(value: float) -> int:
- return round(value * SCALE)
-
-
-def circle(draw: ImageDraw.ImageDraw, cx: float, cy: float, radius: float, fill: str) -> None:
- draw.ellipse((s(cx - radius), s(cy - radius), s(cx + radius), s(cy + radius)), fill=hex_to_rgb(fill))
-
-
-def ellipse(
- draw: ImageDraw.ImageDraw,
- box: tuple[float, float, float, float],
- fill: str,
-) -> None:
- draw.ellipse(tuple(s(value) for value in box), fill=hex_to_rgb(fill))
-
-
-def rounded_rect(
- draw: ImageDraw.ImageDraw,
- box: tuple[float, float, float, float],
- radius: float,
- fill: str,
-) -> None:
- draw.rounded_rectangle(tuple(s(value) for value in box), radius=s(radius), fill=hex_to_rgb(fill))
-
-
-def polygon(cx: float, cy: float, radius: float, sides: int, rotation: float) -> list[tuple[int, int]]:
- return [
- (s(cx + math.cos(rotation + math.tau * index / sides) * radius), s(cy + math.sin(rotation + math.tau * index / sides) * radius))
- for index in range(sides)
- ]
-
-
-def sparkle(cx: float, cy: float, outer: float, inner: float) -> list[tuple[int, int]]:
- points = []
- for index in range(8):
- radius = outer if index % 2 == 0 else inner
- angle = -math.pi / 2 + index * math.pi / 4
- points.append((s(cx + math.cos(angle) * radius), s(cy + math.sin(angle) * radius)))
- return points
-
-
-def draw_clay_sprite(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb(BG))
- rounded_rect(draw, (308, 210, 708, 810), 198, CREAM)
- circle(draw, 672, 302, 82, CLAY)
- circle(draw, 716, 336, 74, BG)
- circle(draw, 402, 458, 34, INK)
- draw.polygon(sparkle(548, 584, 64, 24), fill=hex_to_rgb(GOLD))
- rounded_rect(draw, (362, 742, 660, 792), 25, CLAY)
-
-
-def draw_pinch_orbit(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb(BG_BLUE))
- circle(draw, 512, 512, 276, CREAM)
- circle(draw, 724, 394, 94, BG_BLUE)
- circle(draw, 692, 412, 38, GOLD)
- circle(draw, 314, 512, 76, BG_BLUE)
- rounded_rect(draw, (442, 642, 594, 690), 24, CLAY)
- circle(draw, 438, 448, 32, INK)
-
-
-def draw_seed_totem(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb(BG_WARM))
- draw.polygon(polygon(512, 514, 310, 8, math.pi / 8), fill=hex_to_rgb(CREAM))
- circle(draw, 512, 282, 76, GOLD)
- rounded_rect(draw, (376, 356, 648, 726), 136, CLAY)
- circle(draw, 430, 528, 28, BG_WARM)
- circle(draw, 594, 528, 28, BG_WARM)
- draw.polygon(sparkle(512, 634, 58, 22), fill=hex_to_rgb(CREAM))
- rounded_rect(draw, (386, 764, 638, 818), 27, MINT)
-
-
-def draw_soft_mold(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb(BG))
- rounded_rect(draw, (270, 300, 754, 740), 148, CREAM)
- circle(draw, 270, 520, 84, BG)
- circle(draw, 754, 520, 84, BG)
- rounded_rect(draw, (390, 404, 634, 650), 110, CLAY)
- circle(draw, 512, 526, 62, GOLD)
- circle(draw, 512, 526, 28, BG)
- rounded_rect(draw, (356, 728, 668, 782), 27, CLAY_DARK)
-
-
-def draw_clay_orb(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb(BG_BLUE))
- circle(draw, 512, 512, 274, CREAM)
- circle(draw, 512, 512, 142, BG_BLUE)
- draw.polygon(sparkle(512, 512, 70, 26), fill=hex_to_rgb(GOLD))
- circle(draw, 648, 340, 64, CLAY)
- circle(draw, 666, 360, 40, BG_BLUE)
- circle(draw, 374, 650, 46, MINT)
-
-
-def draw_work_glyph(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb(BG_WARM))
- rounded_rect(draw, (340, 184, 684, 832), 172, CREAM)
- circle(draw, 512, 348, 124, CLAY)
- circle(draw, 512, 348, 56, BG_WARM)
- draw.polygon(sparkle(512, 348, 42, 15), fill=hex_to_rgb(GOLD))
- rounded_rect(draw, (412, 492, 612, 690), 98, BG_WARM)
- circle(draw, 512, 592, 50, GOLD)
- rounded_rect(draw, (404, 764, 620, 824), 30, CREAM)
-
-
-DRAWERS = {
- "clay_sprite": draw_clay_sprite,
- "pinch_orbit": draw_pinch_orbit,
- "seed_totem": draw_seed_totem,
- "soft_mold": draw_soft_mold,
- "clay_orb": draw_clay_orb,
- "work_glyph": draw_work_glyph,
-}
-
-
-def render_variant(style: str) -> Image.Image:
- image = Image.new("RGB", (SIZE * SCALE, SIZE * SCALE), hex_to_rgb(BG))
- draw = ImageDraw.Draw(image)
- DRAWERS[style](draw)
- return image.resize((SIZE, SIZE), Image.Resampling.LANCZOS)
-
-
-def build_svg(style: str) -> str:
- # PNG 用于快速评审;SVG 保留主几何结构,便于后续进入正式矢量设计。
- if style == "clay_sprite":
- body = f'''
-
-
-
-
-
-
- '''
- elif style == "pinch_orbit":
- body = f'''
-
-
-
-
-
-
- '''
- elif style == "seed_totem":
- body = f'''
-
-
-
-
-
-
-
- '''
- elif style == "soft_mold":
- body = f'''
-
-
-
-
-
-
-
- '''
- elif style == "clay_orb":
- body = f'''
-
-
-
-
-
-
- '''
- else:
- body = f'''
-
-
-
-
-
-
-
- '''
- return f'''
-
-'''
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def build_contact_sheet(previews: list[tuple[str, str, Image.Image]]) -> Image.Image:
- cell_size = 320
- label_height = 60
- gap = 28
- columns = 3
- rows = 2
- width = columns * cell_size + (columns + 1) * gap
- height = rows * (cell_size + label_height) + (rows + 1) * gap
- sheet = Image.new("RGB", (width, height), "#eee9df")
- draw = ImageDraw.Draw(sheet)
- font = load_font(23)
-
- for index, (_, title, preview) in enumerate(previews):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_size + label_height + gap)
- thumbnail = preview.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=10,
- fill="#fffdf8",
- )
- label = f"{index + 1:02d} {title}"
- text_box = draw.textbbox((0, 0), label, font=font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=font)
- return sheet
-
-
-def main() -> None:
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- previews: list[tuple[str, str, Image.Image]] = []
-
- for asset_id, title, style in VARIANTS:
- preview = render_variant(style)
- preview.save(OUTPUT_DIR / f"{asset_id}.png")
- (OUTPUT_DIR / f"{asset_id}.svg").write_text(build_svg(style), encoding="utf-8")
- previews.append((asset_id, title, preview))
-
- contact_sheet = build_contact_sheet(previews)
- contact_sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(
- {
- "ok": True,
- "output_dir": str(OUTPUT_DIR),
- "files": [f"{asset_id}.png" for asset_id, _, _ in VARIANTS]
- + [f"{asset_id}.svg" for asset_id, _, _ in VARIANTS]
- + [CONTACT_SHEET_PATH.name],
- }
- )
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-anchor-logo-concepts.py b/scripts/generate-taonier-anchor-logo-concepts.py
deleted file mode 100644
index a744bdfc..00000000
--- a/scripts/generate-taonier-anchor-logo-concepts.py
+++ /dev/null
@@ -1,323 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-from typing import Iterable
-
-from PIL import Image, ImageDraw, ImageFont
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-anchor-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-anchor-contact-sheet.png"
-
-SIZE = 1024
-SCALE = 4
-
-VARIANTS = [
- {
- "id": "taonier-anchor-core",
- "title": "泥点锚标",
- "bg": "#151515",
- "mark": "#ffffff",
- "accent": "#ffffff",
- "style": "core",
- },
- {
- "id": "taonier-anchor-soft-slab",
- "title": "软泥层台",
- "bg": "#111111",
- "mark": "#fffdf4",
- "accent": "#fffdf4",
- "style": "soft_slab",
- },
- {
- "id": "taonier-anchor-work-stack",
- "title": "作品叠层",
- "bg": "#171717",
- "mark": "#ffffff",
- "accent": "#ffffff",
- "style": "work_stack",
- },
- {
- "id": "taonier-anchor-clay-drop",
- "title": "泥点落印",
- "bg": "#151515",
- "mark": "#ffffff",
- "accent": "#f5c95d",
- "style": "clay_drop",
- },
- {
- "id": "taonier-anchor-creation-base",
- "title": "创作底座",
- "bg": "#121212",
- "mark": "#ffffff",
- "accent": "#ffffff",
- "style": "creation_base",
- },
- {
- "id": "taonier-anchor-app-token",
- "title": "泥点应用标",
- "bg": "#101418",
- "mark": "#fffaf0",
- "accent": "#ffd45d",
- "style": "app_token",
- },
-]
-
-
-def hex_to_rgb(value: str) -> tuple[int, int, int]:
- value = value.removeprefix("#")
- return tuple(int(value[index : index + 2], 16) for index in (0, 2, 4))
-
-
-def s(value: float) -> int:
- return round(value * SCALE)
-
-
-def scaled_points(points: Iterable[tuple[float, float]]) -> list[tuple[int, int]]:
- return [(s(x), s(y)) for x, y in points]
-
-
-def quad(
- start: tuple[float, float],
- control: tuple[float, float],
- end: tuple[float, float],
- steps: int = 24,
-) -> list[tuple[float, float]]:
- points: list[tuple[float, float]] = []
- for index in range(steps + 1):
- t = index / steps
- x = (1 - t) * (1 - t) * start[0] + 2 * (1 - t) * t * control[0] + t * t * end[0]
- y = (1 - t) * (1 - t) * start[1] + 2 * (1 - t) * t * control[1] + t * t * end[1]
- points.append((x, y))
- return points
-
-
-def round_line(
- draw: ImageDraw.ImageDraw,
- points: list[tuple[float, float]],
- fill: str,
- width: int,
- closed: bool = False,
-) -> None:
- scaled = scaled_points(points)
- if closed:
- scaled = [*scaled, scaled[0]]
- draw.line(scaled, fill=hex_to_rgb(fill), width=s(width), joint="curve")
- radius = s(width) // 2
- if not closed:
- for x, y in (scaled[0], scaled[-1]):
- draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=hex_to_rgb(fill))
-
-
-def round_circle(draw: ImageDraw.ImageDraw, center: tuple[float, float], radius: float, fill: str) -> None:
- x, y = center
- draw.ellipse((s(x - radius), s(y - radius), s(x + radius), s(y + radius)), fill=hex_to_rgb(fill))
-
-
-def draw_core(draw: ImageDraw.ImageDraw, mark: str, accent: str) -> None:
- round_line(draw, [(512, 326), (512, 514)], mark, 68)
- round_circle(draw, (512, 214), 62, accent)
- round_line(draw, [(244, 548), (512, 430), (780, 548), (512, 674)], mark, 66, closed=True)
- round_line(draw, [(292, 656), (468, 734), (512, 752), (556, 734), (732, 656)], mark, 52)
- round_circle(draw, (337, 548), 17, mark)
-
-
-def draw_soft_slab(draw: ImageDraw.ImageDraw, mark: str, accent: str) -> None:
- round_line(draw, [(512, 316), (512, 518)], mark, 72)
- round_circle(draw, (512, 205), 58, accent)
- top = (
- quad((232, 560), (512, 410), (792, 560), 32)
- + quad((792, 560), (816, 576), (792, 592), 8)[1:]
- + quad((792, 592), (610, 648), (552, 692), 20)[1:]
- + quad((552, 692), (512, 716), (472, 692), 12)[1:]
- + quad((472, 692), (414, 648), (232, 592), 20)[1:]
- + quad((232, 592), (208, 576), (232, 560), 8)[1:]
- )
- round_line(draw, top, mark, 56, closed=True)
- round_line(draw, [(278, 642), (470, 728), (512, 748), (554, 728), (746, 642)], mark, 46)
- round_circle(draw, (342, 554), 15, mark)
-
-
-def draw_work_stack(draw: ImageDraw.ImageDraw, mark: str, accent: str) -> None:
- round_line(draw, [(512, 310), (512, 504)], mark, 64)
- round_circle(draw, (512, 205), 56, accent)
- round_line(draw, [(246, 532), (512, 420), (778, 532), (512, 650)], mark, 58, closed=True)
- round_line(draw, [(286, 628), (472, 710), (512, 728), (552, 710), (738, 628)], mark, 48)
- round_line(draw, [(330, 708), (478, 774), (512, 790), (546, 774), (694, 708)], mark, 38)
- round_circle(draw, (348, 535), 14, mark)
-
-
-def draw_clay_drop(draw: ImageDraw.ImageDraw, mark: str, accent: str) -> None:
- round_line(draw, [(512, 334), (512, 504)], mark, 64)
- round_circle(draw, (512, 204), 62, accent)
- round_line(draw, [(252, 548), (512, 436), (772, 548), (512, 666)], mark, 62, closed=True)
- round_line(draw, [(304, 642), (474, 718), (512, 736), (550, 718), (720, 642)], mark, 50)
- round_circle(draw, (346, 548), 16, mark)
- round_circle(draw, (512, 556), 12, accent)
-
-
-def draw_creation_base(draw: ImageDraw.ImageDraw, mark: str, accent: str) -> None:
- round_line(draw, [(512, 304), (512, 494)], mark, 58)
- round_circle(draw, (512, 202), 55, accent)
- round_line(draw, [(276, 522), (512, 420), (748, 522)], mark, 54)
- round_line(draw, [(236, 586), (512, 708), (788, 586)], mark, 60)
- round_line(draw, [(292, 676), (478, 756), (512, 770), (546, 756), (732, 676)], mark, 42)
- round_circle(draw, (355, 544), 13, mark)
-
-
-def draw_app_token(draw: ImageDraw.ImageDraw, mark: str, accent: str) -> None:
- round_line(draw, [(512, 326), (512, 508)], mark, 70)
- round_circle(draw, (512, 208), 62, accent)
- round_line(draw, [(252, 548), (512, 432), (772, 548), (512, 674)], mark, 66, closed=True)
- round_line(draw, [(296, 656), (470, 732), (512, 752), (554, 732), (728, 656)], mark, 52)
- round_circle(draw, (338, 548), 15, accent)
-
-
-DRAWERS = {
- "core": draw_core,
- "soft_slab": draw_soft_slab,
- "work_stack": draw_work_stack,
- "clay_drop": draw_clay_drop,
- "creation_base": draw_creation_base,
- "app_token": draw_app_token,
-}
-
-
-def build_svg(variant: dict[str, str]) -> str:
- bg = variant["bg"]
- mark = variant["mark"]
- accent = variant["accent"]
- style = variant["style"]
- shared = 'fill="none" stroke-linecap="round" stroke-linejoin="round"'
- dot_fill = accent if style in {"clay_drop", "app_token"} else mark
- left_dot_fill = accent if style == "app_token" else mark
-
- if style == "soft_slab":
- base = f'''
-
-
- '''
- stem = f''
- dot = f''
- elif style == "work_stack":
- base = f'''
-
-
-
- '''
- stem = f''
- dot = f''
- elif style == "clay_drop":
- base = f'''
-
-
-
- '''
- stem = f''
- dot = f''
- elif style == "creation_base":
- base = f'''
-
-
-
- '''
- stem = f''
- dot = f''
- else:
- stroke = 70 if style == "app_token" else 68
- base_width = 66
- layer_width = 52
- base = f'''
-
-
- '''
- stem = f''
- dot = f''
-
- return f'''
-
-'''
-
-
-def render_variant(variant: dict[str, str]) -> Image.Image:
- image = Image.new("RGB", (SIZE * SCALE, SIZE * SCALE), hex_to_rgb(variant["bg"]))
- draw = ImageDraw.Draw(image)
- DRAWERS[variant["style"]](draw, variant["mark"], variant["accent"])
- return image.resize((SIZE, SIZE), Image.Resampling.LANCZOS)
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def build_contact_sheet(previews: list[tuple[dict[str, str], Image.Image]]) -> Image.Image:
- cell_size = 320
- label_height = 60
- gap = 28
- columns = 3
- rows = 2
- width = columns * cell_size + (columns + 1) * gap
- height = rows * (cell_size + label_height) + (rows + 1) * gap
- sheet = Image.new("RGB", (width, height), "#eee9df")
- draw = ImageDraw.Draw(sheet)
- font = load_font(23)
-
- for index, (variant, preview) in enumerate(previews):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_size + label_height + gap)
- thumbnail = preview.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=10,
- fill="#fffdf8",
- )
- label = f"{index + 1:02d} {variant['title']}"
- text_box = draw.textbbox((0, 0), label, font=font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=font)
- return sheet
-
-
-def main() -> None:
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- previews: list[tuple[dict[str, str], Image.Image]] = []
-
- for variant in VARIANTS:
- (OUTPUT_DIR / f"{variant['id']}.svg").write_text(build_svg(variant), encoding="utf-8")
- preview = render_variant(variant)
- preview.save(OUTPUT_DIR / f"{variant['id']}.png")
- previews.append((variant, preview))
-
- contact_sheet = build_contact_sheet(previews)
- contact_sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(
- {
- "ok": True,
- "output_dir": str(OUTPUT_DIR),
- "files": [f"{variant['id']}.svg" for variant in VARIANTS]
- + [f"{variant['id']}.png" for variant in VARIANTS]
- + [CONTACT_SHEET_PATH.name],
- }
- )
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-anti-candy-contact-sheet.py b/scripts/generate-taonier-anti-candy-contact-sheet.py
deleted file mode 100644
index 95c2b889..00000000
--- a/scripts/generate-taonier-anti-candy-contact-sheet.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-anti-candy-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-anti-candy-contact-sheet.png"
-
-ITEMS = [
- ("01 哑光陶泥印章", "taonier-anti-candy-01-matte-clay-stamp"),
- ("02 窑印星核", "taonier-anti-candy-02-kiln-mark-core"),
- ("03 负形星核", "taonier-anti-candy-03-cutout-negative-star"),
- ("04 干陶颗粒", "taonier-anti-candy-04-dry-clay-grain"),
- ("05 手压泥币", "taonier-anti-candy-05-hand-pressed-token"),
- ("06 数字泥符", "taonier-anti-candy-06-digital-clay-glyph"),
- ("07 精品扁平标", "taonier-anti-candy-07-premium-flat-mark"),
- ("08 单色验证版", "taonier-anti-candy-08-monochrome-proof"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- gap = 24
- columns = 4
- rows = 2
- width = columns * cell_size + (columns + 1) * gap
- height = rows * (cell_size + label_height) + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#ebe6dc")
- draw = ImageDraw.Draw(sheet)
- font = load_font(20)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_size + label_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fbfaf6",
- )
- text_box = draw.textbbox((0, 0), label, font=font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-anti-candy-logo-concepts.mjs b/scripts/generate-taonier-anti-candy-logo-concepts.mjs
deleted file mode 100644
index d1dc1e40..00000000
--- a/scripts/generate-taonier-anti-candy-logo-concepts.mjs
+++ /dev/null
@@ -1,420 +0,0 @@
-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-anti-candy-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 basePrompt = [
- '请生成一枚全新的「陶泥儿」产品商标图形标概念稿,但画面中绝对不要出现任何中文字、英文字母、数字、符号字样或品牌字标。',
- '产品定位:精品 AI UGC 轻休闲小游戏创作与传播平台,用户可以像捏陶泥一样把脑洞、梗和灵感塑造成小游戏或趣味内容。',
- '这次必须反糖果化:第一眼必须像哑光陶泥、泥章、窑印、创作印记,绝对不能像糖果、软糖、奶油、饼干、夹心甜点、月饼、巧克力或食品包装。',
- '核心隐喻:脑洞泥印。一块被轻轻捏平的不规则软方圆陶泥印章,中间有一个抽象星核凹印或镂空星形泥印,表达灵感被压印成作品。',
- '风格:扁平矢量商标为主,低高光、低饱和、哑光粉陶质感;轮廓清楚,可后续矢量化,适合商标、App 图标、社区头像。',
- '主色:灰米白、未经上釉的陶土白、陶土褐、深泥灰、少量暗金土黄;颜色必须干燥、克制、低甜度。不使用亮金、糖果黄、奶油黄、粉色、青色、蓝色、紫色、荧光色或彩虹色。',
- '形态:保留不规则软方圆,但不要鼓胀、不要胶状、不要可食用的圆润光泽。边缘可以有少量粗糙泥料纹理、压痕、手捏不均匀。',
- '数字感:只允许 2 到 3 个很小的深泥灰或暗土黄刻点,像生成节点或 UGC 扩散点;不要闪亮星星,不要糖珠。',
- '构图:正方形画布,居中图形标,干净浅灰米白背景,留足安全边距;缩小到 64px 时仍能看清软方圆轮廓和中间星核凹印。',
- '禁止:脸、眼睛、嘴巴、表情、角色身体、吉祥物立绘、儿童黏土玩具感、陶艺工具、手、笔刷、复杂场景、按钮、UI、边框、水印、文字、商标字标、旋涡环形旧稿、三色花瓣旧稿。',
- '强禁止食品感:不要 glossy 高光、不要果冻质感、不要奶油夹心、不要糖霜、不要撒糖粒、不要饼干边、不要巧克力流心、不要金色膨胀星糖。',
-];
-
-const variants = [
- {
- id: '01-matte-clay-stamp',
- title: '哑光陶泥印章',
- prompt: [
- ...basePrompt,
- '本张重点:最克制的陶泥印章。灰米白软方圆主体,中间是压进去的暗陶土星核凹印,只有 2 个微小刻点。几乎无高光。',
- ],
- },
- {
- id: '02-kiln-mark-core',
- title: '窑印星核',
- prompt: [
- ...basePrompt,
- '本张重点:窑印感。中间星核像烧陶后的浅浮雕窑印,用深泥灰边缘和陶土褐阴影表现,不要任何金属或糖果光泽。',
- ],
- },
- {
- id: '03-cutout-negative-star',
- title: '负形星核',
- prompt: [
- ...basePrompt,
- '本张重点:负形。星核用干净的镂空负形或深泥灰内孔表达,主体是单块哑光陶泥,整体更像可注册商标图形。',
- ],
- },
- {
- id: '04-dry-clay-grain',
- title: '干陶颗粒',
- prompt: [
- ...basePrompt,
- '本张重点:干陶质感。加入非常细微的陶土颗粒和粉陶纹理,但保持扁平图标,不要照片写实,不要脏乱。',
- ],
- },
- {
- id: '05-hand-pressed-token',
- title: '手压泥币',
- prompt: [
- ...basePrompt,
- '本张重点:手压泥币。像一枚被手工压平的陶泥代币,边缘不完全对称,中间星核为凹刻符号,但不要出现手或工具。',
- ],
- },
- {
- id: '06-digital-clay-glyph',
- title: '数字泥符',
- prompt: [
- ...basePrompt,
- '本张重点:AI 与 UGC 暗示更强。用 3 个极小方形刻点围绕星核,像生成节点,但必须像刻在陶泥上的小孔。',
- ],
- },
- {
- id: '07-premium-flat-mark',
- title: '精品扁平标',
- prompt: [
- ...basePrompt,
- '本张重点:更互联网精品。减少纹理,强化几何平衡和负形,灰米白主体、深泥灰星核、陶土褐小刻痕,适合 App 图标。',
- ],
- },
- {
- id: '08-monochrome-proof',
- title: '单色验证版',
- prompt: [
- ...basePrompt,
- '本张重点:黑白商标验证。尽量用单色深浅关系表达软方圆和星核凹印,减少装饰,确保黑白化后轮廓仍成立。',
- ],
- },
-];
-
-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-anti-candy-${variant.id}.${image.extension}`);
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(outputDir, 'taonier-logo-anti-candy-manifest.json');
- writeFileSync(
- manifestPath,
- `${JSON.stringify(
- {
- model: 'gpt-image-2-all',
- size: '1024x1024',
- generatedAt: new Date().toISOString(),
- creativeDirection: {
- name: '陶泥儿反糖果化脑洞泥印图形标',
- textPolicy: 'no Chinese, no English, no wordmark',
- palette: '灰米白、陶土白、陶土褐、深泥灰、少量暗金土黄',
- motif: '哑光软方圆陶泥印章 + 星核凹印/负形 + 极少量刻点',
- antiCandyRules:
- 'no glossy highlight, no cream filling, no jelly, no cookie, no chocolate, no candy star',
- },
- 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,
- 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,
- ),
-);
diff --git a/scripts/generate-taonier-braincore-contact-sheet.py b/scripts/generate-taonier-braincore-contact-sheet.py
deleted file mode 100644
index fddbc0d4..00000000
--- a/scripts/generate-taonier-braincore-contact-sheet.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-braincore-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-braincore-contact-sheet.png"
-
-ITEMS = [
- ("01 极简脑洞星核", "taonier-braincore-01-minimal-braincore"),
- ("02 软方圆陶泥印记", "taonier-braincore-02-soft-square-clay-seal"),
- ("03 暖棕星核嵌入", "taonier-braincore-03-warm-brown-embedded-core"),
- ("04 轻微捏痕版本", "taonier-braincore-04-subtle-pinch-marks"),
- ("05 精品几何比例", "taonier-braincore-05-premium-geometric-balance"),
- ("06 柔软陶泥质感", "taonier-braincore-06-soft-clay-texture"),
- ("07 App 图标优先", "taonier-braincore-07-app-icon-ready"),
- ("08 商标黑白提炼", "taonier-braincore-08-trademark-monochrome-ready"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- gap = 24
- columns = 4
- rows = 2
- width = columns * cell_size + (columns + 1) * gap
- height = rows * (cell_size + label_height) + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#eee9df")
- draw = ImageDraw.Draw(sheet)
- font = load_font(20)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_size + label_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-braincore-logo-concepts.mjs b/scripts/generate-taonier-braincore-logo-concepts.mjs
deleted file mode 100644
index 8e6b7d0b..00000000
--- a/scripts/generate-taonier-braincore-logo-concepts.mjs
+++ /dev/null
@@ -1,415 +0,0 @@
-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-braincore-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 basePrompt = [
- '请生成一枚全新的「陶泥儿」产品商标图形标概念稿,但画面中绝对不要出现任何中文字、英文字母、数字、符号字样或品牌字标。',
- '产品定位:精品 AI UGC 轻休闲小游戏创作与传播平台,用户可以像捏陶泥一样把脑洞、梗和灵感塑造成小游戏或趣味内容。',
- '核心隐喻:脑洞星核。一团奶白色、暖棕色调的不规则软方圆陶泥包裹一枚暖金色创意星核,表达灵感被捏造成型。',
- '风格:扁平矢量商标为主,带非常轻微的软陶质感;结构简洁、边缘柔和、轮廓清晰,适合商标、App 图标、社区头像。',
- '主色:奶白、米白、暖棕、陶土棕、少量暖金;整体温暖高级,不使用粉色、青色、蓝色、紫色、荧光色或彩虹色。',
- '数字感:只允许在星核周围出现 2 到 4 个极小暖金星点或像素点,暗示 AI 生成、UGC 传播和轻游戏趣味。',
- '构图:正方形画布,居中图形标,干净浅米白背景,留足安全边距;缩小到 64px 时仍能看清软方圆轮廓和星核。',
- '禁止:脸、眼睛、嘴巴、表情、角色身体、吉祥物立绘、儿童黏土玩具感、陶艺工具、手、笔刷、复杂场景、按钮、UI、边框、水印、文字、商标字标、拟物甜品感。',
- '必须保持全新构图,不参考任何既有陶泥儿 logo 方案,不做旋涡环形旧稿,不做三色花瓣旧稿。',
-];
-
-const variants = [
- {
- id: '01-minimal-braincore',
- title: '极简脑洞星核',
- prompt: [
- ...basePrompt,
- '本张重点:极简。只保留一个奶白不规则软方圆陶泥主体、一个居中的暖金四角星核、2 个极小暖金星点。不要额外装饰。',
- ],
- },
- {
- id: '02-soft-square-clay-seal',
- title: '软方圆陶泥印记',
- prompt: [
- ...basePrompt,
- '本张重点:陶泥印记。主体像被轻轻按压成型的软方圆印章,边缘有自然手捏起伏,但不能像儿童玩具。星核略微偏心。',
- ],
- },
- {
- id: '03-warm-brown-embedded-core',
- title: '暖棕星核嵌入',
- prompt: [
- ...basePrompt,
- '本张重点:嵌入感。用暖棕内凹形或陶土棕阴影承托暖金星核,像灵感被嵌进陶泥里,仍保持扁平商标质感。',
- ],
- },
- {
- id: '04-subtle-pinch-marks',
- title: '轻微捏痕版本',
- prompt: [
- ...basePrompt,
- '本张重点:捏痕。在陶泥主体边缘加入 2 到 3 个极轻微暖棕捏痕或凹口,表现可塑性;捏痕必须抽象、克制、可矢量化。',
- ],
- },
- {
- id: '05-premium-geometric-balance',
- title: '精品几何比例',
- prompt: [
- ...basePrompt,
- '本张重点:精品比例。整体更接近高级互联网 App 图标,几何平衡、负形干净、软方圆轮廓稳定,陶泥质感只保留一点点。',
- ],
- },
- {
- id: '06-soft-clay-texture',
- title: '柔软陶泥质感',
- prompt: [
- ...basePrompt,
- '本张重点:柔软质感。在不破坏扁平矢量感的前提下,加入细腻奶油陶泥的微妙高光和暖棕渐层,不能变成 3D 玩具。',
- ],
- },
- {
- id: '07-app-icon-ready',
- title: 'App 图标优先',
- prompt: [
- ...basePrompt,
- '本张重点:App 图标。图形占画面约 72%,轮廓饱满有记忆点,星核清晰醒目,适合放入圆角方形 App icon。',
- ],
- },
- {
- id: '08-trademark-monochrome-ready',
- title: '商标黑白提炼',
- prompt: [
- ...basePrompt,
- '本张重点:商标注册。优先保证黑白化后仍清楚,减少渐变和细节,用奶白主体、暖棕负形和暖金星核形成强轮廓。',
- ],
- },
-];
-
-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-braincore-${variant.id}.${image.extension}`);
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(outputDir, 'taonier-logo-braincore-manifest.json');
- writeFileSync(
- manifestPath,
- `${JSON.stringify(
- {
- model: 'gpt-image-2-all',
- size: '1024x1024',
- generatedAt: new Date().toISOString(),
- creativeDirection: {
- name: '陶泥儿脑洞星核图形标',
- textPolicy: 'no Chinese, no English, no wordmark',
- palette: '奶白、米白、暖棕、陶土棕、少量暖金',
- motif: '不规则软方圆陶泥团 + 脑洞星核 + 极少量星点',
- },
- variants: variants.map((variant) => ({
- id: variant.id,
- title: variant.title,
- file: files.find((file) => path.basename(file).includes(variant.id))
- ? path.basename(files.find((file) => path.basename(file).includes(variant.id)))
- : 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,
- 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,
- ),
-);
diff --git a/scripts/generate-taonier-capybara-jar-ref01-logo-refine-concepts.mjs b/scripts/generate-taonier-capybara-jar-ref01-logo-refine-concepts.mjs
deleted file mode 100644
index b17dd4bb..00000000
--- a/scripts/generate-taonier-capybara-jar-ref01-logo-refine-concepts.mjs
+++ /dev/null
@@ -1,493 +0,0 @@
-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-capybara-jar-ref01-logo-refine-concepts',
-);
-const referenceImagePath = path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-peeking-head-jar-new-animals-concepts',
- 'taonier-peeking-head-jar-new-animals-01-capybara.png',
-);
-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: '陶泥儿',
- source:
- '基于 peeking-head-jar-new-animals 批次 01 水豚头参考图继续收敛',
- logoType: 'symbol/icon-only mascot mark, no wordmark',
- product:
- 'AI UGC 轻休闲小游戏创作与传播平台,用户把脑洞、梗和灵感塑造成可分享的作品',
- keep: [
- '陶罐容器为主形体',
- '水豚式半圆脑袋只露到眼睛位置',
- '两只纯黑点眼,无高光',
- '小圆耳朵与平静亲和感',
- '中心构图与 32px 可读性',
- ],
- explore: [
- '不同罐子颜色',
- '不同动物头色彩浓度',
- '更扁平、更抽象、更商标化',
- '更强黑白轮廓',
- '减少插画感、渐变感和材质细节',
- ],
- avoid: [
- '中文或英文字',
- '鼻子、嘴巴、腮红、表情高光',
- '罐子表情',
- '星星、闪光、手、陶艺工具',
- '甜点、面包、巧克力、糖果、布丁、餐具感',
- '完整动物身体、爪子、复杂场景',
- '贴纸感、儿童玩具感、写实陶瓷质感',
- ],
-};
-
-const basePrompt = [
- 'Use the uploaded reference image as the composition and pose reference. Keep the same core idea: a ceramic jar container with a capybara-like small animal peeking out only to eye level.',
- 'Create a refined icon-only brand logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.',
- 'Preserve the reference structure: the jar is the main brand shape; the animal shows only small rounded ears, the upper half-dome head, and two matte black dot eyes above the jar rim.',
- 'Do not show nose, mouth, cheek blush, teeth, paws, body, lower face, or a full animal head. The eyes must be pure black dots with no highlights, no white sparkle, and no glossy pupils.',
- 'The jar itself must have no face, no expression, no eyes, no smile, and no decorative emoji marks.',
- 'Make the result more like a trademark and logo than an illustration: simplified silhouette, clean vector curves, broad flat color fields, fewer gradients, fewer soft shadows, no realistic ceramic rendering.',
- 'Keep a warm ceramic-container feeling through color and silhouette only. The jar should not look like a bowl of food, cup, dessert package, pudding cup, bread, bun, chocolate, candy, jelly, or pastry.',
- 'Style target: premium friendly mascot-symbol logo, all-age and women-friendly, calm but memorable, suitable for app icon, social avatar, and trademark review.',
- 'Composition: centered on a clean light background with generous safe area. Strong readable silhouette first. It must still read at 32px and in black and white.',
- 'Avoid star, spark, halo, magic wand, hand, pottery tool, UI, border, sticker outline, background scene, and watermark.',
-];
-
-const variants = [
- {
- id: '01-flat-terracotta',
- title: '扁平陶橙',
- prompt: [
- ...basePrompt,
- 'Variant focus: the most direct logo refinement. Use flat terracotta jar, warm caramel capybara head, minimal rim shadow, almost no gradients. Make the jar silhouette slightly more iconic and compact.',
- ],
- },
- {
- id: '02-cream-cocoa',
- title: '奶白可可',
- prompt: [
- ...basePrompt,
- 'Variant focus: cream ceramic jar with a cocoa-brown capybara head. Keep the palette soft but not edible; use graphic flat fills and a crisp rim shape to avoid dessert feeling.',
- ],
- },
- {
- id: '03-sage-clay',
- title: '鼠尾草陶',
- prompt: [
- ...basePrompt,
- 'Variant focus: muted sage green ceramic jar paired with a warm ochre capybara head. More mature and boutique. Keep the silhouette simple and logo-like, with only two or three main color regions.',
- ],
- },
- {
- id: '04-outline-emblem',
- title: '线面徽记',
- prompt: [
- ...basePrompt,
- 'Variant focus: bolder trademark mark with clean outline plus flat fills. Use a dark warm-brown contour line around the jar and animal, but keep it soft and modern, not sticker-like.',
- ],
- },
- {
- id: '05-abstract-geometric',
- title: '抽象几何',
- prompt: [
- ...basePrompt,
- 'Variant focus: higher abstraction. Reduce the capybara head to a clean half-dome with two round ears and two black dots; reduce the jar to a distinct pot silhouette with a single rim band. Very vector-ready.',
- ],
- },
- {
- id: '06-monochrome-first',
- title: '黑白优先',
- prompt: [
- ...basePrompt,
- 'Variant focus: black-and-white survival first. Design with strong positive and negative shapes; color is secondary. Use warm clay and dark umber, but the mark must remain clear if converted to pure black and white.',
- ],
- },
- {
- id: '07-soft-gradient-logo',
- title: '轻渐变商标',
- prompt: [
- ...basePrompt,
- 'Variant focus: allow only a very subtle premium gradient on broad shapes, like a polished app logo. Keep it much flatter than the reference and remove painterly shadows or texture.',
- ],
- },
- {
- id: '08-bold-avatar',
- title: '头像强识别',
- prompt: [
- ...basePrompt,
- 'Variant focus: compact social-avatar readability. Make the jar a fuller rounded vessel and enlarge the peeking capybara head slightly, while preserving the hidden half-head rhythm and black dot eyes.',
- ],
- },
-];
-
-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 buildVectorEngineImagesEditUrl(baseUrl) {
- return baseUrl.endsWith('/v1')
- ? `${baseUrl}/images/edits`
- : `${baseUrl}/v1/images/edits`;
-}
-
-function buildDryRunFields(variant) {
- return {
- model: 'gpt-image-2',
- prompt: variant.prompt.join('\n'),
- n: '1',
- size: '1024x1024',
- image: referenceImagePath,
- };
-}
-
-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);
- }
-}
-
-function createEditFormData(variant) {
- const form = new FormData();
- const imageBytes = readFileSync(referenceImagePath);
- form.append('model', 'gpt-image-2');
- form.append('prompt', variant.prompt.join('\n'));
- form.append('n', '1');
- form.append('size', '1024x1024');
- form.append(
- 'image',
- new Blob([imageBytes], { type: 'image/png' }),
- path.basename(referenceImagePath),
- );
- return form;
-}
-
-async function generateOne(env, variant) {
- const payload = await fetchJson(
- buildVectorEngineImagesEditUrl(env.baseUrl),
- {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${env.apiKey}`,
- Accept: 'application/json',
- },
- body: createEditFormData(variant),
- },
- 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-capybara-jar-ref01-logo-refine-${variant.id}.${image.extension}`,
- );
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(
- outputDir,
- 'taonier-logo-capybara-jar-ref01-logo-refine-manifest.json',
- );
- writeFileSync(
- manifestPath,
- `${JSON.stringify(
- {
- model: 'gpt-image-2',
- endpoint: '/v1/images/edits',
- size: '1024x1024',
- referenceImage: path.relative(repoRoot, referenceImagePath),
- 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,
- referenceImagePath,
- count: selectedVariants.length,
- brief: logoBrief,
- requests: selectedVariants.map((variant) => ({
- id: variant.id,
- title: variant.title,
- fields: buildDryRunFields(variant),
- })),
- },
- null,
- 2,
- ),
- );
- process.exit(0);
-}
-
-if (!existsSync(referenceImagePath)) {
- console.error(
- JSON.stringify({
- ok: false,
- error: 'Reference image does not exist',
- referenceImagePath,
- }),
- );
- process.exit(1);
-}
-
-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,
- ),
-);
diff --git a/scripts/generate-taonier-capybara-jar-ref01-logo-refine-contact-sheet.py b/scripts/generate-taonier-capybara-jar-ref01-logo-refine-contact-sheet.py
deleted file mode 100644
index 850d3936..00000000
--- a/scripts/generate-taonier-capybara-jar-ref01-logo-refine-contact-sheet.py
+++ /dev/null
@@ -1,144 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-capybara-jar-ref01-logo-refine-concepts"
-)
-CONTACT_SHEET_PATH = (
- OUTPUT_DIR / "taonier-logo-capybara-jar-ref01-logo-refine-contact-sheet.png"
-)
-REFERENCE_IMAGE = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-peeking-head-jar-new-animals-concepts"
- / "taonier-peeking-head-jar-new-animals-01-capybara.png"
-)
-
-ITEMS = [
- ("REF 原01", REFERENCE_IMAGE),
- ("01 扁平陶橙", "taonier-capybara-jar-ref01-logo-refine-01-flat-terracotta"),
- ("02 奶白可可", "taonier-capybara-jar-ref01-logo-refine-02-cream-cocoa"),
- ("03 鼠尾草陶", "taonier-capybara-jar-ref01-logo-refine-03-sage-clay"),
- ("04 线面徽记", "taonier-capybara-jar-ref01-logo-refine-04-outline-emblem"),
- ("05 抽象几何", "taonier-capybara-jar-ref01-logo-refine-05-abstract-geometric"),
- ("06 黑白优先", "taonier-capybara-jar-ref01-logo-refine-06-monochrome-first"),
- ("07 轻渐变商标", "taonier-capybara-jar-ref01-logo-refine-07-soft-gradient-logo"),
- ("08 头像强识别", "taonier-capybara-jar-ref01-logo-refine-08-bold-avatar"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem_or_path: str | Path) -> Path | None:
- if isinstance(stem_or_path, Path):
- return stem_or_path if stem_or_path.exists() else None
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem_or_path}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def normalize_square(image_path: Path) -> Image.Image:
- image = Image.open(image_path).convert("RGB")
- if image.size == (1024, 1024):
- return image
-
- if image.width == image.height:
- normalized = image.resize((1024, 1024), Image.Resampling.LANCZOS)
- else:
- normalized = ImageOps.contain(image, (1024, 1024), Image.Resampling.LANCZOS)
- canvas = Image.new("RGB", (1024, 1024), "#fffdf8")
- x = (1024 - normalized.width) // 2
- y = (1024 - normalized.height) // 2
- canvas.paste(normalized, (x, y))
- normalized = canvas
-
- if image_path.is_relative_to(OUTPUT_DIR):
- normalized.save(image_path, quality=95)
- return normalized
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 268
- label_height = 54
- test_height = 44
- gap = 22
- columns = 3
- rows = 3
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f0ebe5")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(18)
- test_font = load_font(13)
-
- for index, (label, stem_or_path) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem_or_path)
- if image_path is None:
- continue
-
- source = normalize_square(image_path)
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f7f3ed",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 52, test_y + 6))
- sheet.paste(mono, (x + 104, test_y + 6))
- draw.text((x + 150, test_y + 13), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-clay-mascot-contact-sheet.py b/scripts/generate-taonier-clay-mascot-contact-sheet.py
deleted file mode 100644
index 392e67c3..00000000
--- a/scripts/generate-taonier-clay-mascot-contact-sheet.py
+++ /dev/null
@@ -1,71 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-clay-mascot-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-clay-mascot-contact-sheet.png"
-
-ITEMS = [
- ("01 陶泥小人", "taonier-clay-mascot-little-maker.png"),
- ("02 陶泥手办", "taonier-clay-mascot-figurine-token.png"),
- ("03 软陶团子", "taonier-clay-mascot-soft-doll.png"),
- ("04 造物泥偶", "taonier-clay-mascot-creator-totem.png"),
- ("05 陶泥面偶", "taonier-clay-mascot-idol-mask.png"),
- ("06 口袋泥人", "taonier-clay-mascot-pocket-figure.png"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def main() -> None:
- cell_size = 330
- label_height = 58
- gap = 28
- columns = 3
- rows = 2
- width = columns * cell_size + (columns + 1) * gap
- height = rows * (cell_size + label_height) + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#eee9df")
- draw = ImageDraw.Draw(sheet)
- font = load_font(24)
-
- for index, (label, filename) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_size + label_height + gap)
-
- source = Image.open(OUTPUT_DIR / filename).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=10,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=font)
-
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-clay-mascot-logo-concepts.mjs b/scripts/generate-taonier-clay-mascot-logo-concepts.mjs
deleted file mode 100644
index 97476b66..00000000
--- a/scripts/generate-taonier-clay-mascot-logo-concepts.mjs
+++ /dev/null
@@ -1,330 +0,0 @@
-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-clay-mascot-concepts',
-);
-const defaultTimeoutMs = 420000;
-
-const concepts = [
- {
- id: 'taonier-clay-mascot-little-maker',
- title: '陶泥小人',
- prompt:
- '为中文产品“陶泥儿”重新设计一个无文字 Logo 图标。停止此前软泥合拍、旋涡、锚点底座方向,以“陶泥人 / 陶泥手办 / 抽象角色吉祥物”为主线。图形主体是一个被手捏出来的极简陶泥小人:圆头、短身体、短短小手,轮廓像柔软陶泥,但必须压缩成成熟 App 主标,不是完整角色插画。角色胸口或掌心有一颗极简小星点,表达 AI 把脑洞捏成作品。风格:logo-friendly mascot mark, simple silhouette, flat vector feel, friendly, memorable, premium cute, clear at small size。配色使用奶油白、暖陶土、深墨底,可少量暖黄色星点。禁止文字、字母、水印、复杂五官、真实人脸、儿童黏土课、3D 厚重拟物、聊天气泡、播放按钮、手办包装、背景场景。',
- },
- {
- id: 'taonier-clay-mascot-figurine-token',
- title: '陶泥手办',
- prompt:
- '为“陶泥儿”设计无文字 Logo 图标,方向是陶泥手办 / 抽象吉祥物。主体像一枚小型软陶手办的正面主标:圆润头部、简化身体、两只短臂自然张开,底部像一个小底座但不要做雕塑台。它要有手办收藏感和精品感,但仍是极简品牌图标,不是 3D 玩具照片。角色表情只能用非常简洁的点或负形,不要复杂可爱脸。风格:modern mascot logo, flat vector, bold simple shapes, warm, collectible, app icon ready。配色:象牙白主体、深墨背景、暖陶土阴影或小点缀。禁止文字、字母、真实玩具、塑料质感、过多高光、复杂衣服、帽子、聊天气泡、播放键。',
- },
- {
- id: 'taonier-clay-mascot-soft-doll',
- title: '软陶团子',
- prompt:
- '为“陶泥儿”设计无文字 Logo 图标,方向是抽象陶泥角色。主体是一只圆滚滚的软陶团子小人,像一团泥被轻轻捏出头、身体和两只小手,整体剪影非常简单,能一眼记住。它需要有 Q 感和亲和力,但不要像表情包或儿童玩具。中央保留一枚小作品星核或泥点,表达创作生成。风格:minimal clay mascot logo, flat vector style, rounded, cute but mature, clean, scalable。配色:奶白 / 米白主体,暖陶土小阴影,深色或奶油色纯背景,最多 3 色。禁止中文、英文、水印、复杂五官、头发、衣服、真实手指、3D、毛绒、聊天气泡、笑脸贴纸。',
- },
- {
- id: 'taonier-clay-mascot-creator-totem',
- title: '造物泥偶',
- prompt:
- '为“陶泥儿”设计无文字 Logo 图标,方向是陶泥人和品牌图腾之间的抽象角色。主体不是普通人物,而是一个被捏出来的“造物泥偶”:头部圆润,身体像软陶印章,双臂像两处短短捏痕,中间有小星或小孔代表作品核。图形要比吉祥物更符号化,更适合长期主 Logo。风格:abstract mascot brand mark, simple, iconic, flat vector feel, premium, friendly, clear at 32px。配色:深墨背景、奶油白主体、少量暖黄或陶土点缀。禁止真实人、复杂脸、动物、怪物、儿童玩具、厚阴影、3D、文字、字母、水印、UI 元素。',
- },
- {
- id: 'taonier-clay-mascot-idol-mask',
- title: '陶泥面偶',
- prompt:
- '为“陶泥儿”设计无文字 Logo 图标,方向是抽象角色 / 吉祥物主标。主体是一枚圆润陶泥面偶:像小陶泥人的头脸和上半身融合成一个单一徽标,五官极简,只允许两个小点或一条负形捏痕,整体更像品牌符号而不是头像。要有陶泥手工、AI 创意、轻休闲平台的亲和感。风格:flat vector mascot icon, simple face mark, warm, modern, memorable, not childish。配色:暖奶白、陶土橙、深墨,少量金色作品点。禁止文字、字母、水印、复杂表情、emoji、聊天头像、真实陶艺照片、3D、背景场景、动物形象。',
- },
- {
- id: 'taonier-clay-mascot-pocket-figure',
- title: '口袋泥人',
- prompt:
- '为“陶泥儿”设计无文字 Logo 图标,方向是小陶泥人 / 口袋手办 / 抽象吉祥物。主体是一个能放进 App icon 的口袋泥人:小小头、软软身体、两侧短手,整体像被捏出的一枚符号,底部可轻微压扁形成稳定站姿。它应表达“人人都能把脑洞捏成作品”,亲和但不幼稚,适合品牌主标。风格:mascot logo, flat vector, bold silhouette, minimal, cute, premium, high contrast。配色:黑底或深墨底,米白陶泥主体,暖黄色小泥点。禁止文字、字母、水印、复杂五官、衣服配饰、真实手办摄影、玩偶包装、聊天气泡、播放三角、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 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) {
- const requestBody = {
- model: 'gpt-image-2-all',
- quality: String(args.get('--quality') || 'low'),
- prompt: concept.prompt,
- 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 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 (dryRun) {
- console.log(
- JSON.stringify(
- {
- mode: 'dry-run',
- outputDir,
- 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,
- 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));
-}
-
-console.log(
- JSON.stringify(
- {
- ok: true,
- count: generated.length,
- files: generated,
- verifiedFiles: readdirSync(outputDir).sort(),
- },
- null,
- 2,
- ),
-);
diff --git a/scripts/generate-taonier-closed-geo-contact-sheet.py b/scripts/generate-taonier-closed-geo-contact-sheet.py
deleted file mode 100644
index b84958fa..00000000
--- a/scripts/generate-taonier-closed-geo-contact-sheet.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-closed-geo-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-closed-geo-contact-sheet.png"
-
-ITEMS = [
- ("01 有机闭合徽形", "taonier-closed-geo-01-organic-closed-badge"),
- ("02 柔曲陶盾", "taonier-closed-geo-02-smooth-clay-shield"),
- ("03 非对称陶符", "taonier-closed-geo-03-asymmetric-pebble-glyph"),
- ("04 嵌曲陶牌", "taonier-closed-geo-04-inlaid-curve-plate"),
- ("05 扁平矢量符", "taonier-closed-geo-05-flat-vector-symbol"),
- ("06 亲和实体形", "taonier-closed-geo-06-friendly-solid-form"),
- ("07 数字陶泥面", "taonier-closed-geo-07-digital-clay-panel"),
- ("08 商标轮廓款", "taonier-closed-geo-08-trademark-ready-contour"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- gap = 24
- columns = 4
- rows = 2
- width = columns * cell_size + (columns + 1) * gap
- height = rows * (cell_size + label_height) + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#ede8de")
- draw = ImageDraw.Draw(sheet)
- font = load_font(20)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_size + label_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fbfaf6",
- )
- text_box = draw.textbbox((0, 0), label, font=font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-closed-geo-logo-concepts.mjs b/scripts/generate-taonier-closed-geo-logo-concepts.mjs
deleted file mode 100644
index b4e1f55f..00000000
--- a/scripts/generate-taonier-closed-geo-logo-concepts.mjs
+++ /dev/null
@@ -1,419 +0,0 @@
-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-closed-geo-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 basePrompt = [
- '请生成一枚全新的「陶泥儿」产品商标图形标概念稿,但画面中绝对不要出现任何中文字、英文字母、数字、符号字样或品牌字标。',
- '产品定位:精品 AI UGC 轻休闲小游戏创作与传播平台,用户可以像捏陶泥一样把脑洞、梗和灵感塑造成小游戏或趣味内容。',
- '这次的核心要求:底盘必须是由流畅曲线组成的「闭合不规则几何体图案」。它要像一个完整的品牌徽形轮廓,而不是自由飘带、旋涡、S 形、G 形、开放环或散开的泥条。',
- '外轮廓:闭合、连续、平滑、非方形、非圆形、非椭圆、非圆角方块。可以有 5 到 7 个柔和转折点,像自然捏出来的抽象几何石/软陶徽形,但必须干净、可矢量化。',
- '内部结构:可以用 1 到 2 条平滑曲线切分色块或形成负形,但内部结构必须服务整体闭合底盘,不能出现中心星星、中心洞、脸、眼睛、嘴巴或角色感。',
- '识别方向:通过闭合外轮廓和内部曲线分区形成记忆点,不靠星形、不靠文字、不靠食物质感。远看要像互联网产品 logo。',
- '材质与风格:现代扁平矢量商标,极轻微陶泥温度;低高光、低拟物、干净边缘。不要 3D 面包感,不要巧克力、面团、糕点、糖果、饼干或烘焙质感。',
- '配色:暖陶白、浅陶土、陶土橙、暖棕为主体;允许少量低饱和孔雀青、靛蓝灰或深泥灰作为内部曲线色块,占比 6% 到 15%。整体要温暖、有吸引力,但不甜、不像食品。',
- '构图:正方形画布,居中图形标,图形占画面约 68% 到 76%,干净浅色背景,留足安全边距。缩小到 64px 时仍能看出闭合外轮廓和内部曲线分区。',
- '强禁止:自由飘带、开口环、旋涡、S 形、G 形、云朵、面包、巧克力面包、可颂、甜甜圈、糖果、饼干、糕点、奶油、夹心、亮油高光、烘焙纹理、食物摄影感。',
- '强禁止:整体方形底、圆角方块、中心星星、任何星形、闪光、徽章文字、印章文字、脸、表情、手、陶艺工具、笔刷、复杂场景、按钮、UI、边框、水印、文字。',
-];
-
-const variants = [
- {
- id: '01-organic-closed-badge',
- title: '有机闭合徽形',
- prompt: [
- ...basePrompt,
- '本张重点:最基础的闭合不规则几何体。暖陶白主体,陶土橙内部曲线切分,外轮廓像柔和抽象鹅卵石但不是圆形。',
- ],
- },
- {
- id: '02-smooth-clay-shield',
- title: '柔曲陶盾',
- prompt: [
- ...basePrompt,
- '本张重点:更稳定的品牌底盘。外轮廓略像柔化盾形或种子形,但没有尖角;内部一条孔雀青曲线增加识别度。',
- ],
- },
- {
- id: '03-asymmetric-pebble-glyph',
- title: '非对称陶符',
- prompt: [
- ...basePrompt,
- '本张重点:非对称但平衡。闭合外轮廓左右不一样,有 6 个柔和转折点,内部用深泥灰负形曲线形成品牌记忆。',
- ],
- },
- {
- id: '04-inlaid-curve-plate',
- title: '嵌曲陶牌',
- prompt: [
- ...basePrompt,
- '本张重点:内部嵌色曲线。像一块闭合陶牌上嵌入一条平滑色带,色带不能像馅料、巧克力或奶油夹心。',
- ],
- },
- {
- id: '05-flat-vector-symbol',
- title: '扁平矢量符',
- prompt: [
- ...basePrompt,
- '本张重点:最扁平、最商标化。减少材质,只用 2 到 3 个大色块形成闭合不规则几何符号,线条极简。',
- ],
- },
- {
- id: '06-friendly-solid-form',
- title: '亲和实体形',
- prompt: [
- ...basePrompt,
- '本张重点:亲和力。闭合底盘像一个柔软、温和、完整的小世界,但不是角色、不是脸、不是食物。',
- ],
- },
- {
- id: '07-digital-clay-panel',
- title: '数字陶泥面',
- prompt: [
- ...basePrompt,
- '本张重点:AI/UGC 暗示。闭合底盘内有 2 到 3 个很小的几何刻点或短曲线节点,但不能像电路板,也不能像撒糖。',
- ],
- },
- {
- id: '08-trademark-ready-contour',
- title: '商标轮廓款',
- prompt: [
- ...basePrompt,
- '本张重点:可注册轮廓。优先保证黑白化后的闭合外轮廓和内部曲线仍有辨识度,避免渐变和复杂纹理。',
- ],
- },
-];
-
-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-closed-geo-${variant.id}.${image.extension}`);
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(outputDir, 'taonier-logo-closed-geo-manifest.json');
- writeFileSync(
- manifestPath,
- `${JSON.stringify(
- {
- model: 'gpt-image-2-all',
- size: '1024x1024',
- generatedAt: new Date().toISOString(),
- creativeDirection: {
- name: '陶泥儿闭合不规则几何底盘图形标',
- textPolicy: 'no Chinese, no English, no wordmark',
- correction:
- 'closed irregular smooth geometry, not free ribbon, not food, not square base',
- motif: '闭合曲线几何底盘 + 内部曲线分区 + 轻陶泥温度',
- },
- 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,
- 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,
- ),
-);
diff --git a/scripts/generate-taonier-distinctive-contact-sheet.py b/scripts/generate-taonier-distinctive-contact-sheet.py
deleted file mode 100644
index fab620df..00000000
--- a/scripts/generate-taonier-distinctive-contact-sheet.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-distinctive-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-distinctive-contact-sheet.png"
-
-ITEMS = [
- ("01 孔雀青星核", "taonier-distinctive-01-teal-core-pop"),
- ("02 靛蓝切口", "taonier-distinctive-02-indigo-cut-mark"),
- ("03 朱砂陶火", "taonier-distinctive-03-cinnabar-clay-spark"),
- ("04 强轮廓泥符", "taonier-distinctive-04-bold-outline-token"),
- ("05 像素创作种", "taonier-distinctive-05-clay-pixel-seed"),
- ("06 动态软方圆", "taonier-distinctive-06-dynamic-squircle"),
- ("07 应用图标款", "taonier-distinctive-07-app-store-icon"),
- ("08 商标扁平符", "taonier-distinctive-08-trademark-flat-glyph"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- gap = 24
- columns = 4
- rows = 2
- width = columns * cell_size + (columns + 1) * gap
- height = rows * (cell_size + label_height) + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#ede8de")
- draw = ImageDraw.Draw(sheet)
- font = load_font(20)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_size + label_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fbfaf6",
- )
- text_box = draw.textbbox((0, 0), label, font=font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-distinctive-logo-concepts.mjs b/scripts/generate-taonier-distinctive-logo-concepts.mjs
deleted file mode 100644
index 4f2e66ab..00000000
--- a/scripts/generate-taonier-distinctive-logo-concepts.mjs
+++ /dev/null
@@ -1,422 +0,0 @@
-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-distinctive-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 basePrompt = [
- '请生成一枚全新的「陶泥儿」产品商标图形标概念稿,但画面中绝对不要出现任何中文字、英文字母、数字、符号字样或品牌字标。',
- '产品定位:精品 AI UGC 轻休闲小游戏创作与传播平台,用户可以像捏陶泥一样把脑洞、梗和灵感塑造成小游戏或趣味内容。',
- '这次目标是高辨识度和传播吸引力:第一眼要像一个有记忆点的互联网产品 logo,而不是普通泥块、砖块、陶片、印章、饼干或糖果。',
- '核心隐喻:可塑星核。一个独特的「软方圆陶泥外轮廓 + 中心可塑星核」符号,星核像脑洞被捏出的一瞬间,造型要有专属轮廓,不能是普通五角星或普通四角闪光。',
- '风格:现代扁平矢量商标,带轻微哑光陶泥质感;边缘干净、对比清楚、轮廓强,适合 App 图标、社区头像、启动页和商标注册前期筛选。',
- '材质:要能看出陶泥温度,但不要过度粗糙、不要砖、不要土块、不要考古泥片。质感只做轻量表面颗粒和柔和压痕。',
- '配色:以暖陶白或浅陶土色为主体,加入一个高识别点缀色。允许使用低饱和孔雀青、靛蓝灰、朱砂橙、暗金土黄中的一种;点缀色只占 8% 到 18%。整体要更醒目、更年轻,但不要糖果色。',
- '图案:中心星核必须是品牌记忆点,可用负形、嵌色、切口、泥痕、像素刻点形成独特轮廓;周围最多 2 到 3 个小刻点暗示 AI/UGC 扩散。',
- '构图:正方形画布,居中图形标,图形占画面约 70% 到 78%,干净浅色背景,留足安全边距。缩小到 64px 时仍然能认出外轮廓和中心符号。',
- '禁止:脸、眼睛、嘴巴、表情、角色身体、吉祥物立绘、陶艺工具、手、笔刷、复杂场景、按钮、UI、边框、水印、文字、商标字标。',
- '强禁止低辨识:不要普通圆泥饼、不要普通砖块、不要石头、不要考古印章、不要单纯凹星孔、不要灰扑扑单色泥片。',
- '强禁止食品感:不要糖果、软糖、奶油、饼干、夹心甜点、月饼、巧克力、果冻高光、亮金膨胀星糖。',
-];
-
-const variants = [
- {
- id: '01-teal-core-pop',
- title: '孔雀青星核',
- prompt: [
- ...basePrompt,
- '本张重点:孔雀青识别点。暖陶白软方圆主体,中间是孔雀青负形可塑星核,少量陶土褐压痕,整体清爽年轻。',
- ],
- },
- {
- id: '02-indigo-cut-mark',
- title: '靛蓝切口',
- prompt: [
- ...basePrompt,
- '本张重点:靛蓝灰切口。用一条干净的靛蓝灰泥痕切出中心星核,让图形远看有强剪影,不能像旋涡旧稿。',
- ],
- },
- {
- id: '03-cinnabar-clay-spark',
- title: '朱砂陶火',
- prompt: [
- ...basePrompt,
- '本张重点:朱砂橙活力。中心星核或侧边小泥片使用低饱和朱砂橙,像创作火花,但整体保持陶泥材质和精品克制。',
- ],
- },
- {
- id: '04-bold-outline-token',
- title: '强轮廓泥符',
- prompt: [
- ...basePrompt,
- '本张重点:强轮廓。用深泥灰细描边或深色负形强化外轮廓和中心符号,确保黑白化后仍有高辨识度。',
- ],
- },
- {
- id: '05-clay-pixel-seed',
- title: '像素创作种',
- prompt: [
- ...basePrompt,
- '本张重点:AI/UGC 暗示。中心星核周围有 3 个小方形刻点,像生成像素从陶泥里浮现,但不要复杂电路线。',
- ],
- },
- {
- id: '06-dynamic-squircle',
- title: '动态软方圆',
- prompt: [
- ...basePrompt,
- '本张重点:动态轮廓。外形不是静态泥块,而像正在被捏动的软方圆,有一个明显但简洁的非对称记忆点。',
- ],
- },
- {
- id: '07-app-store-icon',
- title: '应用图标款',
- prompt: [
- ...basePrompt,
- '本张重点:App Store 图标。构图饱满、中心符号强、背景干净,视觉冲击比泥章更强,但不出现文字和脸。',
- ],
- },
- {
- id: '08-trademark-flat-glyph',
- title: '商标扁平符',
- prompt: [
- ...basePrompt,
- '本张重点:最终商标潜力。减少材质和阴影,以 2 到 3 个大色块形成独特符号,保留陶泥可塑感和中心可塑星核。',
- ],
- },
-];
-
-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-distinctive-${variant.id}.${image.extension}`);
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(outputDir, 'taonier-logo-distinctive-manifest.json');
- writeFileSync(
- manifestPath,
- `${JSON.stringify(
- {
- model: 'gpt-image-2-all',
- size: '1024x1024',
- generatedAt: new Date().toISOString(),
- creativeDirection: {
- name: '陶泥儿高辨识可塑星核图形标',
- textPolicy: 'no Chinese, no English, no wordmark',
- palette:
- '暖陶白/浅陶土主体 + 8%-18% 孔雀青、靛蓝灰、朱砂橙或暗金土黄点缀',
- motif: '强轮廓软方圆 + 独特可塑星核 + 少量 AI/UGC 刻点',
- correction:
- 'avoid candy, avoid brick, avoid plain mud stamp, increase brand recognition',
- },
- 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,
- 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,
- ),
-);
diff --git a/scripts/generate-taonier-flow-contact-sheet.py b/scripts/generate-taonier-flow-contact-sheet.py
deleted file mode 100644
index 6e4b4215..00000000
--- a/scripts/generate-taonier-flow-contact-sheet.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-flow-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-flow-contact-sheet.png"
-
-ITEMS = [
- ("01 柔泥回环", "taonier-flow-01-soft-ribbon-loop"),
- ("02 陶泥波结", "taonier-flow-02-clay-wave-knot"),
- ("03 脑洞涟漪", "taonier-flow-03-imagination-ripple"),
- ("04 亲和泥流", "taonier-flow-04-friendly-clay-comet"),
- ("05 单笔团块", "taonier-flow-05-single-stroke-blob"),
- ("06 双色软流", "taonier-flow-06-two-tone-soft-flow"),
- ("07 开放泥环", "taonier-flow-07-open-clay-orbit"),
- ("08 品牌曲线符", "taonier-flow-08-brand-flow-glyph"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- gap = 24
- columns = 4
- rows = 2
- width = columns * cell_size + (columns + 1) * gap
- height = rows * (cell_size + label_height) + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#ede8de")
- draw = ImageDraw.Draw(sheet)
- font = load_font(20)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_size + label_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fbfaf6",
- )
- text_box = draw.textbbox((0, 0), label, font=font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-flow-logo-concepts.mjs b/scripts/generate-taonier-flow-logo-concepts.mjs
deleted file mode 100644
index 9de1dbf7..00000000
--- a/scripts/generate-taonier-flow-logo-concepts.mjs
+++ /dev/null
@@ -1,418 +0,0 @@
-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-flow-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 basePrompt = [
- '请生成一枚全新的「陶泥儿」产品商标图形标概念稿,但画面中绝对不要出现任何中文字、英文字母、数字、符号字样或品牌字标。',
- '产品定位:精品 AI UGC 轻休闲小游戏创作与传播平台,用户可以像捏陶泥一样把脑洞、梗和灵感塑造成小游戏或趣味内容。',
- '这次必须彻底放弃方形底和中心星星:不要整体方形、不要圆角方块、不要 App 图标底板、不要中间星形、不要五角星、不要四角星、不要闪光、不要中心挖孔星。',
- '核心隐喻:连续的陶泥曲线。用一整块顺滑、亲和、可塑的陶泥大结构来表达「脑洞被揉开、灵感流动、内容成型」,图形要像一个完整的流动符号,而不是底板加中心图案。',
- '主结构:由平滑曲线组成的大轮廓,可以像柔软泥流、脑洞涟漪、捏合的回环、温和的旋拧、单线团块或开放结。整体必须连贯、圆润、有亲和力,不能分裂成多个碎片。',
- '风格:现代扁平矢量商标,轻微哑光陶泥质感;边缘干净、曲线饱满、负形明确,适合商标、App 图标、社区头像和启动页。',
- '配色:暖陶白、浅陶土、陶土橙、暖棕为主,可加入少量低饱和孔雀青或深泥灰作为曲线内侧阴影或小连接点。颜色要温暖、有吸引力,但不要糖果色。',
- '识别度:远看必须能记住一个独特的大曲线轮廓;不要靠中心图案识别。缩小到 64px 时仍能看出整体曲线姿态。',
- '亲和力:整体像可以被揉捏的柔软小世界,有陪伴感和创作感,但不要做成脸、表情、角色或吉祥物。',
- '禁止:方形底、圆角方块、中心星星、任何星形、闪光、徽章、印章、砖块、泥饼、石头、陶片、饼干、糖果、手、陶艺工具、笔刷、复杂场景、按钮、UI、边框、水印、文字。',
-];
-
-const variants = [
- {
- id: '01-soft-ribbon-loop',
- title: '柔泥回环',
- prompt: [
- ...basePrompt,
- '本张重点:一条宽厚柔软的陶泥带形成开放回环,像脑洞被揉开。轮廓连续,内部负形自然,不出现星形或方形底。',
- ],
- },
- {
- id: '02-clay-wave-knot',
- title: '陶泥波结',
- prompt: [
- ...basePrompt,
- '本张重点:像一个温和波浪结,由 2 条互相捏合的平滑曲线组成,但必须视觉上像一个整体符号,不要碎片化。',
- ],
- },
- {
- id: '03-imagination-ripple',
- title: '脑洞涟漪',
- prompt: [
- ...basePrompt,
- '本张重点:用一整块陶泥曲面形成涟漪状大轮廓,中间负形像柔和水滴或脑洞入口,不能像星星。',
- ],
- },
- {
- id: '04-friendly-clay-comet',
- title: '亲和泥流',
- prompt: [
- ...basePrompt,
- '本张重点:亲和泥流。整体像一个流动的陶泥小世界,前端圆润、尾部自然收束,有动势但不尖锐。',
- ],
- },
- {
- id: '05-single-stroke-blob',
- title: '单笔团块',
- prompt: [
- ...basePrompt,
- '本张重点:单笔成型。像用一笔连续曲线捏出的陶泥团,结构极简但有记忆点,适合后续矢量化。',
- ],
- },
- {
- id: '06-two-tone-soft-flow',
- title: '双色软流',
- prompt: [
- ...basePrompt,
- '本张重点:双色曲线。暖陶白主体配少量孔雀青或陶土橙内侧曲线,让图形更吸引人,但不能变成多片拼贴。',
- ],
- },
- {
- id: '07-open-clay-orbit',
- title: '开放泥环',
- prompt: [
- ...basePrompt,
- '本张重点:开放式泥环。不是闭合圆,也不是旋涡旧稿,而是一枚有缺口和呼吸感的平滑陶泥环形符号。',
- ],
- },
- {
- id: '08-brand-flow-glyph',
- title: '品牌曲线符',
- prompt: [
- ...basePrompt,
- '本张重点:最终品牌符号潜力。减少材质和细节,用 1 到 2 个大曲线色块形成独特、亲和、可注册的图形标。',
- ],
- },
-];
-
-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-flow-${variant.id}.${image.extension}`);
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(outputDir, 'taonier-logo-flow-manifest.json');
- writeFileSync(
- manifestPath,
- `${JSON.stringify(
- {
- model: 'gpt-image-2-all',
- size: '1024x1024',
- generatedAt: new Date().toISOString(),
- creativeDirection: {
- name: '陶泥儿连续曲线亲和图形标',
- textPolicy: 'no Chinese, no English, no wordmark',
- correction:
- 'no square base, no center star, use one continuous friendly clay curve structure',
- motif: '柔泥回环、脑洞涟漪、开放泥环、品牌曲线符',
- },
- 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,
- 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,
- ),
-);
diff --git a/scripts/generate-taonier-geometric-logo-concepts.py b/scripts/generate-taonier-geometric-logo-concepts.py
deleted file mode 100644
index bb8be688..00000000
--- a/scripts/generate-taonier-geometric-logo-concepts.py
+++ /dev/null
@@ -1,300 +0,0 @@
-from __future__ import annotations
-
-import math
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-geometric-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-geometric-contact-sheet.png"
-
-SIZE = 1024
-SCALE = 4
-
-INK = "#151515"
-CREAM = "#fff7e6"
-GOLD = "#ffd25d"
-CORAL = "#ff6a5f"
-MINT = "#29c9ad"
-BLUE = "#2f6bff"
-
-VARIANTS = [
- ("taonier-geometric-offset-core", "偏心泥孔", "offset_core"),
- ("taonier-geometric-mold-chip", "模芯切片", "mold_chip"),
- ("taonier-geometric-pinched-tile", "捏痕方标", "pinched_tile"),
- ("taonier-geometric-dual-plate", "双片合模", "dual_plate"),
- ("taonier-geometric-dot-gate", "泥点入口", "dot_gate"),
- ("taonier-geometric-work-knot", "作品结点", "work_knot"),
-]
-
-
-def hex_to_rgb(value: str) -> tuple[int, int, int]:
- value = value.removeprefix("#")
- return tuple(int(value[index : index + 2], 16) for index in (0, 2, 4))
-
-
-def s(value: float) -> int:
- return round(value * SCALE)
-
-
-def rgba(value: str) -> tuple[int, int, int, int]:
- red, green, blue = hex_to_rgb(value)
- return red, green, blue, 255
-
-
-def regular_polygon(cx: float, cy: float, radius: float, sides: int, rotation: float) -> list[tuple[int, int]]:
- points = []
- for index in range(sides):
- angle = rotation + math.tau * index / sides
- points.append((s(cx + math.cos(angle) * radius), s(cy + math.sin(angle) * radius)))
- return points
-
-
-def rounded_rectangle(
- draw: ImageDraw.ImageDraw,
- box: tuple[float, float, float, float],
- radius: float,
- fill: str,
-) -> None:
- draw.rounded_rectangle(tuple(s(value) for value in box), radius=s(radius), fill=hex_to_rgb(fill))
-
-
-def circle(draw: ImageDraw.ImageDraw, cx: float, cy: float, radius: float, fill: str) -> None:
- draw.ellipse((s(cx - radius), s(cy - radius), s(cx + radius), s(cy + radius)), fill=hex_to_rgb(fill))
-
-
-def draw_offset_core(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#111111"))
- rounded_rectangle(draw, (236, 236, 788, 788), 148, CREAM)
- circle(draw, 610, 456, 116, "#111111")
- circle(draw, 610, 456, 48, GOLD)
- rounded_rectangle(draw, (268, 612, 536, 718), 53, "#111111")
- rounded_rectangle(draw, (294, 638, 500, 690), 26, CREAM)
- circle(draw, 352, 370, 34, "#111111")
- circle(draw, 352, 370, 17, GOLD)
-
-
-def draw_mold_chip(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#101418"))
- draw.polygon(
- [
- (s(278), s(230)),
- (s(734), s(230)),
- (s(828), s(330)),
- (s(828), s(694)),
- (s(706), s(794)),
- (s(278), s(794)),
- (s(196), s(708)),
- (s(196), s(322)),
- ],
- fill=hex_to_rgb(CREAM),
- )
- circle(draw, 512, 512, 144, "#101418")
- circle(draw, 512, 512, 62, GOLD)
- rounded_rectangle(draw, (224, 280, 518, 370), 45, CORAL)
- rounded_rectangle(draw, (574, 654, 796, 736), 41, MINT)
-
-
-def draw_pinched_tile(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#14100d"))
- rounded_rectangle(draw, (232, 250, 792, 774), 170, CREAM)
- circle(draw, 232, 512, 94, "#14100d")
- circle(draw, 792, 512, 94, "#14100d")
- draw.polygon(regular_polygon(512, 512, 104, 4, math.pi / 4), fill=hex_to_rgb("#14100d"))
- circle(draw, 512, 512, 38, GOLD)
- rounded_rectangle(draw, (420, 300, 604, 358), 29, CORAL)
- rounded_rectangle(draw, (420, 666, 604, 724), 29, MINT)
-
-
-def draw_dual_plate(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#111111"))
- rounded_rectangle(draw, (214, 316, 790, 464), 74, CORAL)
- rounded_rectangle(draw, (234, 560, 810, 708), 74, MINT)
- draw.polygon(regular_polygon(512, 512, 138, 4, math.pi / 4), fill=hex_to_rgb(CREAM))
- draw.polygon(regular_polygon(512, 512, 76, 4, math.pi / 4), fill=hex_to_rgb("#111111"))
- circle(draw, 512, 512, 32, GOLD)
- circle(draw, 262, 390, 24, CREAM)
- circle(draw, 762, 634, 24, CREAM)
-
-
-def draw_dot_gate(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#101010"))
- rounded_rectangle(draw, (276, 330, 748, 752), 132, CREAM)
- rounded_rectangle(draw, (386, 440, 638, 752), 126, "#101010")
- circle(draw, 512, 260, 62, GOLD)
- rounded_rectangle(draw, (450, 308, 574, 504), 62, CREAM)
- circle(draw, 512, 518, 40, GOLD)
- rounded_rectangle(draw, (316, 754, 708, 812), 29, CREAM)
-
-
-def draw_work_knot(draw: ImageDraw.ImageDraw) -> None:
- draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#121212"))
- circle(draw, 396, 402, 132, CREAM)
- circle(draw, 628, 402, 132, CREAM)
- circle(draw, 396, 622, 132, CREAM)
- circle(draw, 628, 622, 132, CREAM)
- rounded_rectangle(draw, (386, 386, 638, 638), 72, "#121212")
- draw.polygon(regular_polygon(512, 512, 96, 4, math.pi / 4), fill=hex_to_rgb(GOLD))
- circle(draw, 396, 402, 48, CORAL)
- circle(draw, 628, 622, 48, MINT)
- circle(draw, 628, 402, 28, "#121212")
- circle(draw, 396, 622, 28, "#121212")
-
-
-DRAWERS = {
- "offset_core": draw_offset_core,
- "mold_chip": draw_mold_chip,
- "pinched_tile": draw_pinched_tile,
- "dual_plate": draw_dual_plate,
- "dot_gate": draw_dot_gate,
- "work_knot": draw_work_knot,
-}
-
-
-def render_variant(style: str) -> Image.Image:
- image = Image.new("RGB", (SIZE * SCALE, SIZE * SCALE), hex_to_rgb("#111111"))
- draw = ImageDraw.Draw(image)
- DRAWERS[style](draw)
- return image.resize((SIZE, SIZE), Image.Resampling.LANCZOS)
-
-
-def build_svg(style: str) -> str:
- # PNG 是当前评审主物料,SVG 保留为后续设计师重绘的结构草图。
- if style == "offset_core":
- body = f'''
-
-
-
-
-
-
-
- '''
- elif style == "mold_chip":
- body = f'''
-
-
-
-
-
- '''
- elif style == "pinched_tile":
- body = f'''
-
-
-
-
-
-
-
- '''
- elif style == "dual_plate":
- body = f'''
-
-
-
-
-
-
-
- '''
- elif style == "dot_gate":
- body = f'''
-
-
-
-
-
-
- '''
- else:
- body = f'''
-
-
-
-
-
-
-
-
-
-
- '''
- return f'''
-
-'''
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def build_contact_sheet(previews: list[tuple[str, str, Image.Image]]) -> Image.Image:
- cell_size = 320
- label_height = 60
- gap = 28
- columns = 3
- rows = 2
- width = columns * cell_size + (columns + 1) * gap
- height = rows * (cell_size + label_height) + (rows + 1) * gap
- sheet = Image.new("RGB", (width, height), "#eee9df")
- draw = ImageDraw.Draw(sheet)
- font = load_font(23)
-
- for index, (_, title, preview) in enumerate(previews):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_size + label_height + gap)
- thumbnail = preview.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=10,
- fill="#fffdf8",
- )
- label = f"{index + 1:02d} {title}"
- text_box = draw.textbbox((0, 0), label, font=font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=font)
- return sheet
-
-
-def main() -> None:
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- previews: list[tuple[str, str, Image.Image]] = []
-
- for asset_id, title, style in VARIANTS:
- preview = render_variant(style)
- preview.save(OUTPUT_DIR / f"{asset_id}.png")
- (OUTPUT_DIR / f"{asset_id}.svg").write_text(build_svg(style), encoding="utf-8")
- previews.append((asset_id, title, preview))
-
- contact_sheet = build_contact_sheet(previews)
- contact_sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(
- {
- "ok": True,
- "output_dir": str(OUTPUT_DIR),
- "files": [f"{asset_id}.png" for asset_id, _, _ in VARIANTS]
- + [f"{asset_id}.svg" for asset_id, _, _ in VARIANTS]
- + [CONTACT_SHEET_PATH.name],
- }
- )
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-hand-spirit-bold-color-concepts.mjs b/scripts/generate-taonier-hand-spirit-bold-color-concepts.mjs
deleted file mode 100644
index cb441ff6..00000000
--- a/scripts/generate-taonier-hand-spirit-bold-color-concepts.mjs
+++ /dev/null
@@ -1,405 +0,0 @@
-import { Blob, 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-bold-color-concepts',
-);
-const referenceImagePath = path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-hand-spirit-ref01-logo-refine-concepts',
- 'taonier-hand-spirit-ref01-logo-refine-01-flat-coral-cream.png',
-);
-const timeoutMsDefault = 420000;
-
-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 basePrompt = [
- 'Use the uploaded logo as the structural reference. Keep the same icon-only mark structure: a soft semi-dome clay spirit above a smooth abstract hand-like support curve.',
- 'Create a bolder, more attractive color exploration for a young women-friendly brand logo. No Chinese, no English, no letters, no numbers, no wordmark, no tagline, no text.',
- 'Preserve the existing logo proportions, centered composition, abstract hand support, semi-dome spirit shape, and clean logo silhouette. Do not add a face, eyes, mouth, limbs, star, spark, UI, border, or watermark.',
- 'The goal is more memorable social-platform color, more appealing to young users and women, while still premium and trademark-like.',
- 'Use bold modern brand colors, not realistic material colors. Saturated colors are allowed, but they must read as clean brand color fields, not candy, dessert, food, jelly, bread, pastry, mochi, bun, sauce, or cosmetic packaging.',
- 'Make it flatter and clearer than the original reference: broad vector-friendly shapes, crisp edges, minimal gradients, no glossy highlight unless the variant explicitly asks for a subtle app-logo polish.',
- 'Keep 32px readability and black-white viability. The mark must still work as an app icon, social avatar, and trademark symbol.',
- 'Clean light background, generous safe area. Image-only logo concept.',
-];
-
-const variants = [
- {
- id: '01-berry-aqua-pop',
- title: '莓粉青 aqua',
- prompt: [
- ...basePrompt,
- 'Palette: vivid raspberry pink semi-dome, bright coral side accent, fresh aqua or mint hand support, small cream negative gap. Bold, young, energetic, not sugary.',
- ],
- },
- {
- id: '02-coral-lilac',
- title: '珊瑚丁香',
- prompt: [
- ...basePrompt,
- 'Palette: punchy coral-red semi-dome with warm pink accent, soft lilac-lavender hand support, tiny ivory separator. Feminine, fresh, and premium.',
- ],
- },
- {
- id: '03-mango-turquoise',
- title: '芒果松石',
- prompt: [
- ...basePrompt,
- 'Palette: bright mango-orange semi-dome, hot peach accent, turquoise hand support. High contrast and cheerful, but still flat and logo-like, not food-like.',
- ],
- },
- {
- id: '04-neon-rose-mint',
- title: '玫红薄荷',
- prompt: [
- ...basePrompt,
- 'Palette: neon rose or magenta semi-dome, clean mint green hand support, warm ivory separator. Strong social-avatar memory, modern and playful.',
- ],
- },
- {
- id: '05-poppy-blue',
- title: '罂粟蓝调',
- prompt: [
- ...basePrompt,
- 'Palette: saturated poppy orange-red semi-dome, cobalt or sky-blue support curve, cream separator. More graphic, bold, and youth-culture oriented.',
- ],
- },
- {
- id: '06-violet-peach',
- title: '紫桃撞色',
- prompt: [
- ...basePrompt,
- 'Palette: vivid violet-purple hand support with peach-orange semi-dome and pink accent. Keep the purple limited and crisp so the logo does not become a generic purple tech gradient.',
- ],
- },
- {
- id: '07-flat-duotone',
- title: '双色强记忆',
- prompt: [
- ...basePrompt,
- 'Palette and style: ultra-flat two-color version. Use one bold warm color for the spirit and one bold cool color for the hand. No highlight, no gradient, no shadow. Maximize trademark simplicity.',
- ],
- },
- {
- id: '08-app-icon-bright',
- title: '亮彩头像',
- prompt: [
- ...basePrompt,
- 'Palette and style: brightest app-icon-friendly version. Use coral, hot pink, and aqua with only a very subtle broad gradient. Keep the mark bold and readable at small size.',
- ],
- },
-];
-
-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 buildEditUrl(baseUrl) {
- return baseUrl.endsWith('/v1')
- ? `${baseUrl}/images/edits`
- : `${baseUrl}/v1/images/edits`;
-}
-
-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);
- }
-}
-
-function createEditFormData(variant) {
- const form = new FormData();
- const imageBytes = readFileSync(referenceImagePath);
- form.append('model', 'gpt-image-2');
- form.append('prompt', variant.prompt.join('\n'));
- form.append('n', '1');
- form.append('size', '1024x1024');
- form.append(
- 'image',
- new Blob([imageBytes], { type: 'image/png' }),
- path.basename(referenceImagePath),
- );
- return form;
-}
-
-function buildDryRunFields(variant) {
- return {
- model: 'gpt-image-2',
- prompt: variant.prompt.join('\n'),
- n: '1',
- size: '1024x1024',
- image: referenceImagePath,
- };
-}
-
-async function generateOne(env, variant) {
- const payload = await fetchJson(
- buildEditUrl(env.baseUrl),
- {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${env.apiKey}`,
- Accept: 'application/json',
- },
- body: createEditFormData(variant),
- },
- 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-bold-color-${variant.id}.${image.extension}`,
- );
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(outputDir, 'taonier-hand-spirit-bold-color-manifest.json');
- writeFileSync(
- manifestPath,
- `${JSON.stringify(
- {
- model: 'gpt-image-2',
- endpoint: '/v1/images/edits',
- size: '1024x1024',
- referenceImage: path.relative(repoRoot, referenceImagePath),
- generatedAt: new Date().toISOString(),
- brief: {
- brand: '陶泥儿',
- goal: '更大胆、更吸引女生和年轻人的手托灵体 logo 配色探索',
- keep: '保留托举曲线与半球灵体结构,不加文字、不加脸、不加星星',
- },
- 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,
- referenceImagePath,
- count: selectedVariants.length,
- requests: selectedVariants.map((variant) => ({
- id: variant.id,
- title: variant.title,
- fields: buildDryRunFields(variant),
- })),
- },
- null,
- 2,
- ),
- );
- process.exit(0);
-}
-
-if (!existsSync(referenceImagePath)) {
- console.error(JSON.stringify({ ok: false, error: 'Reference image does not exist' }));
- process.exit(1);
-}
-
-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));
diff --git a/scripts/generate-taonier-hand-spirit-bold-color-contact-sheet.py b/scripts/generate-taonier-hand-spirit-bold-color-contact-sheet.py
deleted file mode 100644
index 5ccc8526..00000000
--- a/scripts/generate-taonier-hand-spirit-bold-color-contact-sheet.py
+++ /dev/null
@@ -1,134 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = (
- REPO_ROOT / "public" / "branding" / "taonier-logo-hand-spirit-bold-color-concepts"
-)
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-hand-spirit-bold-color-contact-sheet.png"
-REFERENCE_IMAGE = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-hand-spirit-ref01-logo-refine-concepts"
- / "taonier-hand-spirit-ref01-logo-refine-01-flat-coral-cream.png"
-)
-
-ITEMS = [
- ("REF 上轮01", REFERENCE_IMAGE),
- ("01 莓粉青 aqua", "taonier-hand-spirit-bold-color-01-berry-aqua-pop"),
- ("02 珊瑚丁香", "taonier-hand-spirit-bold-color-02-coral-lilac"),
- ("03 芒果松石", "taonier-hand-spirit-bold-color-03-mango-turquoise"),
- ("04 玫红薄荷", "taonier-hand-spirit-bold-color-04-neon-rose-mint"),
- ("05 罂粟蓝调", "taonier-hand-spirit-bold-color-05-poppy-blue"),
- ("06 紫桃撞色", "taonier-hand-spirit-bold-color-06-violet-peach"),
- ("07 双色强记忆", "taonier-hand-spirit-bold-color-07-flat-duotone"),
- ("08 亮彩头像", "taonier-hand-spirit-bold-color-08-app-icon-bright"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- for candidate in [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem_or_path: str | Path) -> Path | None:
- if isinstance(stem_or_path, Path):
- return stem_or_path if stem_or_path.exists() else None
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem_or_path}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def normalize_square(image_path: Path) -> Image.Image:
- image = Image.open(image_path).convert("RGB")
- if image.size == (1024, 1024):
- return image
- if image.width == image.height:
- normalized = image.resize((1024, 1024), Image.Resampling.LANCZOS)
- else:
- contained = ImageOps.contain(image, (1024, 1024), Image.Resampling.LANCZOS)
- normalized = Image.new("RGB", (1024, 1024), "#fffdf8")
- x = (1024 - contained.width) // 2
- y = (1024 - contained.height) // 2
- normalized.paste(contained, (x, y))
- if image_path.is_relative_to(OUTPUT_DIR):
- normalized.save(image_path, quality=95)
- return normalized
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 268
- label_height = 54
- test_height = 44
- gap = 22
- columns = 3
- rows = 3
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f0ebe5")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(18)
- test_font = load_font(13)
-
- for index, (label, stem_or_path) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem_or_path)
- if image_path is None:
- continue
-
- source = normalize_square(image_path)
- sheet.paste(source.resize((cell_size, cell_size), Image.Resampling.LANCZOS), (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f7f3ed",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 52, test_y + 6))
- sheet.paste(mono, (x + 104, test_y + 6))
- draw.text((x + 150, test_y + 13), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-hand-spirit-contact-sheet.py b/scripts/generate-taonier-hand-spirit-contact-sheet.py
deleted file mode 100644
index f8effed3..00000000
--- a/scripts/generate-taonier-hand-spirit-contact-sheet.py
+++ /dev/null
@@ -1,107 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-hand-spirit-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-hand-spirit-contact-sheet.png"
-
-ITEMS = [
- ("01 温柔托举灵体", "taonier-hand-spirit-01-gentle-hand-spirit"),
- ("02 分享掌形", "taonier-hand-spirit-02-sharing-palm"),
- ("03 青绿托线", "taonier-hand-spirit-03-teal-support"),
- ("04 拱形泥灵", "taonier-hand-spirit-04-arched-spirit"),
- ("05 轻玩递出", "taonier-hand-spirit-05-playful-offer"),
- ("06 黑白优先", "taonier-hand-spirit-06-monochrome-first"),
- ("07 头像可读", "taonier-hand-spirit-07-avatar-readable"),
- ("08 矢量定稿感", "taonier-hand-spirit-08-vector-ready"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- test_height = 46
- gap = 24
- columns = 4
- rows = 2
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f0ebe5")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(20)
- test_font = load_font(14)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f7f3ed",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 68, test_y + 7))
- sheet.paste(mono, (x + 122, test_y + 7))
- draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-hand-spirit-logo-concepts.mjs b/scripts/generate-taonier-hand-spirit-logo-concepts.mjs
deleted file mode 100644
index b61506e6..00000000
--- a/scripts/generate-taonier-hand-spirit-logo-concepts.mjs
+++ /dev/null
@@ -1,442 +0,0 @@
-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,
- ),
-);
diff --git a/scripts/generate-taonier-hand-spirit-muted-color-concepts.mjs b/scripts/generate-taonier-hand-spirit-muted-color-concepts.mjs
deleted file mode 100644
index 74a84775..00000000
--- a/scripts/generate-taonier-hand-spirit-muted-color-concepts.mjs
+++ /dev/null
@@ -1,404 +0,0 @@
-import { Blob, 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-muted-color-concepts',
-);
-const referenceImagePath = path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-hand-spirit-ref01-logo-refine-concepts',
- 'taonier-hand-spirit-ref01-logo-refine-01-flat-coral-cream.png',
-);
-const timeoutMsDefault = 420000;
-
-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 basePrompt = [
- 'Use the uploaded logo as the structural reference. Keep the same icon-only mark structure: a soft semi-dome clay spirit above a smooth abstract hand-like support curve.',
- 'Create a low-saturation color exploration for a young women-friendly brand logo. No Chinese, no English, no letters, no numbers, no wordmark, no tagline, no text.',
- 'Preserve the existing logo proportions, centered composition, abstract hand support, semi-dome spirit shape, and clean logo silhouette. Do not add a face, eyes, mouth, limbs, star, spark, UI, border, or watermark.',
- 'The goal is softer, more tasteful, more wearable brand color. The palette should feel like a modern lifestyle brand, not a toy, candy, dessert, or cosmetics ad.',
- 'Use muted but attractive colors: dusty rose, misty lavender, sage green, smoke blue, butter cream, muted coral, terracotta clay, pale apricot. Keep the image warm and youthful without becoming loud.',
- 'Make it flatter and clearer than the original reference: broad vector-friendly shapes, crisp edges, minimal gradients, no glossy highlight unless the variant explicitly asks for a subtle polish.',
- 'Keep 32px readability and black-white viability. The mark must still work as an app icon, social avatar, and trademark symbol.',
- 'Clean light background, generous safe area. Image-only logo concept.',
-];
-
-const variants = [
- {
- id: '01-dusty-rose-sage',
- title: '雾玫鼠尾草',
- prompt: [
- ...basePrompt,
- 'Palette: dusty rose semi-dome, sage green support curve, warm ivory gap. Soft, modern, and feminine without being sweet.',
- ],
- },
- {
- id: '02-smoke-blue-apricot',
- title: '烟蓝杏橙',
- prompt: [
- ...basePrompt,
- 'Palette: smoke blue support curve, pale apricot or muted peach semi-dome, cream separator. Calm, fresh, and suitable for young users.',
- ],
- },
- {
- id: '03-misty-lilac-clay',
- title: '雾紫陶土',
- prompt: [
- ...basePrompt,
- 'Palette: misty lilac support, soft terracotta clay spirit, off-white negative space. More boutique and refined, not purple-tech.',
- ],
- },
- {
- id: '04-butter-rose-tea',
- title: '黄油玫瑰茶',
- prompt: [
- ...basePrompt,
- 'Palette: butter cream spirit, muted rose support curve, faint tea-green accent. Gentle, cozy, and premium with low saturation.',
- ],
- },
- {
- id: '05-clay-blue-mint',
- title: '陶蓝薄荷',
- prompt: [
- ...basePrompt,
- 'Palette: clay orange or muted coral semi-dome, powder blue support, tiny mint accent. Softly playful but not heavy.',
- ],
- },
- {
- id: '06-powder-berry-cloud',
- title: '粉雾浆果',
- prompt: [
- ...basePrompt,
- 'Palette: powder berry semi-dome, cloud pink support curve, warm cream gap. Youthful, gentle, and more like a boutique brand than a toy.',
- ],
- },
- {
- id: '07-sand-violet',
- title: '砂紫奶雾',
- prompt: [
- ...basePrompt,
- 'Palette: sand beige or pale almond spirit, muted violet support curve, soft cream separator. Quiet, tasteful, and logo-ready.',
- ],
- },
- {
- id: '08-muted-duotone',
- title: '低饱双色',
- prompt: [
- ...basePrompt,
- 'Palette and style: two-color muted duotone only. Use one subdued warm hue and one subdued cool hue. No shiny gloss, no intense contrast, no candy feeling.',
- ],
- },
-];
-
-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 buildEditUrl(baseUrl) {
- return baseUrl.endsWith('/v1')
- ? `${baseUrl}/images/edits`
- : `${baseUrl}/v1/images/edits`;
-}
-
-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);
- }
-}
-
-function createEditFormData(variant) {
- const form = new FormData();
- const imageBytes = readFileSync(referenceImagePath);
- form.append('model', 'gpt-image-2');
- form.append('prompt', variant.prompt.join('\n'));
- form.append('n', '1');
- form.append('size', '1024x1024');
- form.append(
- 'image',
- new Blob([imageBytes], { type: 'image/png' }),
- path.basename(referenceImagePath),
- );
- return form;
-}
-
-function buildDryRunFields(variant) {
- return {
- model: 'gpt-image-2',
- prompt: variant.prompt.join('\n'),
- n: '1',
- size: '1024x1024',
- image: referenceImagePath,
- };
-}
-
-async function generateOne(env, variant) {
- const payload = await fetchJson(
- buildEditUrl(env.baseUrl),
- {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${env.apiKey}`,
- Accept: 'application/json',
- },
- body: createEditFormData(variant),
- },
- 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-muted-color-${variant.id}.${image.extension}`,
- );
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(outputDir, 'taonier-hand-spirit-muted-color-manifest.json');
- writeFileSync(
- manifestPath,
- `${JSON.stringify(
- {
- model: 'gpt-image-2',
- endpoint: '/v1/images/edits',
- size: '1024x1024',
- referenceImage: path.relative(repoRoot, referenceImagePath),
- generatedAt: new Date().toISOString(),
- brief: {
- brand: '陶泥儿',
- goal: '低饱和度但不寡淡的年轻向颜色探索',
- keep: '保留托举曲线与半球灵体结构,不加文字、不加脸、不加星星',
- },
- 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,
- referenceImagePath,
- count: selectedVariants.length,
- requests: selectedVariants.map((variant) => ({
- id: variant.id,
- title: variant.title,
- fields: buildDryRunFields(variant),
- })),
- },
- null,
- 2,
- ),
- );
- process.exit(0);
-}
-
-if (!existsSync(referenceImagePath)) {
- console.error(JSON.stringify({ ok: false, error: 'Reference image does not exist' }));
- process.exit(1);
-}
-
-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));
diff --git a/scripts/generate-taonier-hand-spirit-muted-color-contact-sheet.py b/scripts/generate-taonier-hand-spirit-muted-color-contact-sheet.py
deleted file mode 100644
index d9127884..00000000
--- a/scripts/generate-taonier-hand-spirit-muted-color-contact-sheet.py
+++ /dev/null
@@ -1,134 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = (
- REPO_ROOT / "public" / "branding" / "taonier-logo-hand-spirit-muted-color-concepts"
-)
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-hand-spirit-muted-color-contact-sheet.png"
-REFERENCE_IMAGE = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-hand-spirit-ref01-logo-refine-concepts"
- / "taonier-hand-spirit-ref01-logo-refine-01-flat-coral-cream.png"
-)
-
-ITEMS = [
- ("REF 上轮01", REFERENCE_IMAGE),
- ("01 雾玫鼠尾草", "taonier-hand-spirit-muted-color-01-dusty-rose-sage"),
- ("02 烟蓝杏橙", "taonier-hand-spirit-muted-color-02-smoke-blue-apricot"),
- ("03 雾紫陶土", "taonier-hand-spirit-muted-color-03-misty-lilac-clay"),
- ("04 黄油玫瑰茶", "taonier-hand-spirit-muted-color-04-butter-rose-tea"),
- ("05 陶蓝薄荷", "taonier-hand-spirit-muted-color-05-clay-blue-mint"),
- ("06 粉雾浆果", "taonier-hand-spirit-muted-color-06-powder-berry-cloud"),
- ("07 砂紫奶雾", "taonier-hand-spirit-muted-color-07-sand-violet"),
- ("08 低饱双色", "taonier-hand-spirit-muted-color-08-muted-duotone"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- for candidate in [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem_or_path: str | Path) -> Path | None:
- if isinstance(stem_or_path, Path):
- return stem_or_path if stem_or_path.exists() else None
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem_or_path}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def normalize_square(image_path: Path) -> Image.Image:
- image = Image.open(image_path).convert("RGB")
- if image.size == (1024, 1024):
- return image
- if image.width == image.height:
- normalized = image.resize((1024, 1024), Image.Resampling.LANCZOS)
- else:
- contained = ImageOps.contain(image, (1024, 1024), Image.Resampling.LANCZOS)
- normalized = Image.new("RGB", (1024, 1024), "#fffdf8")
- x = (1024 - contained.width) // 2
- y = (1024 - contained.height) // 2
- normalized.paste(contained, (x, y))
- if image_path.is_relative_to(OUTPUT_DIR):
- normalized.save(image_path, quality=95)
- return normalized
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 268
- label_height = 54
- test_height = 44
- gap = 22
- columns = 3
- rows = 3
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f0ebe5")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(18)
- test_font = load_font(13)
-
- for index, (label, stem_or_path) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem_or_path)
- if image_path is None:
- continue
-
- source = normalize_square(image_path)
- sheet.paste(source.resize((cell_size, cell_size), Image.Resampling.LANCZOS), (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f7f3ed",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 52, test_y + 6))
- sheet.paste(mono, (x + 104, test_y + 6))
- draw.text((x + 150, test_y + 13), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-hand-spirit-outline-eye-concepts.mjs b/scripts/generate-taonier-hand-spirit-outline-eye-concepts.mjs
deleted file mode 100644
index 85e8f9aa..00000000
--- a/scripts/generate-taonier-hand-spirit-outline-eye-concepts.mjs
+++ /dev/null
@@ -1,424 +0,0 @@
-import { Blob, 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-outline-eye-concepts',
-);
-const referenceImagePath = path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-hand-spirit-ref01-logo-refine-concepts',
- 'taonier-hand-spirit-ref01-logo-refine-01-flat-coral-cream.png',
-);
-const timeoutMsDefault = 420000;
-
-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 basePrompt = [
- 'Use the uploaded logo as the exact structural reference. Keep the same icon-only composition: a soft semi-dome spirit above a smooth abstract hand-like support curve.',
- 'Create a cuter, more logo-like refinement. Add a clean outline around the whole symbol, and add two pure matte black dot eyes to the upper semi-dome part only.',
- 'The eyes must belong to the upper half-circle spirit, not the lower support curve. No nose, no mouth, no eyebrows, no blush, no limbs, no character body, no star, no spark, no text.',
- 'Do not redesign the overall structure. Keep the same proportions, centered layout, and soft brand silhouette. Only make it more cute, more memorable, and more trademark-like.',
- 'The outline should feel clean, friendly, and brand-ready, not sticker-like, not cartoonish, not too thick unless the variant asks for it.',
- 'Use the same soft warm palette family as the reference, but allow subtle low-saturation tweaks to improve charm and contrast. Keep it elegant and youthful, not loud.',
- 'Keep 32px readability and black-white viability. It must still work as an app icon, social avatar, and trademark symbol.',
- 'Clean light background, generous safe area. Image-only logo concept.',
-];
-
-const variants = [
- {
- id: '01-thin-outline-small-eyes',
- title: '细描边小眼',
- prompt: [
- ...basePrompt,
- 'Variant focus: the most restrained cute version. Use a thin warm outline and small matte black dot eyes with calm spacing.',
- ],
- },
- {
- id: '02-medium-outline-round-eyes',
- title: '中描边圆眼',
- prompt: [
- ...basePrompt,
- 'Variant focus: medium outline thickness and slightly rounder dot eyes. Make the face read a touch more openly cute, but still minimal.',
- ],
- },
- {
- id: '03-bold-outline-higher-eyes',
- title: '粗描边高眼',
- prompt: [
- ...basePrompt,
- 'Variant focus: stronger bold outline and eyes placed a little higher on the upper dome, creating a sweeter peeking expression.',
- ],
- },
- {
- id: '04-warm-cocoa-outline',
- title: '暖可可描边',
- prompt: [
- ...basePrompt,
- 'Variant focus: use a warm cocoa or deep beige outline that makes the logo feel softer and more plush, with small centered black eyes.',
- ],
- },
- {
- id: '05-compact-avatar-cute',
- title: '头像可爱款',
- prompt: [
- ...basePrompt,
- 'Variant focus: compact avatar readability. Enlarge the upper dome slightly, keep the hand support bold, and make the eyes more visible without adding any mouth.',
- ],
- },
- {
- id: '06-black-white-first',
- title: '黑白优先',
- prompt: [
- ...basePrompt,
- 'Variant focus: black-and-white survival first. Make the outline and the eyes work clearly even if all color is removed. Very strong logo readability.',
- ],
- },
- {
- id: '07-soft-feminine-cute',
- title: '柔和少女感',
- prompt: [
- ...basePrompt,
- 'Variant focus: a softer feminine-cute version. Keep the outline elegant and the eyes gentle; the whole mark should feel like a friendly brand mascot symbol.',
- ],
- },
- {
- id: '08-vector-ready-cute',
- title: '矢量定稿感',
- prompt: [
- ...basePrompt,
- 'Variant focus: designer-ready vector concept. Clean crisp outline, balanced eye spacing, no decorative detail, very easy to trace into an SVG mark.',
- ],
- },
-];
-
-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 buildEditUrl(baseUrl) {
- return baseUrl.endsWith('/v1')
- ? `${baseUrl}/images/edits`
- : `${baseUrl}/v1/images/edits`;
-}
-
-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);
- }
-}
-
-function createEditFormData(variant) {
- const form = new FormData();
- const imageBytes = readFileSync(referenceImagePath);
- form.append('model', 'gpt-image-2');
- form.append('prompt', variant.prompt.join('\n'));
- form.append('n', '1');
- form.append('size', '1024x1024');
- form.append(
- 'image',
- new Blob([imageBytes], { type: 'image/png' }),
- path.basename(referenceImagePath),
- );
- return form;
-}
-
-function buildDryRunFields(variant) {
- return {
- model: 'gpt-image-2',
- prompt: variant.prompt.join('\n'),
- n: '1',
- size: '1024x1024',
- image: referenceImagePath,
- };
-}
-
-async function generateOne(env, variant) {
- const payload = await fetchJson(
- buildEditUrl(env.baseUrl),
- {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${env.apiKey}`,
- Accept: 'application/json',
- },
- body: createEditFormData(variant),
- },
- 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-outline-eye-${variant.id}.${image.extension}`,
- );
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(
- outputDir,
- 'taonier-hand-spirit-outline-eye-manifest.json',
- );
- writeFileSync(
- manifestPath,
- `${JSON.stringify(
- {
- model: 'gpt-image-2',
- endpoint: '/v1/images/edits',
- size: '1024x1024',
- referenceImage: path.relative(repoRoot, referenceImagePath),
- generatedAt: new Date().toISOString(),
- brief: {
- brand: '陶泥儿',
- goal: '在上轮 01 的基础上加入描边和黑点眼睛,让标志更可爱',
- keep: '保留托举曲线与半球灵体结构,不加文字、不加星星、不改骨架',
- },
- 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,
- referenceImagePath,
- count: selectedVariants.length,
- requests: selectedVariants.map((variant) => ({
- id: variant.id,
- title: variant.title,
- fields: buildDryRunFields(variant),
- })),
- },
- null,
- 2,
- ),
- );
- process.exit(0);
-}
-
-if (!existsSync(referenceImagePath)) {
- console.error(
- JSON.stringify({
- ok: false,
- error: 'Reference image does not exist',
- referenceImagePath,
- }),
- );
- process.exit(1);
-}
-
-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,
- ),
-);
diff --git a/scripts/generate-taonier-hand-spirit-outline-eye-contact-sheet.py b/scripts/generate-taonier-hand-spirit-outline-eye-contact-sheet.py
deleted file mode 100644
index 2c26dea8..00000000
--- a/scripts/generate-taonier-hand-spirit-outline-eye-contact-sheet.py
+++ /dev/null
@@ -1,134 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = (
- REPO_ROOT / "public" / "branding" / "taonier-logo-hand-spirit-outline-eye-concepts"
-)
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-hand-spirit-outline-eye-contact-sheet.png"
-REFERENCE_IMAGE = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-hand-spirit-ref01-logo-refine-concepts"
- / "taonier-hand-spirit-ref01-logo-refine-01-flat-coral-cream.png"
-)
-
-ITEMS = [
- ("REF 上轮01", REFERENCE_IMAGE),
- ("01 细描边小眼", "taonier-hand-spirit-outline-eye-01-thin-outline-small-eyes"),
- ("02 中描边圆眼", "taonier-hand-spirit-outline-eye-02-medium-outline-round-eyes"),
- ("03 粗描边高眼", "taonier-hand-spirit-outline-eye-03-bold-outline-higher-eyes"),
- ("04 暖可可描边", "taonier-hand-spirit-outline-eye-04-warm-cocoa-outline"),
- ("05 头像可爱款", "taonier-hand-spirit-outline-eye-05-compact-avatar-cute"),
- ("06 黑白优先", "taonier-hand-spirit-outline-eye-06-black-white-first"),
- ("07 柔和少女感", "taonier-hand-spirit-outline-eye-07-soft-feminine-cute"),
- ("08 矢量定稿感", "taonier-hand-spirit-outline-eye-08-vector-ready-cute"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- for candidate in [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem_or_path: str | Path) -> Path | None:
- if isinstance(stem_or_path, Path):
- return stem_or_path if stem_or_path.exists() else None
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem_or_path}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def normalize_square(image_path: Path) -> Image.Image:
- image = Image.open(image_path).convert("RGB")
- if image.size == (1024, 1024):
- return image
- if image.width == image.height:
- normalized = image.resize((1024, 1024), Image.Resampling.LANCZOS)
- else:
- contained = ImageOps.contain(image, (1024, 1024), Image.Resampling.LANCZOS)
- normalized = Image.new("RGB", (1024, 1024), "#fffdf8")
- x = (1024 - contained.width) // 2
- y = (1024 - contained.height) // 2
- normalized.paste(contained, (x, y))
- if image_path.is_relative_to(OUTPUT_DIR):
- normalized.save(image_path, quality=95)
- return normalized
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 268
- label_height = 54
- test_height = 44
- gap = 22
- columns = 3
- rows = 3
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f0ebe5")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(18)
- test_font = load_font(13)
-
- for index, (label, stem_or_path) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem_or_path)
- if image_path is None:
- continue
-
- source = normalize_square(image_path)
- sheet.paste(source.resize((cell_size, cell_size), Image.Resampling.LANCZOS), (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f7f3ed",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 52, test_y + 6))
- sheet.paste(mono, (x + 104, test_y + 6))
- draw.text((x + 150, test_y + 13), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-hand-spirit-ref01-logo-refine-concepts.mjs b/scripts/generate-taonier-hand-spirit-ref01-logo-refine-concepts.mjs
deleted file mode 100644
index 89d982fb..00000000
--- a/scripts/generate-taonier-hand-spirit-ref01-logo-refine-concepts.mjs
+++ /dev/null
@@ -1,491 +0,0 @@
-import { Blob, 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-ref01-logo-refine-concepts',
-);
-const referenceImagePath = path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-hand-spirit-concepts',
- 'taonier-hand-spirit-01-gentle-hand-spirit.png',
-);
-const timeoutMsDefault = 420000;
-
-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: '陶泥儿',
- source: '基于 taonier-hand-spirit-01-gentle-hand-spirit 做商标化探索',
- logoType: 'symbol/icon-only mark, no wordmark',
- product:
- 'AI UGC 轻休闲小游戏创作与传播平台,用户把脑洞、梗和灵感塑造成可分享的作品',
- keep: [
- '上方软萌半球陶泥灵体',
- '下方抽象托举手势/掌形曲线',
- '托举、传递、分享的动作语义',
- '无脸、无文字、无星星',
- '亲和、精品、可用于商标和 App 图标',
- ],
- explore: [
- '更扁平的纯色块版本',
- '更精品的低饱和陶器色版本',
- '更强线面结构版本',
- '更抽象、更少形状的版本',
- '更适合 32px 和黑白化的版本',
- ],
- avoid: [
- '中文或英文字',
- '眼睛、嘴巴、表情、角色身体',
- '星星、闪光、魔法符号',
- '真实手指、宗教托举、医疗护理感',
- '面包、甜点、糖果、果冻、奶油、食物包装',
- '复杂背景、边框、UI、按钮、水印',
- ],
-};
-
-const basePrompt = [
- 'Use the uploaded reference image as the composition and shape reference. Keep the same core structure: an abstract hand-like support curve below, gently holding a soft semi-dome clay spirit above.',
- 'Create a refined icon-only brand logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.',
- 'Preserve the brand idea: fun creation, sharing, passing a small playable idea forward. The mark should feel like a small creative object being gently offered, not a scene or illustration.',
- 'Keep the subject face-free: no eyes, no mouth, no expression, no limbs, no character body, no halo, no star, no spark.',
- 'Make it more like a trademark and logo than the reference: fewer shapes, cleaner silhouette, clearer positive/negative structure, more vector-friendly curves, broad flat color regions, less glossy highlight, less painterly shadow.',
- 'The hand must remain abstract: a smooth palm-like support curve or bowl-like gesture, not realistic fingers, not a real human hand, not worship, not medical care.',
- 'The spirit must remain a soft irregular semi-dome or rounded clay core. It should not become a planet, helmet, bread, bun, mochi, dumpling, pastry, candy, jelly, sauce, dessert, or food package.',
- 'Style target: premium friendly logo mark, all-age and women-friendly, warm but not childish, suitable for app icon, social avatar, and trademark review.',
- 'Composition: centered on a clean light background with generous safe area. Strong readable silhouette first. It must still read at 32px and in black and white.',
- 'Avoid UI, border, sticker outline unless explicitly requested by the variant, background scene, watermark, extra marks, and any text.',
-];
-
-const variants = [
- {
- id: '01-flat-coral-cream',
- title: '扁平珊瑚奶白',
- prompt: [
- ...basePrompt,
- 'Variant focus: the most direct flat-logo refinement. Use coral-orange clay spirit and cream hand support. Reduce the glossy highlight to nearly zero. Use 3-4 crisp flat shapes only.',
- ],
- },
- {
- id: '02-warm-clay-premium',
- title: '暖陶精品色',
- prompt: [
- ...basePrompt,
- 'Variant focus: warmer boutique clay palette. Muted terracotta, soft sand, and warm ivory. More mature and premium, with a compact iconic silhouette and no candy gloss.',
- ],
- },
- {
- id: '03-mint-support',
- title: '青绿托举线',
- prompt: [
- ...basePrompt,
- 'Variant focus: stronger color memory. Use a clear muted teal or mint support curve as the hand and a warm peach clay spirit above. Keep it flat, balanced, and not cosmetic.',
- ],
- },
- {
- id: '04-outline-vector',
- title: '线面商标',
- prompt: [
- ...basePrompt,
- 'Variant focus: bolder trademark construction. Use a clean warm-brown contour line combined with flat fills. The outline should clarify the hand and spirit silhouette, modern rather than sticker-like.',
- ],
- },
- {
- id: '05-abstract-two-shape',
- title: '双形抽象',
- prompt: [
- ...basePrompt,
- 'Variant focus: higher abstraction. Reduce the mark to two dominant shapes: one semi-dome spirit and one sweeping hand support. Remove highlight details. Make the silhouette distinctive and vector-ready.',
- ],
- },
- {
- id: '06-monochrome-first',
- title: '黑白优先',
- prompt: [
- ...basePrompt,
- 'Variant focus: black-and-white survival first. Design with strong positive and negative shapes; color may be warm clay, but the mark must remain clear as a pure monochrome logo.',
- ],
- },
- {
- id: '07-soft-gradient-premium',
- title: '轻渐变精品',
- prompt: [
- ...basePrompt,
- 'Variant focus: a polished but still logo-like version. Allow only very subtle broad gradients for premium softness. Remove small glossy highlights and avoid 3D rendering.',
- ],
- },
- {
- id: '08-compact-avatar',
- title: '头像强识别',
- prompt: [
- ...basePrompt,
- 'Variant focus: compact social-avatar readability. Enlarge the clay spirit slightly, thicken the hand support curve, reduce thin gaps, and keep the total mark bold at 32px.',
- ],
- },
-];
-
-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 buildEditUrl(baseUrl) {
- return baseUrl.endsWith('/v1')
- ? `${baseUrl}/images/edits`
- : `${baseUrl}/v1/images/edits`;
-}
-
-function buildDryRunFields(variant) {
- return {
- model: 'gpt-image-2',
- prompt: variant.prompt.join('\n'),
- n: '1',
- size: '1024x1024',
- image: referenceImagePath,
- };
-}
-
-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);
- }
-}
-
-function createEditFormData(variant) {
- const form = new FormData();
- const imageBytes = readFileSync(referenceImagePath);
- form.append('model', 'gpt-image-2');
- form.append('prompt', variant.prompt.join('\n'));
- form.append('n', '1');
- form.append('size', '1024x1024');
- form.append(
- 'image',
- new Blob([imageBytes], { type: 'image/png' }),
- path.basename(referenceImagePath),
- );
- return form;
-}
-
-async function generateOne(env, variant) {
- const payload = await fetchJson(
- buildEditUrl(env.baseUrl),
- {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${env.apiKey}`,
- Accept: 'application/json',
- },
- body: createEditFormData(variant),
- },
- 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-ref01-logo-refine-${variant.id}.${image.extension}`,
- );
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(
- outputDir,
- 'taonier-logo-hand-spirit-ref01-logo-refine-manifest.json',
- );
- writeFileSync(
- manifestPath,
- `${JSON.stringify(
- {
- model: 'gpt-image-2',
- endpoint: '/v1/images/edits',
- size: '1024x1024',
- referenceImage: path.relative(repoRoot, referenceImagePath),
- 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,
- referenceImagePath,
- count: selectedVariants.length,
- brief: logoBrief,
- requests: selectedVariants.map((variant) => ({
- id: variant.id,
- title: variant.title,
- fields: buildDryRunFields(variant),
- })),
- },
- null,
- 2,
- ),
- );
- process.exit(0);
-}
-
-if (!existsSync(referenceImagePath)) {
- console.error(
- JSON.stringify({
- ok: false,
- error: 'Reference image does not exist',
- referenceImagePath,
- }),
- );
- process.exit(1);
-}
-
-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,
- ),
-);
diff --git a/scripts/generate-taonier-hand-spirit-ref01-logo-refine-contact-sheet.py b/scripts/generate-taonier-hand-spirit-ref01-logo-refine-contact-sheet.py
deleted file mode 100644
index e4f1a1b0..00000000
--- a/scripts/generate-taonier-hand-spirit-ref01-logo-refine-contact-sheet.py
+++ /dev/null
@@ -1,147 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-hand-spirit-ref01-logo-refine-concepts"
-)
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-hand-spirit-ref01-logo-refine-contact-sheet.png"
-REFERENCE_IMAGE = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-hand-spirit-concepts"
- / "taonier-hand-spirit-01-gentle-hand-spirit.png"
-)
-
-ITEMS = [
- ("REF 原01", REFERENCE_IMAGE),
- ("01 扁平珊瑚奶白", "taonier-hand-spirit-ref01-logo-refine-01-flat-coral-cream"),
- ("02 暖陶精品色", "taonier-hand-spirit-ref01-logo-refine-02-warm-clay-premium"),
- ("03 青绿托举线", "taonier-hand-spirit-ref01-logo-refine-03-mint-support"),
- ("04 线面商标", "taonier-hand-spirit-ref01-logo-refine-04-outline-vector"),
- ("05 双形抽象", "taonier-hand-spirit-ref01-logo-refine-05-abstract-two-shape"),
- ("06 黑白优先", "taonier-hand-spirit-ref01-logo-refine-06-monochrome-first"),
- ("07 轻渐变精品", "taonier-hand-spirit-ref01-logo-refine-07-soft-gradient-premium"),
- ("08 头像强识别", "taonier-hand-spirit-ref01-logo-refine-08-compact-avatar"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem_or_path: str | Path) -> Path | None:
- if isinstance(stem_or_path, Path):
- return stem_or_path if stem_or_path.exists() else None
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem_or_path}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def composite_on(image: Image.Image, color: str) -> Image.Image:
- rgba = image.convert("RGBA")
- background = Image.new("RGBA", rgba.size, color)
- background.alpha_composite(rgba)
- return background.convert("RGB")
-
-
-def normalize_square(image_path: Path) -> Image.Image:
- image = Image.open(image_path).convert("RGBA")
- if image.size == (1024, 1024):
- normalized = image
- elif image.width == image.height:
- normalized = image.resize((1024, 1024), Image.Resampling.LANCZOS)
- else:
- contained = ImageOps.contain(image, (1024, 1024), Image.Resampling.LANCZOS)
- normalized = Image.new("RGBA", (1024, 1024), (255, 253, 248, 255))
- x = (1024 - contained.width) // 2
- y = (1024 - contained.height) // 2
- normalized.alpha_composite(contained, (x, y))
-
- if image_path.is_relative_to(OUTPUT_DIR) and image.size != (1024, 1024):
- normalized.convert("RGB").save(image_path, quality=95)
- return composite_on(normalized, "#fffdf8")
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 268
- label_height = 54
- test_height = 44
- gap = 22
- columns = 3
- rows = 3
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f0ebe5")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(18)
- test_font = load_font(13)
-
- for index, (label, stem_or_path) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem_or_path)
- if image_path is None:
- continue
-
- source = normalize_square(image_path)
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f7f3ed",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 52, test_y + 6))
- sheet.paste(mono, (x + 104, test_y + 6))
- draw.text((x + 150, test_y + 13), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-hands-logo-concepts.mjs b/scripts/generate-taonier-hands-logo-concepts.mjs
deleted file mode 100644
index c5eb7831..00000000
--- a/scripts/generate-taonier-hands-logo-concepts.mjs
+++ /dev/null
@@ -1,315 +0,0 @@
-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-hands-concepts',
-);
-const defaultTimeoutMs = 420000;
-
-const concepts = [
- {
- id: 'taonier-hands-v2-cradle',
- title: '掌心星核',
- prompt:
- '无文字扁平矢量 Logo,产品名“陶泥儿”。图形是上下两片抽象软掌,轻轻护住中央小星核,像把脑洞捏成作品。主流 App icon,简单、亲和、醒目、小尺寸清晰。珊瑚红、青绿、奶油白,最多 3 色。不要真实手指、播放三角、聊天气泡、笑脸、眼睛、花朵、褐色、文字、字母、3D。',
- },
- {
- id: 'taonier-hands-v2-clap',
- title: '合掌成型',
- prompt:
- '无文字扁平矢量 Logo,产品名“陶泥儿”。用上下两片圆润软形表现合掌捏合,中间一个小圆点正在成型,表达 AI 把灵感变成小游戏作品。图形完整、现代、亲和、可记忆。珊瑚红、薄荷青、奶白,最多 3 色。不要真实手掌、聊天气泡、播放键、笑脸、眼睛、花朵、褐色、碎元素、3D、文字。',
- },
- {
- id: 'taonier-hands-v2-bowl',
- title: '软掌托碗',
- prompt:
- '无文字扁平矢量 Logo,产品名“陶泥儿”。下方一片软掌像托碗,上方一片小软形轻压,中央浮出小星点,表达轻托脑洞、一捏成型。品牌感、主流、温暖、干净。青绿主形、珊瑚红辅助、奶白中心,最多 3 色。不要眼睛、嘴巴、聊天气泡、播放键、真实手、花朵、褐色、3D、文字。',
- },
- {
- id: 'taonier-hands-v2-seal',
- title: '双掌印记',
- prompt:
- '无文字扁平矢量 Logo,产品名“陶泥儿”。两片抽象软掌上下扣合,形成一个圆润印记,中间留出小星形负空间,像双手捏出的创意标记。简洁、亲和、高识别、适合 App icon。珊瑚红、奶油白、青绿,最多 3 色。不要真实手指、宗教手势、医疗标识、聊天气泡、播放三角、眼睛、花朵、褐色、3D、文字。',
- },
- {
- id: 'taonier-hands-v2-pop',
- title: '掌心开捏',
- prompt:
- '无文字扁平矢量 Logo,产品名“陶泥儿”。上下两片软掌像打开的胶囊,中央小星点从掌心弹出,表达脑洞被捏出来。年轻、亲和、醒目、主流娱乐创作 App 风格。亮珊瑚红、薄荷青、奶白,最多 3 色。不要聊天气泡、播放键、笑脸、眼睛、花朵、真实手指、褐色、碎元素、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 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) {
- const requestBody = {
- model: 'gpt-image-2-all',
- prompt: concept.prompt,
- 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 onlyIds = String(args.get('--only') || '')
- .split(',')
- .map((value) => value.trim())
- .filter(Boolean);
-const selected = concepts.filter(
- (concept) => !onlyIds.length || onlyIds.includes(concept.id),
-);
-
-if (dryRun) {
- console.log(
- JSON.stringify(
- {
- mode: 'dry-run',
- outputDir,
- count: selected.length,
- requests: selected.map((concept) => ({
- id: concept.id,
- title: concept.title,
- body: {
- model: 'gpt-image-2-all',
- prompt: concept.prompt,
- 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));
-}
-
-console.log(
- JSON.stringify(
- {
- ok: true,
- count: generated.length,
- files: generated,
- verifiedFiles: readdirSync(outputDir).sort(),
- },
- null,
- 2,
- ),
-);
diff --git a/scripts/generate-taonier-logo-brief-concepts.mjs b/scripts/generate-taonier-logo-brief-concepts.mjs
deleted file mode 100644
index 744eab48..00000000
--- a/scripts/generate-taonier-logo-brief-concepts.mjs
+++ /dev/null
@@ -1,444 +0,0 @@
-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-brief-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: '陶泥儿',
- logoType: 'symbol/icon-only mark, no wordmark',
- product:
- 'AI UGC 轻休闲小游戏创作与传播平台,用户像捏陶泥一样把脑洞、梗和灵感塑造成作品',
- personality: ['亲和', '精品', '创作感', '轻松', '年轻', '有传播记忆点'],
- mustHave: [
- '闭合不规则几何底盘',
- '外轮廓由流畅曲线组成',
- '整体是一个完整符号而不是自由飘带',
- '32px 仍能识别',
- '黑白化后仍成立',
- '无中文、无英文、无字标',
- ],
- avoid: [
- '整体方形或圆角方块',
- '中心星星或任何星形',
- '自由飘带、旋涡、S/G 字母感',
- '巧克力面包、甜点、饼干、糖果等食物感',
- '砖块、土块、泥饼、陶片、考古印章',
- '脸、表情、吉祥物、手、工具',
- ],
-};
-
-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 personality: friendly, premium, creative, lightweight, young, memorable, suitable for an AI UGC casual game creation platform.',
- 'Core metaphor: users shape imagination like clay. The logo should communicate soft creative material becoming a refined digital product symbol.',
- 'Logo type: abstract symbol/icon only. Not an emblem with text, not a mascot, not an app-icon rounded-square background.',
- 'Main element: one closed irregular geometric base shape made from smooth flowing curves. The outer contour must be closed, continuous, recognizable, organic, and vector-friendly.',
- 'The shape must not be a square, rounded square, circle, ellipse, simple blob, ribbon, swirl, S shape, G shape, open ring, or loose strip. It should feel like a refined custom symbol with 5-7 soft curve turns.',
- 'Internal design: use only 1-2 broad smooth curve partitions or negative-shape cuts to make the mark memorable. No center icon inserted. No star, no spark, no hole shaped like a star.',
- 'Style: modern minimalist vector logo with very subtle matte clay warmth. Clean edges, broad shapes, high contrast, no tiny details. It must look good and recognizable at 32px favicon size.',
- 'Color: warm ceramic white, light terracotta, clay orange, warm brown, with optional small low-saturation teal, indigo-gray, or dark mud gray accent. Avoid sweet candy colors. No glossy highlights.',
- 'Food avoidance is critical: the mark must not look like bread, chocolate bread, croissant, pastry, cookie, candy, donut, cream filling, sauce, baked dough, dessert, or food packaging.',
- 'Material avoidance: do not make it look like brick, dirt clod, mud pie, pottery shard, stone, archaeological stamp, or rough handmade craft class object.',
- '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 large shape should be recognizable at 32px.',
-];
-
-const variants = [
- {
- id: '01-closed-curve-mark',
- title: '闭合曲线标',
- prompt: [
- ...basePrompt,
- 'Variant focus: the cleanest closed irregular curve mark. Use two large color areas separated by one smooth internal curve. Maximize 32px readability.',
- ],
- },
- {
- id: '02-friendly-geo-seed',
- title: '亲和几何种',
- prompt: [
- ...basePrompt,
- 'Variant focus: a friendly seed-like closed geometric base, but not a literal seed, not food. Rounded and approachable with one teal accent curve.',
- ],
- },
- {
- id: '03-premium-soft-contour',
- title: '精品软轮廓',
- prompt: [
- ...basePrompt,
- 'Variant focus: premium, calm, fewer colors. Strong outer contour with a dark mud-gray internal negative curve. Very logo-like, not illustrative.',
- ],
- },
- {
- id: '04-playful-closed-tile',
- title: '轻玩闭合片',
- prompt: [
- ...basePrompt,
- 'Variant focus: a more playful closed irregular tile with warm terracotta and ceramic white. The internal curve should suggest creation flow, not filling.',
- ],
- },
- {
- id: '05-monochrome-first',
- title: '黑白优先',
- prompt: [
- ...basePrompt,
- 'Variant focus: design as if it will be converted to black and white. Use bold positive and negative shapes; color only supports the structure.',
- ],
- },
- {
- id: '06-digital-clay-accent',
- title: '数字陶泥点',
- prompt: [
- ...basePrompt,
- 'Variant focus: include at most two tiny geometric accent dots or notches that imply AI/UGC, but they must not look like candy sprinkles or decorative confetti.',
- ],
- },
- {
- id: '07-compact-avatar-symbol',
- title: '头像紧凑标',
- prompt: [
- ...basePrompt,
- 'Variant focus: compact social-avatar readability. The closed contour should be slightly fuller and more iconic, but not a rounded-square app background.',
- ],
- },
- {
- id: '08-designer-vector-ready',
- title: '矢量定稿感',
- prompt: [
- ...basePrompt,
- 'Variant focus: make it look like a designer-ready vector concept: 2-3 flat shapes, crisp boundaries, distinctive closed outer contour, minimal material texture.',
- ],
- },
-];
-
-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-logo-brief-${variant.id}.${image.extension}`);
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(outputDir, 'taonier-logo-brief-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,
- ),
-);
diff --git a/scripts/generate-taonier-logo-brief-contact-sheet.py b/scripts/generate-taonier-logo-brief-contact-sheet.py
deleted file mode 100644
index 99090f7e..00000000
--- a/scripts/generate-taonier-logo-brief-contact-sheet.py
+++ /dev/null
@@ -1,107 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-brief-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-brief-contact-sheet.png"
-
-ITEMS = [
- ("01 闭合曲线标", "taonier-logo-brief-01-closed-curve-mark"),
- ("02 亲和几何种", "taonier-logo-brief-02-friendly-geo-seed"),
- ("03 精品软轮廓", "taonier-logo-brief-03-premium-soft-contour"),
- ("04 轻玩闭合片", "taonier-logo-brief-04-playful-closed-tile"),
- ("05 黑白优先", "taonier-logo-brief-05-monochrome-first"),
- ("06 数字陶泥点", "taonier-logo-brief-06-digital-clay-accent"),
- ("07 头像紧凑标", "taonier-logo-brief-07-compact-avatar-symbol"),
- ("08 矢量定稿感", "taonier-logo-brief-08-designer-vector-ready"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- test_height = 46
- gap = 24
- columns = 4
- rows = 2
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#ede8de")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(20)
- test_font = load_font(14)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fbfaf6",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f4f1ea",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 68, test_y + 7))
- sheet.paste(mono, (x + 122, test_y + 7))
- draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-logo-concepts.mjs b/scripts/generate-taonier-logo-concepts.mjs
deleted file mode 100644
index a26a9587..00000000
--- a/scripts/generate-taonier-logo-concepts.mjs
+++ /dev/null
@@ -1,1196 +0,0 @@
-import { Buffer } from 'node:buffer';
-import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
-import http from 'node:http';
-import https from 'node:https';
-import path from 'node:path';
-
-const repoRoot = process.cwd();
-const outputDir = path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-concepts',
-);
-const defaultTimeoutMs = 1000000;
-
-const dimensionalConcepts = [
- {
- id: 'taonier-clay-spark',
- title: '灵感陶团',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字 Logo 图标。产品是精品 AI UGC 创作与轻休闲小游戏平台,核心理念是把脑洞、梗和小游戏像陶泥一样捏出来。图标主体是一团被轻轻捏塑的温润陶泥,内部自然形成一枚发光灵感火花和少量 AI 节点点线,整体高级、亲切、年轻、有传播感。使用暖陶土色、奶白、薄荷绿、深墨色少量点缀,居中构图,适合作为 App icon 和品牌主标。禁止文字、字母、汉字、水印、按钮、界面元素、复杂背景、儿童黏土课风格。',
- },
- {
- id: 'taonier-play-mold',
- title: '开玩模具',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字 Logo 图标。产品强调 AI 创作、UGC、自制小游戏、玩梗传播和轻度休闲。图标主体是一枚柔软陶泥捏成的圆角播放符号,播放三角像被手指压出的模具凹槽,周围有两三颗精品感小星点和像素级小方块,表达“捏个脑洞,马上开玩”。风格是现代品牌标志,柔软但不幼稚,干净、可缩小识别。禁止文字、字母、汉字、水印、真实陶艺工具、UI 按钮、教程感。',
- },
- {
- id: 'taonier-meme-bubble',
- title: '造梗气泡',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字 Logo 图标。产品是 AI UGC 创作社区,主打精品内容、梗传播、裂变分享、休闲小游戏。图标用一团软陶泥变形成聊天气泡和小表情的组合,气泡边缘像被揉捏过,中心有抽象笑脸和创意火花,但不要做儿童玩具感。品牌气质年轻、松弛、聪明、有社交传播力。配色使用陶土橙、奶白、清爽蓝绿和少量深色轮廓。禁止文字、字母、汉字、水印、复杂场景、表情包文字。',
- },
- {
- id: 'taonier-creation-loop',
- title: '共创回路',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字 Logo 图标。产品理念是 AI 与用户共同把灵感塑形成可玩的 UGC 作品。图标主体由两条柔软陶泥带构成循环造物轨迹,形成一个抽象无限符号和手工捏塑旋涡,中间嵌入一颗小型游戏棋子或星点,表达共创、迭代、传播和精品打磨。风格简洁高级、几何清楚、移动端小尺寸仍可识别。禁止文字、字母、汉字、水印、复杂阴影、科技冷硬金属感。',
- },
- {
- id: 'taonier-premium-seal',
- title: '精品泥印',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字 Logo 图标。产品主打精品 AI 创作、UGC 作品和轻小游戏发布。图标是一个被压印过的软陶徽章,外形像圆润印章但更现代,中间有抽象火花、小游戏方块和一处捏痕,表达“精品内容由脑洞塑形”。整体要有品牌信任感和高级手作质感,不要像儿童陶艺班。使用暖陶土、奶油白、莓红或湖蓝少量点缀,清晰居中。禁止文字、字母、汉字、水印、传统篆刻字、真实照片。',
- },
-];
-
-const flatConcepts = [
- {
- id: 'taonier-flat-play-clay',
- title: '扁平开捏',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品是 AI UGC 创作与轻休闲小游戏平台,主张“把脑洞捏成小游戏”。图标只使用一个柔软圆润的陶泥形主轮廓,内部用极简负形播放三角表达“马上开玩”,整体像现代 App icon 的核心符号。风格要求:flat vector logo, clean geometric, friendly, mainstream, memorable, high contrast, scalable, minimal shapes, solid colors, subtle 2D shadow only。配色使用暖陶土橙、奶油白、清爽薄荷绿或深墨色,最多 3 个主色。禁止:3D、立体、拟物、厚重阴影、渐变高光、照片质感、复杂纹理、中文字、英文字母、水印、UI 按钮、复杂背景、吉祥物。',
- },
- {
- id: 'taonier-flat-spark-clay',
- title: '灵感泥星',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品强调 AI 创作、UGC、造梗、精品轻小游戏。图形主体是一枚圆润陶泥团,中心用简洁四角星或火花负形表达灵感和 AI 生成,外轮廓要一眼像“可塑形的软泥”,但必须保持现代、主流、亲和、有记忆点。风格要求:flat vector brand mark, simple silhouette, app icon ready, no realism, no texture, no 3D, crisp edges, 2D friendly illustration。最多 3 色,暖陶土 + 奶油白 + 少量蓝绿。禁止文字、字母、水印、复杂小节点、儿童手工课风格。',
- },
- {
- id: 'taonier-flat-meme-smile',
- title: '造梗笑泥',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品主打 UGC、玩梗传播、裂变分享和轻休闲小游戏。图形是一团被捏成圆润聊天气泡的陶泥,内部只保留极简笑脸或一颗小星点,表达“造梗”和“分享快乐”。整体要像主流社交娱乐 App 的 Logo,亲和、轻松、容易记住,小尺寸清楚。风格要求:flat vector logo, simple, bold, friendly, clean, no gradients, no 3D, no mascot complexity。配色不超过 3 色。禁止中文字、英文字母、水印、表情包文字、复杂装饰、立体高光。',
- },
- {
- id: 'taonier-flat-loop-mold',
- title: '共创泥环',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品理念是用户与 AI 共同把灵感塑形成可玩的 UGC 作品。图形用一条柔软陶泥带形成简洁闭环或抽象无限符号,中间留出小星点负形,表达共创、迭代、传播和精品打磨。视觉要主流、简洁、亲和,不要科技冷硬。风格要求:flat vector symbol, clean loop mark, minimal, memorable, scalable, solid colors, crisp silhouette, suitable for app icon。禁止 3D、拟物、厚阴影、复杂渐变、文字、字母、水印。',
- },
- {
- id: 'taonier-flat-seal-blocks',
- title: '精品泥印',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品强调精品 AI 作品、UGC 创作和小游戏发布。图形是一枚现代软陶印记,外形为圆角徽章或圆润印章,内部用 2 到 3 个简洁小方块和一颗星点表达“作品”“小游戏”“精品内容”。整体应像可长期使用的品牌主标,主流、干净、亲和、有辨识度。风格要求:flat vector logo, bold simple shapes, app icon ready, minimal color palette, no realism, no texture。禁止文字、字母、水印、传统篆刻、3D、复杂阴影、拟物陶艺。',
- },
-];
-
-const v3Concepts = [
- {
- id: 'taonier-v3-finger-spark',
- title: '灵感捏痕',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。不要使用播放三角、聊天气泡、笑脸、循环无限符号、褐色陶土主色、碎片小元素。产品是 AI UGC 创作与轻休闲小游戏平台,核心是“把脑洞捏成可玩的作品”。图形主体是一个醒目的圆润软形,内部只有一枚极简指纹捏痕与小火花负形,表达“被手指一捏,灵感成型”。风格:主流 App icon、flat vector、bold simple silhouette、friendly、memorable、high contrast、可缩小识别。配色:珊瑚橙或莓红作为主色,奶油白负形,少量青绿色投影或边缘点缀,最多 3 色。画面居中,留白干净。禁止文字、字母、水印、3D、拟物、厚阴影、渐变高光、照片质感、复杂纹理、表情包感、UI 按钮。',
- },
- {
- id: 'taonier-v3-seed-pop',
- title: '脑洞种子',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。不要使用播放三角、聊天气泡、笑脸、无限循环、传统印章、褐色主色或多碎元素。产品主打 AI 创作、UGC、梗传播、精品轻小游戏。图标主体是一颗圆润明亮的“脑洞种子”:像软泥被捏成的一颗种子/小芽,顶部有一个简洁星点缺口,表达灵感生长、内容生成、人人创作。风格:flat vector logo, simple, mainstream, warm, lively, app icon ready, strong outline, minimal shapes。配色使用高饱和青绿、珊瑚粉、奶油白、深墨色中的 2-3 色,不要大面积褐色。禁止文字、字母、水印、3D、拟物、照片、复杂渐变、表情、儿童黏土课风格。',
- },
- {
- id: 'taonier-v3-magic-dot',
- title: '一捏成型',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。避开播放按钮、聊天气泡、笑脸、循环符号、褐色陶土和堆叠小图标。产品理念是用户轻轻一捏,AI 把脑洞生成小游戏和 UGC 作品。图形由两个圆润手捏触点和中间一个闪光成型点组成,像“捏合灵感”的瞬间,但不要画真实手指。整体应非常简洁,有强记忆点,像主流创作娱乐 App 的标志。风格:flat vector, iconic, minimal, friendly, bold shape, clear at 32px。配色:亮紫红或珊瑚红主色,奶油白负形,青绿色小面积辅助。禁止文字、字母、水印、3D、厚阴影、渐变高光、复杂纹理。',
- },
- {
- id: 'taonier-v3-work-gem',
- title: '作品胶囊',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。不要使用播放三角、聊天气泡、笑脸、循环无限符号、褐色主色、多枚小卡片或碎图标。产品强调精品 AI UGC 作品和轻小游戏创作。图形主体是一枚被捏成圆角宝石/胶囊的抽象作品符号,内部只有一条柔软弧线切面和一个小星点,表达“脑洞被打磨成精品”。风格:flat vector logo, premium but friendly, simple, memorable, app icon, solid colors, no texture。配色:湖蓝或青绿主色,珊瑚橙点缀,奶白负形,深墨小轮廓可选。禁止文字、字母、水印、3D、复杂渐变、照片质感、游戏手柄、图片卡片、用户头像。',
- },
- {
- id: 'taonier-v3-soft-t',
- title: '软体 T 形',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。尝试做一个抽象但亲和的品牌首字母符号,灵感来自 Taonier / 陶泥儿 的 T 和“被捏塑的软泥”。不要出现真实字母 T 的硬直排版,而是用一笔圆润软形构成可记忆的图腾。必须避开播放三角、聊天气泡、笑脸、循环符号、褐色陶土主色和碎元素。风格:flat vector brand mark, modern, friendly, bold, iconic, simple silhouette, app icon ready。配色:明亮珊瑚红、奶油白、薄荷青或深墨,最多 3 色。禁止文字、英文字母直出、汉字、水印、3D、拟物、厚阴影、复杂纹理。',
- },
-];
-
-const magicDotConcepts = [
- {
- id: 'taonier-magic-dot-orbit',
- title: '捏合星核',
- prompt:
- '围绕“陶泥儿”V3 方案“一捏成型”做 Logo 延展。设计一个无文字扁平矢量主标:两个圆润软泥触点从左右轻轻合拢,中心不是碰撞爆炸,而是一颗稳定的星核/作品核,外形要形成完整、可记忆的品牌符号。必须避免播放三角、聊天气泡、笑脸、循环无限符号、褐色陶土、真实手指、括号感、爆炸特效和碎元素。风格:flat vector logo, iconic, minimal, friendly, mainstream app icon, strong silhouette, clear at 32px。配色:珊瑚红或莓红主形,奶油白负形,青绿色只做中心小面积,最多 3 色。无文字、无字母、无水印、无 3D、无厚阴影、无拟物。',
- },
- {
- id: 'taonier-magic-dot-seal',
- title: '成型印记',
- prompt:
- '围绕“陶泥儿”V3 方案“一捏成型”做 Logo 延展。设计一个无文字扁平矢量主标:图形像一枚被两侧轻轻按压成型的软形印记,中心留出一个简洁星点或小圆孔,表达 AI 把脑洞塑形成作品。整体要比原本左右括号更完整,外轮廓形成一个独特图腾。禁止播放按钮、聊天气泡、笑脸、循环符号、褐色陶土主色、多小图标、真实手、爆炸火花。风格:flat vector, bold simple shape, friendly premium, memorable, app icon ready, solid colors。配色:亮珊瑚、奶油白、薄荷青或深墨,最多 3 色。',
- },
- {
- id: 'taonier-magic-dot-squish',
- title: '软泥合拍',
- prompt:
- '围绕“陶泥儿”V3 方案“一捏成型”做 Logo 延展。设计一个无文字扁平矢量 Logo:两个软泥形不是分散的括号,而是上下错位地挤压出中心灵感点,像“啪嗒一捏,作品成型”的瞬间。图形需要亲和、轻松、年轻,但不做表情包。必须保持元素极少,只有两块主形和一个中心成型点。禁止播放三角、聊天气泡、笑脸、无限循环、褐色主色、复杂渐变、拟物质感、真实手指、文字、字母。风格:flat vector brand mark, simple, memorable, high contrast, scalable。配色:莓红、奶白、青绿或明黄点缀。',
- },
- {
- id: 'taonier-magic-dot-mold',
- title: '灵感模口',
- prompt:
- '围绕“陶泥儿”V3 方案“一捏成型”做 Logo 延展。设计一个无文字扁平矢量主标:外形像一个被捏开的柔软模口,中心浮出一颗极简星点,表达从软泥模口里生成作品。它应该是一眼可记住的抽象符号,不像聊天框、不像播放键、不像括号。风格:flat vector logo, modern, friendly, clean, bold, minimal, app icon。配色使用高识别珊瑚红或玫粉主色,奶油白负形,少量青绿点缀。禁止褐色陶土、真实陶艺、3D、高光、厚阴影、复杂小碎片、文字、水印。',
- },
- {
- id: 'taonier-magic-dot-bloom',
- title: '捏开灵感',
- prompt:
- '围绕“陶泥儿”V3 方案“一捏成型”做 Logo 延展。设计一个无文字扁平矢量 Logo:用两片圆润软形夹出中央一颗灵感点,整体像一个正在打开的创意容器,但不要像花朵、聊天气泡或笑脸。图形要完整、主流、亲和、醒目,适合 App icon 和品牌主标。禁止播放三角、聊天气泡、笑脸、循环符号、褐色陶土、碎元素、真实手、复杂花瓣。风格:flat vector, minimal brand mark, strong silhouette, warm, youthful, memorable。配色:珊瑚红、奶油白、青绿,最多 3 色。',
- },
-];
-
-const handsConcepts = [
- {
- id: 'taonier-hands-cradle-spark',
- title: '托住灵感',
- prompt:
- '围绕“陶泥儿”Logo 方向 03 的“上下两只手托住灵感”的感觉继续打磨。设计一个无文字扁平矢量主标:上下两片圆润软掌状形体像手但不要画真实手指,轻轻托住中央一颗简洁灵感星核,表达用户与 AI 一起把脑洞捏成作品。整体要完整、主流、亲和、醒目,适合 App icon。避免播放三角、聊天气泡、笑脸、眼睛、花朵、循环符号、褐色陶土、多碎元素和真实手掌插画。风格:flat vector logo, bold simple silhouette, friendly, memorable, premium but warm, clear at 32px。配色:上方珊瑚红、下方青绿色、中央奶油白或金色小星,最多 3 色。无文字、无字母、无水印、无 3D、无厚阴影。',
- },
- {
- id: 'taonier-hands-pinched-gem',
- title: '合捏成珠',
- prompt:
- '围绕“陶泥儿”Logo 方向 03 的“上下两只手”感觉做延展。设计一个无文字扁平矢量主标:上下一对抽象软手 / 软泥掌从两侧微微合捏,中间形成一颗小圆珠或作品核。图形要像品牌符号,不像手势教学图;保留托举与成型的温柔感。禁止播放三角、聊天气泡、笑脸、眼睛、花朵、褐色主色、真实手指、复杂掌纹、碎小图标。风格:flat vector, minimal, mainstream app logo, high contrast, iconic, friendly。配色:莓红、奶白、薄荷青、少量深墨,最多 3 色。',
- },
- {
- id: 'taonier-hands-cradle-v2',
- title: '托星软掌',
- prompt:
- '为“陶泥儿”设计无文字扁平矢量 Logo。图形是上下两片圆润软托,托住中央一颗小星,像把灵感轻轻捏成作品。不要画具体手指,只保留抽象软掌感觉。适合 App icon,简单、亲和、醒目、小尺寸清楚。配色:珊瑚红、薄荷青、奶油白,最多三色。不要播放三角、聊天气泡、笑脸、眼睛、花朵、褐色、文字、字母、3D、碎元素。',
- },
- {
- id: 'taonier-hands-soft-bowl',
- title: '创意托碗',
- prompt:
- '围绕“陶泥儿”Logo 方向 03 的上下手感做主标延展。设计一个无文字扁平矢量 Logo:下方是一片像手掌也像软泥托碗的圆润形体,上方是一片较小软形轻轻压合,中间浮出星点,表达“轻托脑洞、AI 捏成作品”。整体要简洁、有包容感、年轻亲和。避免像眼睛、嘴巴、聊天气泡、播放器、花朵、真实手掌、儿童黏土课。风格:flat vector logo, bold simple shape, app icon ready, clean, memorable。配色:青绿主托、珊瑚红上形、奶白中心,最多 3 色。',
- },
- {
- id: 'taonier-hands-formed-seal',
- title: '双掌泥印',
- prompt:
- '围绕“陶泥儿”Logo 方向 03 的上下两只手感觉做更完整的图腾。设计一个无文字扁平矢量主标:两片抽象软掌上下扣合,外轮廓形成一个圆润印记,中心保留一个星形负空间,像“被双手捏出的创意印记”。要有主流品牌感,不要像宗教手势、医疗关怀、儿童手工。禁止播放三角、聊天气泡、笑脸、眼睛、花朵、循环符号、褐色陶土、真实手指、复杂纹理。风格:flat vector, iconic, simple, friendly premium, solid colors, scalable。配色:珊瑚红、奶油白、青绿或深墨,最多 3 色。',
- },
- {
- id: 'taonier-hands-pop-capsule',
- title: '掌心开捏',
- prompt:
- '围绕“陶泥儿”Logo 方向 03 的“上下两只手托住灵感”感觉做更活泼版本。设计一个无文字扁平矢量 Logo:上下两片软掌像打开的胶囊,中央小星点从掌心弹出,表达“脑洞被捏出来”。图形需要有传播感、亲和力、记忆点,但不要像表情包或聊天软件。禁止播放三角、聊天气泡、笑脸、眼睛、花朵、褐色陶土、真实手指、碎元素。风格:flat vector brand mark, simple, bold, youthful, app icon, high contrast。配色:亮珊瑚红、薄荷青、奶白,最多 3 色。',
- },
-];
-
-const broadConcepts = [
- {
- id: 'taonier-broad-clay-dot-crown',
- title: '泥点皇冠',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品是 AI UGC 创作与轻休闲互动内容平台,用户用“泥点”驱动 AI,把一句脑洞、一张图或一个梗捏成小游戏和可分享作品。本方向把“泥点”做成核心品牌符号:3 到 5 个圆润泥点自然聚合,形成一个像皇冠、火苗、作品星核之间的抽象主轮廓,表达很多灵感汇聚成精品作品。整体必须像成熟 App 主标,亲和、明亮、可注册感强,小尺寸清楚。避免播放三角、聊天气泡、笑脸、真实陶艺、褐色陶土主色、人物、手、复杂碎点。风格:flat vector logo, bold simple silhouette, modern consumer app, warm, memorable, scalable, solid colors。配色:珊瑚红、奶油白、青绿色、少量金色,最多 4 色。无文字、无字母、无水印、无 3D、无厚阴影、无玻璃高光。',
- },
- {
- id: 'taonier-broad-soft-portal',
- title: '软泥入口',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品把 AI 创作、UGC、小游戏、视觉小说、拼图和轻互动作品放在同一平台内,核心感觉是“打开一个软软的创作入口,进去就能造作品”。图形主体是一枚被捏开的柔软入口/门洞,外轮廓像软泥被拉开,中心留出干净负形作品核或小星点。图形要完整、抽象、主流,不像播放器、不像聊天框、不像眼睛。风格:flat vector brand mark, simple, iconic, friendly premium, strong silhouette, app icon ready。配色使用亮珊瑚、薄荷青、奶油白、深墨中的 3 色。禁止中文字、英文字母、真实门、真实陶土、3D、复杂纹理、碎小装饰、UI 按钮。',
- },
- {
- id: 'taonier-broad-work-embryo',
- title: '作品胚芽',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。品牌隐喻不是传统陶艺,而是“灵感胚胎被 AI 塑形成可玩的作品”。图形主体是一颗圆润的作品胚芽:外形像软泥种子、游戏棋子和小宇宙的结合,内部只有一条柔软切面和一个小星点负形。整体高级、温柔、年轻,适合平台主 Logo 和 App icon。避免植物叶子过强、教育儿童感、播放按钮、聊天气泡、笑脸、循环箭头、褐色主色。风格:flat vector, premium friendly app logo, minimal, bold, clear at 32px, solid colors。配色:湖蓝或青绿主色,珊瑚橙点缀,奶白负形,最多 3 色。无文字、无字母、无水印、无 3D、无照片质感。',
- },
- {
- id: 'taonier-broad-game-mold',
- title: '游戏模芯',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品不是工具后台,而是能把脑洞生成拼图、抓大鹅、视觉小说、文字游戏等互动作品的平台。本方向用“游戏模芯”做符号:一个圆润软泥主形中嵌入极简十字方向键或小方块负形,但不要画传统手柄,不要出现播放三角。图形要表达可玩、轻休闲、低门槛创作,同时保持品牌主标感。风格:flat vector logo, simple geometric, friendly, playful but mature, app icon, high contrast。配色:珊瑚红、青绿、奶油白、深墨,最多 4 色。禁止文字、字母、水印、3D、复杂按钮、真实手柄、聊天气泡、笑脸、儿童玩具感。',
- },
- {
- id: 'taonier-broad-tao-negative',
- title: '陶字负形',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。尝试从“陶”的结构提炼抽象负形,但不要直接写汉字,也不要让模型生成可读文字。图形主体是一枚圆润软泥徽标,内部用两到三块负形构成类似陶器开口、耳部、土块和作品核的抽象关系,让熟悉中文的人隐约感到“陶”,但第一眼仍是现代 App 标志。风格:flat vector brand symbol, abstract Chinese-inspired, clean, iconic, friendly premium, scalable。配色:深墨或莓红主形,奶油白负形,青绿小点缀。禁止真实汉字、书法、篆刻、传统印章、褐色陶艺、播放按钮、聊天气泡、人物、3D、水印。',
- },
- {
- id: 'taonier-broad-soft-totem',
- title: '软体图腾',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。基于 Taonier / 陶泥儿 的品牌声母感觉做一个抽象软体图腾,但不要直接画英文字母 T,也不要生成任何文字。图形由一笔连续的圆润软泥带形成稳定的竖向图腾,顶部像被轻捏出的小角,中心有一颗作品星核负形,表达“捏、造、发布”。整体要比普通字母标更独特,适合 App icon、favicon 和平台顶栏。风格:flat vector logo, bold, simple, modern, friendly, memorable, solid colors。配色:珊瑚红主形、奶油白负形、薄荷青小面积辅助。禁止文字、字母直出、播放三角、聊天气泡、笑脸、无限循环、褐色陶土、3D、复杂纹理。',
- },
- {
- id: 'taonier-broad-creation-spark',
- title: '开捏火花',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。核心动作为“开捏”:用户输入灵感,AI 立刻生成可玩的作品。图形不要画真实手,用两块极简软形挤压出中心火花,火花不是爆炸特效,而是一个稳定的四角作品星核。外轮廓要比上一轮左右括号更完整,像一个独立品牌图腾。风格:flat vector logo, iconic, minimal, high contrast, friendly, youthful, app icon ready。配色:莓红或珊瑚红主形,奶油白负形,青绿中心点缀,最多 3 色。禁止文字、字母、水印、播放三角、聊天气泡、笑脸、眼睛、真实手指、碎粒、3D、厚阴影。',
- },
- {
- id: 'taonier-broad-content-orbit',
- title: '作品星轨',
- prompt:
- '为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo 图标。产品承载多种互动内容:RPG、拼图、抓大鹅、视觉小说、文字游戏、儿童寓教于乐。图形用一个软泥圆核和两条极简短弧形成“作品星轨”,表达一个灵感生成多个作品形态;但整体必须是一个凝聚的主标,不是天文图标。风格:flat vector brand mark, simple, premium friendly, clean geometry, app icon, scalable。配色:青绿主核、珊瑚红弧线、奶油白负形、深墨小轮廓可选。禁止文字、字母、水印、真实星球、复杂轨道、科技冷硬、播放键、聊天气泡、循环箭头、3D。',
- },
-];
-
-const freshConcepts = [
- {
- id: 'taonier-fresh-wheel-imprint',
- title: '陶轮印记',
- prompt:
- '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:俯视一个正在旋转的创作轮盘,圆环被轻轻压出一处缺口,像把灵感旋成作品。成熟消费级 App 主标,几何、干净、有速度感。配色:钴蓝、奶白、珊瑚红、少量深墨。不要软手、星核、聊天气泡、播放键、笑脸、真实陶艺、褐色、文字、字母、3D。',
- },
- {
- id: 'taonier-fresh-mold-window',
- title: '模具窗格',
- prompt:
- '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个圆角模具窗口,内部是 2x2 的不规则负形窗格,像多种小游戏和互动作品从同一个模具里生成。主流、简洁、品牌感强、小尺寸清楚。配色:深墨主形、奶油白负形、亮青绿和珊瑚小点缀。不要软手、星星、播放键、聊天气泡、脸、真实陶土、文字、字母、3D。',
- },
- {
- id: 'taonier-fresh-dot-dice',
- title: '泥点骰面',
- prompt:
- '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一枚圆润方形骰面或游戏牌面,5 个泥点孔组成独特节奏,表达泥点、玩法和随机脑洞。不要画立体骰子,只要正面抽象符号。潮流、轻游戏、可注册。配色:象牙白底、黑色主形、荧光青、珊瑚红。不要播放键、聊天气泡、笑脸、星星、软手、褐色、文字、字母、3D。',
- },
- {
- id: 'taonier-fresh-pinwheel',
- title: '灵感风车',
- prompt:
- '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:抽象纸风车,由四片圆润色块围成旋转中心,表达简单、轻松、人人能造内容。它要像品牌主标,不像儿童玩具。配色:莓红、天蓝、薄荷、奶白、深墨。不要软泥团、手、星核、播放键、聊天气泡、笑脸、花朵、文字、字母、3D、复杂渐变。',
- },
- {
- id: 'taonier-fresh-pocket-world',
- title: '口袋世界',
- prompt:
- '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个抽象口袋形徽标,口袋里露出一小块世界切片或舞台切片,表示把脑洞装进口袋随手开玩。现代、亲和、平台感强。配色:青绿色主形、奶白负形、珊瑚红小块、深墨轮廓。不要软手、星核、播放键、聊天气泡、笑脸、地图图钉、真实口袋、文字、字母、3D。',
- },
- {
- id: 'taonier-fresh-builder-blocks',
- title: '创作积木',
- prompt:
- '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:三块圆角积木以不对称方式咬合,形成一个稳定主轮廓,表达 UGC 搭建、模板生成和小游戏创作。不要儿童玩具感,要成熟、潮流、清晰。配色:黑色或深紫主轮廓,珊瑚、青绿、奶白填色。不要软手、星星、播放键、聊天气泡、笑脸、褐色、文字、字母、3D。',
- },
- {
- id: 'taonier-fresh-stage-window',
- title: '叙事舞台窗',
- prompt:
- '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个极简舞台窗或小剧场窗口,左右两片抽象幕布形成负形中心,代表视觉小说、RPG 和互动叙事。它要是 App icon 主标,不是插画。配色:深墨、珊瑚红、奶油白、少量湖蓝。不要播放键、聊天气泡、笑脸、软手、星核、真实舞台、文字、字母、3D。',
- },
- {
- id: 'taonier-fresh-ribbon-knot',
- title: '灵感绳结',
- prompt:
- '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一条圆润彩色泥条打成简洁绳结,像把多个创意线索系成一个作品。形状必须凝聚成单个主标,不能散。配色:珊瑚、钴蓝、薄荷、奶白,边缘干净。不要无限符号、软手、星核、播放键、聊天气泡、笑脸、褐色陶土、文字、字母、3D。',
- },
- {
- id: 'taonier-fresh-folded-sticker',
- title: '贴纸折角',
- prompt:
- '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一张圆角贴纸或作品卡片,右上角轻轻折起,负形像一个小入口。表达 UGC、作品发布、随手开玩。成熟、潮流、极简。配色:奶白、黑、珊瑚、青绿。不要播放键、聊天气泡、笑脸、手、星星、褐色、文字、字母、3D。',
- },
- {
- id: 'taonier-fresh-punch-hole',
- title: '印模孔洞',
- prompt:
- '为“陶泥儿”设计无文字扁平矢量 Logo。完全换方向:一个圆润印模形状,中间被冲出一个不规则圆孔,像从泥板里取出作品。抽象、强轮廓、可注册、小尺寸清楚。配色:黑色主形、奶白负形、荧光青小块、珊瑚红。不要播放键、聊天气泡、笑脸、手、星星、陶罐、文字、字母、3D。',
- },
-];
-
-const punchReferencePath = path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-fresh-concepts',
- 'taonier-fresh-punch-hole.png',
-);
-const punch04ReferencePath = path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-punch-hole-concepts',
- 'taonier-punch-color-inlay.png',
-);
-const paletteRefineReferencePath = path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-ref04-palette-transfer',
- 'taonier-ref04-palette-transfer-warm-yellow-sparkle.png',
-);
-const paletteShapeReferencePath = path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-ref04-locked-color-concepts',
- 'taonier-ref04-locked-warm-ink.png',
-);
-const sparkleRefineReferencePath = path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-ref04-warm-sparkle-v2-concepts',
- 'taonier-ref04-warm-sparkle-terracotta.png',
-);
-const sparkleCropReferencePath = path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-ref04-palette-refine-concepts',
- 'taonier-sparkle-reference-crop.png',
-);
-const paletteRefineV2ReferencePath = path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-ref04-palette-refine-v2-concepts',
- 'taonier-ref04-palette-refine-v2-pale-cream.png',
-);
-const paletteRefineV4PaleButterReferencePath = path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-ref04-palette-refine-v4-concepts',
- 'taonier-ref04-palette-refine-v4-pale-butter.png',
-);
-
-const punchConcepts = [
- {
- id: 'taonier-punch-locked-shape',
- title: '原型锁定微调',
- referenceImages: [punchReferencePath],
- prompt:
- '为“陶泥儿”继续打磨参考图 06 印模孔洞 logo。必须保持参考图基本造型不变:黑色圆润不规则环形主形、中央白色不规则孔洞、右上珊瑚红辅形、左下青蓝辅形。只优化比例、边缘、留白和小尺寸识别,让它更像成熟 App icon。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
- },
- {
- id: 'taonier-punch-stable-icon',
- title: '稳定主标',
- referenceImages: [punchReferencePath],
- prompt:
- '基于参考图 06 印模孔洞,为“陶泥儿”做无文字扁平矢量 logo 延展。保留黑色冲孔主形和中央不规则白洞,但让外轮廓更稳定、更像长期品牌主标。右上珊瑚红和左下青蓝辅形更克制,白底,强轮廓,小尺寸清楚。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
- },
- {
- id: 'taonier-punch-hole-balance',
- title: '孔洞比例',
- referenceImages: [punchReferencePath],
- prompt:
- '基于参考图 06 印模孔洞,为“陶泥儿”延展一个更干净的无文字 logo。核心仍是黑色圆润印模环和中央不规则白色孔洞,重点调整孔洞大小、厚薄关系和负形节奏,让黑形更有张力。珊瑚红、青蓝只作为小面积辅形。白底。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
- },
- {
- id: 'taonier-punch-color-inlay',
- title: '彩色嵌合',
- referenceImages: [punchReferencePath],
- prompt:
- '基于参考图 06 印模孔洞,为“陶泥儿”做彩色嵌合版 logo。黑色主环保持冲孔感,右上珊瑚红和左下青蓝两块辅形与主形更自然嵌合,像从泥板里取出的两片作品碎片。造型简洁、可注册、App icon 友好。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
- },
- {
- id: 'taonier-punch-mono-test',
- title: '单色测试',
- referenceImages: [punchReferencePath],
- prompt:
- '基于参考图 06 印模孔洞,为“陶泥儿”做单色极简版 logo。只保留黑色圆润冲孔主形和中央白色不规则孔洞,去掉彩色辅形。强调强轮廓、可注册、小尺寸识别和品牌符号感。白底。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
- },
- {
- id: 'taonier-punch-app-token',
- title: '应用图标',
- referenceImages: [punchReferencePath],
- prompt:
- '基于参考图 06 印模孔洞,为“陶泥儿”延展一个更完整的 App icon 核心图形。黑色不规则冲孔主形更饱满,中央白洞更清晰,珊瑚红与青蓝辅形保持年轻感但不抢主体。整体像可长期使用的品牌符号,不像插画。白底。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
- },
-];
-
-const punch04Concepts = [
- {
- id: 'taonier-punch04-warm-ink-core',
- title: '暖墨填芯',
- referenceImages: [punch04ReferencePath],
- prompt:
- '基于参考图“04 彩色嵌合”为“陶泥儿”继续做 logo 延展。保持原有基本结构不变:一个圆润不规则环形主形,右上珊瑚红嵌合块,左下青蓝嵌合块,中央不规则孔洞。重点调整配色:中间黑色主形改为温暖深墨灰,不要纯黑;中央孔洞内部加入一枚很简洁的奶油色软泥种子/作品核填充,不要填满,保留留白呼吸。扁平矢量、品牌主标、小尺寸清楚。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
- },
- {
- id: 'taonier-punch04-navy-game-core',
- title: '靛蓝作品核',
- referenceImages: [punch04ReferencePath],
- prompt:
- '基于参考图“04 彩色嵌合”为“陶泥儿”设计一版配色延展。保持黑环、右上红块、左下青块的基本结构和嵌合关系,但把主形从黑色改为深靛蓝或蓝黑色,整体更年轻、更像互联网 App。中央空心区域加入一个极简浅色作品核:小圆角方块或软形小岛,不能像播放键、不能像字母。白底,扁平矢量,干净可注册。无文字、无字母、无聊天气泡、无手、无星星、无3D。',
- },
- {
- id: 'taonier-punch04-cream-window',
- title: '奶油内窗',
- referenceImages: [punch04ReferencePath],
- prompt:
- '基于参考图“04 彩色嵌合”为“陶泥儿”做一版更柔和的 logo。基本结构不变:主环、右上珊瑚红、左下青蓝、中央孔洞都保留。把原黑色主环调整为柔和深紫灰或墨绿色,降低硬度。中央孔洞不再是纯空白,设计成奶油色内窗,里面有两块极简小色面,表达多个作品从同一模具生成。整体仍然极简,不要复杂插画。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
- },
- {
- id: 'taonier-punch04-clay-gradient-flat',
- title: '陶盒彩芯',
- referenceImages: [punch04ReferencePath],
- prompt:
- '基于参考图“04 彩色嵌合”为“陶泥儿”做配色与中孔设计。保持 04 的基本轮廓和红青嵌合块位置。主形不要纯黑,改成深陶紫、莓紫或炭灰紫,仍保持强轮廓。中央孔洞加入一个扁平的彩色泥芯,由珊瑚、青蓝、奶白三块圆润小面组成,像作品被捏出来的内核。不要渐变高光,不要立体,不要复杂细节。无文字、无字母、无播放键、无聊天气泡、无手、无星星。',
- },
- {
- id: 'taonier-punch04-mint-shadow',
- title: '薄荷深影',
- referenceImages: [punch04ReferencePath],
- prompt:
- '基于参考图“04 彩色嵌合”为“陶泥儿”做一版更清爽的品牌 logo。保持 04 的三块嵌合结构不变。把中间黑色主形改成深青绿/墨绿,右上红块更偏珊瑚,左下青块更偏亮薄荷。中央空心处加入一枚小小的浅黄色或奶白圆角形,像可玩的作品胚,不要过大。整体强识别、轻休闲、App icon 友好。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
- },
- {
- id: 'taonier-punch04-negative-tile',
- title: '内嵌拼片',
- referenceImages: [punch04ReferencePath],
- prompt:
- '基于参考图“04 彩色嵌合”为“陶泥儿”做一版中间内容更明确的 logo。保持外部基本结构和红青嵌合块位置不变。主形从纯黑改为深墨蓝灰。中央不规则孔洞内部放入一个极简拼片/圆角模块组合,表示拼图、小游戏、互动作品,但必须非常简洁,不能像 UI 图标堆叠。白底,扁平矢量,主标感强。无文字、无字母、无播放键、无聊天气泡、无手、无星星、无3D。',
- },
-];
-
-const paletteRefineConcepts = [
- {
- id: 'taonier-ref04-palette-refine-butter',
- title: '淡黄黄油',
- referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath],
- prompt:
- '为“陶泥儿”继续调整 REF-04 配色迁移版。必须锁定参考图一的外轮廓和分区:主形、右上红块、左下青块和中间孔洞都保持不变;把中间主形改成温暖、低饱和、很淡的黄油黄或奶油黄,不要脏黄、土黄、芥末黄或偏橙黄。中间的星星必须保持参考图二的原样:四角闪光星,带短小光芒,不能拉伸成细长十字,不能变成五角星,不能加厚底托。整体要像成熟、干净、轻松的品牌 logo。无文字、无字母、无播放键、无聊天气泡、无手、无3D。',
- },
- {
- id: 'taonier-ref04-palette-refine-cream',
- title: '奶油淡黄',
- referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath],
- prompt:
- '基于 REF-04 造型锁定版和四角闪光星参考图,生成一版更高级的暖黄配色。保持图一的造型完全不变,只把中间主形改成低饱和奶油淡黄,颜色要轻、透、干净,避免脏、沉、厚。中心星星完全沿用参考图二的四角闪光样式和短光芒,不要拉伸,不要变形,不要变成五角星。红块和青块保持现有位置与比例。白底、扁平、品牌标志感。无文字、无字母、无手、无播放键、无3D。',
- },
- {
- id: 'taonier-ref04-palette-refine-biscuit',
- title: '饼干淡黄',
- referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath],
- prompt:
- '继续基于 REF-04 造型锁定版做色彩优化。外轮廓、红青辅形、中孔边界全部锁住不变;中间主形换成更淡的饼干黄、奶油黄或浅麦黄,必须低饱和、暖而不脏。中心填充严格使用参考图二的四角闪光星和短光芒,保持原样,不许被拉长,也不许改成几何五角星。整体要简洁、轻盈、专业。无文字、无字母、无聊天气泡、无3D、无复杂阴影。',
- },
- {
- id: 'taonier-ref04-palette-refine-milk',
- title: '牛奶暖黄',
- referenceImages: [paletteRefineReferencePath, sparkleRefineReferencePath],
- prompt:
- '在 REF-04 锁形轮廓上做最后一轮暖黄微调。只改中间主形的颜色,把它变成接近牛奶、黄油、奶霜的浅暖黄,低饱和、柔和、干净,不要土气,不要发灰。中间星星必须保持参考图二的四角闪光星原型和短光芒,不能被拉伸,不能变瘦,不能加底托。红青两块辅形位置不动。白底,极简 logo。无文字、无字母、无手、无播放键、无3D。',
- },
-];
-
-const paletteRefineV2Concepts = [
- {
- id: 'taonier-ref04-palette-refine-v2-soft-butter',
- title: '柔和奶黄',
- referenceImages: [
- paletteShapeReferencePath,
- paletteRefineReferencePath,
- sparkleCropReferencePath,
- ],
- prompt:
- '为“陶泥儿”修正 REF-04 配色迁移版。严格锁定参考图一的造型和分区:不改变外轮廓、不改变右上辅形、不改变左下辅形、不改变中央孔洞边界。只做两处调整:1)把中间主形改成温暖、低饱和、淡淡的奶油黄/黄油黄,颜色要高级、轻、干净,绝对不要土黄、脏黄、芥末黄、焦糖黄、偏橙黄;2)中心空洞里的星星必须使用参考图三的原始四角闪光星和短光芒,保持饱满菱形闪光,不要拉伸成十字,不要变成五角星,不要加底托。保持白底和扁平 logo。无文字、无字母、无手、无播放键、无聊天气泡、无3D。',
- },
- {
- id: 'taonier-ref04-palette-refine-v2-pale-cream',
- title: '浅奶油黄',
- referenceImages: [
- paletteShapeReferencePath,
- paletteRefineReferencePath,
- sparkleCropReferencePath,
- ],
- prompt:
- '基于三张参考图生成一版修正版 logo:参考图一只用于锁定 REF-04 造型;参考图二只用于当前粉红与薄荷青位置;参考图三用于中心星星样式。中间主形颜色改为低饱和浅奶油黄,接近柔和奶霜,不要土气、不要脏、不要高饱和。中心星星必须照参考图三,四角闪光星带短光芒,比例自然饱满,不能被压扁或拉长。外轮廓和孔洞边界不变。白底、干净、成熟品牌 logo。无文字、无字母、无3D。',
- },
- {
- id: 'taonier-ref04-palette-refine-v2-light-vanilla',
- title: '香草淡黄',
- referenceImages: [
- paletteShapeReferencePath,
- paletteRefineReferencePath,
- sparkleCropReferencePath,
- ],
- prompt:
- '继续优化 REF-04 造型锁定 logo。必须保持参考图一的所有轮廓位置,只把中间原黑色区域换成温暖低饱和的香草淡黄,颜色像轻柔黄油、奶油纸、浅米黄,不能像陶土、咖啡、焦糖或芥末。中心空洞填入参考图三的星星:圆润四角闪光、短小光芒、自然比例,不要变瘦,不要拉伸,不要五角星。粉红和薄荷青辅形沿用参考图二的气质。无文字、无字母、无播放键、无聊天气泡、无手、无3D。',
- },
-];
-
-const paletteRefineV3Concepts = [
- {
- id: 'taonier-ref04-palette-refine-v3-butter-soft',
- title: '淡奶油黄',
- referenceImages: [
- paletteShapeReferencePath,
- paletteRefineV2ReferencePath,
- sparkleCropReferencePath,
- ],
- prompt:
- '为“陶泥儿”继续修正 REF-04 的配色迁移版。锁定参考图一的外轮廓、红块、青块和孔洞边界不动;把中间主形调成更高级的淡奶油黄、黄油白黄或柔软黄米色,颜色要更淡一点、更轻一点、更透一点,不要土黄、脏黄、焦糖黄、芥末黄,也不要偏橙偏褐。中心空洞使用参考图三的星星:必须是饱满的四角闪光星,带短小光芒,不能被拉长成细十字,不能变成五角星,也不能出现厚底托。整体保持白底、扁平、品牌 logo 感。无文字、无字母、无3D、无聊天气泡。',
- },
- {
- id: 'taonier-ref04-palette-refine-v3-milk-cream',
- title: '奶霜淡黄',
- referenceImages: [
- paletteShapeReferencePath,
- paletteRefineV2ReferencePath,
- sparkleCropReferencePath,
- ],
- prompt:
- '基于三张参考图输出一版更轻的 REF-04 logo。第一张参考只负责锁定原始造型;第二张参考只负责当前配色关系;第三张参考只负责中心闪光星的样子。中间主形改成低饱和的奶霜淡黄,颜色要轻柔、通透、像淡淡的黄油和牛奶混合,不要土、不要厚、不要脏。星星保持参考图三的四角闪光星和短光芒,不许拉伸,不许变形,不许五角星化。红块和青块位置固定。无文字、无字母、无手、无播放键、无3D。',
- },
- {
- id: 'taonier-ref04-palette-refine-v3-soft-vanilla',
- title: '香草奶黄',
- referenceImages: [
- paletteShapeReferencePath,
- paletteRefineV2ReferencePath,
- sparkleCropReferencePath,
- ],
- prompt:
- '继续保持 REF-04 的造型锁定,做一次更安静的暖黄修正。中间主形变成香草奶黄或浅奶油黄,必须是低饱和、柔和、高级的淡黄,不要像土黄、咖喱黄、焦糖黄或偏橙黄。中心填充沿用参考图三的四角闪光星,星体要圆润饱满,旁边的短光芒保留,但不能夸张,不能拉长。外轮廓完全不动。白底、logo 感、扁平。无文字、无字母、无聊天气泡、无3D。',
- },
-];
-
-const paletteRefineV4Concepts = [
- {
- id: 'taonier-ref04-palette-refine-v4-cream-paper',
- title: '奶油纸淡黄',
- referenceImages: [
- paletteShapeReferencePath,
- paletteRefineReferencePath,
- sparkleCropReferencePath,
- ],
- prompt:
- '继续用 image-2 修正“陶泥儿” REF-04 logo。参考图一只用于锁定 REF-04 原型:外轮廓、右上粉红块、左下薄荷青块、中央孔洞边界都不要重新设计;参考图二只说明当前需要修正的版本;参考图三只用于中心星星。把中间原本土黄/脏黄的主形改成温暖、低饱和、淡淡的奶油纸黄色,接近 #F3E5B4 或 #F6E9C5,颜色要轻、干净、高级,不要陶土黄、芥末黄、咖喱黄、焦糖黄、橙黄、棕黄。中心孔洞里的星星必须保持参考图三原本的四角闪光星比例:上下左右四个圆润尖角,宽高自然,不能被横向或纵向拉伸,不能变成细十字,不能变成五角星,旁边短光芒也保持短小。白底、扁平品牌 logo。无文字、无字母、无播放键、无聊天气泡、无手、无3D。',
- },
- {
- id: 'taonier-ref04-palette-refine-v4-warm-ivory',
- title: '暖象牙淡黄',
- referenceImages: [
- paletteShapeReferencePath,
- paletteRefineReferencePath,
- sparkleCropReferencePath,
- ],
- prompt:
- '基于三张参考图输出一版 REF-04 精修 logo。第一张参考图的形状和分区必须优先:主形轮廓不改、粉红块和薄荷青块位置不改、中间白色孔洞不改;只把中间主形从现在偏土的黄改成暖象牙淡黄,像很淡的黄油白、奶油米白、暖白纸,低饱和、柔和、通透,不要厚重和脏感。第三张参考图的四角闪光星需要原样放进中心:星体不能被压扁、不能拉长、不能瘦成十字,短光芒不要变多。整体保持成熟、干净、可做 App icon 的扁平 logo。无文字、无字母、无3D。',
- },
- {
- id: 'taonier-ref04-palette-refine-v4-soft-champagne',
- title: '淡香槟暖黄',
- referenceImages: [
- paletteShapeReferencePath,
- paletteRefineV2ReferencePath,
- sparkleCropReferencePath,
- ],
- prompt:
- '为“陶泥儿”做一版更高级的 REF-04 暖黄精修。参考图一锁定基本造型,不允许改成播放按钮、三角形、气泡或新图标;参考图二只参考淡黄的轻盈程度;参考图三锁定星星。中间主形使用低饱和淡香槟黄/奶霜黄,颜色要非常淡、温暖、干净,不能像泥土、咖喱、焦糖、芥末或橙棕。中心星星必须是参考图三那种饱满四角闪光,保留短光芒,按原始宽高比例绘制,不能拉伸、不能变形、不能五角星化。粉红和薄荷青辅形保持克制。白底、扁平、品牌主标感。无文字、无字母、无3D、无复杂阴影。',
- },
- {
- id: 'taonier-ref04-palette-refine-v4-pale-butter',
- title: '淡黄油暖白',
- referenceImages: [
- paletteShapeReferencePath,
- paletteRefineV2ReferencePath,
- sparkleCropReferencePath,
- ],
- prompt:
- '继续调整 REF-04 配色版本,只修正颜色和中心星星,不重画 logo。外轮廓、三块嵌合关系、中央孔洞边界以参考图一为准;中间主形换成淡黄油暖白,像轻薄奶油、温暖米白、浅黄纸,低饱和、不土、不脏、不橙、不褐。中心孔洞填入参考图三原样的四角闪光星:星星要圆润饱满,四个尖角长度均衡,短光芒短而自然,不能拉伸成细长十字。保留白底和扁平矢量 logo 气质。禁止文字、字母、五角星、播放键、聊天气泡、手、3D。',
- },
-];
-
-const paletteRefineV5Concepts = [
- {
- id: 'taonier-ref04-palette-refine-v5-filled-centered-spark',
- title: '填心居中亮星',
- referenceImages: [
- paletteRefineV4PaleButterReferencePath,
- paletteShapeReferencePath,
- sparkleCropReferencePath,
- ],
- prompt:
- '根据参考图修改“陶泥儿”04 图标,保留右上粉红块、左下薄荷青块和整体软泥圆润气质。重点做三处修改:1)补全左侧外轮廓曲线,让左侧从上到下形成连续、顺滑、饱满的弧线,不能有缺口、锯齿、截断或不自然凹陷;2)把中央白色空心孔洞完全用主体同色的温暖低饱和淡奶油黄填平,不能再出现白色中孔、白色环或内窗;3)把参考星星改成明亮的黄色四角闪光星,放在整个淡黄主体的视觉中央,星星清晰、圆润、比例自然,不要五角星,不要拉伸成十字。白底、扁平品牌 logo、干净高级。无文字、无字母、无播放键、无聊天气泡、无手、无3D。',
- },
- {
- id: 'taonier-ref04-palette-refine-v5-smooth-left-small-spark',
- title: '顺滑左弧小亮星',
- referenceImages: [
- paletteRefineV4PaleButterReferencePath,
- paletteShapeReferencePath,
- sparkleCropReferencePath,
- ],
- prompt:
- '继续精修“陶泥儿”04 图标。以参考图一的 04 配色和比例为基础,但不要保留中央白洞。左侧外边缘需要补成更完整、更协调的连续曲线,像一整块柔软陶泥的自然外轮廓;中间原空心区域必须填成和主形一致的淡奶油黄色,与主体融为一体。中心放一枚明亮黄色四角闪光星,星星略小、居中、干净,不带复杂底托,不是五角星,不是细长十字。粉红块和薄荷青块仍然分离在右上和左下,白色间隔保持干净。无文字、无字母、无3D。',
- },
- {
- id: 'taonier-ref04-palette-refine-v5-balanced-bright-spark',
- title: '平衡亮星',
- referenceImages: [
- paletteRefineV4PaleButterReferencePath,
- paletteShapeReferencePath,
- sparkleCropReferencePath,
- ],
- prompt:
- '为“陶泥儿”输出一版更协调的 04 图标修改稿。主形是温暖、低饱和、淡淡的奶油黄色;请补齐左侧曲线,让左边外轮廓更圆润完整,整体重心更稳。中央空心区域不再留白,必须填平为同样的淡黄色主形。把四角闪光星改成更明亮、更清楚的黄色,准确放在图标中央,星体饱满,四个尖角均衡,可以有很短的小光芒但不要抢主体。保持扁平 logo 感和白底。禁止文字、字母、五角星、播放键、聊天气泡、手、3D。',
- },
- {
- id: 'taonier-ref04-palette-refine-v5-solid-core-no-hole',
- title: '实体主形亮星',
- referenceImages: [
- paletteRefineV4PaleButterReferencePath,
- paletteShapeReferencePath,
- sparkleCropReferencePath,
- ],
- prompt:
- '按用户参考图修改 04 logo:把淡黄主形做成一个更完整的实体软泥形。左侧曲线补全并顺滑化,外轮廓不要破碎;原中央白色孔洞完全消失,改成与主形同色的淡奶油黄实体面;在实体面的正中央放一枚明亮黄色四角闪光星,星星比主体颜色更亮,有明确识别但不幼稚。保持右上粉红块和左下薄荷青块的年轻配色,整体干净、轻盈、品牌主标感。无文字、无字母、无内孔、无白色中窗、无五角星、无播放键、无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);
- }
-}
-
-const style = String(args.get('--style') || 'dimensional').trim();
-const concepts =
- style === 'flat'
- ? flatConcepts
- : style === 'v3'
- ? v3Concepts
- : style === 'magic'
- ? magicDotConcepts
- : style === 'hands'
- ? handsConcepts
- : style === 'broad'
- ? broadConcepts
- : style === 'fresh'
- ? freshConcepts
- : style === 'punch'
- ? punchConcepts
- : style === 'punch04'
- ? punch04Concepts
- : style === 'palette-refine'
- ? paletteRefineConcepts
- : style === 'palette-refine-v2'
- ? paletteRefineV2Concepts
- : style === 'palette-refine-v3'
- ? paletteRefineV3Concepts
- : style === 'palette-refine-v4'
- ? paletteRefineV4Concepts
- : style === 'palette-refine-v5'
- ? paletteRefineV5Concepts
- : dimensionalConcepts;
-const selectedOutputDir =
- style === 'flat'
- ? path.join(repoRoot, 'public', 'branding', 'taonier-logo-flat-concepts')
- : style === 'v3'
- ? path.join(repoRoot, 'public', 'branding', 'taonier-logo-v3-concepts')
- : style === 'magic'
- ? path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-magic-dot-concepts',
- )
- : style === 'hands'
- ? path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-hands-concepts',
- )
- : style === 'broad'
- ? path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-broad-concepts',
- )
- : style === 'fresh'
- ? path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-fresh-concepts',
- )
- : style === 'punch'
- ? path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-punch-hole-concepts',
- )
- : style === 'punch04'
- ? path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-punch04-color-concepts',
- )
- : style === 'palette-refine'
- ? path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-ref04-palette-refine-concepts',
- )
- : style === 'palette-refine-v2'
- ? path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-ref04-palette-refine-v2-concepts',
- )
- : style === 'palette-refine-v3'
- ? path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-ref04-palette-refine-v3-concepts',
- )
- : style === 'palette-refine-v4'
- ? path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-ref04-palette-refine-v4-concepts',
- )
- : style === 'palette-refine-v5'
- ? path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-ref04-palette-refine-v5-concepts',
- )
- : outputDir;
-
-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 buildGenerationUrl(baseUrl) {
- return baseUrl.endsWith('/v1')
- ? `${baseUrl}/images/generations`
- : `${baseUrl}/v1/images/generations`;
-}
-
-function buildEditUrl(baseUrl) {
- return baseUrl.endsWith('/v1')
- ? `${baseUrl}/images/edits`
- : `${baseUrl}/v1/images/edits`;
-}
-
-function hasHeader(headers, targetName) {
- return Object.keys(headers).some(
- (name) => name.toLowerCase() === targetName.toLowerCase(),
- );
-}
-
-async function requestBuffer(url, options, timeoutMs, redirectCount = 0) {
- const body =
- typeof options.body === 'string'
- ? Buffer.from(options.body)
- : options.body || null;
- const headers = { ...(options.headers || {}) };
- if (body && !hasHeader(headers, 'content-length')) {
- headers['Content-Length'] = String(body.length);
- }
-
- return new Promise((resolve, reject) => {
- const parsedUrl = new URL(url);
- const transport = parsedUrl.protocol === 'http:' ? http : https;
- const request = transport.request(
- parsedUrl,
- {
- method: options.method || 'GET',
- headers,
- },
- (response) => {
- const statusCode = response.statusCode || 0;
- const location = response.headers.location;
- if (
- statusCode >= 300 &&
- statusCode < 400 &&
- location &&
- redirectCount < 5
- ) {
- response.resume();
- const redirectedUrl = new URL(location, parsedUrl).toString();
- const preserveBody = statusCode === 307 || statusCode === 308;
- requestBuffer(
- redirectedUrl,
- preserveBody
- ? options
- : {
- method: 'GET',
- headers: options.headers,
- },
- timeoutMs,
- redirectCount + 1,
- )
- .then(resolve)
- .catch(reject);
- return;
- }
-
- const chunks = [];
- response.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
- response.on('end', () =>
- resolve({
- statusCode,
- headers: response.headers,
- bytes: Buffer.concat(chunks),
- }),
- );
- },
- );
-
- request.setTimeout(timeoutMs, () => {
- request.destroy(new Error(`request timed out after ${timeoutMs}ms`));
- });
- request.on('error', reject);
- if (body) {
- request.write(body);
- }
- request.end();
- });
-}
-
-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';
-}
-
-function imagePathToReferenceImage(imagePath) {
- if (!existsSync(imagePath)) {
- throw new Error(`Reference image not found: ${imagePath}`);
- }
-
- const bytes = readFileSync(imagePath);
- const extension = path.extname(imagePath).toLowerCase();
- const mimeType =
- extension === '.jpg' || extension === '.jpeg'
- ? 'image/jpeg'
- : extension === '.webp'
- ? 'image/webp'
- : 'image/png';
- return {
- fieldName: 'image',
- fileName: path.basename(imagePath).replace(/"/gu, '_'),
- mimeType,
- bytes,
- };
-}
-
-function buildMultipartBody(fields, files) {
- const boundary = `----genarrative-${Date.now().toString(16)}-${Math.random()
- .toString(16)
- .slice(2)}`;
- const chunks = [];
- const push = (value) => {
- chunks.push(Buffer.isBuffer(value) ? value : Buffer.from(value));
- };
-
- for (const [name, value] of Object.entries(fields)) {
- push(`--${boundary}\r\n`);
- push(`Content-Disposition: form-data; name="${name}"\r\n\r\n`);
- push(`${value}\r\n`);
- }
-
- for (const file of files) {
- push(`--${boundary}\r\n`);
- push(
- `Content-Disposition: form-data; name="${file.fieldName}"; filename="${file.fileName}"\r\n`,
- );
- push(`Content-Type: ${file.mimeType}\r\n\r\n`);
- push(file.bytes);
- push('\r\n');
- }
-
- push(`--${boundary}--\r\n`);
- return {
- body: Buffer.concat(chunks),
- contentType: `multipart/form-data; boundary=${boundary}`,
- };
-}
-
-async function fetchJson(url, options, timeoutMs) {
- try {
- const response = await requestBuffer(url, options, timeoutMs);
- const text = response.bytes.toString('utf8');
- if (response.statusCode < 200 || response.statusCode >= 300) {
- throw new Error(
- `VectorEngine ${response.statusCode}: ${text.slice(0, 600)}`,
- );
- }
- return JSON.parse(text);
- } catch (error) {
- if (String(error?.message || '').includes('timed out')) {
- throw new Error(
- `VectorEngine request timed out after ${timeoutMs}ms`,
- { cause: error },
- );
- }
- throw error;
- }
-}
-
-async function downloadUrl(url, timeoutMs) {
- try {
- const response = await requestBuffer(url, { method: 'GET' }, timeoutMs);
- if (response.statusCode < 200 || response.statusCode >= 300) {
- throw new Error(`download ${response.statusCode}`);
- }
- return response.bytes;
- } catch (error) {
- if (String(error?.message || '').includes('timed out')) {
- throw new Error(
- `Generated image download timed out after ${timeoutMs}ms`,
- { cause: error },
- );
- }
- throw error;
- }
-}
-
-async function generateConcept(env, concept) {
- const requestBody = {
- model: 'gpt-image-2',
- prompt: concept.prompt,
- n: 1,
- size: '1024x1024',
- };
-
- const referenceImages = (concept.referenceImages || []).map(
- imagePathToReferenceImage,
- );
- const payload = referenceImages.length
- ? await (async () => {
- const multipart = buildMultipartBody(requestBody, referenceImages);
- return fetchJson(
- buildEditUrl(env.baseUrl),
- {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${env.apiKey}`,
- Accept: 'application/json',
- 'Content-Type': multipart.contentType,
- },
- body: multipart.body,
- },
- env.timeoutMs,
- );
- })()
- : await fetchJson(
- buildGenerationUrl(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(selectedOutputDir, { recursive: true });
- const extension = inferExtensionFromBytes(bytes);
- const outputPath = path.join(selectedOutputDir, `${concept.id}.${extension}`);
- writeFileSync(outputPath, bytes);
- return outputPath;
-}
-
-const dryRun = args.has('--dry-run') || !args.has('--live');
-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 (dryRun) {
- console.log(
- JSON.stringify(
- {
- mode: 'dry-run',
- style,
- outputDir: selectedOutputDir,
- count: selected.length,
- requests: selected.map((concept) => ({
- id: concept.id,
- title: concept.title,
- endpoint: concept.referenceImages?.length
- ? '/v1/images/edits'
- : '/v1/images/generations',
- body: concept.referenceImages?.length
- ? undefined
- : {
- model: 'gpt-image-2',
- prompt: concept.prompt,
- n: 1,
- size: '1024x1024',
- },
- form: concept.referenceImages?.length
- ? {
- model: 'gpt-image-2',
- prompt: concept.prompt,
- n: 1,
- size: '1024x1024',
- imageParts: concept.referenceImages.map((imagePath) =>
- path.relative(repoRoot, imagePath),
- ),
- }
- : undefined,
- })),
- },
- 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));
-}
-
-console.log(
- JSON.stringify(
- {
- ok: true,
- count: generated.length,
- files: generated,
- },
- null,
- 2,
- ),
-);
diff --git a/scripts/generate-taonier-mascot-symbol-contact-sheet.py b/scripts/generate-taonier-mascot-symbol-contact-sheet.py
deleted file mode 100644
index 5a295064..00000000
--- a/scripts/generate-taonier-mascot-symbol-contact-sheet.py
+++ /dev/null
@@ -1,107 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-mascot-symbol-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-mascot-symbol-contact-sheet.png"
-
-ITEMS = [
- ("01 抽象人形", "taonier-mascot-symbol-01-clay-humanoid"),
- ("02 陶泥精灵", "taonier-mascot-symbol-02-clay-sprite"),
- ("03 软萌怪物", "taonier-mascot-symbol-03-soft-monster"),
- ("04 抽象动物", "taonier-mascot-symbol-04-animal-abstract"),
- ("05 泥团小灵", "taonier-mascot-symbol-05-clay-orb-being"),
- ("06 轻玩小怪", "taonier-mascot-symbol-06-playful-creature"),
- ("07 头像可读", "taonier-mascot-symbol-07-avatar-readable"),
- ("08 矢量符号感", "taonier-mascot-symbol-08-vector-ready"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- test_height = 46
- gap = 24
- columns = 4
- rows = 2
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f0ebe5")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(20)
- test_font = load_font(14)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f7f3ed",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 68, test_y + 7))
- sheet.paste(mono, (x + 122, test_y + 7))
- draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-mascot-symbol-logo-concepts.mjs b/scripts/generate-taonier-mascot-symbol-logo-concepts.mjs
deleted file mode 100644
index 99013634..00000000
--- a/scripts/generate-taonier-mascot-symbol-logo-concepts.mjs
+++ /dev/null
@@ -1,448 +0,0 @@
-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-mascot-symbol-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: '女性用户友好、全年龄向、年轻明亮但不低幼',
- mascotRules: [
- '必须是 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.',
- 'Brand core belief: fun creation. Users can turn imagination, memes, and ideas into playable AI/UGC casual game works and share them with others.',
- 'Logo type: abstract mascot symbol only. It must be a simple brand mark, not a full character illustration, not a scene, not a sticker sheet, not an app-icon rounded-square background.',
- 'Mascot meaning: a soft clay-born little being that represents playful creation, shareable ideas, and a friendly AI/UGC companion.',
- 'Style: modern minimalist vector mascot mark, compact silhouette, broad simple shapes, fresh bright palette, very light clay warmth only. It must look good and recognizable at 32px favicon size.',
- 'Character abstraction: use a highly simplified head/body silhouette or creature glyph. No detailed anatomy. No clothing. No props. No complex limbs. Optional face should be extremely minimal, at most two small eyes; no mouth unless it is a tiny simple mark.',
- 'Shape language: soft, rounded, slightly irregular, as if gently pinched from clay, but clean and designer-ready. Friendly and memorable, not childish.',
- '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 realistic clay texture, dirty clay, brick, pottery shard, mud pie, rough craft class object, and archaeology stamp feeling.',
- 'Food avoidance: do not make it look like bread, mochi, dumpling, cake, cookie, candy, jelly, donut, dessert, or food packaging.',
- 'Avoid scary monster, aggressive teeth, claws, realistic animal, pet-shop icon, emoji face, toy mascot, chibi over-detailing, or generic blob.',
- 'No star, no spark, no halo, no magic wand, no hand, no pottery tool, no UI, no border, no watermark.',
- 'Composition: centered on a clean light background, generous safe area. Use simple readable silhouette first.',
- 'Validation targets: black-and-white version should still read clearly; the mascot silhouette should be recognizable at 32px.',
-];
-
-const variants = [
- {
- id: '01-clay-humanoid',
- title: '抽象人形',
- prompt: [
- ...basePrompt,
- 'Variant focus: abstract humanoid mascot. A tiny soft clay person-like glyph with rounded head and merged body, no limbs or very minimal arms, friendly but not childish.',
- ],
- },
- {
- id: '02-clay-sprite',
- title: '陶泥精灵',
- prompt: [
- ...basePrompt,
- 'Variant focus: clay sprite. A small semi-dome spirit with a gentle lifted silhouette, like a friendly creative helper, no wings, no magic stars, no fantasy clutter.',
- ],
- },
- {
- id: '03-soft-monster',
- title: '软萌怪物',
- prompt: [
- ...basePrompt,
- 'Variant focus: soft friendly monster glyph. Cute but not scary, no teeth, no claws, one distinctive head shape, perhaps tiny horn-like soft bumps but not devilish.',
- ],
- },
- {
- id: '04-animal-abstract',
- title: '抽象动物',
- prompt: [
- ...basePrompt,
- 'Variant focus: abstract animal-like mascot. Suggest a small rounded creature through ears or tail-like curves, but not a specific real animal and not pet logo.',
- ],
- },
- {
- id: '05-clay-orb-being',
- title: '泥团小灵',
- prompt: [
- ...basePrompt,
- 'Variant focus: orb-like clay being. A simple irregular rounded body with minimal face or no face, strong silhouette, playful creation companion.',
- ],
- },
- {
- id: '06-playful-creature',
- title: '轻玩小怪',
- prompt: [
- ...basePrompt,
- 'Variant focus: more playful creature mark. Dynamic but compact, one distinctive asymmetric curve, readable at 32px, still premium and not a toy brand.',
- ],
- },
- {
- id: '07-avatar-readable',
- title: '头像可读',
- prompt: [
- ...basePrompt,
- 'Variant focus: social avatar and favicon readability. Bold compact mascot head/body silhouette, minimal inner detail, high black-and-white clarity.',
- ],
- },
- {
- id: '08-vector-ready',
- title: '矢量符号感',
- prompt: [
- ...basePrompt,
- 'Variant focus: designer-ready vector mascot concept. 2-3 flat shapes, crisp boundaries, distinctive silhouette, minimal material cue, no illustration shading.',
- ],
- },
-];
-
-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-mascot-symbol-${variant.id}.${image.extension}`);
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(outputDir, 'taonier-logo-mascot-symbol-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,
- ),
-);
diff --git a/scripts/generate-taonier-pair-ears-jar-contact-sheet.py b/scripts/generate-taonier-pair-ears-jar-contact-sheet.py
deleted file mode 100644
index 4e665ce8..00000000
--- a/scripts/generate-taonier-pair-ears-jar-contact-sheet.py
+++ /dev/null
@@ -1,107 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-pair-ears-jar-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-pair-ears-jar-contact-sheet.png"
-
-ITEMS = [
- ("01 兔耳陶罐", "taonier-pair-ears-jar-01-rabbit-jar"),
- ("02 猫耳陶罐", "taonier-pair-ears-jar-02-cat-jar"),
- ("03 狐耳陶罐", "taonier-pair-ears-jar-03-fox-jar"),
- ("04 熊耳陶罐", "taonier-pair-ears-jar-04-bear-jar"),
- ("05 狗耳陶罐", "taonier-pair-ears-jar-05-dog-jar"),
- ("06 双耳组合", "taonier-pair-ears-jar-06-dual-ears"),
- ("07 高罐长耳", "taonier-pair-ears-jar-07-tall-jar"),
- ("08 商标定稿感", "taonier-pair-ears-jar-08-jar-mark"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- test_height = 46
- gap = 24
- columns = 4
- rows = 2
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f0ebe5")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(20)
- test_font = load_font(14)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f7f3ed",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 68, test_y + 7))
- sheet.paste(mono, (x + 122, test_y + 7))
- draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-pair-ears-jar-logo-concepts.mjs b/scripts/generate-taonier-pair-ears-jar-logo-concepts.mjs
deleted file mode 100644
index 2b04cec7..00000000
--- a/scripts/generate-taonier-pair-ears-jar-logo-concepts.mjs
+++ /dev/null
@@ -1,450 +0,0 @@
-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,
- ),
-);
diff --git a/scripts/generate-taonier-peeking-head-jar-blackdot-eye-contact-sheet.py b/scripts/generate-taonier-peeking-head-jar-blackdot-eye-contact-sheet.py
deleted file mode 100644
index 1ea44fb7..00000000
--- a/scripts/generate-taonier-peeking-head-jar-blackdot-eye-contact-sheet.py
+++ /dev/null
@@ -1,112 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-peeking-head-jar-blackdot-eye-concepts"
-)
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-peeking-head-jar-blackdot-eye-contact-sheet.png"
-
-ITEMS = [
- ("01 陶粉兔头", "taonier-peeking-head-jar-blackdot-eye-01-clay-rabbit"),
- ("02 灰陶猫头", "taonier-peeking-head-jar-blackdot-eye-02-ash-cat"),
- ("03 陶红狐头", "taonier-peeking-head-jar-blackdot-eye-03-terracotta-fox"),
- ("04 横纹熊头", "taonier-peeking-head-jar-blackdot-eye-04-striped-bear"),
- ("05 长颈狗头", "taonier-peeking-head-jar-blackdot-eye-05-long-neck-dog"),
- ("06 低矮鼠头", "taonier-peeking-head-jar-blackdot-eye-06-low-mouse"),
- ("07 偏心鹿头", "taonier-peeking-head-jar-blackdot-eye-07-asym-deer"),
- ("08 紧凑熊猫", "taonier-peeking-head-jar-blackdot-eye-08-compact-panda"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- test_height = 46
- gap = 24
- columns = 4
- rows = 2
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f0ebe5")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(20)
- test_font = load_font(14)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f7f3ed",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 68, test_y + 7))
- sheet.paste(mono, (x + 122, test_y + 7))
- draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-peeking-head-jar-blackdot-eye-logo-concepts.mjs b/scripts/generate-taonier-peeking-head-jar-blackdot-eye-logo-concepts.mjs
deleted file mode 100644
index e23c39ac..00000000
--- a/scripts/generate-taonier-peeking-head-jar-blackdot-eye-logo-concepts.mjs
+++ /dev/null
@@ -1,464 +0,0 @@
-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-blackdot-eye-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 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 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 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, or cool ash 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-clay-rabbit',
- title: '陶粉兔头',
- prompt: [
- ...basePrompt,
- 'Variant focus: tall slim clay jar with rabbit ears and a cream rabbit head. Use pale clay beige jar and soft peach ear interiors, eyes are black dots only.',
- ],
- },
- {
- id: '02-ash-cat',
- title: '灰陶猫头',
- prompt: [
- ...basePrompt,
- 'Variant focus: squat ash-clay jar with cat ears and a gray-white cat head. Use muted ash beige jar and compact triangular ears, eyes are black dots only.',
- ],
- },
- {
- id: '03-terracotta-fox',
- title: '陶红狐头',
- prompt: [
- ...basePrompt,
- 'Variant focus: flared terracotta jar with fox ears and orange fox head. Use muted terracotta jar, cream face area, and warm orange ear tips, eyes are black dots only.',
- ],
- },
- {
- id: '04-striped-bear',
- title: '横纹熊头',
- prompt: [
- ...basePrompt,
- 'Variant focus: jar with subtle ceramic stripe bands, bear ears, and a cocoa-brown bear head. The eyes remain black dots only, no extra facial marks.',
- ],
- },
- {
- id: '05-long-neck-dog',
- title: '长颈狗头',
- prompt: [
- ...basePrompt,
- 'Variant focus: long-neck jar with floppy dog ears and a tan dog head. Use a soft gray-beige jar and warm tan ears, eyes are black dots only.',
- ],
- },
- {
- id: '06-low-mouse',
- title: '低矮鼠头',
- prompt: [
- ...basePrompt,
- 'Variant focus: low wide jar with mouse ears and a pale gray mouse head. Add tiny pink ear interiors, eyes are black dots only, cute and slightly mischievous.',
- ],
- },
- {
- id: '07-asym-deer',
- title: '偏心鹿头',
- prompt: [
- ...basePrompt,
- 'Variant focus: slightly asymmetrical jar with deer ears and a soft brown deer head. Keep the ears upright and slender, with a calm cream-beige jar, eyes are black dots only.',
- ],
- },
- {
- id: '08-compact-panda',
- title: '紧凑熊猫',
- prompt: [
- ...basePrompt,
- 'Variant focus: compact trademark jar with panda ears and a black-and-cream panda head. Keep the silhouette bold and simple for strongest brand recognition, 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-blackdot-eye-${variant.id}.${image.extension}`,
- );
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(
- outputDir,
- 'taonier-logo-peeking-head-jar-blackdot-eye-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,
- ),
-);
diff --git a/scripts/generate-taonier-peeking-head-jar-broad-contact-sheet.py b/scripts/generate-taonier-peeking-head-jar-broad-contact-sheet.py
deleted file mode 100644
index 0a5aa7cd..00000000
--- a/scripts/generate-taonier-peeking-head-jar-broad-contact-sheet.py
+++ /dev/null
@@ -1,107 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-peeking-head-jar-broad-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-peeking-head-jar-broad-contact-sheet.png"
-
-ITEMS = [
- ("01 橄榄兔头", "taonier-peeking-head-jar-broad-01-olive-rabbit"),
- ("02 砂陶猫头", "taonier-peeking-head-jar-broad-02-sand-cat"),
- ("03 杏陶狐头", "taonier-peeking-head-jar-broad-03-apricot-fox"),
- ("04 双带熊头", "taonier-peeking-head-jar-broad-04-banded-bear"),
- ("05 细颈狗头", "taonier-peeking-head-jar-broad-05-necked-dog"),
- ("06 扁罐鼠头", "taonier-peeking-head-jar-broad-06-flat-mouse"),
- ("07 斜肩鹿头", "taonier-peeking-head-jar-broad-07-tilted-deer"),
- ("08 紧凑熊猫", "taonier-peeking-head-jar-broad-08-compact-panda"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- test_height = 46
- gap = 24
- columns = 4
- rows = 2
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f0ebe5")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(20)
- test_font = load_font(14)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f7f3ed",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 68, test_y + 7))
- sheet.paste(mono, (x + 122, test_y + 7))
- draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-peeking-head-jar-broad-logo-concepts.mjs b/scripts/generate-taonier-peeking-head-jar-broad-logo-concepts.mjs
deleted file mode 100644
index 55f4f9dd..00000000
--- a/scripts/generate-taonier-peeking-head-jar-broad-logo-concepts.mjs
+++ /dev/null
@@ -1,464 +0,0 @@
-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-broad-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-olive-rabbit',
- title: '橄榄兔头',
- prompt: [
- ...basePrompt,
- 'Variant focus: tall slim jar with a muted olive-clay body and rabbit ears. The rabbit head is cream colored with soft peach inner ears, eyes are black dots only.',
- ],
- },
- {
- id: '02-sand-cat',
- title: '砂陶猫头',
- prompt: [
- ...basePrompt,
- 'Variant focus: squat sand-colored jar with cat ears and a gray-white cat head. Make the rim compact and the body broad, eyes are black dots only.',
- ],
- },
- {
- id: '03-apricot-fox',
- title: '杏陶狐头',
- prompt: [
- ...basePrompt,
- 'Variant focus: flared apricot-terracotta jar with fox ears and a warm orange fox head. Use a cream face area and strong ear silhouette, eyes are black dots only.',
- ],
- },
- {
- id: '04-banded-bear',
- title: '双带熊头',
- prompt: [
- ...basePrompt,
- 'Variant focus: jar with two subtle ceramic bands, bear ears, and a cocoa-brown bear head. Keep the vessel sturdy and broad, eyes are black dots only.',
- ],
- },
- {
- id: '05-necked-dog',
- title: '细颈狗头',
- prompt: [
- ...basePrompt,
- 'Variant focus: tall narrow-neck jar with floppy dog ears and a tan dog head. Use a warm gray-beige jar and slightly longer ear shapes, eyes are black dots only.',
- ],
- },
- {
- id: '06-flat-mouse',
- title: '扁罐鼠头',
- prompt: [
- ...basePrompt,
- 'Variant focus: low flat jar with mouse ears and a pale gray mouse head. Add tiny pink ear interiors and a wider mouth rim, eyes are black dots only.',
- ],
- },
- {
- id: '07-tilted-deer',
- title: '斜肩鹿头',
- prompt: [
- ...basePrompt,
- 'Variant focus: slightly tilted jar with deer ears and a soft brown deer head. Use a calm cream-beige jar with a subtle shoulder shift, eyes are black dots only.',
- ],
- },
- {
- id: '08-compact-panda',
- title: '紧凑熊猫',
- prompt: [
- ...basePrompt,
- 'Variant focus: compact trademark jar with panda ears and a black-and-cream panda head. Keep the silhouette bold, simple, and easy to read at 32px, 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-broad-${variant.id}.${image.extension}`,
- );
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(
- outputDir,
- 'taonier-logo-peeking-head-jar-broad-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,
- ),
-);
diff --git a/scripts/generate-taonier-peeking-head-jar-contact-sheet.py b/scripts/generate-taonier-peeking-head-jar-contact-sheet.py
deleted file mode 100644
index 1f8a3427..00000000
--- a/scripts/generate-taonier-peeking-head-jar-contact-sheet.py
+++ /dev/null
@@ -1,107 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-peeking-head-jar-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-peeking-head-jar-contact-sheet.png"
-
-ITEMS = [
- ("01 兔头探出", "taonier-peeking-head-jar-01-rabbit-peek"),
- ("02 猫头探出", "taonier-peeking-head-jar-02-cat-peek"),
- ("03 狐头探出", "taonier-peeking-head-jar-03-fox-peek"),
- ("04 熊头探出", "taonier-peeking-head-jar-04-bear-peek"),
- ("05 狗头探出", "taonier-peeking-head-jar-05-dog-peek"),
- ("06 混合小灵", "taonier-peeking-head-jar-06-mixed-peek"),
- ("07 高罐探头", "taonier-peeking-head-jar-07-tall-peek"),
- ("08 商标探头", "taonier-peeking-head-jar-08-trademark-peek"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- test_height = 46
- gap = 24
- columns = 4
- rows = 2
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f0ebe5")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(20)
- test_font = load_font(14)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f7f3ed",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 68, test_y + 7))
- sheet.paste(mono, (x + 122, test_y + 7))
- draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-peeking-head-jar-expanded-contact-sheet.py b/scripts/generate-taonier-peeking-head-jar-expanded-contact-sheet.py
deleted file mode 100644
index 2d043043..00000000
--- a/scripts/generate-taonier-peeking-head-jar-expanded-contact-sheet.py
+++ /dev/null
@@ -1,112 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-peeking-head-jar-expanded-concepts"
-)
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-peeking-head-jar-expanded-contact-sheet.png"
-
-ITEMS = [
- ("01 高罐兔耳", "taonier-peeking-head-jar-expanded-01-tall-rabbit"),
- ("02 矮罐猫头", "taonier-peeking-head-jar-expanded-02-squat-cat"),
- ("03 阔口狐头", "taonier-peeking-head-jar-expanded-03-flared-fox"),
- ("04 双圈熊头", "taonier-peeking-head-jar-expanded-04-double-band-bear"),
- ("05 长颈狗头", "taonier-peeking-head-jar-expanded-05-long-neck-dog"),
- ("06 低矮鼠头", "taonier-peeking-head-jar-expanded-06-low-mouse"),
- ("07 偏心鹿头", "taonier-peeking-head-jar-expanded-07-asym-deer"),
- ("08 紧凑熊猫", "taonier-peeking-head-jar-expanded-08-compact-panda"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- test_height = 46
- gap = 24
- columns = 4
- rows = 2
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f0ebe5")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(20)
- test_font = load_font(14)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f7f3ed",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 68, test_y + 7))
- sheet.paste(mono, (x + 122, test_y + 7))
- draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-peeking-head-jar-expanded-logo-concepts.mjs b/scripts/generate-taonier-peeking-head-jar-expanded-logo-concepts.mjs
deleted file mode 100644
index 622ab03f..00000000
--- a/scripts/generate-taonier-peeking-head-jar-expanded-logo-concepts.mjs
+++ /dev/null
@@ -1,461 +0,0 @@
-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-expanded-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 a small animal peeking out only to eye level. Show the animal ears, the upper half of the head, and two simple 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 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.',
- '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, or cool ash 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-tall-rabbit',
- title: '高罐兔耳',
- prompt: [
- ...basePrompt,
- 'Variant focus: tall slim jar with soft rabbit ears and a cream rabbit head peeking out. Use pale beige jar and soft peach inner ears, elegant and light.',
- ],
- },
- {
- id: '02-squat-cat',
- title: '矮罐猫头',
- prompt: [
- ...basePrompt,
- 'Variant focus: squat round jar with cat ears and a gray-white cat head. Use a warmer taupe jar and small triangular ears, compact and cozy.',
- ],
- },
- {
- id: '03-flared-fox',
- title: '阔口狐头',
- prompt: [
- ...basePrompt,
- 'Variant focus: flared-rim jar with fox ears and orange fox head. Use muted terracotta jar, cream face area, and warm orange ear tips.',
- ],
- },
- {
- id: '04-double-band-bear',
- title: '双圈熊头',
- prompt: [
- ...basePrompt,
- 'Variant focus: double-band jar with bear ears and a cocoa-brown bear head. The jar can have two subtle horizontal rings for ceramic rhythm.',
- ],
- },
- {
- id: '05-long-neck-dog',
- title: '长颈狗头',
- prompt: [
- ...basePrompt,
- 'Variant focus: long-neck jar with floppy dog ears and a tan dog head. Use a soft gray-beige jar and warm tan ears, friendly and upright.',
- ],
- },
- {
- id: '06-low-mouse',
- title: '低矮鼠头',
- prompt: [
- ...basePrompt,
- 'Variant focus: low wide jar with mouse ears and a pale gray mouse head. Add tiny pink ear interiors, cute and slightly mischievous.',
- ],
- },
- {
- id: '07-asym-deer',
- title: '偏心鹿头',
- prompt: [
- ...basePrompt,
- 'Variant focus: slightly asymmetrical jar with deer ears and a soft brown deer head. Keep the ears upright and slender, with a calm cream-beige jar.',
- ],
- },
- {
- id: '08-compact-panda',
- title: '紧凑熊猫',
- prompt: [
- ...basePrompt,
- 'Variant focus: compact trademark jar with panda ears and a black-and-cream panda head. Keep the silhouette bold and simple for strongest brand recognition.',
- ],
- },
-];
-
-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-expanded-${variant.id}.${image.extension}`,
- );
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(
- outputDir,
- 'taonier-logo-peeking-head-jar-expanded-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,
- ),
-);
diff --git a/scripts/generate-taonier-peeking-head-jar-logo-concepts.mjs b/scripts/generate-taonier-peeking-head-jar-logo-concepts.mjs
deleted file mode 100644
index d568ff0f..00000000
--- a/scripts/generate-taonier-peeking-head-jar-logo-concepts.mjs
+++ /dev/null
@@ -1,452 +0,0 @@
-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-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 a small animal peeking out only to eye level. Show the animal ears, the upper half of the head, and two simple 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 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.',
- 'Jar policy: the jar itself has no face or expression. No eyes, no smile, no blush, no emoji marks on the jar.',
- 'Optional tiny jar feet or short side 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. Animal head and ears should use soft natural animal colors that fit the variant.',
- '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-rabbit-peek',
- title: '兔头探出',
- prompt: [
- ...basePrompt,
- 'Variant focus: rabbit. Long soft rabbit ears, upper half of rabbit head and two simple eyes peeking above the jar rim, gentle and premium.',
- ],
- },
- {
- id: '02-cat-peek',
- title: '猫头探出',
- prompt: [
- ...basePrompt,
- 'Variant focus: cat. Small triangular cat ears, upper half of cat head and two simple eyes peeking out, clever and cute, no whiskers.',
- ],
- },
- {
- id: '03-fox-peek',
- title: '狐头探出',
- prompt: [
- ...basePrompt,
- 'Variant focus: fox. Slender fox ears, warm orange upper head with two simple eyes, playful but not sharp or aggressive.',
- ],
- },
- {
- id: '04-bear-peek',
- title: '熊头探出',
- prompt: [
- ...basePrompt,
- 'Variant focus: bear. Rounded bear ears and rounded upper head, two simple eyes, cozy and calm, no muzzle.',
- ],
- },
- {
- id: '05-dog-peek',
- title: '狗头探出',
- prompt: [
- ...basePrompt,
- 'Variant focus: dog. Soft floppy dog ears and upper head peeking from the jar, friendly but not a pet logo, no nose or mouth.',
- ],
- },
- {
- id: '06-mixed-peek',
- title: '混合小灵',
- prompt: [
- ...basePrompt,
- 'Variant focus: ambiguous animal spirit. Ear shapes sit between rabbit and cat, upper head and eyes only, more original and less species-specific.',
- ],
- },
- {
- id: '07-tall-peek',
- title: '高罐探头',
- prompt: [
- ...basePrompt,
- 'Variant focus: taller jar silhouette with animal head peeking to eye level. Make the jar and head relationship clear at favicon size.',
- ],
- },
- {
- id: '08-trademark-peek',
- title: '商标探头',
- prompt: [
- ...basePrompt,
- 'Variant focus: strongest trademark readability. Compact jar, simple half-head, two eyes, very few 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-peeking-head-jar-${variant.id}.${image.extension}`);
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(outputDir, 'taonier-logo-peeking-head-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,
- ),
-);
diff --git a/scripts/generate-taonier-peeking-head-jar-new-animals-contact-sheet.py b/scripts/generate-taonier-peeking-head-jar-new-animals-contact-sheet.py
deleted file mode 100644
index 84109ff9..00000000
--- a/scripts/generate-taonier-peeking-head-jar-new-animals-contact-sheet.py
+++ /dev/null
@@ -1,112 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-peeking-head-jar-new-animals-concepts"
-)
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-peeking-head-jar-new-animals-contact-sheet.png"
-
-ITEMS = [
- ("01 水豚头", "taonier-peeking-head-jar-new-animals-01-capybara"),
- ("02 仓鼠头", "taonier-peeking-head-jar-new-animals-02-hamster"),
- ("03 考拉头", "taonier-peeking-head-jar-new-animals-03-koala"),
- ("04 水獭头", "taonier-peeking-head-jar-new-animals-04-otter"),
- ("05 松鼠头", "taonier-peeking-head-jar-new-animals-05-squirrel"),
- ("06 浣熊头", "taonier-peeking-head-jar-new-animals-06-raccoon"),
- ("07 小羊头", "taonier-peeking-head-jar-new-animals-07-lamb"),
- ("08 刺猬头", "taonier-peeking-head-jar-new-animals-08-hedgehog"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- test_height = 46
- gap = 24
- columns = 4
- rows = 2
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f0ebe5")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(20)
- test_font = load_font(14)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f7f3ed",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 68, test_y + 7))
- sheet.paste(mono, (x + 122, test_y + 7))
- draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-peeking-head-jar-new-animals-logo-concepts.mjs b/scripts/generate-taonier-peeking-head-jar-new-animals-logo-concepts.mjs
deleted file mode 100644
index 7b52b506..00000000
--- a/scripts/generate-taonier-peeking-head-jar-new-animals-logo-concepts.mjs
+++ /dev/null
@@ -1,464 +0,0 @@
-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,
- ),
-);
diff --git a/scripts/generate-taonier-playful-bean-contact-sheet.py b/scripts/generate-taonier-playful-bean-contact-sheet.py
deleted file mode 100644
index c5fcb486..00000000
--- a/scripts/generate-taonier-playful-bean-contact-sheet.py
+++ /dev/null
@@ -1,107 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-playful-bean-concepts"
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-playful-bean-contact-sheet.png"
-
-ITEMS = [
- ("01 清新豆形标", "taonier-playful-bean-01-fresh-bean-mark"),
- ("02 蜜桃软几何", "taonier-playful-bean-02-peach-soft-geometry"),
- ("03 青绿创作胚", "taonier-playful-bean-03-mint-creation-embryo"),
- ("04 女性向明亮款", "taonier-playful-bean-04-female-bright-mark"),
- ("05 全龄轻玩款", "taonier-playful-bean-05-all-age-play-mark"),
- ("06 黑白优先款", "taonier-playful-bean-06-monochrome-first"),
- ("07 头像小尺寸款", "taonier-playful-bean-07-avatar-readable"),
- ("08 矢量定稿感款", "taonier-playful-bean-08-vector-ready"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- test_height = 46
- gap = 24
- columns = 4
- rows = 2
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f0ebe5")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(20)
- test_font = load_font(14)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f7f3ed",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 68, test_y + 7))
- sheet.paste(mono, (x + 122, test_y + 7))
- draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-playful-bean-logo-concepts.mjs b/scripts/generate-taonier-playful-bean-logo-concepts.mjs
deleted file mode 100644
index 0193bc63..00000000
--- a/scripts/generate-taonier-playful-bean-logo-concepts.mjs
+++ /dev/null
@@ -1,444 +0,0 @@
-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-playful-bean-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 轻休闲小游戏创作与传播平台,用户像捏陶泥一样把脑洞、梗和灵感塑造成可玩的作品',
- coreMetaphor: '已经成形的可玩作品胚',
- audience: '女性用户友好、全年龄向、年轻明亮但不低幼',
- visualLanguage: '抽象但有玩性的软几何玩具感',
- material: '只保留陶泥温度,不追求泥土质感',
- shape: '闭合不规则圆润豆形,外轮廓流畅、亲和、有玩性',
- colors: ['珊瑚橙', '蜜桃粉', '奶油白', '清透青绿', '少量暖黄或柔紫可选'],
- 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.',
- 'Logo meaning: an already-formed playable creation embryo. It should feel like a small friendly finished creative object, not a process diagram and not raw clay.',
- 'Logo type: abstract symbol/icon only. No wordmark, no mascot, no face, no app-icon rounded-square background.',
- 'Main element: one closed irregular rounded bean-like soft geometric shape. The outer contour must be smooth, friendly, memorable, complete, and vector-friendly.',
- 'The shape must not be a square, rounded square, circle, ellipse, simple blob, ribbon, swirl, S shape, G shape, open ring, loose strip, badge, stamp, or tile.',
- 'The symbol should be abstract but playful: it should feel like a soft geometric toy-like creation object, mature enough for a premium brand and not childish.',
- 'Internal design: optional 1-2 broad curved color fields, soft cut-ins, or abstract playful inlays. Do not show a transformation process. Do not add a center icon.',
- 'No star, no spark, no star-shaped negative space, no eyes, no mouth, no character body, no hand, no pottery tool.',
- 'Style: modern minimalist vector logo. Flat, crisp, simple broad shapes, very light clay warmth only. No realistic texture. It must look good and recognizable at 32px favicon size.',
- 'Color direction: fresh bright palette for women-friendly and all-age users: coral orange, peach pink, cream white, clear soft teal or mint green. Use brand-color flat shapes, not candy rendering.',
- 'Avoid muted mud colors as the dominant palette. Avoid dirty clay, brick, pottery shard, mud pie, rough craft class object, and archaeology stamp feeling.',
- 'Food avoidance is critical: do not make it look like bread, chocolate bread, pastry, cookie, candy, jelly, donut, cream filling, sauce, baked dough, dessert, fruit candy, or food packaging.',
- '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 closed bean-like silhouette should be recognizable at 32px.',
-];
-
-const variants = [
- {
- id: '01-fresh-bean-mark',
- title: '清新豆形标',
- prompt: [
- ...basePrompt,
- 'Variant focus: the cleanest fresh bean mark. Use coral orange and cream white with a tiny soft teal accent. Strong closed irregular bean silhouette, very readable.',
- ],
- },
- {
- id: '02-peach-soft-geometry',
- title: '蜜桃软几何',
- prompt: [
- ...basePrompt,
- 'Variant focus: peach pink and coral soft geometry. Feminine-friendly but not cosmetic, not candy. One smooth inner color field supports the closed bean shape.',
- ],
- },
- {
- id: '03-mint-creation-embryo',
- title: '青绿创作胚',
- prompt: [
- ...basePrompt,
- 'Variant focus: clear mint or teal as the memory accent, with warm cream and coral. The mark should feel like a playable creation object, not a leaf or seed.',
- ],
- },
- {
- id: '04-female-bright-mark',
- title: '女性向明亮款',
- prompt: [
- ...basePrompt,
- 'Variant focus: brighter women-friendly palette, soft coral, peach, cream, and one clean mint accent. Keep it premium and avoid beauty-brand cliché.',
- ],
- },
- {
- id: '05-all-age-play-mark',
- title: '全龄轻玩款',
- prompt: [
- ...basePrompt,
- 'Variant focus: all-age casual play. More energetic and memorable, but still simple. Use two or three flat color fields, no small decorative details.',
- ],
- },
- {
- id: '06-monochrome-first',
- title: '黑白优先款',
- prompt: [
- ...basePrompt,
- 'Variant focus: design for black-and-white survival first. Bold positive and negative shapes, color only supports the structure. No delicate gradients.',
- ],
- },
- {
- id: '07-avatar-readable',
- title: '头像小尺寸款',
- prompt: [
- ...basePrompt,
- 'Variant focus: social avatar and favicon readability. Full, compact closed bean silhouette with one distinctive broad internal curve; no tiny dots.',
- ],
- },
- {
- id: '08-vector-ready',
- title: '矢量定稿感款',
- prompt: [
- ...basePrompt,
- 'Variant focus: designer-ready vector concept. 2-3 flat shapes, crisp boundaries, distinctive closed rounded-bean contour, 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-playful-bean-${variant.id}.${image.extension}`);
- writeFileSync(outputPath, image.bytes);
- return outputPath;
-}
-
-function writeManifest(files) {
- const manifestPath = path.join(outputDir, 'taonier-logo-playful-bean-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,
- ),
-);
diff --git a/scripts/generate-taonier-ref04-locked-color-variants.py b/scripts/generate-taonier-ref04-locked-color-variants.py
deleted file mode 100644
index ef31b5cb..00000000
--- a/scripts/generate-taonier-ref04-locked-color-variants.py
+++ /dev/null
@@ -1,544 +0,0 @@
-from collections import deque
-import math
-from pathlib import Path
-
-from PIL import Image, ImageChops, ImageDraw, ImageFont
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-REFERENCE_PATH = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-punch-hole-concepts"
- / "taonier-punch-color-inlay.png"
-)
-OUTPUT_DIR = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-ref04-locked-color-concepts"
-)
-
-
-def is_red(pixel):
- r, g, b = pixel
- return r > 160 and g < 155 and b < 145 and r - g > 45
-
-
-def is_cyan(pixel):
- r, g, b = pixel
- return r < 120 and g > 125 and b > 125 and b - r > 65
-
-
-def is_open_light(pixel):
- r, g, b = pixel
- lum = (r + g + b) // 3
- return lum > 174 and max(pixel) - min(pixel) < 105 and not is_red(pixel) and not is_cyan(pixel)
-
-
-def colorize(pixel, target, category):
- r, g, b = pixel
- lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
- if category == "dark":
- factor = 0.88 + min(lum, 0.42) * 0.44
- else:
- factor = 0.9 + lum * 0.2
- return tuple(max(0, min(255, round(channel * factor))) for channel in target)
-
-
-def build_masks(image):
- width, height = image.size
- pixels = image.load()
- open_mask = bytearray(width * height)
-
- for y in range(height):
- for x in range(width):
- if is_open_light(pixels[x, y]):
- open_mask[y * width + x] = 1
-
- # 从画布边缘连通的浅色区域是外部背景;剩下的浅色闭合区域就是中孔。
- external_mask = bytearray(width * height)
- queue = deque()
- for x in range(width):
- for y in (0, height - 1):
- index = y * width + x
- if open_mask[index] and not external_mask[index]:
- external_mask[index] = 1
- queue.append((x, y))
- for y in range(height):
- for x in (0, width - 1):
- index = y * width + x
- if open_mask[index] and not external_mask[index]:
- external_mask[index] = 1
- queue.append((x, y))
-
- while queue:
- x, y = queue.popleft()
- for next_x, next_y in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
- if 0 <= next_x < width and 0 <= next_y < height:
- index = next_y * width + next_x
- if open_mask[index] and not external_mask[index]:
- external_mask[index] = 1
- queue.append((next_x, next_y))
-
- hole_mask = bytearray(width * height)
- red_mask = bytearray(width * height)
- cyan_mask = bytearray(width * height)
- dark_mask = bytearray(width * height)
- for y in range(height):
- for x in range(width):
- index = y * width + x
- pixel = pixels[x, y]
- if open_mask[index] and not external_mask[index]:
- hole_mask[index] = 1
- elif not external_mask[index]:
- if is_red(pixel):
- red_mask[index] = 1
- elif is_cyan(pixel):
- cyan_mask[index] = 1
- elif not open_mask[index]:
- dark_mask[index] = 1
-
- return {
- "dark": dark_mask,
- "red": red_mask,
- "cyan": cyan_mask,
- "hole": hole_mask,
- }
-
-
-def mask_bounds(mask, width, height):
- xs = []
- ys = []
- for index, value in enumerate(mask):
- if value:
- xs.append(index % width)
- ys.append(index // width)
- return min(xs), min(ys), max(xs), max(ys)
-
-
-def draw_center_content(size, hole_mask, variant):
- width, height = size
- hole_bounds = mask_bounds(hole_mask, width, height)
- left, top, right, bottom = hole_bounds
- center_x = (left + right) / 2
- center_y = (top + bottom) / 2
- hole_w = right - left
- hole_h = bottom - top
-
- scale = 4
- layer = Image.new("RGBA", (width * scale, height * scale), (0, 0, 0, 0))
- draw = ImageDraw.Draw(layer)
-
- def box(cx, cy, w, h):
- return [
- int((cx - w / 2) * scale),
- int((cy - h / 2) * scale),
- int((cx + w / 2) * scale),
- int((cy + h / 2) * scale),
- ]
-
- def rounded(cx, cy, w, h, radius, fill):
- draw.rounded_rectangle(box(cx, cy, w, h), radius=int(radius * scale), fill=fill)
-
- def ellipse(cx, cy, w, h, fill):
- draw.ellipse(box(cx, cy, w, h), fill=fill)
-
- def star(cx, cy, outer, inner, fill):
- points = []
- for index in range(10):
- angle = -90 + index * 36
- radius = outer if index % 2 == 0 else inner
- points.append(
- (
- int((cx + radius * math.cos(math.radians(angle))) * scale),
- int((cy + radius * math.sin(math.radians(angle))) * scale),
- )
- )
- draw.polygon(points, fill=fill)
-
- def sparkle(cx, cy, radius, fill, with_rays=False):
- points = []
- point_count = 128
- for index in range(point_count):
- theta = -math.pi / 2 + index * math.tau / point_count
- pulse = abs(math.cos(2 * theta)) ** 4.2
- current_radius = radius * (0.12 + 0.88 * pulse)
- points.append(
- (
- int((cx + math.cos(theta) * current_radius * 0.78) * scale),
- int((cy + math.sin(theta) * current_radius * 1.12) * scale),
- )
- )
- draw.polygon(points, fill=fill)
- if not with_rays:
- return
-
- ray_color = fill
- line_width = max(4, int(radius * 0.11 * scale))
- cap = line_width // 2
-
- def rounded_line(start, end):
- draw.line(
- (
- int(start[0] * scale),
- int(start[1] * scale),
- int(end[0] * scale),
- int(end[1] * scale),
- ),
- fill=ray_color,
- width=line_width,
- )
- for point in (start, end):
- draw.ellipse(
- (
- int(point[0] * scale) - cap,
- int(point[1] * scale) - cap,
- int(point[0] * scale) + cap,
- int(point[1] * scale) + cap,
- ),
- fill=ray_color,
- )
-
- rounded_line((cx - radius * 1.48, cy - radius * 0.15), (cx - radius * 1.18, cy - radius * 0.08))
- rounded_line((cx - radius * 1.35, cy + radius * 0.5), (cx - radius * 1.1, cy + radius * 0.3))
- rounded_line((cx + radius * 1.12, cy - radius * 0.42), (cx + radius * 1.33, cy - radius * 0.64))
- rounded_line((cx + radius * 1.25, cy + radius * 0.08), (cx + radius * 1.52, cy + radius * 0.15))
-
- if variant == "cream_seed":
- rounded(center_x, center_y, hole_w * 0.42, hole_h * 0.34, 44, (244, 216, 166, 255))
- elif variant == "soft_dot":
- rounded(center_x, center_y, hole_w * 0.32, hole_h * 0.28, 36, (250, 219, 157, 255))
- elif variant == "double_piece":
- rounded(center_x - hole_w * 0.08, center_y + hole_h * 0.01, hole_w * 0.24, hole_h * 0.22, 30, (249, 202, 174, 255))
- rounded(center_x + hole_w * 0.13, center_y - hole_h * 0.02, hole_w * 0.22, hole_h * 0.21, 28, (143, 207, 205, 255))
- elif variant == "tiny_kernel":
- ellipse(center_x, center_y, hole_w * 0.26, hole_h * 0.24, (252, 223, 157, 255))
- elif variant == "filled_core":
- rounded(center_x, center_y, hole_w * 0.58, hole_h * 0.5, 58, (248, 231, 196, 255))
- ellipse(center_x - hole_w * 0.09, center_y + hole_h * 0.02, hole_w * 0.13, hole_h * 0.12, (240, 93, 82, 255))
- ellipse(center_x + hole_w * 0.1, center_y - hole_h * 0.02, hole_w * 0.13, hole_h * 0.12, (20, 183, 196, 255))
- elif variant == "clay_pearl":
- rounded(center_x, center_y, hole_w * 0.36, hole_h * 0.3, 40, (255, 226, 177, 255))
- ellipse(center_x + hole_w * 0.06, center_y - hole_h * 0.05, hole_w * 0.08, hole_h * 0.07, (255, 246, 220, 180))
- elif variant == "cream_star":
- star(center_x, center_y, min(hole_w, hole_h) * 0.17, min(hole_w, hole_h) * 0.075, (255, 223, 154, 255))
- elif variant == "small_star":
- star(center_x, center_y, min(hole_w, hole_h) * 0.135, min(hole_w, hole_h) * 0.06, (255, 231, 177, 255))
- elif variant == "soft_star_badge":
- rounded(center_x, center_y, hole_w * 0.38, hole_h * 0.34, 42, (255, 239, 207, 255))
- star(center_x, center_y, min(hole_w, hole_h) * 0.115, min(hole_w, hole_h) * 0.052, (238, 129, 80, 255))
- elif variant == "coral_star":
- star(center_x, center_y, min(hole_w, hole_h) * 0.15, min(hole_w, hole_h) * 0.067, (241, 108, 82, 255))
- elif variant == "mint_star":
- star(center_x, center_y, min(hole_w, hole_h) * 0.15, min(hole_w, hole_h) * 0.067, (78, 198, 183, 255))
- elif variant == "soft_sparkle":
- sparkle(center_x, center_y, min(hole_w, hole_h) * 0.18, (255, 205, 61, 255), True)
- elif variant == "small_sparkle":
- sparkle(center_x, center_y, min(hole_w, hole_h) * 0.145, (255, 214, 91, 255), True)
- elif variant == "bright_sparkle":
- sparkle(center_x, center_y, min(hole_w, hole_h) * 0.17, (255, 197, 43, 255), True)
- elif variant == "quiet_sparkle":
- sparkle(center_x, center_y, min(hole_w, hole_h) * 0.155, (255, 224, 139, 255), False)
-
- layer = layer.resize(size, Image.Resampling.LANCZOS)
- alpha = Image.frombytes("L", size, bytes(255 if value else 0 for value in hole_mask))
- layer_alpha = layer.getchannel("A")
- layer.putalpha(ImageChops.multiply(layer_alpha, alpha))
- return layer
-
-
-VARIANTS = [
- {
- "id": "taonier-ref04-locked-warm-ink",
- "label": "01 warm",
- "dark": (63, 58, 53),
- "red": (243, 82, 69),
- "cyan": (14, 183, 198),
- "content": "cream_seed",
- },
- {
- "id": "taonier-ref04-locked-blue-ink",
- "label": "02 blue",
- "dark": (30, 39, 72),
- "red": (255, 89, 84),
- "cyan": (28, 181, 207),
- "content": "soft_dot",
- },
- {
- "id": "taonier-ref04-locked-plum-ink",
- "label": "03 plum",
- "dark": (69, 53, 72),
- "red": (255, 98, 86),
- "cyan": (34, 188, 198),
- "content": "double_piece",
- },
- {
- "id": "taonier-ref04-locked-green-ink",
- "label": "04 green",
- "dark": (11, 83, 78),
- "red": (255, 107, 88),
- "cyan": (68, 209, 192),
- "content": "tiny_kernel",
- },
- {
- "id": "taonier-ref04-locked-shrink-core",
- "label": "05 fill",
- "dark": (43, 43, 47),
- "red": (239, 84, 75),
- "cyan": (17, 178, 193),
- "content": "filled_core",
- },
- {
- "id": "taonier-ref04-locked-soft-charcoal",
- "label": "06 soft",
- "dark": (82, 76, 68),
- "red": (242, 105, 90),
- "cyan": (37, 188, 195),
- "content": "clay_pearl",
- },
-]
-
-STAR_OUTPUT_DIR = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-ref04-warm-star-concepts"
-)
-
-STAR_VARIANTS = [
- {
- "id": "taonier-ref04-warm-star-terracotta",
- "label": "01 clay",
- "dark": (121, 76, 54),
- "red": (244, 86, 70),
- "cyan": (15, 184, 198),
- "content": "cream_star",
- },
- {
- "id": "taonier-ref04-warm-star-caramel",
- "label": "02 caramel",
- "dark": (142, 94, 51),
- "red": (247, 91, 73),
- "cyan": (13, 185, 196),
- "content": "small_star",
- },
- {
- "id": "taonier-ref04-warm-star-cocoa",
- "label": "03 cocoa",
- "dark": (89, 64, 47),
- "red": (240, 88, 72),
- "cyan": (17, 181, 194),
- "content": "soft_star_badge",
- },
- {
- "id": "taonier-ref04-warm-star-rust",
- "label": "04 rust",
- "dark": (111, 62, 54),
- "red": (249, 93, 75),
- "cyan": (15, 184, 198),
- "content": "cream_star",
- },
- {
- "id": "taonier-ref04-warm-star-olive",
- "label": "05 olive",
- "dark": (92, 81, 48),
- "red": (245, 94, 76),
- "cyan": (25, 185, 187),
- "content": "coral_star",
- },
- {
- "id": "taonier-ref04-warm-star-plum",
- "label": "06 plum",
- "dark": (95, 57, 66),
- "red": (250, 94, 81),
- "cyan": (26, 185, 197),
- "content": "mint_star",
- },
-]
-
-SPARKLE_OUTPUT_DIR = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-ref04-warm-sparkle-concepts"
-)
-
-SPARKLE_V2_OUTPUT_DIR = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-ref04-warm-sparkle-v2-concepts"
-)
-
-PALETTE_TRANSFER_OUTPUT_DIR = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-ref04-palette-transfer"
-)
-
-SPARKLE_VARIANTS = [
- {
- "id": "taonier-ref04-warm-sparkle-terracotta",
- "label": "01 clay",
- "dark": (121, 76, 54),
- "red": (244, 86, 70),
- "cyan": (15, 184, 198),
- "content": "soft_sparkle",
- },
- {
- "id": "taonier-ref04-warm-sparkle-rust",
- "label": "02 rust",
- "dark": (111, 62, 54),
- "red": (249, 93, 75),
- "cyan": (15, 184, 198),
- "content": "soft_sparkle",
- },
- {
- "id": "taonier-ref04-warm-sparkle-caramel",
- "label": "03 caramel",
- "dark": (142, 94, 51),
- "red": (247, 91, 73),
- "cyan": (13, 185, 196),
- "content": "small_sparkle",
- },
- {
- "id": "taonier-ref04-warm-sparkle-cocoa",
- "label": "04 cocoa",
- "dark": (89, 64, 47),
- "red": (240, 88, 72),
- "cyan": (17, 181, 194),
- "content": "bright_sparkle",
- },
- {
- "id": "taonier-ref04-warm-sparkle-clay-quiet",
- "label": "05 quiet",
- "dark": (121, 76, 54),
- "red": (244, 86, 70),
- "cyan": (15, 184, 198),
- "content": "quiet_sparkle",
- },
- {
- "id": "taonier-ref04-warm-sparkle-plum",
- "label": "06 plum",
- "dark": (95, 57, 66),
- "red": (250, 94, 81),
- "cyan": (26, 185, 197),
- "content": "soft_sparkle",
- },
-]
-
-PALETTE_TRANSFER_VARIANTS = [
- {
- "id": "taonier-ref04-palette-transfer-warm-yellow-sparkle",
- "label": "transfer",
- "dark": (224, 162, 58),
- "red": (255, 113, 132),
- "cyan": (91, 213, 192),
- "content": "soft_sparkle",
- },
-]
-
-
-def apply_variant(reference, masks, variant):
- image = reference.copy().convert("RGBA")
- source = reference.convert("RGB")
- width, height = source.size
- source_pixels = source.load()
- result_pixels = image.load()
-
- for y in range(height):
- for x in range(width):
- index = y * width + x
- pixel = source_pixels[x, y]
- if masks["dark"][index]:
- result_pixels[x, y] = (*colorize(pixel, variant["dark"], "dark"), 255)
- elif masks["red"][index]:
- result_pixels[x, y] = (*colorize(pixel, variant["red"], "accent"), 255)
- elif masks["cyan"][index]:
- result_pixels[x, y] = (*colorize(pixel, variant["cyan"], "accent"), 255)
-
- content = draw_center_content(source.size, masks["hole"], variant["content"])
- return Image.alpha_composite(image, content).convert("RGB")
-
-
-def build_contact_sheet(items, output_path):
- thumb = 260
- label_h = 34
- pad = 18
- cols = 4
- rows = (len(items) + cols - 1) // cols
- sheet_w = cols * thumb + (cols + 1) * pad
- sheet_h = rows * (thumb + label_h) + (rows + 1) * pad
- sheet = Image.new("RGB", (sheet_w, sheet_h), "#f7f3ea")
- draw = ImageDraw.Draw(sheet)
- try:
- font = ImageFont.truetype("arial.ttf", 18)
- except OSError:
- font = ImageFont.load_default()
-
- for index, (label, path) in enumerate(items):
- image = Image.open(path).convert("RGB")
- image.thumbnail((thumb, thumb), Image.Resampling.LANCZOS)
- row, col = divmod(index, cols)
- x = pad + col * (thumb + pad)
- y = pad + row * (thumb + label_h + pad)
- bg = Image.new("RGB", (thumb, thumb), "#fffaf1")
- bg.paste(image, ((thumb - image.width) // 2, (thumb - image.height) // 2))
- sheet.paste(bg, (x, y))
- draw.rectangle((x, y, x + thumb - 1, y + thumb - 1), outline="#ded5c6", width=1)
- bbox = draw.textbbox((0, 0), label, font=font)
- draw.text((x + (thumb - (bbox[2] - bbox[0])) // 2, y + thumb + 8), label, fill="#211f1c", font=font)
-
- sheet.save(output_path)
-
-
-def generate_set(output_dir, variants, contact_name):
- output_dir.mkdir(parents=True, exist_ok=True)
- reference = Image.open(REFERENCE_PATH).convert("RGB")
- masks = build_masks(reference)
- contact_items = [("REF-04", REFERENCE_PATH)]
-
- for variant in variants:
- output_path = output_dir / f"{variant['id']}.png"
- apply_variant(reference, masks, variant).save(output_path)
- contact_items.append((variant["label"], output_path))
-
- build_contact_sheet(
- contact_items,
- output_dir / contact_name,
- )
-
-
-def main():
- generate_set(
- OUTPUT_DIR,
- VARIANTS,
- "taonier-logo-ref04-locked-color-contact-sheet.png",
- )
- generate_set(
- STAR_OUTPUT_DIR,
- STAR_VARIANTS,
- "taonier-logo-ref04-warm-star-contact-sheet.png",
- )
- generate_set(
- SPARKLE_OUTPUT_DIR,
- SPARKLE_VARIANTS,
- "taonier-logo-ref04-warm-sparkle-contact-sheet.png",
- )
- generate_set(
- SPARKLE_V2_OUTPUT_DIR,
- SPARKLE_VARIANTS,
- "taonier-logo-ref04-warm-sparkle-v2-contact-sheet.png",
- )
- generate_set(
- PALETTE_TRANSFER_OUTPUT_DIR,
- PALETTE_TRANSFER_VARIANTS,
- "taonier-logo-ref04-palette-transfer-contact-sheet.png",
- )
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-short-foot-creature-contact-sheet.py b/scripts/generate-taonier-short-foot-creature-contact-sheet.py
deleted file mode 100644
index adc8459f..00000000
--- a/scripts/generate-taonier-short-foot-creature-contact-sheet.py
+++ /dev/null
@@ -1,112 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont, ImageOps
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = (
- REPO_ROOT
- / "public"
- / "branding"
- / "taonier-logo-short-foot-creature-concepts"
-)
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-short-foot-creature-contact-sheet.png"
-
-ITEMS = [
- ("01 弯角泥团", "taonier-short-foot-creature-01-curled-tip"),
- ("02 软芽泥团", "taonier-short-foot-creature-02-soft-sprout"),
- ("03 波浪小怪", "taonier-short-foot-creature-03-wave-tuft"),
- ("04 圆角小怪", "taonier-short-foot-creature-04-round-horn"),
- ("05 低趴泥团", "taonier-short-foot-creature-05-low-squat"),
- ("06 偏心灵体", "taonier-short-foot-creature-06-asymmetric-charm"),
- ("07 头像强识别", "taonier-short-foot-creature-07-avatar-bold"),
- ("08 商标轮廓", "taonier-short-foot-creature-08-vector-outline"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def find_image(stem: str) -> Path | None:
- for extension in ("png", "webp", "jpg", "jpeg"):
- candidate = OUTPUT_DIR / f"{stem}.{extension}"
- if candidate.exists():
- return candidate
- return None
-
-
-def bw_preview(image: Image.Image, size: int) -> Image.Image:
- thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
- return ImageOps.autocontrast(thumb).convert("RGB")
-
-
-def main() -> None:
- cell_size = 300
- label_height = 58
- test_height = 46
- gap = 24
- columns = 4
- rows = 2
- cell_total_height = cell_size + label_height + test_height
- width = columns * cell_size + (columns + 1) * gap
- height = rows * cell_total_height + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f0ebe5")
- draw = ImageDraw.Draw(sheet)
- label_font = load_font(20)
- test_font = load_font(14)
-
- for index, (label, stem) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_total_height + gap)
-
- image_path = find_image(stem)
- if image_path is None:
- continue
-
- source = Image.open(image_path).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=8,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=label_font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
-
- test_y = y + cell_size + label_height
- draw.rounded_rectangle(
- (x, test_y, x + cell_size, test_y + test_height),
- radius=8,
- fill="#f7f3ed",
- )
- tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
- mono = bw_preview(source, 32)
- sheet.paste(tiny, (x + 68, test_y + 7))
- sheet.paste(mono, (x + 122, test_y + 7))
- draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
-
- OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-short-foot-creature-logo-concepts.mjs b/scripts/generate-taonier-short-foot-creature-logo-concepts.mjs
deleted file mode 100644
index fb127cec..00000000
--- a/scripts/generate-taonier-short-foot-creature-logo-concepts.mjs
+++ /dev/null
@@ -1,457 +0,0 @@
-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,
- ),
-);
diff --git a/scripts/generate-taonier-spiral-contact-sheet.py b/scripts/generate-taonier-spiral-contact-sheet.py
deleted file mode 100644
index 15bd4c9f..00000000
--- a/scripts/generate-taonier-spiral-contact-sheet.py
+++ /dev/null
@@ -1,74 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-
-from PIL import Image, ImageDraw, ImageFont
-
-
-REPO_ROOT = Path(__file__).resolve().parents[1]
-OUTPUT_DIR = (
- REPO_ROOT / "public" / "branding" / "taonier-logo-spiral-reference-concepts"
-)
-CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-spiral-reference-contact-sheet.png"
-
-ITEMS = [
- ("01 软泥旋合", "taonier-spiral-soft-squish.png"),
- ("02 糖果泥卷", "taonier-spiral-candy-roll.png"),
- ("03 星核涡标", "taonier-spiral-star-core.png"),
- ("04 Q弹泥涡", "taonier-spiral-bouncy-clay.png"),
- ("05 创作星涡", "taonier-spiral-creation-whirl.png"),
- ("06 旋合软标", "taonier-spiral-soft-token.png"),
-]
-
-
-def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
- candidates = [
- Path("C:/Windows/Fonts/msyh.ttc"),
- Path("C:/Windows/Fonts/simhei.ttf"),
- Path("C:/Windows/Fonts/simsun.ttc"),
- ]
- for candidate in candidates:
- if candidate.exists():
- return ImageFont.truetype(str(candidate), size)
- return ImageFont.load_default()
-
-
-def main() -> None:
- cell_size = 330
- label_height = 58
- gap = 28
- columns = 3
- rows = 2
- width = columns * cell_size + (columns + 1) * gap
- height = rows * (cell_size + label_height) + (rows + 1) * gap
-
- sheet = Image.new("RGB", (width, height), "#f6f2eb")
- draw = ImageDraw.Draw(sheet)
- font = load_font(24)
-
- for index, (label, filename) in enumerate(ITEMS):
- row = index // columns
- column = index % columns
- x = gap + column * (cell_size + gap)
- y = gap + row * (cell_size + label_height + gap)
-
- source = Image.open(OUTPUT_DIR / filename).convert("RGB")
- thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
- sheet.paste(thumbnail, (x, y))
-
- draw.rounded_rectangle(
- (x, y + cell_size, x + cell_size, y + cell_size + label_height),
- radius=10,
- fill="#fffdf8",
- )
- text_box = draw.textbbox((0, 0), label, font=font)
- text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
- text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
- draw.text((text_x, text_y), label, fill="#302a25", font=font)
-
- sheet.save(CONTACT_SHEET_PATH, quality=95)
- print(CONTACT_SHEET_PATH)
-
-
-if __name__ == "__main__":
- main()
diff --git a/scripts/generate-taonier-spiral-logo-concepts.mjs b/scripts/generate-taonier-spiral-logo-concepts.mjs
deleted file mode 100644
index 10b44914..00000000
--- a/scripts/generate-taonier-spiral-logo-concepts.mjs
+++ /dev/null
@@ -1,373 +0,0 @@
-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,
- ),
-);
diff --git a/scripts/generate-taonier-squish-logo-concepts.mjs b/scripts/generate-taonier-squish-logo-concepts.mjs
deleted file mode 100644
index 198712bc..00000000
--- a/scripts/generate-taonier-squish-logo-concepts.mjs
+++ /dev/null
@@ -1,315 +0,0 @@
-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-squish-concepts',
-);
-const defaultTimeoutMs = 420000;
-
-const concepts = [
- {
- id: 'taonier-squish-v2-pulse',
- title: '软泥合拍',
- prompt:
- '无文字扁平矢量 Logo,产品名“陶泥儿”。参考方向:上下两团抽象软泥轻快合拍,中间一颗星点被捏出来。不要画手、手指、掌纹,不要聊天气泡、笑脸、眼睛、花朵、播放键。整体年轻、主流、抽象、生动、像娱乐创作 App icon。上方珊瑚红软形,下方青绿软形,中央奶油白或金色星点,最多 3 色。形状简洁,小尺寸清晰。',
- },
- {
- id: 'taonier-squish-v2-bounce',
- title: '弹力成型',
- prompt:
- '无文字扁平矢量 Logo,产品名“陶泥儿”。图形由上下两块弹性的软泥豆形组成,中间留出弯曲的白色空间和一颗小星点,表达脑洞被轻轻一压就成型。要抽象、有动感、亲和,不像手、不像眼睛、不像聊天气泡。主流 App icon 风格,简洁、高识别。配色:亮珊瑚、薄荷青、奶油白,最多 3 色。禁止文字、字母、3D、褐色、碎元素。',
- },
- {
- id: 'taonier-squish-v2-spark-gap',
- title: '星隙合拍',
- prompt:
- '无文字扁平矢量 Logo,产品名“陶泥儿”。上下两团圆润软泥彼此靠近,中间形成一个自然的星形负空间,像灵感在缝隙中被捏出来。图形必须抽象、现代、活泼,不出现手、眼睛、嘴巴、聊天气泡、播放符号或花朵。适合 App icon,小尺寸一眼识别。配色:玫红 / 珊瑚红主上形,青绿色下形,奶白负形,最多 3 色。',
- },
- {
- id: 'taonier-squish-v2-comet',
- title: '合拍星流',
- prompt:
- '无文字扁平矢量 Logo,产品名“陶泥儿”。两个抽象软泥形上下错位合拍,中央小星点带出一条短短的流线,表达 AI 把脑洞捏成会传播的作品。风格轻快、年轻、抽象、生动,但不要像表情包或特效贴纸。禁止手、眼睛、聊天气泡、笑脸、花朵、播放键、褐色、3D、文字。配色:珊瑚红、青绿、奶白,最多 3 色,元素要少。',
- },
- {
- id: 'taonier-squish-v2-token',
- title: '成型软标',
- prompt:
- '无文字扁平矢量 Logo,产品名“陶泥儿”。把“软泥合拍”做得更像长期品牌主标:上下两块抽象软泥围成一个完整圆润符号,中间只有一颗小星或圆点,表达创作成型。不要手、眼睛、嘴巴、聊天气泡、播放键、花朵、褐色陶土、复杂碎片。主流、亲和、醒目、可记忆,App icon 风格。配色:珊瑚红、青绿、奶白,最多 3 色。',
- },
-];
-
-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 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) {
- const requestBody = {
- model: 'gpt-image-2-all',
- prompt: concept.prompt,
- 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 onlyIds = String(args.get('--only') || '')
- .split(',')
- .map((value) => value.trim())
- .filter(Boolean);
-const selected = concepts.filter(
- (concept) => !onlyIds.length || onlyIds.includes(concept.id),
-);
-
-if (dryRun) {
- console.log(
- JSON.stringify(
- {
- mode: 'dry-run',
- outputDir,
- count: selected.length,
- requests: selected.map((concept) => ({
- id: concept.id,
- title: concept.title,
- body: {
- model: 'gpt-image-2-all',
- prompt: concept.prompt,
- 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));
-}
-
-console.log(
- JSON.stringify(
- {
- ok: true,
- count: generated.length,
- files: generated,
- verifiedFiles: readdirSync(outputDir).sort(),
- },
- null,
- 2,
- ),
-);
diff --git a/scripts/generate-taonier-squish-logo-variants.mjs b/scripts/generate-taonier-squish-logo-variants.mjs
deleted file mode 100644
index 4f6dcc84..00000000
--- a/scripts/generate-taonier-squish-logo-variants.mjs
+++ /dev/null
@@ -1,183 +0,0 @@
-import { mkdirSync, writeFileSync } from 'node:fs';
-import path from 'node:path';
-
-const repoRoot = process.cwd();
-const outputDir = path.join(
- repoRoot,
- 'public',
- 'branding',
- 'taonier-logo-squish-variants',
-);
-
-const variants = [
- {
- id: 'taonier-squish-berry-mint',
- title: '莓果薄荷',
- topStart: '#ff4778',
- topEnd: '#ff6b5f',
- bottomStart: '#12c9b7',
- bottomEnd: '#16b899',
- starStart: '#ffd54c',
- starEnd: '#ffb82e',
- accent: '#ffc545',
- background: '#fffaf2',
- },
- {
- id: 'taonier-squish-candy-pop',
- title: '糖果桃青',
- topStart: '#ff5fa2',
- topEnd: '#ff8670',
- bottomStart: '#2ed7c5',
- bottomEnd: '#65d8f4',
- starStart: '#ffe06f',
- starEnd: '#ffbf4d',
- accent: '#ffce5e',
- background: '#fff8fb',
- },
- {
- id: 'taonier-squish-jelly-cream',
- title: '奶油果冻',
- topStart: '#ff758d',
- topEnd: '#ff9a70',
- bottomStart: '#42d6b5',
- bottomEnd: '#7ce3c5',
- starStart: '#fff07a',
- starEnd: '#ffc955',
- accent: '#ffd76a',
- background: '#fffdf4',
- },
- {
- id: 'taonier-squish-bubble-bright',
- title: '亮彩泡泡',
- topStart: '#ff3f8f',
- topEnd: '#ff6d6d',
- bottomStart: '#00c2b8',
- bottomEnd: '#00d69a',
- starStart: '#fff15c',
- starEnd: '#ffbe35',
- accent: '#ffbd3c',
- background: '#fdfcff',
- },
- {
- id: 'taonier-squish-sunny-coral',
- title: '暖日珊瑚',
- topStart: '#ff684f',
- topEnd: '#ff8d67',
- bottomStart: '#22c4a8',
- bottomEnd: '#4dd9b5',
- starStart: '#ffe36d',
- starEnd: '#ffb948',
- accent: '#ffc04a',
- background: '#fff8ed',
- },
- {
- id: 'taonier-squish-neon-cute',
- title: '霓虹可爱',
- topStart: '#ff3d7f',
- topEnd: '#ff4fb8',
- bottomStart: '#00bfae',
- bottomEnd: '#00d2ff',
- starStart: '#fff16a',
- starEnd: '#ffd13d',
- accent: '#ffcf45',
- background: '#fbfbff',
- },
-];
-
-function buildLogoSvg(variant, includeLabel = false) {
- const labelHeight = includeLabel ? 72 : 0;
- const height = 1024 + labelHeight;
- const labelMarkup = includeLabel
- ? `
-
- ${variant.title}`
- : '';
-
- return `
-`;
-}
-
-function buildContactSheetSvg() {
- const cell = 320;
- const label = 64;
- const gap = 28;
- const width = cell * 3 + gap * 4;
- const height = (cell + label) * 2 + gap * 3;
- const items = variants
- .map((variant, index) => {
- const row = Math.floor(index / 3);
- const col = index % 3;
- const x = gap + col * (cell + gap);
- const y = gap + row * (cell + label + gap);
- const logo = buildLogoSvg(variant)
- .replace(/<\?xml[^>]+>\n/u, '')
- .replace('