feat: refresh creation config and visual assets

This commit is contained in:
2026-05-20 14:02:36 +08:00
parent 83e92fc3c4
commit ef09a23c35
509 changed files with 19470 additions and 43 deletions

View File

@@ -0,0 +1,491 @@
import { Blob, 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-hand-spirit-ref01-logo-refine-concepts',
);
const referenceImagePath = path.join(
repoRoot,
'public',
'branding',
'taonier-logo-hand-spirit-concepts',
'taonier-hand-spirit-01-gentle-hand-spirit.png',
);
const timeoutMsDefault = 420000;
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: '陶泥儿',
source: '基于 taonier-hand-spirit-01-gentle-hand-spirit 做商标化探索',
logoType: 'symbol/icon-only mark, no wordmark',
product:
'AI UGC 轻休闲小游戏创作与传播平台,用户把脑洞、梗和灵感塑造成可分享的作品',
keep: [
'上方软萌半球陶泥灵体',
'下方抽象托举手势/掌形曲线',
'托举、传递、分享的动作语义',
'无脸、无文字、无星星',
'亲和、精品、可用于商标和 App 图标',
],
explore: [
'更扁平的纯色块版本',
'更精品的低饱和陶器色版本',
'更强线面结构版本',
'更抽象、更少形状的版本',
'更适合 32px 和黑白化的版本',
],
avoid: [
'中文或英文字',
'眼睛、嘴巴、表情、角色身体',
'星星、闪光、魔法符号',
'真实手指、宗教托举、医疗护理感',
'面包、甜点、糖果、果冻、奶油、食物包装',
'复杂背景、边框、UI、按钮、水印',
],
};
const basePrompt = [
'Use the uploaded reference image as the composition and shape reference. Keep the same core structure: an abstract hand-like support curve below, gently holding a soft semi-dome clay spirit above.',
'Create a refined icon-only brand logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.',
'Preserve the brand idea: fun creation, sharing, passing a small playable idea forward. The mark should feel like a small creative object being gently offered, not a scene or illustration.',
'Keep the subject face-free: no eyes, no mouth, no expression, no limbs, no character body, no halo, no star, no spark.',
'Make it more like a trademark and logo than the reference: fewer shapes, cleaner silhouette, clearer positive/negative structure, more vector-friendly curves, broad flat color regions, less glossy highlight, less painterly shadow.',
'The hand must remain abstract: a smooth palm-like support curve or bowl-like gesture, not realistic fingers, not a real human hand, not worship, not medical care.',
'The spirit must remain a soft irregular semi-dome or rounded clay core. It should not become a planet, helmet, bread, bun, mochi, dumpling, pastry, candy, jelly, sauce, dessert, or food package.',
'Style target: premium friendly logo mark, all-age and women-friendly, warm but not childish, suitable for app icon, social avatar, and trademark review.',
'Composition: centered on a clean light background with generous safe area. Strong readable silhouette first. It must still read at 32px and in black and white.',
'Avoid UI, border, sticker outline unless explicitly requested by the variant, background scene, watermark, extra marks, and any text.',
];
const variants = [
{
id: '01-flat-coral-cream',
title: '扁平珊瑚奶白',
prompt: [
...basePrompt,
'Variant focus: the most direct flat-logo refinement. Use coral-orange clay spirit and cream hand support. Reduce the glossy highlight to nearly zero. Use 3-4 crisp flat shapes only.',
],
},
{
id: '02-warm-clay-premium',
title: '暖陶精品色',
prompt: [
...basePrompt,
'Variant focus: warmer boutique clay palette. Muted terracotta, soft sand, and warm ivory. More mature and premium, with a compact iconic silhouette and no candy gloss.',
],
},
{
id: '03-mint-support',
title: '青绿托举线',
prompt: [
...basePrompt,
'Variant focus: stronger color memory. Use a clear muted teal or mint support curve as the hand and a warm peach clay spirit above. Keep it flat, balanced, and not cosmetic.',
],
},
{
id: '04-outline-vector',
title: '线面商标',
prompt: [
...basePrompt,
'Variant focus: bolder trademark construction. Use a clean warm-brown contour line combined with flat fills. The outline should clarify the hand and spirit silhouette, modern rather than sticker-like.',
],
},
{
id: '05-abstract-two-shape',
title: '双形抽象',
prompt: [
...basePrompt,
'Variant focus: higher abstraction. Reduce the mark to two dominant shapes: one semi-dome spirit and one sweeping hand support. Remove highlight details. Make the silhouette distinctive and vector-ready.',
],
},
{
id: '06-monochrome-first',
title: '黑白优先',
prompt: [
...basePrompt,
'Variant focus: black-and-white survival first. Design with strong positive and negative shapes; color may be warm clay, but the mark must remain clear as a pure monochrome logo.',
],
},
{
id: '07-soft-gradient-premium',
title: '轻渐变精品',
prompt: [
...basePrompt,
'Variant focus: a polished but still logo-like version. Allow only very subtle broad gradients for premium softness. Remove small glossy highlights and avoid 3D rendering.',
],
},
{
id: '08-compact-avatar',
title: '头像强识别',
prompt: [
...basePrompt,
'Variant focus: compact social-avatar readability. Enlarge the clay spirit slightly, thicken the hand support curve, reduce thin gaps, and keep the total mark bold at 32px.',
],
},
];
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 buildEditUrl(baseUrl) {
return baseUrl.endsWith('/v1')
? `${baseUrl}/images/edits`
: `${baseUrl}/v1/images/edits`;
}
function buildDryRunFields(variant) {
return {
model: 'gpt-image-2',
prompt: variant.prompt.join('\n'),
n: '1',
size: '1024x1024',
image: referenceImagePath,
};
}
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);
}
}
function createEditFormData(variant) {
const form = new FormData();
const imageBytes = readFileSync(referenceImagePath);
form.append('model', 'gpt-image-2');
form.append('prompt', variant.prompt.join('\n'));
form.append('n', '1');
form.append('size', '1024x1024');
form.append(
'image',
new Blob([imageBytes], { type: 'image/png' }),
path.basename(referenceImagePath),
);
return form;
}
async function generateOne(env, variant) {
const payload = await fetchJson(
buildEditUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
},
body: createEditFormData(variant),
},
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-hand-spirit-ref01-logo-refine-${variant.id}.${image.extension}`,
);
writeFileSync(outputPath, image.bytes);
return outputPath;
}
function writeManifest(files) {
const manifestPath = path.join(
outputDir,
'taonier-logo-hand-spirit-ref01-logo-refine-manifest.json',
);
writeFileSync(
manifestPath,
`${JSON.stringify(
{
model: 'gpt-image-2',
endpoint: '/v1/images/edits',
size: '1024x1024',
referenceImage: path.relative(repoRoot, referenceImagePath),
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,
referenceImagePath,
count: selectedVariants.length,
brief: logoBrief,
requests: selectedVariants.map((variant) => ({
id: variant.id,
title: variant.title,
fields: buildDryRunFields(variant),
})),
},
null,
2,
),
);
process.exit(0);
}
if (!existsSync(referenceImagePath)) {
console.error(
JSON.stringify({
ok: false,
error: 'Reference image does not exist',
referenceImagePath,
}),
);
process.exit(1);
}
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,
),
);