Files
Genarrative/scripts/generate-taonier-distinctive-logo-concepts.mjs

423 lines
13 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-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,
),
);