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

331 lines
12 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,
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,
),
);