465 lines
15 KiB
JavaScript
465 lines
15 KiB
JavaScript
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-peeking-head-jar-blackdot-eye-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: '女性用户友好、全年龄向、年轻明亮但不低幼',
|
|
structureRules: [
|
|
'主形体仍然是陶罐容器,罐子负责陶器和包裹感',
|
|
'动物只露出耳朵、上半个脑袋和两只黑点眼睛',
|
|
'眼睛不能有高光、不能有白点反光、不能有玻璃感',
|
|
'不露鼻子、嘴巴、身体、爪子或完整动物脸',
|
|
'罐子绝对不能有表情元素',
|
|
],
|
|
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.',
|
|
'Core idea: a ceramic jar container with a small animal peeking out only to eye level. Show the animal ears, the upper half of the head, and two simple eyes. Do not show nose, mouth, lower face, paws, body, or full head.',
|
|
'The jar must remain the main brand shape. The animal head should feel hidden inside the jar, as if a playful idea is peeking out.',
|
|
'Brand core: fun creation. The logo should feel like a tiny clay-born creative spirit that can hold playful ideas, memes, and shareable works.',
|
|
'Logo type: simple mascot symbol only. It must be a brand mark, not a full character illustration, not a scene, not a sticker, not a rounded-square app icon background.',
|
|
'Main structure: a closed or mostly closed ceramic jar, pot, or vessel with a clear rim and body silhouette. The jar should read as a container first, not as a cup, vase, bowl, bottle, or food package.',
|
|
'Animal visibility: show only ears, top half of head, and two eye dots above or behind the jar rim. The rim may partially cover the lower half of the animal head. No animal mouth, no nose, no cheek blush, no paws, no body.',
|
|
'Eye policy: the eyes must be pure black dots. No highlights, no glossy sparkles, no white reflective spots, no glassy pupils, no shiny toy eyes.',
|
|
'Jar policy: the jar itself has no face or expression. No eyes, no smile, no blush, no emoji marks on the jar.',
|
|
'Style: modern minimalist vector mascot logo, clean curves, flat brand-color shapes, premium but warm. Slight clay warmth through color and form only; no realistic texture.',
|
|
'Jar color direction: low-saturation ceramic tones such as beige, sand, warm gray, muted terracotta, pale taupe, clay pink, smoky cream, or cool ash clay. Each variant should shift the jar silhouette and color slightly.',
|
|
'Animal color direction: use soft natural animal colors that fit the species, but keep them gentle and logo-friendly. The animal head should be more alive and colorful than the jar.',
|
|
'Avoid realistic ceramic glaze, muddy pottery, rough handmade class object, 3D render, ceramic shine, brick, food-like container, toy feeling, chibi over-detailing, or generic blob.',
|
|
'Food avoidance is critical: do not make it look like bread, mochi, dumpling, bun, cake, cookie, candy, pudding, chocolate, jelly, or dessert packaging.',
|
|
'Avoid star, spark, halo, magic wand, hand, pottery tool, UI, border, or watermark.',
|
|
'Composition: centered on a clean light background with generous safe area. Strong readable silhouette first. Must still read at 32px and in black and white.',
|
|
];
|
|
|
|
const variants = [
|
|
{
|
|
id: '01-clay-rabbit',
|
|
title: '陶粉兔头',
|
|
prompt: [
|
|
...basePrompt,
|
|
'Variant focus: tall slim clay jar with rabbit ears and a cream rabbit head. Use pale clay beige jar and soft peach ear interiors, eyes are black dots only.',
|
|
],
|
|
},
|
|
{
|
|
id: '02-ash-cat',
|
|
title: '灰陶猫头',
|
|
prompt: [
|
|
...basePrompt,
|
|
'Variant focus: squat ash-clay jar with cat ears and a gray-white cat head. Use muted ash beige jar and compact triangular ears, eyes are black dots only.',
|
|
],
|
|
},
|
|
{
|
|
id: '03-terracotta-fox',
|
|
title: '陶红狐头',
|
|
prompt: [
|
|
...basePrompt,
|
|
'Variant focus: flared terracotta jar with fox ears and orange fox head. Use muted terracotta jar, cream face area, and warm orange ear tips, eyes are black dots only.',
|
|
],
|
|
},
|
|
{
|
|
id: '04-striped-bear',
|
|
title: '横纹熊头',
|
|
prompt: [
|
|
...basePrompt,
|
|
'Variant focus: jar with subtle ceramic stripe bands, bear ears, and a cocoa-brown bear head. The eyes remain black dots only, no extra facial marks.',
|
|
],
|
|
},
|
|
{
|
|
id: '05-long-neck-dog',
|
|
title: '长颈狗头',
|
|
prompt: [
|
|
...basePrompt,
|
|
'Variant focus: long-neck jar with floppy dog ears and a tan dog head. Use a soft gray-beige jar and warm tan ears, eyes are black dots only.',
|
|
],
|
|
},
|
|
{
|
|
id: '06-low-mouse',
|
|
title: '低矮鼠头',
|
|
prompt: [
|
|
...basePrompt,
|
|
'Variant focus: low wide jar with mouse ears and a pale gray mouse head. Add tiny pink ear interiors, eyes are black dots only, cute and slightly mischievous.',
|
|
],
|
|
},
|
|
{
|
|
id: '07-asym-deer',
|
|
title: '偏心鹿头',
|
|
prompt: [
|
|
...basePrompt,
|
|
'Variant focus: slightly asymmetrical jar with deer ears and a soft brown deer head. Keep the ears upright and slender, with a calm cream-beige jar, eyes are black dots only.',
|
|
],
|
|
},
|
|
{
|
|
id: '08-compact-panda',
|
|
title: '紧凑熊猫',
|
|
prompt: [
|
|
...basePrompt,
|
|
'Variant focus: compact trademark jar with panda ears and a black-and-cream panda head. Keep the silhouette bold and simple for strongest brand recognition, eyes are black dots only.',
|
|
],
|
|
},
|
|
];
|
|
|
|
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-peeking-head-jar-blackdot-eye-${variant.id}.${image.extension}`,
|
|
);
|
|
writeFileSync(outputPath, image.bytes);
|
|
return outputPath;
|
|
}
|
|
|
|
function writeManifest(files) {
|
|
const manifestPath = path.join(
|
|
outputDir,
|
|
'taonier-logo-peeking-head-jar-blackdot-eye-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,
|
|
),
|
|
);
|