Files
Genarrative/scripts/generate-taonier-closed-geo-logo-concepts.mjs

420 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-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,
),
);