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

405 lines
12 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-muted-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 low-saturation 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 softer, more tasteful, more wearable brand color. The palette should feel like a modern lifestyle brand, not a toy, candy, dessert, or cosmetics ad.',
'Use muted but attractive colors: dusty rose, misty lavender, sage green, smoke blue, butter cream, muted coral, terracotta clay, pale apricot. Keep the image warm and youthful without becoming loud.',
'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 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-dusty-rose-sage',
title: '雾玫鼠尾草',
prompt: [
...basePrompt,
'Palette: dusty rose semi-dome, sage green support curve, warm ivory gap. Soft, modern, and feminine without being sweet.',
],
},
{
id: '02-smoke-blue-apricot',
title: '烟蓝杏橙',
prompt: [
...basePrompt,
'Palette: smoke blue support curve, pale apricot or muted peach semi-dome, cream separator. Calm, fresh, and suitable for young users.',
],
},
{
id: '03-misty-lilac-clay',
title: '雾紫陶土',
prompt: [
...basePrompt,
'Palette: misty lilac support, soft terracotta clay spirit, off-white negative space. More boutique and refined, not purple-tech.',
],
},
{
id: '04-butter-rose-tea',
title: '黄油玫瑰茶',
prompt: [
...basePrompt,
'Palette: butter cream spirit, muted rose support curve, faint tea-green accent. Gentle, cozy, and premium with low saturation.',
],
},
{
id: '05-clay-blue-mint',
title: '陶蓝薄荷',
prompt: [
...basePrompt,
'Palette: clay orange or muted coral semi-dome, powder blue support, tiny mint accent. Softly playful but not heavy.',
],
},
{
id: '06-powder-berry-cloud',
title: '粉雾浆果',
prompt: [
...basePrompt,
'Palette: powder berry semi-dome, cloud pink support curve, warm cream gap. Youthful, gentle, and more like a boutique brand than a toy.',
],
},
{
id: '07-sand-violet',
title: '砂紫奶雾',
prompt: [
...basePrompt,
'Palette: sand beige or pale almond spirit, muted violet support curve, soft cream separator. Quiet, tasteful, and logo-ready.',
],
},
{
id: '08-muted-duotone',
title: '低饱双色',
prompt: [
...basePrompt,
'Palette and style: two-color muted duotone only. Use one subdued warm hue and one subdued cool hue. No shiny gloss, no intense contrast, no candy feeling.',
],
},
];
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-muted-color-${variant.id}.${image.extension}`,
);
writeFileSync(outputPath, image.bytes);
return outputPath;
}
function writeManifest(files) {
const manifestPath = path.join(outputDir, 'taonier-hand-spirit-muted-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: '低饱和度但不寡淡的年轻向颜色探索',
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));