feat: refresh creation config and visual assets

This commit is contained in:
2026-05-20 14:02:36 +08:00
parent 83e92fc3c4
commit ef09a23c35
509 changed files with 19470 additions and 43 deletions

View File

@@ -0,0 +1,444 @@
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,
),
);