feat: refresh creation config and visual assets
This commit is contained in:
@@ -0,0 +1,461 @@
|
||||
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-expanded-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: [
|
||||
'主形体仍然是陶罐容器,罐子负责陶器和包裹感',
|
||||
'动物只露出耳朵、上半个脑袋和两只眼睛',
|
||||
'不露鼻子、嘴巴、身体、爪子或完整动物脸',
|
||||
'罐子绝对不能有表情元素',
|
||||
'整体必须是 logo 符号级别,不是完整插画角色',
|
||||
],
|
||||
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.',
|
||||
'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-tall-rabbit',
|
||||
title: '高罐兔耳',
|
||||
prompt: [
|
||||
...basePrompt,
|
||||
'Variant focus: tall slim jar with soft rabbit ears and a cream rabbit head peeking out. Use pale beige jar and soft peach inner ears, elegant and light.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '02-squat-cat',
|
||||
title: '矮罐猫头',
|
||||
prompt: [
|
||||
...basePrompt,
|
||||
'Variant focus: squat round jar with cat ears and a gray-white cat head. Use a warmer taupe jar and small triangular ears, compact and cozy.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '03-flared-fox',
|
||||
title: '阔口狐头',
|
||||
prompt: [
|
||||
...basePrompt,
|
||||
'Variant focus: flared-rim jar with fox ears and orange fox head. Use muted terracotta jar, cream face area, and warm orange ear tips.',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '04-double-band-bear',
|
||||
title: '双圈熊头',
|
||||
prompt: [
|
||||
...basePrompt,
|
||||
'Variant focus: double-band jar with bear ears and a cocoa-brown bear head. The jar can have two subtle horizontal rings for ceramic rhythm.',
|
||||
],
|
||||
},
|
||||
{
|
||||
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, friendly and upright.',
|
||||
],
|
||||
},
|
||||
{
|
||||
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, 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.',
|
||||
],
|
||||
},
|
||||
{
|
||||
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.',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
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-expanded-${variant.id}.${image.extension}`,
|
||||
);
|
||||
writeFileSync(outputPath, image.bytes);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
function writeManifest(files) {
|
||||
const manifestPath = path.join(
|
||||
outputDir,
|
||||
'taonier-logo-peeking-head-jar-expanded-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,
|
||||
),
|
||||
);
|
||||
Reference in New Issue
Block a user