Files
Genarrative/scripts/generate-taonier-mascot-symbol-logo-concepts.mjs

449 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
);