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-broad-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 black-dot 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 solid black 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 matte 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, cool ash clay, or muted olive 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-olive-rabbit',
|
|
title: '橄榄兔头',
|
|
prompt: [
|
|
...basePrompt,
|
|
'Variant focus: tall slim jar with a muted olive-clay body and rabbit ears. The rabbit head is cream colored with soft peach inner ears, eyes are black dots only.',
|
|
],
|
|
},
|
|
{
|
|
id: '02-sand-cat',
|
|
title: '砂陶猫头',
|
|
prompt: [
|
|
...basePrompt,
|
|
'Variant focus: squat sand-colored jar with cat ears and a gray-white cat head. Make the rim compact and the body broad, eyes are black dots only.',
|
|
],
|
|
},
|
|
{
|
|
id: '03-apricot-fox',
|
|
title: '杏陶狐头',
|
|
prompt: [
|
|
...basePrompt,
|
|
'Variant focus: flared apricot-terracotta jar with fox ears and a warm orange fox head. Use a cream face area and strong ear silhouette, eyes are black dots only.',
|
|
],
|
|
},
|
|
{
|
|
id: '04-banded-bear',
|
|
title: '双带熊头',
|
|
prompt: [
|
|
...basePrompt,
|
|
'Variant focus: jar with two subtle ceramic bands, bear ears, and a cocoa-brown bear head. Keep the vessel sturdy and broad, eyes are black dots only.',
|
|
],
|
|
},
|
|
{
|
|
id: '05-necked-dog',
|
|
title: '细颈狗头',
|
|
prompt: [
|
|
...basePrompt,
|
|
'Variant focus: tall narrow-neck jar with floppy dog ears and a tan dog head. Use a warm gray-beige jar and slightly longer ear shapes, eyes are black dots only.',
|
|
],
|
|
},
|
|
{
|
|
id: '06-flat-mouse',
|
|
title: '扁罐鼠头',
|
|
prompt: [
|
|
...basePrompt,
|
|
'Variant focus: low flat jar with mouse ears and a pale gray mouse head. Add tiny pink ear interiors and a wider mouth rim, eyes are black dots only.',
|
|
],
|
|
},
|
|
{
|
|
id: '07-tilted-deer',
|
|
title: '斜肩鹿头',
|
|
prompt: [
|
|
...basePrompt,
|
|
'Variant focus: slightly tilted jar with deer ears and a soft brown deer head. Use a calm cream-beige jar with a subtle shoulder shift, 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, simple, and easy to read at 32px, 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-broad-${variant.id}.${image.extension}`,
|
|
);
|
|
writeFileSync(outputPath, image.bytes);
|
|
return outputPath;
|
|
}
|
|
|
|
function writeManifest(files) {
|
|
const manifestPath = path.join(
|
|
outputDir,
|
|
'taonier-logo-peeking-head-jar-broad-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,
|
|
),
|
|
);
|