Files
Genarrative/scripts/generate-taonier-hand-spirit-bold-color-concepts.mjs

406 lines
13 KiB
JavaScript

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-bold-color-concepts',
);
const referenceImagePath = path.join(
repoRoot,
'public',
'branding',
'taonier-logo-hand-spirit-ref01-logo-refine-concepts',
'taonier-hand-spirit-ref01-logo-refine-01-flat-coral-cream.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 basePrompt = [
'Use the uploaded logo as the structural reference. Keep the same icon-only mark structure: a soft semi-dome clay spirit above a smooth abstract hand-like support curve.',
'Create a bolder, more attractive color exploration for a young women-friendly brand logo. No Chinese, no English, no letters, no numbers, no wordmark, no tagline, no text.',
'Preserve the existing logo proportions, centered composition, abstract hand support, semi-dome spirit shape, and clean logo silhouette. Do not add a face, eyes, mouth, limbs, star, spark, UI, border, or watermark.',
'The goal is more memorable social-platform color, more appealing to young users and women, while still premium and trademark-like.',
'Use bold modern brand colors, not realistic material colors. Saturated colors are allowed, but they must read as clean brand color fields, not candy, dessert, food, jelly, bread, pastry, mochi, bun, sauce, or cosmetic packaging.',
'Make it flatter and clearer than the original reference: broad vector-friendly shapes, crisp edges, minimal gradients, no glossy highlight unless the variant explicitly asks for a subtle app-logo polish.',
'Keep 32px readability and black-white viability. The mark must still work as an app icon, social avatar, and trademark symbol.',
'Clean light background, generous safe area. Image-only logo concept.',
];
const variants = [
{
id: '01-berry-aqua-pop',
title: '莓粉青 aqua',
prompt: [
...basePrompt,
'Palette: vivid raspberry pink semi-dome, bright coral side accent, fresh aqua or mint hand support, small cream negative gap. Bold, young, energetic, not sugary.',
],
},
{
id: '02-coral-lilac',
title: '珊瑚丁香',
prompt: [
...basePrompt,
'Palette: punchy coral-red semi-dome with warm pink accent, soft lilac-lavender hand support, tiny ivory separator. Feminine, fresh, and premium.',
],
},
{
id: '03-mango-turquoise',
title: '芒果松石',
prompt: [
...basePrompt,
'Palette: bright mango-orange semi-dome, hot peach accent, turquoise hand support. High contrast and cheerful, but still flat and logo-like, not food-like.',
],
},
{
id: '04-neon-rose-mint',
title: '玫红薄荷',
prompt: [
...basePrompt,
'Palette: neon rose or magenta semi-dome, clean mint green hand support, warm ivory separator. Strong social-avatar memory, modern and playful.',
],
},
{
id: '05-poppy-blue',
title: '罂粟蓝调',
prompt: [
...basePrompt,
'Palette: saturated poppy orange-red semi-dome, cobalt or sky-blue support curve, cream separator. More graphic, bold, and youth-culture oriented.',
],
},
{
id: '06-violet-peach',
title: '紫桃撞色',
prompt: [
...basePrompt,
'Palette: vivid violet-purple hand support with peach-orange semi-dome and pink accent. Keep the purple limited and crisp so the logo does not become a generic purple tech gradient.',
],
},
{
id: '07-flat-duotone',
title: '双色强记忆',
prompt: [
...basePrompt,
'Palette and style: ultra-flat two-color version. Use one bold warm color for the spirit and one bold cool color for the hand. No highlight, no gradient, no shadow. Maximize trademark simplicity.',
],
},
{
id: '08-app-icon-bright',
title: '亮彩头像',
prompt: [
...basePrompt,
'Palette and style: brightest app-icon-friendly version. Use coral, hot pink, and aqua with only a very subtle broad gradient. Keep the mark bold and readable at small size.',
],
},
];
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 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;
}
function buildDryRunFields(variant) {
return {
model: 'gpt-image-2',
prompt: variant.prompt.join('\n'),
n: '1',
size: '1024x1024',
image: referenceImagePath,
};
}
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-bold-color-${variant.id}.${image.extension}`,
);
writeFileSync(outputPath, image.bytes);
return outputPath;
}
function writeManifest(files) {
const manifestPath = path.join(outputDir, 'taonier-hand-spirit-bold-color-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(),
brief: {
brand: '陶泥儿',
goal: '更大胆、更吸引女生和年轻人的手托灵体 logo 配色探索',
keep: '保留托举曲线与半球灵体结构,不加文字、不加脸、不加星星',
},
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,
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' }));
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));