Files
Genarrative/scripts/generate-taonier-squish-logo-concepts.mjs

316 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Buffer } from 'node:buffer';
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const repoRoot = process.cwd();
const outputDir = path.join(
repoRoot,
'public',
'branding',
'taonier-logo-squish-concepts',
);
const defaultTimeoutMs = 420000;
const concepts = [
{
id: 'taonier-squish-v2-pulse',
title: '软泥合拍',
prompt:
'无文字扁平矢量 Logo产品名“陶泥儿”。参考方向上下两团抽象软泥轻快合拍中间一颗星点被捏出来。不要画手、手指、掌纹不要聊天气泡、笑脸、眼睛、花朵、播放键。整体年轻、主流、抽象、生动、像娱乐创作 App icon。上方珊瑚红软形下方青绿软形中央奶油白或金色星点最多 3 色。形状简洁,小尺寸清晰。',
},
{
id: 'taonier-squish-v2-bounce',
title: '弹力成型',
prompt:
'无文字扁平矢量 Logo产品名“陶泥儿”。图形由上下两块弹性的软泥豆形组成中间留出弯曲的白色空间和一颗小星点表达脑洞被轻轻一压就成型。要抽象、有动感、亲和不像手、不像眼睛、不像聊天气泡。主流 App icon 风格,简洁、高识别。配色:亮珊瑚、薄荷青、奶油白,最多 3 色。禁止文字、字母、3D、褐色、碎元素。',
},
{
id: 'taonier-squish-v2-spark-gap',
title: '星隙合拍',
prompt:
'无文字扁平矢量 Logo产品名“陶泥儿”。上下两团圆润软泥彼此靠近中间形成一个自然的星形负空间像灵感在缝隙中被捏出来。图形必须抽象、现代、活泼不出现手、眼睛、嘴巴、聊天气泡、播放符号或花朵。适合 App icon小尺寸一眼识别。配色玫红 / 珊瑚红主上形,青绿色下形,奶白负形,最多 3 色。',
},
{
id: 'taonier-squish-v2-comet',
title: '合拍星流',
prompt:
'无文字扁平矢量 Logo产品名“陶泥儿”。两个抽象软泥形上下错位合拍中央小星点带出一条短短的流线表达 AI 把脑洞捏成会传播的作品。风格轻快、年轻、抽象、生动但不要像表情包或特效贴纸。禁止手、眼睛、聊天气泡、笑脸、花朵、播放键、褐色、3D、文字。配色珊瑚红、青绿、奶白最多 3 色,元素要少。',
},
{
id: 'taonier-squish-v2-token',
title: '成型软标',
prompt:
'无文字扁平矢量 Logo产品名“陶泥儿”。把“软泥合拍”做得更像长期品牌主标上下两块抽象软泥围成一个完整圆润符号中间只有一颗小星或圆点表达创作成型。不要手、眼睛、嘴巴、聊天气泡、播放键、花朵、褐色陶土、复杂碎片。主流、亲和、醒目、可记忆App icon 风格。配色:珊瑚红、青绿、奶白,最多 3 色。',
},
];
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);
}
}
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 || defaultTimeoutMs),
10,
),
};
}
function buildUrl(baseUrl) {
return baseUrl.endsWith('/v1')
? `${baseUrl}/images/generations`
: `${baseUrl}/v1/images/generations`;
}
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 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}`);
}
return Buffer.from(await response.arrayBuffer());
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`Generated image download timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timer);
}
}
async function generateConcept(env, concept) {
const requestBody = {
model: 'gpt-image-2-all',
prompt: concept.prompt,
n: 1,
size: '1024x1024',
};
const payload = await fetchJson(
buildUrl(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 bytes;
if (urls[0]) {
bytes = await downloadUrl(urls[0], env.timeoutMs);
} else if (b64Images[0]) {
bytes = Buffer.from(b64Images[0], 'base64');
} else {
throw new Error(`VectorEngine returned no image for ${concept.id}`);
}
mkdirSync(outputDir, { recursive: true });
const extension = inferExtensionFromBytes(bytes);
const outputPath = path.join(outputDir, `${concept.id}.${extension}`);
writeFileSync(outputPath, bytes);
return outputPath;
}
const dryRun = args.has('--dry-run') || !args.has('--live');
const onlyIds = String(args.get('--only') || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const selected = concepts.filter(
(concept) => !onlyIds.length || onlyIds.includes(concept.id),
);
if (dryRun) {
console.log(
JSON.stringify(
{
mode: 'dry-run',
outputDir,
count: selected.length,
requests: selected.map((concept) => ({
id: concept.id,
title: concept.title,
body: {
model: 'gpt-image-2-all',
prompt: concept.prompt,
n: 1,
size: '1024x1024',
},
})),
},
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 concept of selected) {
console.log(`Generating ${concept.id}...`);
generated.push(await generateConcept(env, concept));
}
console.log(
JSON.stringify(
{
ok: true,
count: generated.length,
files: generated,
verifiedFiles: readdirSync(outputDir).sort(),
},
null,
2,
),
);