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

374 lines
12 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-spiral-reference-concepts',
);
const defaultTimeoutMs = 420000;
const defaultReferenceImagePath = path.join(
outputDir,
'taonier-spiral-reference.jpg',
);
const concepts = [
{
id: 'taonier-spiral-soft-squish',
title: '软泥旋合',
prompt:
'参考输入图的粗圆头螺旋动势,但不要照抄黑白图,也不要使用黑底白线。为中文产品“陶泥儿”设计一个无文字扁平矢量 Logo。结合此前认可的“软泥合拍”造型上下两团抽象软泥被旋转吸入中心像把脑洞轻轻揉成作品。上方使用糖果莓粉 / 珊瑚粉,下方使用薄荷青 / 青绿中央一颗暖黄色小星点。整体主流、亲和、Q 弹、可爱但不幼稚,适合作为 App icon。禁止真实手、手指、播放键、聊天气泡、笑脸、眼睛、花朵、褐色陶土、文字、字母、水印、3D、厚阴影、复杂碎元素。',
},
{
id: 'taonier-spiral-candy-roll',
title: '糖果泥卷',
prompt:
'以参考输入图的单笔圆头旋涡为结构灵感,为“陶泥儿”设计一个无文字扁平矢量 Logo。图形像一条柔软陶泥带被卷成可爱的糖果泥卷但需要保留上下双色软泥合拍的感觉外侧莓粉内侧薄荷青中心有小小暖黄星核。造型要圆润、干净、强记忆点小尺寸清晰像年轻创作娱乐 App 的主标。不要直接做黑白旋涡不要做催眠、棒棒糖、浏览器加载、循环箭头或太极图。禁止文字、字母、水印、3D、真实陶艺、聊天气泡、播放三角、笑脸、眼睛。',
},
{
id: 'taonier-spiral-star-core',
title: '星核涡标',
prompt:
'使用参考输入图的向心旋转和包裹感,设计“陶泥儿”无文字扁平矢量 Logo。两块软泥形沿螺旋方向轻轻包住中央作品星核像 AI 把灵感旋成小游戏。整体比普通旋涡更像品牌符号:线条粗、端点圆、负形干净,不能像手或眼睛。配色沿用陶泥儿前序方向:莓粉 / 珊瑚粉、薄荷青 / 青绿、奶油白、暖黄色星点。风格主流、亲和、可爱、现代、清晰。禁止黑白原图复刻、聊天气泡、播放键、笑脸、花朵、真实手指、褐色主色、文字、水印。',
},
{
id: 'taonier-spiral-bouncy-clay',
title: 'Q弹泥涡',
prompt:
'参考输入图的圆润螺旋,但把它转化成“陶泥儿”的 Q 弹软泥 Logo两条短而厚的软泥弧线从上下错位旋入中心中间不是黑洞而是一颗小星 / 小作品核。颜色更可爱:粉桃、薄荷、奶油白、暖黄。图形要比参考更轻、更甜、更品牌化,保留一点“软泥合拍”的上下关系。适合 App icon 和启动页。不要像棒棒糖、蚊香、加载图标、太极、旋风、眼睛、聊天气泡、播放按钮禁止文字、字母、水印、3D。',
},
{
id: 'taonier-spiral-creation-whirl',
title: '创作星涡',
prompt:
'结合参考输入图的螺旋势能和陶泥儿此前“软泥合拍”的粉绿配色,设计无文字扁平矢量 Logo。主形是一个开放式旋涡像软泥被轻轻揉动中心生成暖黄色四角星。整体要活泼、生动、主流、容易记住不要太抽象成通用旋涡。形体应保留圆头粗线和柔软手感但不能出现具体手。颜色亮莓粉、清爽薄荷青、奶白、暖黄最多四色。禁止黑白照搬、褐色陶土、播放三角、聊天气泡、笑脸、眼睛、花朵、文字、水印、复杂碎点。',
},
{
id: 'taonier-spiral-soft-token',
title: '旋合软标',
prompt:
'以参考输入图的粗线螺旋为参考,为“陶泥儿”做更成熟的 App icon 主标。把螺旋收敛成一个完整圆润的软泥符号:上半莓粉,下半青绿,中间用奶白负形形成自然旋转缝隙,中心保留一枚小暖黄星点。它需要兼顾精品 AI 创作、UGC、小游戏和传播感不能太儿童、不能太像加载图。风格flat vector logo, clean, friendly, cute, memorable, scalable, solid colors。禁止文字、字母、水印、3D、厚阴影、真实手、播放键、聊天气泡、笑脸、眼睛、太极、棒棒糖。',
},
];
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 getMimeType(filePath) {
const extension = path.extname(filePath).toLowerCase();
if (extension === '.jpg' || extension === '.jpeg') {
return 'image/jpeg';
}
if (extension === '.webp') {
return 'image/webp';
}
return 'image/png';
}
function readReferenceDataUrl(filePath) {
const bytes = readFileSync(filePath);
return `data:${getMimeType(filePath)};base64,${bytes.toString('base64')}`;
}
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, referenceDataUrl) {
const requestBody = {
model: 'gpt-image-2-all',
quality: String(args.get('--quality') || 'low'),
prompt: concept.prompt,
image: [referenceDataUrl],
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 referenceImagePath = String(
args.get('--reference') || defaultReferenceImagePath,
);
const onlyIds = String(args.get('--only') || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const limit = Number.parseInt(String(args.get('--limit') || '0'), 10);
const selected = concepts
.filter((concept) => !onlyIds.length || onlyIds.includes(concept.id))
.slice(0, limit > 0 ? limit : concepts.length);
if (!existsSync(referenceImagePath)) {
console.error(
JSON.stringify({
ok: false,
error: 'Reference image not found',
referenceImagePath,
}),
);
process.exit(1);
}
const referenceDataUrl = readReferenceDataUrl(referenceImagePath);
if (dryRun) {
console.log(
JSON.stringify(
{
mode: 'dry-run',
outputDir,
referenceImagePath,
referenceImage: {
mimeType: getMimeType(referenceImagePath),
dataUrlLength: referenceDataUrl.length,
},
count: selected.length,
requests: selected.map((concept) => ({
id: concept.id,
title: concept.title,
body: {
model: 'gpt-image-2-all',
quality: String(args.get('--quality') || 'low'),
prompt: concept.prompt,
image: ['<reference image data URL omitted>'],
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, referenceDataUrl));
}
console.log(
JSON.stringify(
{
ok: true,
count: generated.length,
files: generated,
verifiedFiles: readdirSync(outputDir).sort(),
},
null,
2,
),
);