chore: remove redundant asset generation scripts
This commit is contained in:
@@ -1,145 +0,0 @@
|
|||||||
import { Buffer } from 'node:buffer';
|
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
const repoRoot = process.cwd();
|
|
||||||
const promptsPath = path.join(repoRoot, 'public', 'bark-battle-assets', 'bark-battle-image-prompts.json');
|
|
||||||
const outDir = path.join(repoRoot, 'public', 'bark-battle-assets', 'generated');
|
|
||||||
const args = new Set(process.argv.slice(2));
|
|
||||||
|
|
||||||
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 || 1000000), 10),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function generationUrl(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) => typeof entry === 'string' && entry.trim() && output.push(entry.trim()));
|
|
||||||
}
|
|
||||||
collectStringsByKey(nested, targetKey, output);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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, 300)}`);
|
|
||||||
return JSON.parse(text);
|
|
||||||
} 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());
|
|
||||||
const type = response.headers.get('content-type') || '';
|
|
||||||
const extension = type.includes('webp') ? 'webp' : type.includes('jpeg') ? 'jpg' : 'png';
|
|
||||||
return { bytes, extension };
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawTemplates = JSON.parse(readFileSync(promptsPath, 'utf8'));
|
|
||||||
const onlyIds = process.argv
|
|
||||||
.slice(2)
|
|
||||||
.flatMap((arg, index, values) => (arg === '--only' ? String(values[index + 1] || '').split(',') : []))
|
|
||||||
.map((value) => value.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const templates = rawTemplates.filter((template) => !onlyIds.length || onlyIds.includes(template.id));
|
|
||||||
const dryRun = args.has('--dry-run') || !args.has('--live');
|
|
||||||
const requests = templates.map((template) => ({ id: template.id, title: template.title, body: { model: 'gpt-image-2', prompt: template.prompt, n: 1, size: '1024x1024' } }));
|
|
||||||
if (dryRun) {
|
|
||||||
console.log(JSON.stringify({ mode: 'dry-run', outDir, count: requests.length, requests }, 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(outDir, { recursive: true });
|
|
||||||
const files = [];
|
|
||||||
for (const request of requests) {
|
|
||||||
console.log(`Generating ${request.id}...`);
|
|
||||||
const payload = await fetchJson(generationUrl(env.baseUrl), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${env.apiKey}`, Accept: 'application/json', 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(request.body),
|
|
||||||
}, env.timeoutMs);
|
|
||||||
const urls = [];
|
|
||||||
const b64 = [];
|
|
||||||
collectStringsByKey(payload, 'url', urls);
|
|
||||||
collectStringsByKey(payload, 'image', urls);
|
|
||||||
collectStringsByKey(payload, 'image_url', urls);
|
|
||||||
collectStringsByKey(payload, 'b64_json', b64);
|
|
||||||
let image;
|
|
||||||
const url = [...new Set(urls)].find((item) => /^https?:\/\//u.test(item));
|
|
||||||
if (url) {
|
|
||||||
image = await downloadUrl(url, env.timeoutMs);
|
|
||||||
} else if (b64[0]) {
|
|
||||||
const bytes = Buffer.from(b64[0], 'base64');
|
|
||||||
image = { bytes, extension: inferExtensionFromBytes(bytes) };
|
|
||||||
} else {
|
|
||||||
throw new Error(`VectorEngine returned no image for ${request.id}`);
|
|
||||||
}
|
|
||||||
const outputPath = path.join(outDir, `${request.id}.${image.extension}`);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
files.push(outputPath);
|
|
||||||
}
|
|
||||||
console.log(JSON.stringify({ ok: true, count: files.length, files }, null, 2));
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,394 +0,0 @@
|
|||||||
import { Buffer } from 'node:buffer';
|
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
const repoRoot = process.cwd();
|
|
||||||
const outDir = path.join(
|
|
||||||
repoRoot,
|
|
||||||
'output',
|
|
||||||
'imagegen',
|
|
||||||
'edutainment-tv-map-entry-concepts-20260518',
|
|
||||||
);
|
|
||||||
const styleReferencePath = path.join(
|
|
||||||
repoRoot,
|
|
||||||
'public',
|
|
||||||
'child-motion-demo',
|
|
||||||
'picture-book-grass-stage.png',
|
|
||||||
);
|
|
||||||
const defaultTimeoutMs = 1000000;
|
|
||||||
const commonStyle = [
|
|
||||||
'横屏 16:9 电视端寓教于乐板块交互入口概念图。',
|
|
||||||
'画面像儿童主题乐园地图,不是现实品牌乐园,不出现迪士尼、环球影城、城堡商标、影视 IP、真实品牌或可识别版权角色。',
|
|
||||||
'整体保持 Genarrative 寓教于乐现有明亮卡通绘本插画风:柔和水彩笔触、轻微纸张纹理、温暖草地、浅蓝天空、圆润可爱、低噪声、儿童友好。',
|
|
||||||
'地图分为 5 到 6 个清晰区域,每个区域像可点击玩法入口:宝贝识物、宝贝爱画、动作热身、拼图启蒙、声音节奏、自然探索;用图形和场景暗示模块,不写文字。',
|
|
||||||
'需要有主路径、分叉小路、入口节点、空白安全区和明显的焦点层级,适合后续在网页上叠加按钮、焦点光圈和中文标题。',
|
|
||||||
'构图为电视大屏横屏,远看是完整乐园地图,近看每个区域可作为独立交互入口;边缘留出安全裁切,不要把重要入口贴边。',
|
|
||||||
'不要出现文字、数字、字母、按钮文案、UI 面板、教程说明、水印、logo、真实照片质感、暗色科技风、过度商业广告感。',
|
|
||||||
].join('');
|
|
||||||
|
|
||||||
const concepts = [
|
|
||||||
{
|
|
||||||
id: 'edutainment-tv-map-01-ring-park',
|
|
||||||
title: '环形乐园岛',
|
|
||||||
prompt: [
|
|
||||||
commonStyle,
|
|
||||||
'版式方向:俯视略带透视的环形乐园岛,中央是柔软草地广场,外圈有一条蜿蜒小路串联 6 个入口区域。',
|
|
||||||
'区域暗示:左侧水果与小篮子区域代表宝贝识物;左下彩色画笔和画纸区域代表宝贝爱画;下方开阔草地圆环代表动作热身;右下拼图积木和图块小屋代表拼图启蒙;右侧小舞台和音符形花朵代表声音节奏;上方小树林和放大镜步道代表自然探索。',
|
|
||||||
'每个入口用圆润小建筑、道具和地形分区表现,入口节点尺寸接近,主路清楚,中央保留可叠加推荐焦点的位置。',
|
|
||||||
].join(''),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'edutainment-tv-map-02-open-book',
|
|
||||||
title: '展开绘本地图',
|
|
||||||
prompt: [
|
|
||||||
commonStyle,
|
|
||||||
'版式方向:一本巨大的横向展开绘本变成乐园地图,左右两页自然连接,中缝是一条小河或小路。',
|
|
||||||
'左页偏认知与绘画:果园篮子、动物剪影卡片、彩色蜡笔丘陵、画纸小屋;右页偏运动与探索:草地热身舞台、拼图桥、小音符剧场、树林观察台。',
|
|
||||||
'入口像从纸页上立起来的立体绘本机关,边缘有轻微纸张纹理和翻页层次,整体仍是干净可交互背景,不要文字。',
|
|
||||||
].join(''),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'edutainment-tv-map-03-floating-islands',
|
|
||||||
title: '云朵空中岛',
|
|
||||||
prompt: [
|
|
||||||
commonStyle,
|
|
||||||
'版式方向:浅蓝天空中的多个漂浮小岛,岛与岛之间由彩虹桥、云朵步道和藤蔓小路连接,横向展开适合电视端选择入口。',
|
|
||||||
'每个小岛是一个玩法模块入口:水果识物岛、画笔创作岛、草地运动岛、拼图机械小岛、声音花园岛、自然观察岛。',
|
|
||||||
'中央主岛最大,左右分布保持平衡,背景云层干净明亮,入口岛轮廓清晰,适合后续做焦点放大和悬停动效。',
|
|
||||||
].join(''),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'edutainment-tv-map-04-stage-garden',
|
|
||||||
title: '草地舞台地图',
|
|
||||||
prompt: [
|
|
||||||
commonStyle,
|
|
||||||
'版式方向:把现有寓教于乐草地舞台扩展成横屏互动乐园,前景是开阔草地,远景是小山、树木和柔和天空。',
|
|
||||||
'入口沿一条 S 形小路从左到右铺开:篮子果园、画画帐篷、动作圆环舞台、拼图桥、声音小剧场、探索小树林。',
|
|
||||||
'整体更接近实际运行态背景,可直接想象成电视端页面首屏;中心下方需要留空,给遥控器焦点框、入口标题或儿童角色站位使用。',
|
|
||||||
].join(''),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 generationUrl(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 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';
|
|
||||||
}
|
|
||||||
|
|
||||||
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/jpeg') {
|
|
||||||
return 'jpg';
|
|
||||||
}
|
|
||||||
return 'png';
|
|
||||||
}
|
|
||||||
|
|
||||||
function toDataUrl(filePath) {
|
|
||||||
if (!existsSync(filePath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const bytes = readFileSync(filePath);
|
|
||||||
const extension = inferExtensionFromBytes(bytes);
|
|
||||||
const mime = extension === 'jpg' ? 'image/jpeg' : `image/${extension}`;
|
|
||||||
return `data:${mime};base64,${bytes.toString('base64')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(concept, size) {
|
|
||||||
const body = {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: concept.prompt,
|
|
||||||
n: 1,
|
|
||||||
size,
|
|
||||||
};
|
|
||||||
const styleReference = toDataUrl(styleReferencePath);
|
|
||||||
if (styleReference) {
|
|
||||||
body.image = [styleReference];
|
|
||||||
}
|
|
||||||
return body;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDryRunRequestBody(concept, size, hasStyleReference) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: concept.prompt,
|
|
||||||
n: 1,
|
|
||||||
size,
|
|
||||||
imageReferenceCount: hasStyleReference ? 1 : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
bytes: Buffer.from(await response.arrayBuffer()),
|
|
||||||
extension: inferExtensionFromContentType(
|
|
||||||
response.headers.get('content-type') || '',
|
|
||||||
),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.name === 'AbortError') {
|
|
||||||
throw new Error(`Generated image download timed out after ${timeoutMs}ms`);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, concept, size) {
|
|
||||||
const payload = await fetchJson(
|
|
||||||
generationUrl(env.baseUrl),
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${env.apiKey}`,
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(buildRequestBody(concept, size)),
|
|
||||||
},
|
|
||||||
env.timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
const urls = [];
|
|
||||||
const b64Images = [];
|
|
||||||
collectStringsByKey(payload, 'url', urls);
|
|
||||||
collectStringsByKey(payload, 'image', urls);
|
|
||||||
collectStringsByKey(payload, 'image_url', urls);
|
|
||||||
collectStringsByKey(payload, 'b64_json', b64Images);
|
|
||||||
|
|
||||||
let image;
|
|
||||||
const imageUrl = [...new Set(urls)].find((url) => /^https?:\/\//u.test(url));
|
|
||||||
if (imageUrl) {
|
|
||||||
image = await downloadUrl(imageUrl, 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 ${concept.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(outDir, { recursive: true });
|
|
||||||
const outputPath = path.join(outDir, `${concept.id}.${image.extension}`);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = new Map();
|
|
||||||
for (let index = 2; index < process.argv.length; index += 1) {
|
|
||||||
const raw = process.argv[index];
|
|
||||||
if (raw.startsWith('--')) {
|
|
||||||
const next = process.argv[index + 1];
|
|
||||||
if (next && !next.startsWith('--')) {
|
|
||||||
args.set(raw, next);
|
|
||||||
index += 1;
|
|
||||||
} else {
|
|
||||||
args.set(raw, true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const size = String(args.get('--size') || '2048x1152');
|
|
||||||
const dryRun = args.has('--dry-run') || !args.has('--live');
|
|
||||||
const selectedIds = String(args.get('--only') || '')
|
|
||||||
.split(',')
|
|
||||||
.map((value) => value.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const selectedConcepts = concepts.filter(
|
|
||||||
(concept) => selectedIds.length === 0 || selectedIds.includes(concept.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
mode: 'dry-run',
|
|
||||||
outDir,
|
|
||||||
size,
|
|
||||||
hasStyleReference: existsSync(styleReferencePath),
|
|
||||||
count: selectedConcepts.length,
|
|
||||||
requests: selectedConcepts.map((concept) => ({
|
|
||||||
id: concept.id,
|
|
||||||
title: concept.title,
|
|
||||||
body: buildDryRunRequestBody(
|
|
||||||
concept,
|
|
||||||
size,
|
|
||||||
existsSync(styleReferencePath),
|
|
||||||
),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 files = [];
|
|
||||||
for (const concept of selectedConcepts) {
|
|
||||||
console.log(`Generating ${concept.id}...`);
|
|
||||||
files.push(await generateOne(env, concept, size));
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(
|
|
||||||
path.join(outDir, 'generation-metadata.json'),
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size,
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
styleReference: existsSync(styleReferencePath)
|
|
||||||
? styleReferencePath
|
|
||||||
: null,
|
|
||||||
files: selectedConcepts.map((concept, index) => ({
|
|
||||||
id: concept.id,
|
|
||||||
title: concept.title,
|
|
||||||
file: files[index],
|
|
||||||
prompt: concept.prompt,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(JSON.stringify({ ok: true, count: files.length, files }, null, 2));
|
|
||||||
@@ -1,327 +0,0 @@
|
|||||||
import { Buffer } from 'node:buffer';
|
|
||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const repoRoot = path.resolve(scriptDir, '..');
|
|
||||||
const defaultOutDir = path.join(repoRoot, 'public', 'match3d-style-references');
|
|
||||||
const defaultTimeoutMs = 1000000;
|
|
||||||
|
|
||||||
const styleTemplates = [
|
|
||||||
{
|
|
||||||
id: 'flat-icon',
|
|
||||||
title: '扁平图标',
|
|
||||||
prompt:
|
|
||||||
'扁平矢量游戏道具图标风格,干净色块,正面视角,深色清晰轮廓,移动端休闲游戏素材,可读性很强。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'cel-cartoon',
|
|
||||||
title: '赛璐璐卡通',
|
|
||||||
prompt:
|
|
||||||
'赛璐璐卡通游戏道具风格,明亮配色,清晰线稿,硬边阴影,边缘干净,像轻松休闲手游里的 2D 素材。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pixel-retro',
|
|
||||||
title: '像素复古',
|
|
||||||
prompt:
|
|
||||||
'真正复古像素游戏道具 sprite 风格,先以约 64x64 低分辨率像素块绘制再按整数倍放大,硬边方块像素清晰可见,有限色板 12-24 色,主体轮廓稳定,禁止抗锯齿、柔焦、平滑渐变、真实 3D 渲染、PBR 材质和摄影光照。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'watercolor',
|
|
||||||
title: '手绘水彩',
|
|
||||||
prompt:
|
|
||||||
'手绘水彩游戏道具风格,柔和纸张纹理,透明叠色,边缘轻微晕染,但主体剪影仍然清楚。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'sticker-outline',
|
|
||||||
title: '贴纸描边',
|
|
||||||
prompt:
|
|
||||||
'贴纸描边游戏道具素材风格,粗白边,深色外轮廓,柔和投影,色彩活泼,适合休闲消除游戏。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'painterly-icon',
|
|
||||||
title: '厚涂图标',
|
|
||||||
prompt:
|
|
||||||
'厚涂游戏道具图标风格,细腻笔触,明确体积光影,中心构图,清晰剪影,适合高品质 2D 道具素材。',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 中文注释:入口缩略图只用于比较画风,必须展示单个代表道具,避免误导为一组待切割物品。
|
|
||||||
function buildPrompt(template) {
|
|
||||||
return [
|
|
||||||
'请生成一张 1:1 方形抓大鹅入口 2D 素材风格参考图。',
|
|
||||||
'画面只允许出现 1 个完整独立的游戏道具主体,题材固定为一颗红苹果,不要出现第二个物品。',
|
|
||||||
`整体风格:${template.prompt}`,
|
|
||||||
'要求:这个道具是独立 2D 素材示例,主体集中,轮廓清晰,适合作为抓大鹅局内物品素材。',
|
|
||||||
'构图:浅色干净背景,单物体居中放大,四周留少量呼吸感,不要九宫格边框,不要 UI 面板,不要按钮。',
|
|
||||||
'避免:多个物品、5 个物品、物品组合、重复视角、散点排列、文字、水印、logo、教程标注、真实照片、复杂场景、人物、动物、3D 模型视口、明显透视地面、厚重阴影。',
|
|
||||||
].join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchWithTimeout(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 text;
|
|
||||||
} catch (error) {
|
|
||||||
if (error?.name === 'AbortError') {
|
|
||||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadImage(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 generateOne(env, template, outDir, size) {
|
|
||||||
const requestBody = {
|
|
||||||
model: 'gpt-image-2',
|
|
||||||
prompt: buildPrompt(template),
|
|
||||||
n: 1,
|
|
||||||
size,
|
|
||||||
};
|
|
||||||
const payloadText = await fetchWithTimeout(
|
|
||||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${env.apiKey}`,
|
|
||||||
Accept: 'application/json',
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify(requestBody),
|
|
||||||
},
|
|
||||||
env.timeoutMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
const payload = JSON.parse(payloadText);
|
|
||||||
const urls = extractImageUrls(payload);
|
|
||||||
const base64Images = extractBase64Images(payload);
|
|
||||||
const imageBytes = urls[0]
|
|
||||||
? await downloadImage(urls[0], env.timeoutMs)
|
|
||||||
: base64Images[0]
|
|
||||||
? Buffer.from(base64Images[0], 'base64')
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!imageBytes) {
|
|
||||||
throw new Error(`VectorEngine returned no image for ${template.id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(outDir, { recursive: true });
|
|
||||||
const outPath = path.join(outDir, `${template.id}.png`);
|
|
||||||
writeFileSync(outPath, imageBytes);
|
|
||||||
return {
|
|
||||||
file: outPath,
|
|
||||||
source: urls[0] ? 'url' : 'b64_json',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const dryRun = args.has('--dry-run') || !args.has('--live');
|
|
||||||
const outDir = path.resolve(String(args.get('--out-dir') || defaultOutDir));
|
|
||||||
const size = String(args.get('--size') || '1024x1024');
|
|
||||||
const onlyIds = String(args.get('--only') || '')
|
|
||||||
.split(',')
|
|
||||||
.map((value) => value.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const templates = styleTemplates.filter(
|
|
||||||
(template) => !onlyIds.length || onlyIds.includes(template.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (dryRun) {
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
mode: 'dry-run',
|
|
||||||
outDir,
|
|
||||||
count: templates.length,
|
|
||||||
requests: templates.map((template) => ({
|
|
||||||
id: template.id,
|
|
||||||
title: template.title,
|
|
||||||
body: {
|
|
||||||
model: 'gpt-image-2',
|
|
||||||
prompt: buildPrompt(template),
|
|
||||||
n: 1,
|
|
||||||
size,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 template of templates) {
|
|
||||||
console.log(`Generating ${template.id}...`);
|
|
||||||
generated.push({
|
|
||||||
id: template.id,
|
|
||||||
...(await generateOne(env, template, outDir, size)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
count: generated.length,
|
|
||||||
files: generated,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,321 +0,0 @@
|
|||||||
import {spawn} from 'node:child_process';
|
|
||||||
import {existsSync} from 'node:fs';
|
|
||||||
import {cp, mkdir, readdir, rm, stat} from 'node:fs/promises';
|
|
||||||
import path from 'node:path';
|
|
||||||
import {fileURLToPath} from 'node:url';
|
|
||||||
|
|
||||||
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
||||||
const REPO_ROOT = path.resolve(SCRIPT_DIR, '..');
|
|
||||||
const MODULE_PATH = path.join(REPO_ROOT, 'server-rs', 'crates', 'spacetime-module');
|
|
||||||
|
|
||||||
const TARGETS = [
|
|
||||||
{
|
|
||||||
name: 'Rust',
|
|
||||||
lang: 'rust',
|
|
||||||
tempName: 'rs',
|
|
||||||
outDir: path.join(
|
|
||||||
REPO_ROOT,
|
|
||||||
'server-rs',
|
|
||||||
'crates',
|
|
||||||
'spacetime-client',
|
|
||||||
'src',
|
|
||||||
'module_bindings',
|
|
||||||
),
|
|
||||||
entryFile: path.join(
|
|
||||||
REPO_ROOT,
|
|
||||||
'server-rs',
|
|
||||||
'crates',
|
|
||||||
'spacetime-client',
|
|
||||||
'src',
|
|
||||||
'module_bindings.rs',
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const args = new Set(process.argv.slice(2));
|
|
||||||
const KNOWN_ARGS = new Set(['--rust-only']);
|
|
||||||
|
|
||||||
for (const arg of args) {
|
|
||||||
if (!KNOWN_ARGS.has(arg)) {
|
|
||||||
console.error(`[spacetime:generate] 未知参数: ${arg}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(path.join(MODULE_PATH, 'Cargo.toml'))) {
|
|
||||||
console.error(`[spacetime:generate] 未找到模块: ${MODULE_PATH}`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tempRoot = resolveTempRoot();
|
|
||||||
assertSafeTempRoot(tempRoot);
|
|
||||||
const selectedTargets = TARGETS.filter((target) => shouldRunTarget(target.lang));
|
|
||||||
|
|
||||||
if (selectedTargets.length === 0) {
|
|
||||||
console.error('[spacetime:generate] 没有需要生成的目标。');
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
await mkdir(tempRoot, {recursive: true});
|
|
||||||
|
|
||||||
for (const target of selectedTargets) {
|
|
||||||
const tempOutDir = path.join(tempRoot, target.tempName);
|
|
||||||
await recreateTempDir(tempOutDir);
|
|
||||||
|
|
||||||
console.log(`[spacetime:generate] 生成 ${target.name} bindings 到短路径: ${tempOutDir}`);
|
|
||||||
await generateBindings(target, tempOutDir);
|
|
||||||
|
|
||||||
const fileCount = await countFiles(tempOutDir);
|
|
||||||
if (fileCount === 0) {
|
|
||||||
throw new Error(`${target.name} bindings 未生成任何文件。`);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[spacetime:generate] 同步 ${fileCount} 个文件到 ${target.outDir}`);
|
|
||||||
await replaceGeneratedDir(tempOutDir, target.outDir);
|
|
||||||
await moveGeneratedEntryFile(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
await rm(tempRoot, {recursive: true, force: true});
|
|
||||||
console.log('[spacetime:generate] bindings 生成完成。');
|
|
||||||
|
|
||||||
function shouldRunTarget(lang) {
|
|
||||||
if (args.has('--rust-only')) {
|
|
||||||
return lang === 'rust';
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveTempRoot() {
|
|
||||||
if (process.env.GENARRATIVE_BINDGEN_TEMP_ROOT) {
|
|
||||||
return path.resolve(process.env.GENARRATIVE_BINDGEN_TEMP_ROOT);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Windows 下 SpacetimeDB CLI 2.1.0 会把所有生成文件路径一次性传给 formatter;
|
|
||||||
// Rust bindings 文件数较多,输出到仓库深目录时容易触发 CreateProcess 路径总长限制。
|
|
||||||
if (process.platform === 'win32') {
|
|
||||||
return path.join(path.parse(REPO_ROOT).root, '.genarrative-bindgen');
|
|
||||||
}
|
|
||||||
|
|
||||||
return path.join(REPO_ROOT, 'tmp', 'spacetime-bindgen');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function recreateTempDir(dir) {
|
|
||||||
assertInside(dir, tempRoot, '临时生成目录');
|
|
||||||
await rm(dir, {recursive: true, force: true});
|
|
||||||
await mkdir(dir, {recursive: true});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function replaceGeneratedDir(fromDir, toDir) {
|
|
||||||
assertInside(toDir, REPO_ROOT, '仓库生成目录');
|
|
||||||
await rm(toDir, {recursive: true, force: true});
|
|
||||||
await mkdir(toDir, {recursive: true});
|
|
||||||
const entries = await readdir(fromDir, {withFileTypes: true});
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
await cp(path.join(fromDir, entry.name), path.join(toDir, entry.name), {
|
|
||||||
recursive: true,
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function moveGeneratedEntryFile(target) {
|
|
||||||
if (!target.entryFile) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
assertInside(target.entryFile, REPO_ROOT, '生成入口文件');
|
|
||||||
const generatedModFile = path.join(target.outDir, 'mod.rs');
|
|
||||||
|
|
||||||
if (!existsSync(generatedModFile)) {
|
|
||||||
throw new Error(`${target.name} bindings 缺少入口文件: ${generatedModFile}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await rm(target.entryFile, {force: true});
|
|
||||||
await cp(generatedModFile, target.entryFile, {force: true});
|
|
||||||
await rm(generatedModFile, {force: true});
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertInside(candidate, parent, label) {
|
|
||||||
const relative = path.relative(path.resolve(parent), path.resolve(candidate));
|
|
||||||
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
||||||
throw new Error(`${label} 不在预期目录内: ${candidate}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertSafeTempRoot(dir) {
|
|
||||||
const resolved = path.resolve(dir);
|
|
||||||
const parsed = path.parse(resolved);
|
|
||||||
const basename = path.basename(resolved).toLowerCase();
|
|
||||||
|
|
||||||
if (resolved === path.resolve(REPO_ROOT) || resolved === parsed.root) {
|
|
||||||
throw new Error(`临时根目录不允许指向仓库或磁盘根目录: ${resolved}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!basename.includes('bindgen')) {
|
|
||||||
throw new Error(`临时根目录必须是明确的 bindings 生成目录: ${resolved}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildGenerateArgs(target, outDir) {
|
|
||||||
const generateArgs = [
|
|
||||||
'generate',
|
|
||||||
'--no-config',
|
|
||||||
'--lang',
|
|
||||||
target.lang,
|
|
||||||
'--out-dir',
|
|
||||||
outDir,
|
|
||||||
'--module-path',
|
|
||||||
MODULE_PATH,
|
|
||||||
'--include-private',
|
|
||||||
'--yes',
|
|
||||||
];
|
|
||||||
|
|
||||||
return generateArgs;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateBindings(target, outDir) {
|
|
||||||
const result = await run('spacetime', buildGenerateArgs(target, outDir), {
|
|
||||||
allowGeneratedFormatFailure: target.lang === 'rust',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.generatedFormatFailed) {
|
|
||||||
// Windows 下 SpacetimeDB CLI 2.1.0 会把所有 Rust 文件一次性传给 formatter;
|
|
||||||
// 这里只接管“文件已生成但 CLI 格式化失败”的尾段,并仍然只同步生成目录。
|
|
||||||
console.warn(
|
|
||||||
`[spacetime:generate] ${target.name} bindings 已生成,但 SpacetimeDB CLI 自带格式化失败;改用短路径分批 rustfmt。`,
|
|
||||||
);
|
|
||||||
await formatRustBindings(outDir);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function formatRustBindings(outDir) {
|
|
||||||
const rustFiles = await collectRustFiles(outDir);
|
|
||||||
if (rustFiles.length === 0) {
|
|
||||||
throw new Error(`Rust bindings 未生成任何 .rs 文件,无法格式化: ${outDir}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const chunk of chunkCommandArgs(rustFiles)) {
|
|
||||||
await run('rustfmt', ['--edition', '2024', ...chunk]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function collectRustFiles(dir) {
|
|
||||||
const files = [];
|
|
||||||
const entries = await readdir(dir, {withFileTypes: true});
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const entryPath = path.join(dir, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
files.push(...(await collectRustFiles(entryPath)));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isFile() && entry.name.endsWith('.rs')) {
|
|
||||||
files.push(entryPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
function chunkCommandArgs(argsToChunk) {
|
|
||||||
// Windows CreateProcess 受命令行长度限制;分批能避免 bindings 文件变多后再次失败。
|
|
||||||
const maxCommandLineChars = process.platform === 'win32' ? 20_000 : 100_000;
|
|
||||||
const chunks = [];
|
|
||||||
let current = [];
|
|
||||||
let currentLength = 0;
|
|
||||||
|
|
||||||
for (const arg of argsToChunk) {
|
|
||||||
const argLength = arg.length + 3;
|
|
||||||
if (current.length > 0 && currentLength + argLength > maxCommandLineChars) {
|
|
||||||
chunks.push(current);
|
|
||||||
current = [];
|
|
||||||
currentLength = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
current.push(arg);
|
|
||||||
currentLength += argLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.length > 0) {
|
|
||||||
chunks.push(current);
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
function run(command, commandArgs, options = {}) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const child = spawn(command, commandArgs, {
|
|
||||||
cwd: REPO_ROOT,
|
|
||||||
env: process.env,
|
|
||||||
shell: false,
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
});
|
|
||||||
|
|
||||||
let output = '';
|
|
||||||
|
|
||||||
child.stdout.on('data', (chunk) => {
|
|
||||||
const text = chunk.toString();
|
|
||||||
output += text;
|
|
||||||
process.stdout.write(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
child.stderr.on('data', (chunk) => {
|
|
||||||
const text = chunk.toString();
|
|
||||||
output += text;
|
|
||||||
process.stderr.write(text);
|
|
||||||
});
|
|
||||||
|
|
||||||
child.on('error', reject);
|
|
||||||
child.on('exit', (code, signal) => {
|
|
||||||
if (signal) {
|
|
||||||
reject(new Error(`${command} 被信号中断: ${signal}`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const generatedFormatFailed = output.includes('Could not format generated files');
|
|
||||||
|
|
||||||
if (generatedFormatFailed && options.allowGeneratedFormatFailure) {
|
|
||||||
console.warn(`[spacetime:generate] ${command} generated files but formatting failed; continuing with validation.`);
|
|
||||||
resolve({generatedFormatFailed});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (generatedFormatFailed) {
|
|
||||||
reject(new Error(`${command} generated files but formatting failed.`));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (code === 0) {
|
|
||||||
resolve({generatedFormatFailed: false});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
reject(new Error(`${command} 退出码: ${code ?? 'unknown'}`));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function countFiles(dir) {
|
|
||||||
let count = 0;
|
|
||||||
const entries = await readdir(dir, {withFileTypes: true});
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const entryPath = path.join(dir, entry.name);
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
count += await countFiles(entryPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (entry.isFile() || (await stat(entryPath)).isFile()) {
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return count;
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-abstract-mascot-image2-concepts"
|
|
||||||
)
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-abstract-mascot-image2-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 泥灵符号", "taonier-image2-clay-spirit-glyph.png"),
|
|
||||||
("02 捏胚小偶", "taonier-image2-pinched-seed-mascot.png"),
|
|
||||||
("03 软陶图灵", "taonier-image2-soft-totem-creature.png"),
|
|
||||||
("04 口袋泥符", "taonier-image2-clay-pocket-token.png"),
|
|
||||||
("05 作品泥偶", "taonier-image2-work-core-puppet.png"),
|
|
||||||
("06 模团伙伴", "taonier-image2-mold-blob-companion.png"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 330
|
|
||||||
label_height = 58
|
|
||||||
gap = 28
|
|
||||||
columns = 3
|
|
||||||
rows = 2
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * (cell_size + label_height) + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#eee9df")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
font = load_font(24)
|
|
||||||
|
|
||||||
for index, (label, filename) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_size + label_height + gap)
|
|
||||||
|
|
||||||
source_path = OUTPUT_DIR / filename
|
|
||||||
if not source_path.exists():
|
|
||||||
continue
|
|
||||||
source = Image.open(source_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=10,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=font)
|
|
||||||
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
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-abstract-mascot-image2-concepts',
|
|
||||||
);
|
|
||||||
const defaultTimeoutMs = 420000;
|
|
||||||
|
|
||||||
const concepts = [
|
|
||||||
{
|
|
||||||
id: 'taonier-image2-clay-spirit-glyph',
|
|
||||||
title: '泥灵符号',
|
|
||||||
prompt:
|
|
||||||
'为中文产品“陶泥儿”设计一个无文字 Logo 图标。方向:陶泥人、陶泥手办、抽象角色 / 吉祥物,但不要做人体形象,也不要做完整角色插画。主体像一块被轻轻捏出生命感的陶泥符号:单一圆润剪影,内部一个极简星核或负形孔洞,只有很少的点状生命感。必须是扁平矢量、主流 App icon、简单几何、亲和、醒目、可记忆、小尺寸清晰。配色:奶油白主形、暖陶土橙点缀、深墨背景、少量金色星核。禁止文字、字母、汉字、水印、真实人形、手脚、复杂五官、聊天气泡、播放三角、儿童黏土课、3D、厚阴影、贴纸感。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-image2-pinched-seed-mascot',
|
|
||||||
title: '捏胚小偶',
|
|
||||||
prompt:
|
|
||||||
'为“陶泥儿”设计无文字品牌主标。把“陶泥小人 / 手办 / 吉祥物”的精神压缩成一个非人形的几何泥胚:像一颗被捏过的种子或软陶坯,带一点抽象生命感,但没有手、脚、头发、衣服。中心有一颗极简四角闪光星,表达 AI 把脑洞捏成作品。风格:flat vector mascot mark, modern, friendly, geometric, logo-ready, not illustration。颜色控制在 3 到 4 色:深墨、奶白、陶土橙、暖黄。禁止文字、字母、汉字、真实陶艺工具、儿童玩具、emoji 表情、聊天气泡、播放按钮、复杂装饰、立体渲染。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-image2-soft-totem-creature',
|
|
||||||
title: '软陶图灵',
|
|
||||||
prompt:
|
|
||||||
'为“陶泥儿”设计无文字 Logo。主体是一个抽象陶泥角色图腾,不是人,也不是动物,而是一枚软陶手办被极简化后的品牌符号:上窄下稳、边缘像手捏过,中央有一个圆形作品核和一个小泥点。要有吉祥物的亲和力,但更像成熟平台主标。风格:bold flat vector, iconic silhouette, playful premium, highly memorable, simple enough for favicon。配色:深色背景、奶油白大形、陶土橙作品核、薄荷青或金色小点。禁止中文英文、复杂脸、两只眼睛加嘴的头像感、人体、手、脚、聊天气泡、播放三角、3D 质感。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-image2-clay-pocket-token',
|
|
||||||
title: '口袋泥符',
|
|
||||||
prompt:
|
|
||||||
'为“陶泥儿”设计一个能做 App icon 的无文字主 Logo。方向是“抽象口袋陶泥手办”:它像一个可以被收藏的小软陶 token,但不能是具体人物。主图形由一个圆角几何泥块、一处被捏出的缺口、一枚小星核组成,轮廓要一眼能记住。气质年轻、Q、可爱但不幼稚,适合 AI UGC 轻休闲小游戏平台。扁平矢量感,少色,高对比。禁止文字、字母、水印、真实人脸、手脚、表情包、聊天气泡、播放按钮、过多小元素、3D、照片感。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-image2-work-core-puppet',
|
|
||||||
title: '作品泥偶',
|
|
||||||
prompt:
|
|
||||||
'为“陶泥儿”设计无文字 Logo 图标。把陶泥手办的收藏感、AI 创作的作品核、UGC 玩梗传播的轻松感融合成一个抽象泥偶符号。不要画成人体,只用几何软块和负形孔洞表现“像有生命的陶泥作品”。主体简单、厚实、圆润,中心一枚四角星或小圆核,最多两个辅助泥点。风格:minimal vector mascot logo, clean, premium cute, mainstream consumer app icon。颜色:奶白、陶土橙、深墨、暖黄,可少量青绿。禁止文字、英文、汉字、复杂背景、复杂五官、真实手办、玩具包装、儿童黏土、3D 厚重阴影。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-image2-mold-blob-companion',
|
|
||||||
title: '模团伙伴',
|
|
||||||
prompt:
|
|
||||||
'为“陶泥儿”做一个无文字 Logo 主标。方向:非人形抽象吉祥物,像一团被模具轻轻压出的陶泥伙伴。整体是一个简单几何软形,带一个偏心孔洞和一个小闪光星,让人感觉它能承载用户脑洞、生成小游戏作品。要求主流、亲和、可爱、扁平、矢量、识别强,不能像插画或头像。配色:深墨底、奶油白主体、陶土橙和暖黄点缀,最多 4 色。禁止文字、字母、汉字、水印、手脚、五官表情、聊天气泡、播放三角、复杂碎片、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 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',
|
|
||||||
quality: String(args.get('--quality') || 'low'),
|
|
||||||
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 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 (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',
|
|
||||||
quality: String(args.get('--quality') || 'low'),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-abstract-mascot-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-abstract-mascot-contact-sheet.png"
|
|
||||||
|
|
||||||
SIZE = 1024
|
|
||||||
SCALE = 4
|
|
||||||
|
|
||||||
INK = "#121212"
|
|
||||||
INK_BLUE = "#101418"
|
|
||||||
CREAM = "#fff5df"
|
|
||||||
CLAY = "#d77750"
|
|
||||||
CLAY_DARK = "#b95f3f"
|
|
||||||
GOLD = "#ffd25d"
|
|
||||||
MINT = "#31c7a9"
|
|
||||||
CORAL = "#ff6a5f"
|
|
||||||
|
|
||||||
VARIANTS = [
|
|
||||||
("taonier-abstract-mascot-clay-bean", "陶泥豆偶", "clay_bean"),
|
|
||||||
("taonier-abstract-mascot-mold-baby", "模胚小灵", "mold_baby"),
|
|
||||||
("taonier-abstract-mascot-dot-face", "泥点面偶", "dot_face"),
|
|
||||||
("taonier-abstract-mascot-soft-totem", "软陶图腾", "soft_totem"),
|
|
||||||
("taonier-abstract-mascot-clay-seed", "陶泥种子", "clay_seed"),
|
|
||||||
("taonier-abstract-mascot-work-puppet", "作品泥灵", "work_puppet"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def hex_to_rgb(value: str) -> tuple[int, int, int]:
|
|
||||||
value = value.removeprefix("#")
|
|
||||||
return tuple(int(value[index : index + 2], 16) for index in (0, 2, 4))
|
|
||||||
|
|
||||||
|
|
||||||
def s(value: float) -> int:
|
|
||||||
return round(value * SCALE)
|
|
||||||
|
|
||||||
|
|
||||||
def circle(draw: ImageDraw.ImageDraw, cx: float, cy: float, radius: float, fill: str) -> None:
|
|
||||||
draw.ellipse((s(cx - radius), s(cy - radius), s(cx + radius), s(cy + radius)), fill=hex_to_rgb(fill))
|
|
||||||
|
|
||||||
|
|
||||||
def rounded_rect(
|
|
||||||
draw: ImageDraw.ImageDraw,
|
|
||||||
box: tuple[float, float, float, float],
|
|
||||||
radius: float,
|
|
||||||
fill: str,
|
|
||||||
) -> None:
|
|
||||||
draw.rounded_rectangle(tuple(s(value) for value in box), radius=s(radius), fill=hex_to_rgb(fill))
|
|
||||||
|
|
||||||
|
|
||||||
def polygon(cx: float, cy: float, radius: float, sides: int, rotation: float) -> list[tuple[int, int]]:
|
|
||||||
return [
|
|
||||||
(s(cx + math.cos(rotation + math.tau * index / sides) * radius), s(cy + math.sin(rotation + math.tau * index / sides) * radius))
|
|
||||||
for index in range(sides)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def star_points(cx: float, cy: float, outer: float, inner: float, count: int = 4) -> list[tuple[int, int]]:
|
|
||||||
points = []
|
|
||||||
for index in range(count * 2):
|
|
||||||
radius = outer if index % 2 == 0 else inner
|
|
||||||
angle = -math.pi / 2 + index * math.pi / count
|
|
||||||
points.append((s(cx + math.cos(angle) * radius), s(cy + math.sin(angle) * radius)))
|
|
||||||
return points
|
|
||||||
|
|
||||||
|
|
||||||
def draw_clay_bean(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#101010"))
|
|
||||||
rounded_rect(draw, (270, 214, 754, 820), 228, CREAM)
|
|
||||||
circle(draw, 650, 330, 112, CLAY)
|
|
||||||
circle(draw, 676, 354, 86, CREAM)
|
|
||||||
circle(draw, 430, 470, 34, INK)
|
|
||||||
circle(draw, 590, 470, 34, INK)
|
|
||||||
draw.polygon(star_points(512, 618, 66, 28), fill=hex_to_rgb(GOLD))
|
|
||||||
rounded_rect(draw, (360, 742, 664, 790), 24, CLAY)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_mold_baby(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb(INK_BLUE))
|
|
||||||
draw.polygon(polygon(512, 498, 304, 8, math.pi / 8), fill=hex_to_rgb(CREAM))
|
|
||||||
circle(draw, 512, 398, 126, "#101418")
|
|
||||||
circle(draw, 512, 398, 62, GOLD)
|
|
||||||
circle(draw, 390, 570, 30, "#101418")
|
|
||||||
circle(draw, 634, 570, 30, "#101418")
|
|
||||||
rounded_rect(draw, (380, 704, 644, 758), 27, MINT)
|
|
||||||
rounded_rect(draw, (318, 268, 460, 326), 29, CORAL)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_dot_face(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#17110e"))
|
|
||||||
rounded_rect(draw, (254, 254, 770, 770), 190, CLAY)
|
|
||||||
rounded_rect(draw, (330, 320, 694, 706), 140, CREAM)
|
|
||||||
circle(draw, 440, 478, 30, "#17110e")
|
|
||||||
circle(draw, 584, 478, 30, "#17110e")
|
|
||||||
rounded_rect(draw, (458, 594, 566, 638), 22, CLAY)
|
|
||||||
circle(draw, 512, 254, 54, GOLD)
|
|
||||||
circle(draw, 512, 254, 24, "#17110e")
|
|
||||||
|
|
||||||
|
|
||||||
def draw_soft_totem(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#111111"))
|
|
||||||
rounded_rect(draw, (340, 188, 684, 836), 172, CREAM)
|
|
||||||
circle(draw, 512, 336, 112, CLAY)
|
|
||||||
rounded_rect(draw, (390, 442, 634, 722), 122, "#111111")
|
|
||||||
circle(draw, 512, 582, 58, GOLD)
|
|
||||||
circle(draw, 432, 332, 24, INK)
|
|
||||||
circle(draw, 592, 332, 24, INK)
|
|
||||||
rounded_rect(draw, (404, 782, 620, 842), 30, CREAM)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_clay_seed(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#101418"))
|
|
||||||
draw.pieslice((s(236), s(176), s(788), s(832)), start=218, end=578, fill=hex_to_rgb(CREAM))
|
|
||||||
circle(draw, 618, 326, 72, "#101418")
|
|
||||||
circle(draw, 618, 326, 34, GOLD)
|
|
||||||
circle(draw, 438, 488, 30, "#101418")
|
|
||||||
rounded_rect(draw, (506, 548, 632, 594), 23, CLAY)
|
|
||||||
draw.polygon(star_points(512, 682, 58, 24), fill=hex_to_rgb(GOLD))
|
|
||||||
|
|
||||||
|
|
||||||
def draw_work_puppet(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#101010"))
|
|
||||||
rounded_rect(draw, (286, 300, 738, 756), 150, CREAM)
|
|
||||||
circle(draw, 286, 530, 88, "#101010")
|
|
||||||
circle(draw, 738, 530, 88, "#101010")
|
|
||||||
circle(draw, 512, 300, 76, GOLD)
|
|
||||||
circle(draw, 430, 474, 24, "#101010")
|
|
||||||
circle(draw, 594, 474, 24, "#101010")
|
|
||||||
draw.polygon(star_points(512, 604, 68, 28), fill=hex_to_rgb("#101010"))
|
|
||||||
draw.polygon(star_points(512, 604, 34, 14), fill=hex_to_rgb(GOLD))
|
|
||||||
rounded_rect(draw, (360, 744, 664, 798), 27, CLAY)
|
|
||||||
|
|
||||||
|
|
||||||
DRAWERS = {
|
|
||||||
"clay_bean": draw_clay_bean,
|
|
||||||
"mold_baby": draw_mold_baby,
|
|
||||||
"dot_face": draw_dot_face,
|
|
||||||
"soft_totem": draw_soft_totem,
|
|
||||||
"clay_seed": draw_clay_seed,
|
|
||||||
"work_puppet": draw_work_puppet,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def render_variant(style: str) -> Image.Image:
|
|
||||||
image = Image.new("RGB", (SIZE * SCALE, SIZE * SCALE), hex_to_rgb("#111111"))
|
|
||||||
draw = ImageDraw.Draw(image)
|
|
||||||
DRAWERS[style](draw)
|
|
||||||
return image.resize((SIZE, SIZE), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
|
|
||||||
def build_svg(style: str) -> str:
|
|
||||||
# PNG 是评审主物料;SVG 保留几何结构,供后续人工矢量微调。
|
|
||||||
if style == "clay_bean":
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="#101010"/>
|
|
||||||
<rect x="270" y="214" width="484" height="606" rx="228" fill="{CREAM}"/>
|
|
||||||
<circle cx="650" cy="330" r="112" fill="{CLAY}"/>
|
|
||||||
<circle cx="676" cy="354" r="86" fill="{CREAM}"/>
|
|
||||||
<circle cx="430" cy="470" r="34" fill="{INK}"/>
|
|
||||||
<circle cx="590" cy="470" r="34" fill="{INK}"/>
|
|
||||||
<path d="M512 552L540 590L578 618L540 646L512 684L484 646L446 618L484 590Z" fill="{GOLD}"/>
|
|
||||||
<rect x="360" y="742" width="304" height="48" rx="24" fill="{CLAY}"/>'''
|
|
||||||
elif style == "mold_baby":
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="{INK_BLUE}"/>
|
|
||||||
<path d="M628 217L759 272L814 402L759 724L628 779H396L265 724L210 402L265 272L396 217Z" fill="{CREAM}"/>
|
|
||||||
<circle cx="512" cy="398" r="126" fill="{INK_BLUE}"/>
|
|
||||||
<circle cx="512" cy="398" r="62" fill="{GOLD}"/>
|
|
||||||
<circle cx="390" cy="570" r="30" fill="{INK_BLUE}"/>
|
|
||||||
<circle cx="634" cy="570" r="30" fill="{INK_BLUE}"/>
|
|
||||||
<rect x="380" y="704" width="264" height="54" rx="27" fill="{MINT}"/>
|
|
||||||
<rect x="318" y="268" width="142" height="58" rx="29" fill="{CORAL}"/>'''
|
|
||||||
elif style == "dot_face":
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="#17110e"/>
|
|
||||||
<rect x="254" y="254" width="516" height="516" rx="190" fill="{CLAY}"/>
|
|
||||||
<rect x="330" y="320" width="364" height="386" rx="140" fill="{CREAM}"/>
|
|
||||||
<circle cx="440" cy="478" r="30" fill="#17110e"/>
|
|
||||||
<circle cx="584" cy="478" r="30" fill="#17110e"/>
|
|
||||||
<rect x="458" y="594" width="108" height="44" rx="22" fill="{CLAY}"/>
|
|
||||||
<circle cx="512" cy="254" r="54" fill="{GOLD}"/>
|
|
||||||
<circle cx="512" cy="254" r="24" fill="#17110e"/>'''
|
|
||||||
elif style == "soft_totem":
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="#111111"/>
|
|
||||||
<rect x="340" y="188" width="344" height="648" rx="172" fill="{CREAM}"/>
|
|
||||||
<circle cx="512" cy="336" r="112" fill="{CLAY}"/>
|
|
||||||
<rect x="390" y="442" width="244" height="280" rx="122" fill="#111111"/>
|
|
||||||
<circle cx="512" cy="582" r="58" fill="{GOLD}"/>
|
|
||||||
<circle cx="432" cy="332" r="24" fill="{INK}"/>
|
|
||||||
<circle cx="592" cy="332" r="24" fill="{INK}"/>
|
|
||||||
<rect x="404" y="782" width="216" height="60" rx="30" fill="{CREAM}"/>'''
|
|
||||||
elif style == "clay_seed":
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="{INK_BLUE}"/>
|
|
||||||
<path d="M236 504C236 294 382 176 512 176C676 176 788 322 788 504C788 702 648 832 512 832C372 832 236 702 236 504Z" fill="{CREAM}"/>
|
|
||||||
<circle cx="618" cy="326" r="72" fill="{INK_BLUE}"/>
|
|
||||||
<circle cx="618" cy="326" r="34" fill="{GOLD}"/>
|
|
||||||
<circle cx="438" cy="488" r="30" fill="{INK_BLUE}"/>
|
|
||||||
<rect x="506" y="548" width="126" height="46" rx="23" fill="{CLAY}"/>
|
|
||||||
<path d="M512 624L536 658L570 682L536 706L512 740L488 706L454 682L488 658Z" fill="{GOLD}"/>'''
|
|
||||||
else:
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="#101010"/>
|
|
||||||
<rect x="286" y="300" width="452" height="456" rx="150" fill="{CREAM}"/>
|
|
||||||
<circle cx="286" cy="530" r="88" fill="#101010"/>
|
|
||||||
<circle cx="738" cy="530" r="88" fill="#101010"/>
|
|
||||||
<circle cx="512" cy="300" r="76" fill="{GOLD}"/>
|
|
||||||
<circle cx="430" cy="474" r="24" fill="#101010"/>
|
|
||||||
<circle cx="594" cy="474" r="24" fill="#101010"/>
|
|
||||||
<path d="M512 536L540 576L580 604L540 632L512 672L484 632L444 604L484 576Z" fill="#101010"/>
|
|
||||||
<path d="M512 570L526 590L546 604L526 618L512 638L498 618L478 604L498 590Z" fill="{GOLD}"/>
|
|
||||||
<rect x="360" y="744" width="304" height="54" rx="27" fill="{CLAY}"/>'''
|
|
||||||
return f'''<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
{body}
|
|
||||||
</svg>
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def build_contact_sheet(previews: list[tuple[str, str, Image.Image]]) -> Image.Image:
|
|
||||||
cell_size = 320
|
|
||||||
label_height = 60
|
|
||||||
gap = 28
|
|
||||||
columns = 3
|
|
||||||
rows = 2
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * (cell_size + label_height) + (rows + 1) * gap
|
|
||||||
sheet = Image.new("RGB", (width, height), "#eee9df")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
font = load_font(23)
|
|
||||||
|
|
||||||
for index, (_, title, preview) in enumerate(previews):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_size + label_height + gap)
|
|
||||||
thumbnail = preview.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=10,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
label = f"{index + 1:02d} {title}"
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=font)
|
|
||||||
return sheet
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
previews: list[tuple[str, str, Image.Image]] = []
|
|
||||||
|
|
||||||
for asset_id, title, style in VARIANTS:
|
|
||||||
preview = render_variant(style)
|
|
||||||
preview.save(OUTPUT_DIR / f"{asset_id}.png")
|
|
||||||
(OUTPUT_DIR / f"{asset_id}.svg").write_text(build_svg(style), encoding="utf-8")
|
|
||||||
previews.append((asset_id, title, preview))
|
|
||||||
|
|
||||||
contact_sheet = build_contact_sheet(previews)
|
|
||||||
contact_sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"output_dir": str(OUTPUT_DIR),
|
|
||||||
"files": [f"{asset_id}.png" for asset_id, _, _ in VARIANTS]
|
|
||||||
+ [f"{asset_id}.svg" for asset_id, _, _ in VARIANTS]
|
|
||||||
+ [CONTACT_SHEET_PATH.name],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,77 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-abstract-mascot-minimal-concepts"
|
|
||||||
)
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-abstract-mascot-minimal-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 泥芯主标", "taonier-minimal-clay-core.png"),
|
|
||||||
("02 泥标小偶", "taonier-minimal-clay-token.png"),
|
|
||||||
("03 泥种图符", "taonier-minimal-seed-glyph.png"),
|
|
||||||
("04 模胚小芽", "taonier-minimal-mold-bud.png"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 330
|
|
||||||
label_height = 58
|
|
||||||
gap = 28
|
|
||||||
columns = 2
|
|
||||||
rows = 2
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * (cell_size + label_height) + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#eee9df")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
font = load_font(24)
|
|
||||||
|
|
||||||
for index, (label, filename) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_size + label_height + gap)
|
|
||||||
|
|
||||||
source_path = OUTPUT_DIR / filename
|
|
||||||
if not source_path.exists():
|
|
||||||
continue
|
|
||||||
source = Image.open(source_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=10,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=font)
|
|
||||||
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,318 +0,0 @@
|
|||||||
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-abstract-mascot-minimal-concepts',
|
|
||||||
);
|
|
||||||
const defaultTimeoutMs = 420000;
|
|
||||||
|
|
||||||
const concepts = [
|
|
||||||
{
|
|
||||||
id: 'taonier-minimal-clay-core',
|
|
||||||
title: '泥芯主标',
|
|
||||||
prompt:
|
|
||||||
'为中文产品“陶泥儿”设计一个无文字 Logo。方向是抽象陶泥角色 / 吉祥物,但不要人形,不要脸,不要手脚。主体是一枚极简陶泥胚,只有一个主轮廓、一个偏心孔或作品核、一个小星点,像会呼吸的陶泥主标。必须扁平、几何、简洁、亲和、主流 App icon 风格。配色:奶油白、陶土橙、深墨底、少量金色。禁止文字、字母、汉字、水印、复杂五官、聊天气泡、播放三角、儿童玩具、3D、厚阴影、背景场景。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-minimal-clay-token',
|
|
||||||
title: '泥标小偶',
|
|
||||||
prompt:
|
|
||||||
'为“陶泥儿”设计无文字品牌 Logo。主体不是人物,而是一枚像被捏出来的软陶 token:圆润、稳定、边缘有手捏感,内部只有一个极简星核或孔洞,不要眼睛鼻子嘴巴。风格:flat vector mascot mark, simple, memorable, logo-ready, cute but mature. 配色限制在 3 色到 4 色:奶白、陶土橙、深墨、暖黄。禁止文字、字母、汉字、表情包、聊天气泡、播放按钮、真实陶艺工具、复杂碎片、3D、照片感。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-minimal-seed-glyph',
|
|
||||||
title: '泥种图符',
|
|
||||||
prompt:
|
|
||||||
'为中文产品“陶泥儿”设计一个无文字 Logo 图标。主题是“抽象陶泥角色”,但造型不使用人体。图形像一颗被轻轻捏过的种子,或者一枚从模具里长出的泥符,轮廓简单,记忆点集中在一个偏心洞和一颗小星核。要求简洁、几何、扁平、可注册、适合 App icon。配色:奶油白主体、暖陶土点缀、深墨背景、少量金黄。禁止文字、字母、汉字、水印、五官、手脚、动物、聊天气泡、播放三角、厚阴影、3D、背景道具。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-minimal-mold-bud',
|
|
||||||
title: '模胚小芽',
|
|
||||||
prompt:
|
|
||||||
'为“陶泥儿”设计无文字 Logo。主体像一枚从模具里鼓起来的陶泥小芽,只有一个主形、一个缺口、一个闪光点,不要人形,不要头像,不要复杂装饰。整体要像能代表 AI 创作、UGC 造物、轻休闲平台的品牌主标。风格:minimal flat mascot logo, clean, playful, premium, scalable. 配色:深墨、奶白、陶土橙、薄荷青或暖黄。禁止文字、字母、汉字、真实脸、聊天气泡、播放键、儿童卡通、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 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',
|
|
||||||
quality: String(args.get('--quality') || 'low'),
|
|
||||||
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 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 (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',
|
|
||||||
quality: String(args.get('--quality') || 'low'),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,297 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-abstract-mascot-v2-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-abstract-mascot-v2-contact-sheet.png"
|
|
||||||
|
|
||||||
SIZE = 1024
|
|
||||||
SCALE = 4
|
|
||||||
|
|
||||||
BG = "#101010"
|
|
||||||
BG_WARM = "#17110e"
|
|
||||||
BG_BLUE = "#101418"
|
|
||||||
INK = "#111111"
|
|
||||||
CREAM = "#fff3d7"
|
|
||||||
CLAY = "#df7650"
|
|
||||||
CLAY_DARK = "#bd5b3d"
|
|
||||||
GOLD = "#ffd35f"
|
|
||||||
MINT = "#2ec5ad"
|
|
||||||
CORAL = "#ff6b61"
|
|
||||||
|
|
||||||
VARIANTS = [
|
|
||||||
("taonier-abstract-mascot-v2-clay-sprite", "陶泥小灵", "clay_sprite"),
|
|
||||||
("taonier-abstract-mascot-v2-pinch-orbit", "捏孔泥偶", "pinch_orbit"),
|
|
||||||
("taonier-abstract-mascot-v2-seed-totem", "星胚图腾", "seed_totem"),
|
|
||||||
("taonier-abstract-mascot-v2-soft-mold", "软模团子", "soft_mold"),
|
|
||||||
("taonier-abstract-mascot-v2-clay-orb", "泥芯圆偶", "clay_orb"),
|
|
||||||
("taonier-abstract-mascot-v2-work-glyph", "作品泥符", "work_glyph"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def hex_to_rgb(value: str) -> tuple[int, int, int]:
|
|
||||||
value = value.removeprefix("#")
|
|
||||||
return tuple(int(value[index : index + 2], 16) for index in (0, 2, 4))
|
|
||||||
|
|
||||||
|
|
||||||
def s(value: float) -> int:
|
|
||||||
return round(value * SCALE)
|
|
||||||
|
|
||||||
|
|
||||||
def circle(draw: ImageDraw.ImageDraw, cx: float, cy: float, radius: float, fill: str) -> None:
|
|
||||||
draw.ellipse((s(cx - radius), s(cy - radius), s(cx + radius), s(cy + radius)), fill=hex_to_rgb(fill))
|
|
||||||
|
|
||||||
|
|
||||||
def ellipse(
|
|
||||||
draw: ImageDraw.ImageDraw,
|
|
||||||
box: tuple[float, float, float, float],
|
|
||||||
fill: str,
|
|
||||||
) -> None:
|
|
||||||
draw.ellipse(tuple(s(value) for value in box), fill=hex_to_rgb(fill))
|
|
||||||
|
|
||||||
|
|
||||||
def rounded_rect(
|
|
||||||
draw: ImageDraw.ImageDraw,
|
|
||||||
box: tuple[float, float, float, float],
|
|
||||||
radius: float,
|
|
||||||
fill: str,
|
|
||||||
) -> None:
|
|
||||||
draw.rounded_rectangle(tuple(s(value) for value in box), radius=s(radius), fill=hex_to_rgb(fill))
|
|
||||||
|
|
||||||
|
|
||||||
def polygon(cx: float, cy: float, radius: float, sides: int, rotation: float) -> list[tuple[int, int]]:
|
|
||||||
return [
|
|
||||||
(s(cx + math.cos(rotation + math.tau * index / sides) * radius), s(cy + math.sin(rotation + math.tau * index / sides) * radius))
|
|
||||||
for index in range(sides)
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def sparkle(cx: float, cy: float, outer: float, inner: float) -> list[tuple[int, int]]:
|
|
||||||
points = []
|
|
||||||
for index in range(8):
|
|
||||||
radius = outer if index % 2 == 0 else inner
|
|
||||||
angle = -math.pi / 2 + index * math.pi / 4
|
|
||||||
points.append((s(cx + math.cos(angle) * radius), s(cy + math.sin(angle) * radius)))
|
|
||||||
return points
|
|
||||||
|
|
||||||
|
|
||||||
def draw_clay_sprite(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb(BG))
|
|
||||||
rounded_rect(draw, (308, 210, 708, 810), 198, CREAM)
|
|
||||||
circle(draw, 672, 302, 82, CLAY)
|
|
||||||
circle(draw, 716, 336, 74, BG)
|
|
||||||
circle(draw, 402, 458, 34, INK)
|
|
||||||
draw.polygon(sparkle(548, 584, 64, 24), fill=hex_to_rgb(GOLD))
|
|
||||||
rounded_rect(draw, (362, 742, 660, 792), 25, CLAY)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_pinch_orbit(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb(BG_BLUE))
|
|
||||||
circle(draw, 512, 512, 276, CREAM)
|
|
||||||
circle(draw, 724, 394, 94, BG_BLUE)
|
|
||||||
circle(draw, 692, 412, 38, GOLD)
|
|
||||||
circle(draw, 314, 512, 76, BG_BLUE)
|
|
||||||
rounded_rect(draw, (442, 642, 594, 690), 24, CLAY)
|
|
||||||
circle(draw, 438, 448, 32, INK)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_seed_totem(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb(BG_WARM))
|
|
||||||
draw.polygon(polygon(512, 514, 310, 8, math.pi / 8), fill=hex_to_rgb(CREAM))
|
|
||||||
circle(draw, 512, 282, 76, GOLD)
|
|
||||||
rounded_rect(draw, (376, 356, 648, 726), 136, CLAY)
|
|
||||||
circle(draw, 430, 528, 28, BG_WARM)
|
|
||||||
circle(draw, 594, 528, 28, BG_WARM)
|
|
||||||
draw.polygon(sparkle(512, 634, 58, 22), fill=hex_to_rgb(CREAM))
|
|
||||||
rounded_rect(draw, (386, 764, 638, 818), 27, MINT)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_soft_mold(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb(BG))
|
|
||||||
rounded_rect(draw, (270, 300, 754, 740), 148, CREAM)
|
|
||||||
circle(draw, 270, 520, 84, BG)
|
|
||||||
circle(draw, 754, 520, 84, BG)
|
|
||||||
rounded_rect(draw, (390, 404, 634, 650), 110, CLAY)
|
|
||||||
circle(draw, 512, 526, 62, GOLD)
|
|
||||||
circle(draw, 512, 526, 28, BG)
|
|
||||||
rounded_rect(draw, (356, 728, 668, 782), 27, CLAY_DARK)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_clay_orb(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb(BG_BLUE))
|
|
||||||
circle(draw, 512, 512, 274, CREAM)
|
|
||||||
circle(draw, 512, 512, 142, BG_BLUE)
|
|
||||||
draw.polygon(sparkle(512, 512, 70, 26), fill=hex_to_rgb(GOLD))
|
|
||||||
circle(draw, 648, 340, 64, CLAY)
|
|
||||||
circle(draw, 666, 360, 40, BG_BLUE)
|
|
||||||
circle(draw, 374, 650, 46, MINT)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_work_glyph(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb(BG_WARM))
|
|
||||||
rounded_rect(draw, (340, 184, 684, 832), 172, CREAM)
|
|
||||||
circle(draw, 512, 348, 124, CLAY)
|
|
||||||
circle(draw, 512, 348, 56, BG_WARM)
|
|
||||||
draw.polygon(sparkle(512, 348, 42, 15), fill=hex_to_rgb(GOLD))
|
|
||||||
rounded_rect(draw, (412, 492, 612, 690), 98, BG_WARM)
|
|
||||||
circle(draw, 512, 592, 50, GOLD)
|
|
||||||
rounded_rect(draw, (404, 764, 620, 824), 30, CREAM)
|
|
||||||
|
|
||||||
|
|
||||||
DRAWERS = {
|
|
||||||
"clay_sprite": draw_clay_sprite,
|
|
||||||
"pinch_orbit": draw_pinch_orbit,
|
|
||||||
"seed_totem": draw_seed_totem,
|
|
||||||
"soft_mold": draw_soft_mold,
|
|
||||||
"clay_orb": draw_clay_orb,
|
|
||||||
"work_glyph": draw_work_glyph,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def render_variant(style: str) -> Image.Image:
|
|
||||||
image = Image.new("RGB", (SIZE * SCALE, SIZE * SCALE), hex_to_rgb(BG))
|
|
||||||
draw = ImageDraw.Draw(image)
|
|
||||||
DRAWERS[style](draw)
|
|
||||||
return image.resize((SIZE, SIZE), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
|
|
||||||
def build_svg(style: str) -> str:
|
|
||||||
# PNG 用于快速评审;SVG 保留主几何结构,便于后续进入正式矢量设计。
|
|
||||||
if style == "clay_sprite":
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="{BG}"/>
|
|
||||||
<rect x="308" y="210" width="400" height="600" rx="198" fill="{CREAM}"/>
|
|
||||||
<circle cx="672" cy="302" r="82" fill="{CLAY}"/>
|
|
||||||
<circle cx="716" cy="336" r="74" fill="{BG}"/>
|
|
||||||
<circle cx="402" cy="458" r="34" fill="{INK}"/>
|
|
||||||
<path d="M548 520L572 560L612 584L572 608L548 648L524 608L484 584L524 560Z" fill="{GOLD}"/>
|
|
||||||
<rect x="362" y="742" width="298" height="50" rx="25" fill="{CLAY}"/>'''
|
|
||||||
elif style == "pinch_orbit":
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="{BG_BLUE}"/>
|
|
||||||
<circle cx="512" cy="512" r="276" fill="{CREAM}"/>
|
|
||||||
<circle cx="724" cy="394" r="94" fill="{BG_BLUE}"/>
|
|
||||||
<circle cx="692" cy="412" r="38" fill="{GOLD}"/>
|
|
||||||
<circle cx="314" cy="512" r="76" fill="{BG_BLUE}"/>
|
|
||||||
<rect x="442" y="642" width="152" height="48" rx="24" fill="{CLAY}"/>
|
|
||||||
<circle cx="438" cy="448" r="32" fill="{INK}"/>'''
|
|
||||||
elif style == "seed_totem":
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="{BG_WARM}"/>
|
|
||||||
<path d="M630 228L796 394V630L630 796H394L228 630V394L394 228Z" fill="{CREAM}"/>
|
|
||||||
<circle cx="512" cy="282" r="76" fill="{GOLD}"/>
|
|
||||||
<rect x="376" y="356" width="272" height="370" rx="136" fill="{CLAY}"/>
|
|
||||||
<circle cx="430" cy="528" r="28" fill="{BG_WARM}"/>
|
|
||||||
<circle cx="594" cy="528" r="28" fill="{BG_WARM}"/>
|
|
||||||
<path d="M512 576L534 612L570 634L534 656L512 692L490 656L454 634L490 612Z" fill="{CREAM}"/>
|
|
||||||
<rect x="386" y="764" width="252" height="54" rx="27" fill="{MINT}"/>'''
|
|
||||||
elif style == "soft_mold":
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="{BG}"/>
|
|
||||||
<rect x="270" y="300" width="484" height="440" rx="148" fill="{CREAM}"/>
|
|
||||||
<circle cx="270" cy="520" r="84" fill="{BG}"/>
|
|
||||||
<circle cx="754" cy="520" r="84" fill="{BG}"/>
|
|
||||||
<rect x="390" y="404" width="244" height="246" rx="110" fill="{CLAY}"/>
|
|
||||||
<circle cx="512" cy="526" r="62" fill="{GOLD}"/>
|
|
||||||
<circle cx="512" cy="526" r="28" fill="{BG}"/>
|
|
||||||
<rect x="356" y="728" width="312" height="54" rx="27" fill="{CLAY_DARK}"/>'''
|
|
||||||
elif style == "clay_orb":
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="{BG_BLUE}"/>
|
|
||||||
<circle cx="512" cy="512" r="274" fill="{CREAM}"/>
|
|
||||||
<circle cx="512" cy="512" r="142" fill="{BG_BLUE}"/>
|
|
||||||
<path d="M512 442L538 486L582 512L538 538L512 582L486 538L442 512L486 486Z" fill="{GOLD}"/>
|
|
||||||
<circle cx="648" cy="340" r="64" fill="{CLAY}"/>
|
|
||||||
<circle cx="666" cy="360" r="40" fill="{BG_BLUE}"/>
|
|
||||||
<circle cx="374" cy="650" r="46" fill="{MINT}"/>'''
|
|
||||||
else:
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="{BG_WARM}"/>
|
|
||||||
<rect x="340" y="184" width="344" height="648" rx="172" fill="{CREAM}"/>
|
|
||||||
<circle cx="512" cy="348" r="124" fill="{CLAY}"/>
|
|
||||||
<circle cx="512" cy="348" r="56" fill="{BG_WARM}"/>
|
|
||||||
<path d="M512 306L527 333L554 348L527 363L512 390L497 363L470 348L497 333Z" fill="{GOLD}"/>
|
|
||||||
<rect x="412" y="492" width="200" height="198" rx="98" fill="{BG_WARM}"/>
|
|
||||||
<circle cx="512" cy="592" r="50" fill="{GOLD}"/>
|
|
||||||
<rect x="404" y="764" width="216" height="60" rx="30" fill="{CREAM}"/>'''
|
|
||||||
return f'''<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
{body}
|
|
||||||
</svg>
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def build_contact_sheet(previews: list[tuple[str, str, Image.Image]]) -> Image.Image:
|
|
||||||
cell_size = 320
|
|
||||||
label_height = 60
|
|
||||||
gap = 28
|
|
||||||
columns = 3
|
|
||||||
rows = 2
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * (cell_size + label_height) + (rows + 1) * gap
|
|
||||||
sheet = Image.new("RGB", (width, height), "#eee9df")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
font = load_font(23)
|
|
||||||
|
|
||||||
for index, (_, title, preview) in enumerate(previews):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_size + label_height + gap)
|
|
||||||
thumbnail = preview.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=10,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
label = f"{index + 1:02d} {title}"
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=font)
|
|
||||||
return sheet
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
previews: list[tuple[str, str, Image.Image]] = []
|
|
||||||
|
|
||||||
for asset_id, title, style in VARIANTS:
|
|
||||||
preview = render_variant(style)
|
|
||||||
preview.save(OUTPUT_DIR / f"{asset_id}.png")
|
|
||||||
(OUTPUT_DIR / f"{asset_id}.svg").write_text(build_svg(style), encoding="utf-8")
|
|
||||||
previews.append((asset_id, title, preview))
|
|
||||||
|
|
||||||
contact_sheet = build_contact_sheet(previews)
|
|
||||||
contact_sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"output_dir": str(OUTPUT_DIR),
|
|
||||||
"files": [f"{asset_id}.png" for asset_id, _, _ in VARIANTS]
|
|
||||||
+ [f"{asset_id}.svg" for asset_id, _, _ in VARIANTS]
|
|
||||||
+ [CONTACT_SHEET_PATH.name],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Iterable
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-anchor-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-anchor-contact-sheet.png"
|
|
||||||
|
|
||||||
SIZE = 1024
|
|
||||||
SCALE = 4
|
|
||||||
|
|
||||||
VARIANTS = [
|
|
||||||
{
|
|
||||||
"id": "taonier-anchor-core",
|
|
||||||
"title": "泥点锚标",
|
|
||||||
"bg": "#151515",
|
|
||||||
"mark": "#ffffff",
|
|
||||||
"accent": "#ffffff",
|
|
||||||
"style": "core",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-anchor-soft-slab",
|
|
||||||
"title": "软泥层台",
|
|
||||||
"bg": "#111111",
|
|
||||||
"mark": "#fffdf4",
|
|
||||||
"accent": "#fffdf4",
|
|
||||||
"style": "soft_slab",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-anchor-work-stack",
|
|
||||||
"title": "作品叠层",
|
|
||||||
"bg": "#171717",
|
|
||||||
"mark": "#ffffff",
|
|
||||||
"accent": "#ffffff",
|
|
||||||
"style": "work_stack",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-anchor-clay-drop",
|
|
||||||
"title": "泥点落印",
|
|
||||||
"bg": "#151515",
|
|
||||||
"mark": "#ffffff",
|
|
||||||
"accent": "#f5c95d",
|
|
||||||
"style": "clay_drop",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-anchor-creation-base",
|
|
||||||
"title": "创作底座",
|
|
||||||
"bg": "#121212",
|
|
||||||
"mark": "#ffffff",
|
|
||||||
"accent": "#ffffff",
|
|
||||||
"style": "creation_base",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-anchor-app-token",
|
|
||||||
"title": "泥点应用标",
|
|
||||||
"bg": "#101418",
|
|
||||||
"mark": "#fffaf0",
|
|
||||||
"accent": "#ffd45d",
|
|
||||||
"style": "app_token",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def hex_to_rgb(value: str) -> tuple[int, int, int]:
|
|
||||||
value = value.removeprefix("#")
|
|
||||||
return tuple(int(value[index : index + 2], 16) for index in (0, 2, 4))
|
|
||||||
|
|
||||||
|
|
||||||
def s(value: float) -> int:
|
|
||||||
return round(value * SCALE)
|
|
||||||
|
|
||||||
|
|
||||||
def scaled_points(points: Iterable[tuple[float, float]]) -> list[tuple[int, int]]:
|
|
||||||
return [(s(x), s(y)) for x, y in points]
|
|
||||||
|
|
||||||
|
|
||||||
def quad(
|
|
||||||
start: tuple[float, float],
|
|
||||||
control: tuple[float, float],
|
|
||||||
end: tuple[float, float],
|
|
||||||
steps: int = 24,
|
|
||||||
) -> list[tuple[float, float]]:
|
|
||||||
points: list[tuple[float, float]] = []
|
|
||||||
for index in range(steps + 1):
|
|
||||||
t = index / steps
|
|
||||||
x = (1 - t) * (1 - t) * start[0] + 2 * (1 - t) * t * control[0] + t * t * end[0]
|
|
||||||
y = (1 - t) * (1 - t) * start[1] + 2 * (1 - t) * t * control[1] + t * t * end[1]
|
|
||||||
points.append((x, y))
|
|
||||||
return points
|
|
||||||
|
|
||||||
|
|
||||||
def round_line(
|
|
||||||
draw: ImageDraw.ImageDraw,
|
|
||||||
points: list[tuple[float, float]],
|
|
||||||
fill: str,
|
|
||||||
width: int,
|
|
||||||
closed: bool = False,
|
|
||||||
) -> None:
|
|
||||||
scaled = scaled_points(points)
|
|
||||||
if closed:
|
|
||||||
scaled = [*scaled, scaled[0]]
|
|
||||||
draw.line(scaled, fill=hex_to_rgb(fill), width=s(width), joint="curve")
|
|
||||||
radius = s(width) // 2
|
|
||||||
if not closed:
|
|
||||||
for x, y in (scaled[0], scaled[-1]):
|
|
||||||
draw.ellipse((x - radius, y - radius, x + radius, y + radius), fill=hex_to_rgb(fill))
|
|
||||||
|
|
||||||
|
|
||||||
def round_circle(draw: ImageDraw.ImageDraw, center: tuple[float, float], radius: float, fill: str) -> None:
|
|
||||||
x, y = center
|
|
||||||
draw.ellipse((s(x - radius), s(y - radius), s(x + radius), s(y + radius)), fill=hex_to_rgb(fill))
|
|
||||||
|
|
||||||
|
|
||||||
def draw_core(draw: ImageDraw.ImageDraw, mark: str, accent: str) -> None:
|
|
||||||
round_line(draw, [(512, 326), (512, 514)], mark, 68)
|
|
||||||
round_circle(draw, (512, 214), 62, accent)
|
|
||||||
round_line(draw, [(244, 548), (512, 430), (780, 548), (512, 674)], mark, 66, closed=True)
|
|
||||||
round_line(draw, [(292, 656), (468, 734), (512, 752), (556, 734), (732, 656)], mark, 52)
|
|
||||||
round_circle(draw, (337, 548), 17, mark)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_soft_slab(draw: ImageDraw.ImageDraw, mark: str, accent: str) -> None:
|
|
||||||
round_line(draw, [(512, 316), (512, 518)], mark, 72)
|
|
||||||
round_circle(draw, (512, 205), 58, accent)
|
|
||||||
top = (
|
|
||||||
quad((232, 560), (512, 410), (792, 560), 32)
|
|
||||||
+ quad((792, 560), (816, 576), (792, 592), 8)[1:]
|
|
||||||
+ quad((792, 592), (610, 648), (552, 692), 20)[1:]
|
|
||||||
+ quad((552, 692), (512, 716), (472, 692), 12)[1:]
|
|
||||||
+ quad((472, 692), (414, 648), (232, 592), 20)[1:]
|
|
||||||
+ quad((232, 592), (208, 576), (232, 560), 8)[1:]
|
|
||||||
)
|
|
||||||
round_line(draw, top, mark, 56, closed=True)
|
|
||||||
round_line(draw, [(278, 642), (470, 728), (512, 748), (554, 728), (746, 642)], mark, 46)
|
|
||||||
round_circle(draw, (342, 554), 15, mark)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_work_stack(draw: ImageDraw.ImageDraw, mark: str, accent: str) -> None:
|
|
||||||
round_line(draw, [(512, 310), (512, 504)], mark, 64)
|
|
||||||
round_circle(draw, (512, 205), 56, accent)
|
|
||||||
round_line(draw, [(246, 532), (512, 420), (778, 532), (512, 650)], mark, 58, closed=True)
|
|
||||||
round_line(draw, [(286, 628), (472, 710), (512, 728), (552, 710), (738, 628)], mark, 48)
|
|
||||||
round_line(draw, [(330, 708), (478, 774), (512, 790), (546, 774), (694, 708)], mark, 38)
|
|
||||||
round_circle(draw, (348, 535), 14, mark)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_clay_drop(draw: ImageDraw.ImageDraw, mark: str, accent: str) -> None:
|
|
||||||
round_line(draw, [(512, 334), (512, 504)], mark, 64)
|
|
||||||
round_circle(draw, (512, 204), 62, accent)
|
|
||||||
round_line(draw, [(252, 548), (512, 436), (772, 548), (512, 666)], mark, 62, closed=True)
|
|
||||||
round_line(draw, [(304, 642), (474, 718), (512, 736), (550, 718), (720, 642)], mark, 50)
|
|
||||||
round_circle(draw, (346, 548), 16, mark)
|
|
||||||
round_circle(draw, (512, 556), 12, accent)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_creation_base(draw: ImageDraw.ImageDraw, mark: str, accent: str) -> None:
|
|
||||||
round_line(draw, [(512, 304), (512, 494)], mark, 58)
|
|
||||||
round_circle(draw, (512, 202), 55, accent)
|
|
||||||
round_line(draw, [(276, 522), (512, 420), (748, 522)], mark, 54)
|
|
||||||
round_line(draw, [(236, 586), (512, 708), (788, 586)], mark, 60)
|
|
||||||
round_line(draw, [(292, 676), (478, 756), (512, 770), (546, 756), (732, 676)], mark, 42)
|
|
||||||
round_circle(draw, (355, 544), 13, mark)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_app_token(draw: ImageDraw.ImageDraw, mark: str, accent: str) -> None:
|
|
||||||
round_line(draw, [(512, 326), (512, 508)], mark, 70)
|
|
||||||
round_circle(draw, (512, 208), 62, accent)
|
|
||||||
round_line(draw, [(252, 548), (512, 432), (772, 548), (512, 674)], mark, 66, closed=True)
|
|
||||||
round_line(draw, [(296, 656), (470, 732), (512, 752), (554, 732), (728, 656)], mark, 52)
|
|
||||||
round_circle(draw, (338, 548), 15, accent)
|
|
||||||
|
|
||||||
|
|
||||||
DRAWERS = {
|
|
||||||
"core": draw_core,
|
|
||||||
"soft_slab": draw_soft_slab,
|
|
||||||
"work_stack": draw_work_stack,
|
|
||||||
"clay_drop": draw_clay_drop,
|
|
||||||
"creation_base": draw_creation_base,
|
|
||||||
"app_token": draw_app_token,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def build_svg(variant: dict[str, str]) -> str:
|
|
||||||
bg = variant["bg"]
|
|
||||||
mark = variant["mark"]
|
|
||||||
accent = variant["accent"]
|
|
||||||
style = variant["style"]
|
|
||||||
shared = 'fill="none" stroke-linecap="round" stroke-linejoin="round"'
|
|
||||||
dot_fill = accent if style in {"clay_drop", "app_token"} else mark
|
|
||||||
left_dot_fill = accent if style == "app_token" else mark
|
|
||||||
|
|
||||||
if style == "soft_slab":
|
|
||||||
base = f'''
|
|
||||||
<path d="M232 560 Q512 410 792 560 Q816 576 792 592 Q610 648 552 692 Q512 716 472 692 Q414 648 232 592 Q208 576 232 560 Z" {shared} stroke="{mark}" stroke-width="56"/>
|
|
||||||
<path d="M278 642 L470 728 Q512 748 554 728 L746 642" {shared} stroke="{mark}" stroke-width="46"/>
|
|
||||||
<circle cx="342" cy="554" r="15" fill="{mark}"/>'''
|
|
||||||
stem = f'<path d="M512 316 L512 518" {shared} stroke="{mark}" stroke-width="72"/>'
|
|
||||||
dot = f'<circle cx="512" cy="205" r="58" fill="{dot_fill}"/>'
|
|
||||||
elif style == "work_stack":
|
|
||||||
base = f'''
|
|
||||||
<path d="M246 532 L512 420 L778 532 L512 650 Z" {shared} stroke="{mark}" stroke-width="58"/>
|
|
||||||
<path d="M286 628 L472 710 Q512 728 552 710 L738 628" {shared} stroke="{mark}" stroke-width="48"/>
|
|
||||||
<path d="M330 708 L478 774 Q512 790 546 774 L694 708" {shared} stroke="{mark}" stroke-width="38"/>
|
|
||||||
<circle cx="348" cy="535" r="14" fill="{mark}"/>'''
|
|
||||||
stem = f'<path d="M512 310 L512 504" {shared} stroke="{mark}" stroke-width="64"/>'
|
|
||||||
dot = f'<circle cx="512" cy="205" r="56" fill="{dot_fill}"/>'
|
|
||||||
elif style == "clay_drop":
|
|
||||||
base = f'''
|
|
||||||
<path d="M252 548 L512 436 L772 548 L512 666 Z" {shared} stroke="{mark}" stroke-width="62"/>
|
|
||||||
<path d="M304 642 L474 718 Q512 736 550 718 L720 642" {shared} stroke="{mark}" stroke-width="50"/>
|
|
||||||
<circle cx="346" cy="548" r="16" fill="{mark}"/>
|
|
||||||
<circle cx="512" cy="556" r="12" fill="{accent}"/>'''
|
|
||||||
stem = f'<path d="M512 334 L512 504" {shared} stroke="{mark}" stroke-width="64"/>'
|
|
||||||
dot = f'<circle cx="512" cy="204" r="62" fill="{dot_fill}"/>'
|
|
||||||
elif style == "creation_base":
|
|
||||||
base = f'''
|
|
||||||
<path d="M276 522 L512 420 L748 522" {shared} stroke="{mark}" stroke-width="54"/>
|
|
||||||
<path d="M236 586 L512 708 L788 586" {shared} stroke="{mark}" stroke-width="60"/>
|
|
||||||
<path d="M292 676 L478 756 Q512 770 546 756 L732 676" {shared} stroke="{mark}" stroke-width="42"/>
|
|
||||||
<circle cx="355" cy="544" r="13" fill="{mark}"/>'''
|
|
||||||
stem = f'<path d="M512 304 L512 494" {shared} stroke="{mark}" stroke-width="58"/>'
|
|
||||||
dot = f'<circle cx="512" cy="202" r="55" fill="{dot_fill}"/>'
|
|
||||||
else:
|
|
||||||
stroke = 70 if style == "app_token" else 68
|
|
||||||
base_width = 66
|
|
||||||
layer_width = 52
|
|
||||||
base = f'''
|
|
||||||
<path d="M244 548 L512 430 L780 548 L512 674 Z" {shared} stroke="{mark}" stroke-width="{base_width}"/>
|
|
||||||
<path d="M292 656 L468 734 Q512 752 556 734 L732 656" {shared} stroke="{mark}" stroke-width="{layer_width}"/>
|
|
||||||
<circle cx="337" cy="548" r="{15 if style == 'app_token' else 17}" fill="{left_dot_fill}"/>'''
|
|
||||||
stem = f'<path d="M512 326 L512 514" {shared} stroke="{mark}" stroke-width="{stroke}"/>'
|
|
||||||
dot = f'<circle cx="512" cy="{208 if style == "app_token" else 214}" r="62" fill="{dot_fill}"/>'
|
|
||||||
|
|
||||||
return f'''<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="{bg}"/>
|
|
||||||
{base}
|
|
||||||
{stem}
|
|
||||||
{dot}
|
|
||||||
</svg>
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def render_variant(variant: dict[str, str]) -> Image.Image:
|
|
||||||
image = Image.new("RGB", (SIZE * SCALE, SIZE * SCALE), hex_to_rgb(variant["bg"]))
|
|
||||||
draw = ImageDraw.Draw(image)
|
|
||||||
DRAWERS[variant["style"]](draw, variant["mark"], variant["accent"])
|
|
||||||
return image.resize((SIZE, SIZE), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def build_contact_sheet(previews: list[tuple[dict[str, str], Image.Image]]) -> Image.Image:
|
|
||||||
cell_size = 320
|
|
||||||
label_height = 60
|
|
||||||
gap = 28
|
|
||||||
columns = 3
|
|
||||||
rows = 2
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * (cell_size + label_height) + (rows + 1) * gap
|
|
||||||
sheet = Image.new("RGB", (width, height), "#eee9df")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
font = load_font(23)
|
|
||||||
|
|
||||||
for index, (variant, preview) in enumerate(previews):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_size + label_height + gap)
|
|
||||||
thumbnail = preview.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=10,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
label = f"{index + 1:02d} {variant['title']}"
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=font)
|
|
||||||
return sheet
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
previews: list[tuple[dict[str, str], Image.Image]] = []
|
|
||||||
|
|
||||||
for variant in VARIANTS:
|
|
||||||
(OUTPUT_DIR / f"{variant['id']}.svg").write_text(build_svg(variant), encoding="utf-8")
|
|
||||||
preview = render_variant(variant)
|
|
||||||
preview.save(OUTPUT_DIR / f"{variant['id']}.png")
|
|
||||||
previews.append((variant, preview))
|
|
||||||
|
|
||||||
contact_sheet = build_contact_sheet(previews)
|
|
||||||
contact_sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"output_dir": str(OUTPUT_DIR),
|
|
||||||
"files": [f"{variant['id']}.svg" for variant in VARIANTS]
|
|
||||||
+ [f"{variant['id']}.png" for variant in VARIANTS]
|
|
||||||
+ [CONTACT_SHEET_PATH.name],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-anti-candy-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-anti-candy-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 哑光陶泥印章", "taonier-anti-candy-01-matte-clay-stamp"),
|
|
||||||
("02 窑印星核", "taonier-anti-candy-02-kiln-mark-core"),
|
|
||||||
("03 负形星核", "taonier-anti-candy-03-cutout-negative-star"),
|
|
||||||
("04 干陶颗粒", "taonier-anti-candy-04-dry-clay-grain"),
|
|
||||||
("05 手压泥币", "taonier-anti-candy-05-hand-pressed-token"),
|
|
||||||
("06 数字泥符", "taonier-anti-candy-06-digital-clay-glyph"),
|
|
||||||
("07 精品扁平标", "taonier-anti-candy-07-premium-flat-mark"),
|
|
||||||
("08 单色验证版", "taonier-anti-candy-08-monochrome-proof"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * (cell_size + label_height) + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#ebe6dc")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
font = load_font(20)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_size + label_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fbfaf6",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,420 +0,0 @@
|
|||||||
import { 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-anti-candy-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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 = [
|
|
||||||
'请生成一枚全新的「陶泥儿」产品商标图形标概念稿,但画面中绝对不要出现任何中文字、英文字母、数字、符号字样或品牌字标。',
|
|
||||||
'产品定位:精品 AI UGC 轻休闲小游戏创作与传播平台,用户可以像捏陶泥一样把脑洞、梗和灵感塑造成小游戏或趣味内容。',
|
|
||||||
'这次必须反糖果化:第一眼必须像哑光陶泥、泥章、窑印、创作印记,绝对不能像糖果、软糖、奶油、饼干、夹心甜点、月饼、巧克力或食品包装。',
|
|
||||||
'核心隐喻:脑洞泥印。一块被轻轻捏平的不规则软方圆陶泥印章,中间有一个抽象星核凹印或镂空星形泥印,表达灵感被压印成作品。',
|
|
||||||
'风格:扁平矢量商标为主,低高光、低饱和、哑光粉陶质感;轮廓清楚,可后续矢量化,适合商标、App 图标、社区头像。',
|
|
||||||
'主色:灰米白、未经上釉的陶土白、陶土褐、深泥灰、少量暗金土黄;颜色必须干燥、克制、低甜度。不使用亮金、糖果黄、奶油黄、粉色、青色、蓝色、紫色、荧光色或彩虹色。',
|
|
||||||
'形态:保留不规则软方圆,但不要鼓胀、不要胶状、不要可食用的圆润光泽。边缘可以有少量粗糙泥料纹理、压痕、手捏不均匀。',
|
|
||||||
'数字感:只允许 2 到 3 个很小的深泥灰或暗土黄刻点,像生成节点或 UGC 扩散点;不要闪亮星星,不要糖珠。',
|
|
||||||
'构图:正方形画布,居中图形标,干净浅灰米白背景,留足安全边距;缩小到 64px 时仍能看清软方圆轮廓和中间星核凹印。',
|
|
||||||
'禁止:脸、眼睛、嘴巴、表情、角色身体、吉祥物立绘、儿童黏土玩具感、陶艺工具、手、笔刷、复杂场景、按钮、UI、边框、水印、文字、商标字标、旋涡环形旧稿、三色花瓣旧稿。',
|
|
||||||
'强禁止食品感:不要 glossy 高光、不要果冻质感、不要奶油夹心、不要糖霜、不要撒糖粒、不要饼干边、不要巧克力流心、不要金色膨胀星糖。',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-matte-clay-stamp',
|
|
||||||
title: '哑光陶泥印章',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:最克制的陶泥印章。灰米白软方圆主体,中间是压进去的暗陶土星核凹印,只有 2 个微小刻点。几乎无高光。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-kiln-mark-core',
|
|
||||||
title: '窑印星核',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:窑印感。中间星核像烧陶后的浅浮雕窑印,用深泥灰边缘和陶土褐阴影表现,不要任何金属或糖果光泽。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-cutout-negative-star',
|
|
||||||
title: '负形星核',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:负形。星核用干净的镂空负形或深泥灰内孔表达,主体是单块哑光陶泥,整体更像可注册商标图形。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-dry-clay-grain',
|
|
||||||
title: '干陶颗粒',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:干陶质感。加入非常细微的陶土颗粒和粉陶纹理,但保持扁平图标,不要照片写实,不要脏乱。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-hand-pressed-token',
|
|
||||||
title: '手压泥币',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:手压泥币。像一枚被手工压平的陶泥代币,边缘不完全对称,中间星核为凹刻符号,但不要出现手或工具。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-digital-clay-glyph',
|
|
||||||
title: '数字泥符',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:AI 与 UGC 暗示更强。用 3 个极小方形刻点围绕星核,像生成节点,但必须像刻在陶泥上的小孔。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-premium-flat-mark',
|
|
||||||
title: '精品扁平标',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:更互联网精品。减少纹理,强化几何平衡和负形,灰米白主体、深泥灰星核、陶土褐小刻痕,适合 App 图标。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-monochrome-proof',
|
|
||||||
title: '单色验证版',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:黑白商标验证。尽量用单色深浅关系表达软方圆和星核凹印,减少装饰,确保黑白化后轮廓仍成立。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-anti-candy-${variant.id}.${image.extension}`);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(outputDir, 'taonier-logo-anti-candy-manifest.json');
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
creativeDirection: {
|
|
||||||
name: '陶泥儿反糖果化脑洞泥印图形标',
|
|
||||||
textPolicy: 'no Chinese, no English, no wordmark',
|
|
||||||
palette: '灰米白、陶土白、陶土褐、深泥灰、少量暗金土黄',
|
|
||||||
motif: '哑光软方圆陶泥印章 + 星核凹印/负形 + 极少量刻点',
|
|
||||||
antiCandyRules:
|
|
||||||
'no glossy highlight, no cream filling, no jelly, no cookie, no chocolate, no candy star',
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-braincore-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-braincore-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 极简脑洞星核", "taonier-braincore-01-minimal-braincore"),
|
|
||||||
("02 软方圆陶泥印记", "taonier-braincore-02-soft-square-clay-seal"),
|
|
||||||
("03 暖棕星核嵌入", "taonier-braincore-03-warm-brown-embedded-core"),
|
|
||||||
("04 轻微捏痕版本", "taonier-braincore-04-subtle-pinch-marks"),
|
|
||||||
("05 精品几何比例", "taonier-braincore-05-premium-geometric-balance"),
|
|
||||||
("06 柔软陶泥质感", "taonier-braincore-06-soft-clay-texture"),
|
|
||||||
("07 App 图标优先", "taonier-braincore-07-app-icon-ready"),
|
|
||||||
("08 商标黑白提炼", "taonier-braincore-08-trademark-monochrome-ready"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * (cell_size + label_height) + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#eee9df")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
font = load_font(20)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_size + label_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
import { 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-braincore-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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 = [
|
|
||||||
'请生成一枚全新的「陶泥儿」产品商标图形标概念稿,但画面中绝对不要出现任何中文字、英文字母、数字、符号字样或品牌字标。',
|
|
||||||
'产品定位:精品 AI UGC 轻休闲小游戏创作与传播平台,用户可以像捏陶泥一样把脑洞、梗和灵感塑造成小游戏或趣味内容。',
|
|
||||||
'核心隐喻:脑洞星核。一团奶白色、暖棕色调的不规则软方圆陶泥包裹一枚暖金色创意星核,表达灵感被捏造成型。',
|
|
||||||
'风格:扁平矢量商标为主,带非常轻微的软陶质感;结构简洁、边缘柔和、轮廓清晰,适合商标、App 图标、社区头像。',
|
|
||||||
'主色:奶白、米白、暖棕、陶土棕、少量暖金;整体温暖高级,不使用粉色、青色、蓝色、紫色、荧光色或彩虹色。',
|
|
||||||
'数字感:只允许在星核周围出现 2 到 4 个极小暖金星点或像素点,暗示 AI 生成、UGC 传播和轻游戏趣味。',
|
|
||||||
'构图:正方形画布,居中图形标,干净浅米白背景,留足安全边距;缩小到 64px 时仍能看清软方圆轮廓和星核。',
|
|
||||||
'禁止:脸、眼睛、嘴巴、表情、角色身体、吉祥物立绘、儿童黏土玩具感、陶艺工具、手、笔刷、复杂场景、按钮、UI、边框、水印、文字、商标字标、拟物甜品感。',
|
|
||||||
'必须保持全新构图,不参考任何既有陶泥儿 logo 方案,不做旋涡环形旧稿,不做三色花瓣旧稿。',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-minimal-braincore',
|
|
||||||
title: '极简脑洞星核',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:极简。只保留一个奶白不规则软方圆陶泥主体、一个居中的暖金四角星核、2 个极小暖金星点。不要额外装饰。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-soft-square-clay-seal',
|
|
||||||
title: '软方圆陶泥印记',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:陶泥印记。主体像被轻轻按压成型的软方圆印章,边缘有自然手捏起伏,但不能像儿童玩具。星核略微偏心。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-warm-brown-embedded-core',
|
|
||||||
title: '暖棕星核嵌入',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:嵌入感。用暖棕内凹形或陶土棕阴影承托暖金星核,像灵感被嵌进陶泥里,仍保持扁平商标质感。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-subtle-pinch-marks',
|
|
||||||
title: '轻微捏痕版本',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:捏痕。在陶泥主体边缘加入 2 到 3 个极轻微暖棕捏痕或凹口,表现可塑性;捏痕必须抽象、克制、可矢量化。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-premium-geometric-balance',
|
|
||||||
title: '精品几何比例',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:精品比例。整体更接近高级互联网 App 图标,几何平衡、负形干净、软方圆轮廓稳定,陶泥质感只保留一点点。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-soft-clay-texture',
|
|
||||||
title: '柔软陶泥质感',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:柔软质感。在不破坏扁平矢量感的前提下,加入细腻奶油陶泥的微妙高光和暖棕渐层,不能变成 3D 玩具。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-app-icon-ready',
|
|
||||||
title: 'App 图标优先',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:App 图标。图形占画面约 72%,轮廓饱满有记忆点,星核清晰醒目,适合放入圆角方形 App icon。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-trademark-monochrome-ready',
|
|
||||||
title: '商标黑白提炼',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:商标注册。优先保证黑白化后仍清楚,减少渐变和细节,用奶白主体、暖棕负形和暖金星核形成强轮廓。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-braincore-${variant.id}.${image.extension}`);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(outputDir, 'taonier-logo-braincore-manifest.json');
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
creativeDirection: {
|
|
||||||
name: '陶泥儿脑洞星核图形标',
|
|
||||||
textPolicy: 'no Chinese, no English, no wordmark',
|
|
||||||
palette: '奶白、米白、暖棕、陶土棕、少量暖金',
|
|
||||||
motif: '不规则软方圆陶泥团 + 脑洞星核 + 极少量星点',
|
|
||||||
},
|
|
||||||
variants: variants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
file: files.find((file) => path.basename(file).includes(variant.id))
|
|
||||||
? path.basename(files.find((file) => path.basename(file).includes(variant.id)))
|
|
||||||
: 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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,493 +0,0 @@
|
|||||||
import { 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-capybara-jar-ref01-logo-refine-concepts',
|
|
||||||
);
|
|
||||||
const referenceImagePath = path.join(
|
|
||||||
repoRoot,
|
|
||||||
'public',
|
|
||||||
'branding',
|
|
||||||
'taonier-logo-peeking-head-jar-new-animals-concepts',
|
|
||||||
'taonier-peeking-head-jar-new-animals-01-capybara.png',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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:
|
|
||||||
'基于 peeking-head-jar-new-animals 批次 01 水豚头参考图继续收敛',
|
|
||||||
logoType: 'symbol/icon-only mascot mark, no wordmark',
|
|
||||||
product:
|
|
||||||
'AI UGC 轻休闲小游戏创作与传播平台,用户把脑洞、梗和灵感塑造成可分享的作品',
|
|
||||||
keep: [
|
|
||||||
'陶罐容器为主形体',
|
|
||||||
'水豚式半圆脑袋只露到眼睛位置',
|
|
||||||
'两只纯黑点眼,无高光',
|
|
||||||
'小圆耳朵与平静亲和感',
|
|
||||||
'中心构图与 32px 可读性',
|
|
||||||
],
|
|
||||||
explore: [
|
|
||||||
'不同罐子颜色',
|
|
||||||
'不同动物头色彩浓度',
|
|
||||||
'更扁平、更抽象、更商标化',
|
|
||||||
'更强黑白轮廓',
|
|
||||||
'减少插画感、渐变感和材质细节',
|
|
||||||
],
|
|
||||||
avoid: [
|
|
||||||
'中文或英文字',
|
|
||||||
'鼻子、嘴巴、腮红、表情高光',
|
|
||||||
'罐子表情',
|
|
||||||
'星星、闪光、手、陶艺工具',
|
|
||||||
'甜点、面包、巧克力、糖果、布丁、餐具感',
|
|
||||||
'完整动物身体、爪子、复杂场景',
|
|
||||||
'贴纸感、儿童玩具感、写实陶瓷质感',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const basePrompt = [
|
|
||||||
'Use the uploaded reference image as the composition and pose reference. Keep the same core idea: a ceramic jar container with a capybara-like small animal peeking out only to eye level.',
|
|
||||||
'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 reference structure: the jar is the main brand shape; the animal shows only small rounded ears, the upper half-dome head, and two matte black dot eyes above the jar rim.',
|
|
||||||
'Do not show nose, mouth, cheek blush, teeth, paws, body, lower face, or a full animal head. The eyes must be pure black dots with no highlights, no white sparkle, and no glossy pupils.',
|
|
||||||
'The jar itself must have no face, no expression, no eyes, no smile, and no decorative emoji marks.',
|
|
||||||
'Make the result more like a trademark and logo than an illustration: simplified silhouette, clean vector curves, broad flat color fields, fewer gradients, fewer soft shadows, no realistic ceramic rendering.',
|
|
||||||
'Keep a warm ceramic-container feeling through color and silhouette only. The jar should not look like a bowl of food, cup, dessert package, pudding cup, bread, bun, chocolate, candy, jelly, or pastry.',
|
|
||||||
'Style target: premium friendly mascot-symbol logo, all-age and women-friendly, calm but memorable, 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 star, spark, halo, magic wand, hand, pottery tool, UI, border, sticker outline, background scene, and watermark.',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-flat-terracotta',
|
|
||||||
title: '扁平陶橙',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: the most direct logo refinement. Use flat terracotta jar, warm caramel capybara head, minimal rim shadow, almost no gradients. Make the jar silhouette slightly more iconic and compact.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-cream-cocoa',
|
|
||||||
title: '奶白可可',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: cream ceramic jar with a cocoa-brown capybara head. Keep the palette soft but not edible; use graphic flat fills and a crisp rim shape to avoid dessert feeling.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-sage-clay',
|
|
||||||
title: '鼠尾草陶',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: muted sage green ceramic jar paired with a warm ochre capybara head. More mature and boutique. Keep the silhouette simple and logo-like, with only two or three main color regions.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-outline-emblem',
|
|
||||||
title: '线面徽记',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: bolder trademark mark with clean outline plus flat fills. Use a dark warm-brown contour line around the jar and animal, but keep it soft and modern, not sticker-like.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-abstract-geometric',
|
|
||||||
title: '抽象几何',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: higher abstraction. Reduce the capybara head to a clean half-dome with two round ears and two black dots; reduce the jar to a distinct pot silhouette with a single rim band. Very vector-ready.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-monochrome-first',
|
|
||||||
title: '黑白优先',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: black-and-white survival first. Design with strong positive and negative shapes; color is secondary. Use warm clay and dark umber, but the mark must remain clear if converted to pure black and white.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-soft-gradient-logo',
|
|
||||||
title: '轻渐变商标',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: allow only a very subtle premium gradient on broad shapes, like a polished app logo. Keep it much flatter than the reference and remove painterly shadows or texture.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-bold-avatar',
|
|
||||||
title: '头像强识别',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: compact social-avatar readability. Make the jar a fuller rounded vessel and enlarge the peeking capybara head slightly, while preserving the hidden half-head rhythm and black dot eyes.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesEditUrl(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(
|
|
||||||
buildVectorEngineImagesEditUrl(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-capybara-jar-ref01-logo-refine-${variant.id}.${image.extension}`,
|
|
||||||
);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(
|
|
||||||
outputDir,
|
|
||||||
'taonier-logo-capybara-jar-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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-capybara-jar-ref01-logo-refine-concepts"
|
|
||||||
)
|
|
||||||
CONTACT_SHEET_PATH = (
|
|
||||||
OUTPUT_DIR / "taonier-logo-capybara-jar-ref01-logo-refine-contact-sheet.png"
|
|
||||||
)
|
|
||||||
REFERENCE_IMAGE = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-peeking-head-jar-new-animals-concepts"
|
|
||||||
/ "taonier-peeking-head-jar-new-animals-01-capybara.png"
|
|
||||||
)
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("REF 原01", REFERENCE_IMAGE),
|
|
||||||
("01 扁平陶橙", "taonier-capybara-jar-ref01-logo-refine-01-flat-terracotta"),
|
|
||||||
("02 奶白可可", "taonier-capybara-jar-ref01-logo-refine-02-cream-cocoa"),
|
|
||||||
("03 鼠尾草陶", "taonier-capybara-jar-ref01-logo-refine-03-sage-clay"),
|
|
||||||
("04 线面徽记", "taonier-capybara-jar-ref01-logo-refine-04-outline-emblem"),
|
|
||||||
("05 抽象几何", "taonier-capybara-jar-ref01-logo-refine-05-abstract-geometric"),
|
|
||||||
("06 黑白优先", "taonier-capybara-jar-ref01-logo-refine-06-monochrome-first"),
|
|
||||||
("07 轻渐变商标", "taonier-capybara-jar-ref01-logo-refine-07-soft-gradient-logo"),
|
|
||||||
("08 头像强识别", "taonier-capybara-jar-ref01-logo-refine-08-bold-avatar"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem_or_path: str | Path) -> Path | None:
|
|
||||||
if isinstance(stem_or_path, Path):
|
|
||||||
return stem_or_path if stem_or_path.exists() else None
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem_or_path}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_square(image_path: Path) -> Image.Image:
|
|
||||||
image = Image.open(image_path).convert("RGB")
|
|
||||||
if image.size == (1024, 1024):
|
|
||||||
return image
|
|
||||||
|
|
||||||
if image.width == image.height:
|
|
||||||
normalized = image.resize((1024, 1024), Image.Resampling.LANCZOS)
|
|
||||||
else:
|
|
||||||
normalized = ImageOps.contain(image, (1024, 1024), Image.Resampling.LANCZOS)
|
|
||||||
canvas = Image.new("RGB", (1024, 1024), "#fffdf8")
|
|
||||||
x = (1024 - normalized.width) // 2
|
|
||||||
y = (1024 - normalized.height) // 2
|
|
||||||
canvas.paste(normalized, (x, y))
|
|
||||||
normalized = canvas
|
|
||||||
|
|
||||||
if image_path.is_relative_to(OUTPUT_DIR):
|
|
||||||
normalized.save(image_path, quality=95)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 268
|
|
||||||
label_height = 54
|
|
||||||
test_height = 44
|
|
||||||
gap = 22
|
|
||||||
columns = 3
|
|
||||||
rows = 3
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f0ebe5")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(18)
|
|
||||||
test_font = load_font(13)
|
|
||||||
|
|
||||||
for index, (label, stem_or_path) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem_or_path)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = normalize_square(image_path)
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f7f3ed",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 52, test_y + 6))
|
|
||||||
sheet.paste(mono, (x + 104, test_y + 6))
|
|
||||||
draw.text((x + 150, test_y + 13), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-clay-mascot-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-clay-mascot-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 陶泥小人", "taonier-clay-mascot-little-maker.png"),
|
|
||||||
("02 陶泥手办", "taonier-clay-mascot-figurine-token.png"),
|
|
||||||
("03 软陶团子", "taonier-clay-mascot-soft-doll.png"),
|
|
||||||
("04 造物泥偶", "taonier-clay-mascot-creator-totem.png"),
|
|
||||||
("05 陶泥面偶", "taonier-clay-mascot-idol-mask.png"),
|
|
||||||
("06 口袋泥人", "taonier-clay-mascot-pocket-figure.png"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 330
|
|
||||||
label_height = 58
|
|
||||||
gap = 28
|
|
||||||
columns = 3
|
|
||||||
rows = 2
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * (cell_size + label_height) + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#eee9df")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
font = load_font(24)
|
|
||||||
|
|
||||||
for index, (label, filename) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_size + label_height + gap)
|
|
||||||
|
|
||||||
source = Image.open(OUTPUT_DIR / filename).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=10,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=font)
|
|
||||||
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,330 +0,0 @@
|
|||||||
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-clay-mascot-concepts',
|
|
||||||
);
|
|
||||||
const defaultTimeoutMs = 420000;
|
|
||||||
|
|
||||||
const concepts = [
|
|
||||||
{
|
|
||||||
id: 'taonier-clay-mascot-little-maker',
|
|
||||||
title: '陶泥小人',
|
|
||||||
prompt:
|
|
||||||
'为中文产品“陶泥儿”重新设计一个无文字 Logo 图标。停止此前软泥合拍、旋涡、锚点底座方向,以“陶泥人 / 陶泥手办 / 抽象角色吉祥物”为主线。图形主体是一个被手捏出来的极简陶泥小人:圆头、短身体、短短小手,轮廓像柔软陶泥,但必须压缩成成熟 App 主标,不是完整角色插画。角色胸口或掌心有一颗极简小星点,表达 AI 把脑洞捏成作品。风格:logo-friendly mascot mark, simple silhouette, flat vector feel, friendly, memorable, premium cute, clear at small size。配色使用奶油白、暖陶土、深墨底,可少量暖黄色星点。禁止文字、字母、水印、复杂五官、真实人脸、儿童黏土课、3D 厚重拟物、聊天气泡、播放按钮、手办包装、背景场景。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-clay-mascot-figurine-token',
|
|
||||||
title: '陶泥手办',
|
|
||||||
prompt:
|
|
||||||
'为“陶泥儿”设计无文字 Logo 图标,方向是陶泥手办 / 抽象吉祥物。主体像一枚小型软陶手办的正面主标:圆润头部、简化身体、两只短臂自然张开,底部像一个小底座但不要做雕塑台。它要有手办收藏感和精品感,但仍是极简品牌图标,不是 3D 玩具照片。角色表情只能用非常简洁的点或负形,不要复杂可爱脸。风格:modern mascot logo, flat vector, bold simple shapes, warm, collectible, app icon ready。配色:象牙白主体、深墨背景、暖陶土阴影或小点缀。禁止文字、字母、真实玩具、塑料质感、过多高光、复杂衣服、帽子、聊天气泡、播放键。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-clay-mascot-soft-doll',
|
|
||||||
title: '软陶团子',
|
|
||||||
prompt:
|
|
||||||
'为“陶泥儿”设计无文字 Logo 图标,方向是抽象陶泥角色。主体是一只圆滚滚的软陶团子小人,像一团泥被轻轻捏出头、身体和两只小手,整体剪影非常简单,能一眼记住。它需要有 Q 感和亲和力,但不要像表情包或儿童玩具。中央保留一枚小作品星核或泥点,表达创作生成。风格:minimal clay mascot logo, flat vector style, rounded, cute but mature, clean, scalable。配色:奶白 / 米白主体,暖陶土小阴影,深色或奶油色纯背景,最多 3 色。禁止中文、英文、水印、复杂五官、头发、衣服、真实手指、3D、毛绒、聊天气泡、笑脸贴纸。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-clay-mascot-creator-totem',
|
|
||||||
title: '造物泥偶',
|
|
||||||
prompt:
|
|
||||||
'为“陶泥儿”设计无文字 Logo 图标,方向是陶泥人和品牌图腾之间的抽象角色。主体不是普通人物,而是一个被捏出来的“造物泥偶”:头部圆润,身体像软陶印章,双臂像两处短短捏痕,中间有小星或小孔代表作品核。图形要比吉祥物更符号化,更适合长期主 Logo。风格:abstract mascot brand mark, simple, iconic, flat vector feel, premium, friendly, clear at 32px。配色:深墨背景、奶油白主体、少量暖黄或陶土点缀。禁止真实人、复杂脸、动物、怪物、儿童玩具、厚阴影、3D、文字、字母、水印、UI 元素。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-clay-mascot-idol-mask',
|
|
||||||
title: '陶泥面偶',
|
|
||||||
prompt:
|
|
||||||
'为“陶泥儿”设计无文字 Logo 图标,方向是抽象角色 / 吉祥物主标。主体是一枚圆润陶泥面偶:像小陶泥人的头脸和上半身融合成一个单一徽标,五官极简,只允许两个小点或一条负形捏痕,整体更像品牌符号而不是头像。要有陶泥手工、AI 创意、轻休闲平台的亲和感。风格:flat vector mascot icon, simple face mark, warm, modern, memorable, not childish。配色:暖奶白、陶土橙、深墨,少量金色作品点。禁止文字、字母、水印、复杂表情、emoji、聊天头像、真实陶艺照片、3D、背景场景、动物形象。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-clay-mascot-pocket-figure',
|
|
||||||
title: '口袋泥人',
|
|
||||||
prompt:
|
|
||||||
'为“陶泥儿”设计无文字 Logo 图标,方向是小陶泥人 / 口袋手办 / 抽象吉祥物。主体是一个能放进 App icon 的口袋泥人:小小头、软软身体、两侧短手,整体像被捏出的一枚符号,底部可轻微压扁形成稳定站姿。它应表达“人人都能把脑洞捏成作品”,亲和但不幼稚,适合品牌主标。风格:mascot logo, flat vector, bold silhouette, minimal, cute, premium, high contrast。配色:黑底或深墨底,米白陶泥主体,暖黄色小泥点。禁止文字、字母、水印、复杂五官、衣服配饰、真实手办摄影、玩偶包装、聊天气泡、播放三角、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 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',
|
|
||||||
quality: String(args.get('--quality') || 'low'),
|
|
||||||
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 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 (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',
|
|
||||||
quality: String(args.get('--quality') || 'low'),
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-closed-geo-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-closed-geo-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 有机闭合徽形", "taonier-closed-geo-01-organic-closed-badge"),
|
|
||||||
("02 柔曲陶盾", "taonier-closed-geo-02-smooth-clay-shield"),
|
|
||||||
("03 非对称陶符", "taonier-closed-geo-03-asymmetric-pebble-glyph"),
|
|
||||||
("04 嵌曲陶牌", "taonier-closed-geo-04-inlaid-curve-plate"),
|
|
||||||
("05 扁平矢量符", "taonier-closed-geo-05-flat-vector-symbol"),
|
|
||||||
("06 亲和实体形", "taonier-closed-geo-06-friendly-solid-form"),
|
|
||||||
("07 数字陶泥面", "taonier-closed-geo-07-digital-clay-panel"),
|
|
||||||
("08 商标轮廓款", "taonier-closed-geo-08-trademark-ready-contour"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * (cell_size + label_height) + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#ede8de")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
font = load_font(20)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_size + label_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fbfaf6",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,419 +0,0 @@
|
|||||||
import { 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-closed-geo-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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 = [
|
|
||||||
'请生成一枚全新的「陶泥儿」产品商标图形标概念稿,但画面中绝对不要出现任何中文字、英文字母、数字、符号字样或品牌字标。',
|
|
||||||
'产品定位:精品 AI UGC 轻休闲小游戏创作与传播平台,用户可以像捏陶泥一样把脑洞、梗和灵感塑造成小游戏或趣味内容。',
|
|
||||||
'这次的核心要求:底盘必须是由流畅曲线组成的「闭合不规则几何体图案」。它要像一个完整的品牌徽形轮廓,而不是自由飘带、旋涡、S 形、G 形、开放环或散开的泥条。',
|
|
||||||
'外轮廓:闭合、连续、平滑、非方形、非圆形、非椭圆、非圆角方块。可以有 5 到 7 个柔和转折点,像自然捏出来的抽象几何石/软陶徽形,但必须干净、可矢量化。',
|
|
||||||
'内部结构:可以用 1 到 2 条平滑曲线切分色块或形成负形,但内部结构必须服务整体闭合底盘,不能出现中心星星、中心洞、脸、眼睛、嘴巴或角色感。',
|
|
||||||
'识别方向:通过闭合外轮廓和内部曲线分区形成记忆点,不靠星形、不靠文字、不靠食物质感。远看要像互联网产品 logo。',
|
|
||||||
'材质与风格:现代扁平矢量商标,极轻微陶泥温度;低高光、低拟物、干净边缘。不要 3D 面包感,不要巧克力、面团、糕点、糖果、饼干或烘焙质感。',
|
|
||||||
'配色:暖陶白、浅陶土、陶土橙、暖棕为主体;允许少量低饱和孔雀青、靛蓝灰或深泥灰作为内部曲线色块,占比 6% 到 15%。整体要温暖、有吸引力,但不甜、不像食品。',
|
|
||||||
'构图:正方形画布,居中图形标,图形占画面约 68% 到 76%,干净浅色背景,留足安全边距。缩小到 64px 时仍能看出闭合外轮廓和内部曲线分区。',
|
|
||||||
'强禁止:自由飘带、开口环、旋涡、S 形、G 形、云朵、面包、巧克力面包、可颂、甜甜圈、糖果、饼干、糕点、奶油、夹心、亮油高光、烘焙纹理、食物摄影感。',
|
|
||||||
'强禁止:整体方形底、圆角方块、中心星星、任何星形、闪光、徽章文字、印章文字、脸、表情、手、陶艺工具、笔刷、复杂场景、按钮、UI、边框、水印、文字。',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-organic-closed-badge',
|
|
||||||
title: '有机闭合徽形',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:最基础的闭合不规则几何体。暖陶白主体,陶土橙内部曲线切分,外轮廓像柔和抽象鹅卵石但不是圆形。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-smooth-clay-shield',
|
|
||||||
title: '柔曲陶盾',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:更稳定的品牌底盘。外轮廓略像柔化盾形或种子形,但没有尖角;内部一条孔雀青曲线增加识别度。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-asymmetric-pebble-glyph',
|
|
||||||
title: '非对称陶符',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:非对称但平衡。闭合外轮廓左右不一样,有 6 个柔和转折点,内部用深泥灰负形曲线形成品牌记忆。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-inlaid-curve-plate',
|
|
||||||
title: '嵌曲陶牌',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:内部嵌色曲线。像一块闭合陶牌上嵌入一条平滑色带,色带不能像馅料、巧克力或奶油夹心。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-flat-vector-symbol',
|
|
||||||
title: '扁平矢量符',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:最扁平、最商标化。减少材质,只用 2 到 3 个大色块形成闭合不规则几何符号,线条极简。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-friendly-solid-form',
|
|
||||||
title: '亲和实体形',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:亲和力。闭合底盘像一个柔软、温和、完整的小世界,但不是角色、不是脸、不是食物。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-digital-clay-panel',
|
|
||||||
title: '数字陶泥面',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:AI/UGC 暗示。闭合底盘内有 2 到 3 个很小的几何刻点或短曲线节点,但不能像电路板,也不能像撒糖。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-trademark-ready-contour',
|
|
||||||
title: '商标轮廓款',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:可注册轮廓。优先保证黑白化后的闭合外轮廓和内部曲线仍有辨识度,避免渐变和复杂纹理。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-closed-geo-${variant.id}.${image.extension}`);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(outputDir, 'taonier-logo-closed-geo-manifest.json');
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
creativeDirection: {
|
|
||||||
name: '陶泥儿闭合不规则几何底盘图形标',
|
|
||||||
textPolicy: 'no Chinese, no English, no wordmark',
|
|
||||||
correction:
|
|
||||||
'closed irregular smooth geometry, not free ribbon, not food, not square base',
|
|
||||||
motif: '闭合曲线几何底盘 + 内部曲线分区 + 轻陶泥温度',
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-distinctive-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-distinctive-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 孔雀青星核", "taonier-distinctive-01-teal-core-pop"),
|
|
||||||
("02 靛蓝切口", "taonier-distinctive-02-indigo-cut-mark"),
|
|
||||||
("03 朱砂陶火", "taonier-distinctive-03-cinnabar-clay-spark"),
|
|
||||||
("04 强轮廓泥符", "taonier-distinctive-04-bold-outline-token"),
|
|
||||||
("05 像素创作种", "taonier-distinctive-05-clay-pixel-seed"),
|
|
||||||
("06 动态软方圆", "taonier-distinctive-06-dynamic-squircle"),
|
|
||||||
("07 应用图标款", "taonier-distinctive-07-app-store-icon"),
|
|
||||||
("08 商标扁平符", "taonier-distinctive-08-trademark-flat-glyph"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * (cell_size + label_height) + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#ede8de")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
font = load_font(20)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_size + label_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fbfaf6",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,422 +0,0 @@
|
|||||||
import { 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-distinctive-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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 = [
|
|
||||||
'请生成一枚全新的「陶泥儿」产品商标图形标概念稿,但画面中绝对不要出现任何中文字、英文字母、数字、符号字样或品牌字标。',
|
|
||||||
'产品定位:精品 AI UGC 轻休闲小游戏创作与传播平台,用户可以像捏陶泥一样把脑洞、梗和灵感塑造成小游戏或趣味内容。',
|
|
||||||
'这次目标是高辨识度和传播吸引力:第一眼要像一个有记忆点的互联网产品 logo,而不是普通泥块、砖块、陶片、印章、饼干或糖果。',
|
|
||||||
'核心隐喻:可塑星核。一个独特的「软方圆陶泥外轮廓 + 中心可塑星核」符号,星核像脑洞被捏出的一瞬间,造型要有专属轮廓,不能是普通五角星或普通四角闪光。',
|
|
||||||
'风格:现代扁平矢量商标,带轻微哑光陶泥质感;边缘干净、对比清楚、轮廓强,适合 App 图标、社区头像、启动页和商标注册前期筛选。',
|
|
||||||
'材质:要能看出陶泥温度,但不要过度粗糙、不要砖、不要土块、不要考古泥片。质感只做轻量表面颗粒和柔和压痕。',
|
|
||||||
'配色:以暖陶白或浅陶土色为主体,加入一个高识别点缀色。允许使用低饱和孔雀青、靛蓝灰、朱砂橙、暗金土黄中的一种;点缀色只占 8% 到 18%。整体要更醒目、更年轻,但不要糖果色。',
|
|
||||||
'图案:中心星核必须是品牌记忆点,可用负形、嵌色、切口、泥痕、像素刻点形成独特轮廓;周围最多 2 到 3 个小刻点暗示 AI/UGC 扩散。',
|
|
||||||
'构图:正方形画布,居中图形标,图形占画面约 70% 到 78%,干净浅色背景,留足安全边距。缩小到 64px 时仍然能认出外轮廓和中心符号。',
|
|
||||||
'禁止:脸、眼睛、嘴巴、表情、角色身体、吉祥物立绘、陶艺工具、手、笔刷、复杂场景、按钮、UI、边框、水印、文字、商标字标。',
|
|
||||||
'强禁止低辨识:不要普通圆泥饼、不要普通砖块、不要石头、不要考古印章、不要单纯凹星孔、不要灰扑扑单色泥片。',
|
|
||||||
'强禁止食品感:不要糖果、软糖、奶油、饼干、夹心甜点、月饼、巧克力、果冻高光、亮金膨胀星糖。',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-teal-core-pop',
|
|
||||||
title: '孔雀青星核',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:孔雀青识别点。暖陶白软方圆主体,中间是孔雀青负形可塑星核,少量陶土褐压痕,整体清爽年轻。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-indigo-cut-mark',
|
|
||||||
title: '靛蓝切口',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:靛蓝灰切口。用一条干净的靛蓝灰泥痕切出中心星核,让图形远看有强剪影,不能像旋涡旧稿。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-cinnabar-clay-spark',
|
|
||||||
title: '朱砂陶火',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:朱砂橙活力。中心星核或侧边小泥片使用低饱和朱砂橙,像创作火花,但整体保持陶泥材质和精品克制。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-bold-outline-token',
|
|
||||||
title: '强轮廓泥符',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:强轮廓。用深泥灰细描边或深色负形强化外轮廓和中心符号,确保黑白化后仍有高辨识度。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-clay-pixel-seed',
|
|
||||||
title: '像素创作种',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:AI/UGC 暗示。中心星核周围有 3 个小方形刻点,像生成像素从陶泥里浮现,但不要复杂电路线。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-dynamic-squircle',
|
|
||||||
title: '动态软方圆',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:动态轮廓。外形不是静态泥块,而像正在被捏动的软方圆,有一个明显但简洁的非对称记忆点。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-app-store-icon',
|
|
||||||
title: '应用图标款',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:App Store 图标。构图饱满、中心符号强、背景干净,视觉冲击比泥章更强,但不出现文字和脸。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-trademark-flat-glyph',
|
|
||||||
title: '商标扁平符',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:最终商标潜力。减少材质和阴影,以 2 到 3 个大色块形成独特符号,保留陶泥可塑感和中心可塑星核。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-distinctive-${variant.id}.${image.extension}`);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(outputDir, 'taonier-logo-distinctive-manifest.json');
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
creativeDirection: {
|
|
||||||
name: '陶泥儿高辨识可塑星核图形标',
|
|
||||||
textPolicy: 'no Chinese, no English, no wordmark',
|
|
||||||
palette:
|
|
||||||
'暖陶白/浅陶土主体 + 8%-18% 孔雀青、靛蓝灰、朱砂橙或暗金土黄点缀',
|
|
||||||
motif: '强轮廓软方圆 + 独特可塑星核 + 少量 AI/UGC 刻点',
|
|
||||||
correction:
|
|
||||||
'avoid candy, avoid brick, avoid plain mud stamp, increase brand recognition',
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-flow-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-flow-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 柔泥回环", "taonier-flow-01-soft-ribbon-loop"),
|
|
||||||
("02 陶泥波结", "taonier-flow-02-clay-wave-knot"),
|
|
||||||
("03 脑洞涟漪", "taonier-flow-03-imagination-ripple"),
|
|
||||||
("04 亲和泥流", "taonier-flow-04-friendly-clay-comet"),
|
|
||||||
("05 单笔团块", "taonier-flow-05-single-stroke-blob"),
|
|
||||||
("06 双色软流", "taonier-flow-06-two-tone-soft-flow"),
|
|
||||||
("07 开放泥环", "taonier-flow-07-open-clay-orbit"),
|
|
||||||
("08 品牌曲线符", "taonier-flow-08-brand-flow-glyph"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * (cell_size + label_height) + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#ede8de")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
font = load_font(20)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_size + label_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fbfaf6",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,418 +0,0 @@
|
|||||||
import { 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-flow-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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 = [
|
|
||||||
'请生成一枚全新的「陶泥儿」产品商标图形标概念稿,但画面中绝对不要出现任何中文字、英文字母、数字、符号字样或品牌字标。',
|
|
||||||
'产品定位:精品 AI UGC 轻休闲小游戏创作与传播平台,用户可以像捏陶泥一样把脑洞、梗和灵感塑造成小游戏或趣味内容。',
|
|
||||||
'这次必须彻底放弃方形底和中心星星:不要整体方形、不要圆角方块、不要 App 图标底板、不要中间星形、不要五角星、不要四角星、不要闪光、不要中心挖孔星。',
|
|
||||||
'核心隐喻:连续的陶泥曲线。用一整块顺滑、亲和、可塑的陶泥大结构来表达「脑洞被揉开、灵感流动、内容成型」,图形要像一个完整的流动符号,而不是底板加中心图案。',
|
|
||||||
'主结构:由平滑曲线组成的大轮廓,可以像柔软泥流、脑洞涟漪、捏合的回环、温和的旋拧、单线团块或开放结。整体必须连贯、圆润、有亲和力,不能分裂成多个碎片。',
|
|
||||||
'风格:现代扁平矢量商标,轻微哑光陶泥质感;边缘干净、曲线饱满、负形明确,适合商标、App 图标、社区头像和启动页。',
|
|
||||||
'配色:暖陶白、浅陶土、陶土橙、暖棕为主,可加入少量低饱和孔雀青或深泥灰作为曲线内侧阴影或小连接点。颜色要温暖、有吸引力,但不要糖果色。',
|
|
||||||
'识别度:远看必须能记住一个独特的大曲线轮廓;不要靠中心图案识别。缩小到 64px 时仍能看出整体曲线姿态。',
|
|
||||||
'亲和力:整体像可以被揉捏的柔软小世界,有陪伴感和创作感,但不要做成脸、表情、角色或吉祥物。',
|
|
||||||
'禁止:方形底、圆角方块、中心星星、任何星形、闪光、徽章、印章、砖块、泥饼、石头、陶片、饼干、糖果、手、陶艺工具、笔刷、复杂场景、按钮、UI、边框、水印、文字。',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-soft-ribbon-loop',
|
|
||||||
title: '柔泥回环',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:一条宽厚柔软的陶泥带形成开放回环,像脑洞被揉开。轮廓连续,内部负形自然,不出现星形或方形底。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-clay-wave-knot',
|
|
||||||
title: '陶泥波结',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:像一个温和波浪结,由 2 条互相捏合的平滑曲线组成,但必须视觉上像一个整体符号,不要碎片化。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-imagination-ripple',
|
|
||||||
title: '脑洞涟漪',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:用一整块陶泥曲面形成涟漪状大轮廓,中间负形像柔和水滴或脑洞入口,不能像星星。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-friendly-clay-comet',
|
|
||||||
title: '亲和泥流',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:亲和泥流。整体像一个流动的陶泥小世界,前端圆润、尾部自然收束,有动势但不尖锐。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-single-stroke-blob',
|
|
||||||
title: '单笔团块',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:单笔成型。像用一笔连续曲线捏出的陶泥团,结构极简但有记忆点,适合后续矢量化。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-two-tone-soft-flow',
|
|
||||||
title: '双色软流',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:双色曲线。暖陶白主体配少量孔雀青或陶土橙内侧曲线,让图形更吸引人,但不能变成多片拼贴。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-open-clay-orbit',
|
|
||||||
title: '开放泥环',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:开放式泥环。不是闭合圆,也不是旋涡旧稿,而是一枚有缺口和呼吸感的平滑陶泥环形符号。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-brand-flow-glyph',
|
|
||||||
title: '品牌曲线符',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'本张重点:最终品牌符号潜力。减少材质和细节,用 1 到 2 个大曲线色块形成独特、亲和、可注册的图形标。',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-flow-${variant.id}.${image.extension}`);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(outputDir, 'taonier-logo-flow-manifest.json');
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
generatedAt: new Date().toISOString(),
|
|
||||||
creativeDirection: {
|
|
||||||
name: '陶泥儿连续曲线亲和图形标',
|
|
||||||
textPolicy: 'no Chinese, no English, no wordmark',
|
|
||||||
correction:
|
|
||||||
'no square base, no center star, use one continuous friendly clay curve structure',
|
|
||||||
motif: '柔泥回环、脑洞涟漪、开放泥环、品牌曲线符',
|
|
||||||
},
|
|
||||||
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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,300 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import math
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-geometric-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-geometric-contact-sheet.png"
|
|
||||||
|
|
||||||
SIZE = 1024
|
|
||||||
SCALE = 4
|
|
||||||
|
|
||||||
INK = "#151515"
|
|
||||||
CREAM = "#fff7e6"
|
|
||||||
GOLD = "#ffd25d"
|
|
||||||
CORAL = "#ff6a5f"
|
|
||||||
MINT = "#29c9ad"
|
|
||||||
BLUE = "#2f6bff"
|
|
||||||
|
|
||||||
VARIANTS = [
|
|
||||||
("taonier-geometric-offset-core", "偏心泥孔", "offset_core"),
|
|
||||||
("taonier-geometric-mold-chip", "模芯切片", "mold_chip"),
|
|
||||||
("taonier-geometric-pinched-tile", "捏痕方标", "pinched_tile"),
|
|
||||||
("taonier-geometric-dual-plate", "双片合模", "dual_plate"),
|
|
||||||
("taonier-geometric-dot-gate", "泥点入口", "dot_gate"),
|
|
||||||
("taonier-geometric-work-knot", "作品结点", "work_knot"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def hex_to_rgb(value: str) -> tuple[int, int, int]:
|
|
||||||
value = value.removeprefix("#")
|
|
||||||
return tuple(int(value[index : index + 2], 16) for index in (0, 2, 4))
|
|
||||||
|
|
||||||
|
|
||||||
def s(value: float) -> int:
|
|
||||||
return round(value * SCALE)
|
|
||||||
|
|
||||||
|
|
||||||
def rgba(value: str) -> tuple[int, int, int, int]:
|
|
||||||
red, green, blue = hex_to_rgb(value)
|
|
||||||
return red, green, blue, 255
|
|
||||||
|
|
||||||
|
|
||||||
def regular_polygon(cx: float, cy: float, radius: float, sides: int, rotation: float) -> list[tuple[int, int]]:
|
|
||||||
points = []
|
|
||||||
for index in range(sides):
|
|
||||||
angle = rotation + math.tau * index / sides
|
|
||||||
points.append((s(cx + math.cos(angle) * radius), s(cy + math.sin(angle) * radius)))
|
|
||||||
return points
|
|
||||||
|
|
||||||
|
|
||||||
def rounded_rectangle(
|
|
||||||
draw: ImageDraw.ImageDraw,
|
|
||||||
box: tuple[float, float, float, float],
|
|
||||||
radius: float,
|
|
||||||
fill: str,
|
|
||||||
) -> None:
|
|
||||||
draw.rounded_rectangle(tuple(s(value) for value in box), radius=s(radius), fill=hex_to_rgb(fill))
|
|
||||||
|
|
||||||
|
|
||||||
def circle(draw: ImageDraw.ImageDraw, cx: float, cy: float, radius: float, fill: str) -> None:
|
|
||||||
draw.ellipse((s(cx - radius), s(cy - radius), s(cx + radius), s(cy + radius)), fill=hex_to_rgb(fill))
|
|
||||||
|
|
||||||
|
|
||||||
def draw_offset_core(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#111111"))
|
|
||||||
rounded_rectangle(draw, (236, 236, 788, 788), 148, CREAM)
|
|
||||||
circle(draw, 610, 456, 116, "#111111")
|
|
||||||
circle(draw, 610, 456, 48, GOLD)
|
|
||||||
rounded_rectangle(draw, (268, 612, 536, 718), 53, "#111111")
|
|
||||||
rounded_rectangle(draw, (294, 638, 500, 690), 26, CREAM)
|
|
||||||
circle(draw, 352, 370, 34, "#111111")
|
|
||||||
circle(draw, 352, 370, 17, GOLD)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_mold_chip(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#101418"))
|
|
||||||
draw.polygon(
|
|
||||||
[
|
|
||||||
(s(278), s(230)),
|
|
||||||
(s(734), s(230)),
|
|
||||||
(s(828), s(330)),
|
|
||||||
(s(828), s(694)),
|
|
||||||
(s(706), s(794)),
|
|
||||||
(s(278), s(794)),
|
|
||||||
(s(196), s(708)),
|
|
||||||
(s(196), s(322)),
|
|
||||||
],
|
|
||||||
fill=hex_to_rgb(CREAM),
|
|
||||||
)
|
|
||||||
circle(draw, 512, 512, 144, "#101418")
|
|
||||||
circle(draw, 512, 512, 62, GOLD)
|
|
||||||
rounded_rectangle(draw, (224, 280, 518, 370), 45, CORAL)
|
|
||||||
rounded_rectangle(draw, (574, 654, 796, 736), 41, MINT)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_pinched_tile(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#14100d"))
|
|
||||||
rounded_rectangle(draw, (232, 250, 792, 774), 170, CREAM)
|
|
||||||
circle(draw, 232, 512, 94, "#14100d")
|
|
||||||
circle(draw, 792, 512, 94, "#14100d")
|
|
||||||
draw.polygon(regular_polygon(512, 512, 104, 4, math.pi / 4), fill=hex_to_rgb("#14100d"))
|
|
||||||
circle(draw, 512, 512, 38, GOLD)
|
|
||||||
rounded_rectangle(draw, (420, 300, 604, 358), 29, CORAL)
|
|
||||||
rounded_rectangle(draw, (420, 666, 604, 724), 29, MINT)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_dual_plate(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#111111"))
|
|
||||||
rounded_rectangle(draw, (214, 316, 790, 464), 74, CORAL)
|
|
||||||
rounded_rectangle(draw, (234, 560, 810, 708), 74, MINT)
|
|
||||||
draw.polygon(regular_polygon(512, 512, 138, 4, math.pi / 4), fill=hex_to_rgb(CREAM))
|
|
||||||
draw.polygon(regular_polygon(512, 512, 76, 4, math.pi / 4), fill=hex_to_rgb("#111111"))
|
|
||||||
circle(draw, 512, 512, 32, GOLD)
|
|
||||||
circle(draw, 262, 390, 24, CREAM)
|
|
||||||
circle(draw, 762, 634, 24, CREAM)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_dot_gate(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#101010"))
|
|
||||||
rounded_rectangle(draw, (276, 330, 748, 752), 132, CREAM)
|
|
||||||
rounded_rectangle(draw, (386, 440, 638, 752), 126, "#101010")
|
|
||||||
circle(draw, 512, 260, 62, GOLD)
|
|
||||||
rounded_rectangle(draw, (450, 308, 574, 504), 62, CREAM)
|
|
||||||
circle(draw, 512, 518, 40, GOLD)
|
|
||||||
rounded_rectangle(draw, (316, 754, 708, 812), 29, CREAM)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_work_knot(draw: ImageDraw.ImageDraw) -> None:
|
|
||||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#121212"))
|
|
||||||
circle(draw, 396, 402, 132, CREAM)
|
|
||||||
circle(draw, 628, 402, 132, CREAM)
|
|
||||||
circle(draw, 396, 622, 132, CREAM)
|
|
||||||
circle(draw, 628, 622, 132, CREAM)
|
|
||||||
rounded_rectangle(draw, (386, 386, 638, 638), 72, "#121212")
|
|
||||||
draw.polygon(regular_polygon(512, 512, 96, 4, math.pi / 4), fill=hex_to_rgb(GOLD))
|
|
||||||
circle(draw, 396, 402, 48, CORAL)
|
|
||||||
circle(draw, 628, 622, 48, MINT)
|
|
||||||
circle(draw, 628, 402, 28, "#121212")
|
|
||||||
circle(draw, 396, 622, 28, "#121212")
|
|
||||||
|
|
||||||
|
|
||||||
DRAWERS = {
|
|
||||||
"offset_core": draw_offset_core,
|
|
||||||
"mold_chip": draw_mold_chip,
|
|
||||||
"pinched_tile": draw_pinched_tile,
|
|
||||||
"dual_plate": draw_dual_plate,
|
|
||||||
"dot_gate": draw_dot_gate,
|
|
||||||
"work_knot": draw_work_knot,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def render_variant(style: str) -> Image.Image:
|
|
||||||
image = Image.new("RGB", (SIZE * SCALE, SIZE * SCALE), hex_to_rgb("#111111"))
|
|
||||||
draw = ImageDraw.Draw(image)
|
|
||||||
DRAWERS[style](draw)
|
|
||||||
return image.resize((SIZE, SIZE), Image.Resampling.LANCZOS)
|
|
||||||
|
|
||||||
|
|
||||||
def build_svg(style: str) -> str:
|
|
||||||
# PNG 是当前评审主物料,SVG 保留为后续设计师重绘的结构草图。
|
|
||||||
if style == "offset_core":
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="#111111"/>
|
|
||||||
<rect x="236" y="236" width="552" height="552" rx="148" fill="{CREAM}"/>
|
|
||||||
<circle cx="610" cy="456" r="116" fill="#111111"/>
|
|
||||||
<circle cx="610" cy="456" r="48" fill="{GOLD}"/>
|
|
||||||
<rect x="268" y="612" width="268" height="106" rx="53" fill="#111111"/>
|
|
||||||
<rect x="294" y="638" width="206" height="52" rx="26" fill="{CREAM}"/>
|
|
||||||
<circle cx="352" cy="370" r="34" fill="#111111"/>
|
|
||||||
<circle cx="352" cy="370" r="17" fill="{GOLD}"/>'''
|
|
||||||
elif style == "mold_chip":
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="#101418"/>
|
|
||||||
<path d="M278 230H734L828 330V694L706 794H278L196 708V322Z" fill="{CREAM}"/>
|
|
||||||
<circle cx="512" cy="512" r="144" fill="#101418"/>
|
|
||||||
<circle cx="512" cy="512" r="62" fill="{GOLD}"/>
|
|
||||||
<rect x="224" y="280" width="294" height="90" rx="45" fill="{CORAL}"/>
|
|
||||||
<rect x="574" y="654" width="222" height="82" rx="41" fill="{MINT}"/>'''
|
|
||||||
elif style == "pinched_tile":
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="#14100d"/>
|
|
||||||
<rect x="232" y="250" width="560" height="524" rx="170" fill="{CREAM}"/>
|
|
||||||
<circle cx="232" cy="512" r="94" fill="#14100d"/>
|
|
||||||
<circle cx="792" cy="512" r="94" fill="#14100d"/>
|
|
||||||
<path d="M512 408L616 512L512 616L408 512Z" fill="#14100d"/>
|
|
||||||
<circle cx="512" cy="512" r="38" fill="{GOLD}"/>
|
|
||||||
<rect x="420" y="300" width="184" height="58" rx="29" fill="{CORAL}"/>
|
|
||||||
<rect x="420" y="666" width="184" height="58" rx="29" fill="{MINT}"/>'''
|
|
||||||
elif style == "dual_plate":
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="#111111"/>
|
|
||||||
<rect x="214" y="316" width="576" height="148" rx="74" fill="{CORAL}"/>
|
|
||||||
<rect x="234" y="560" width="576" height="148" rx="74" fill="{MINT}"/>
|
|
||||||
<path d="M512 374L650 512L512 650L374 512Z" fill="{CREAM}"/>
|
|
||||||
<path d="M512 436L588 512L512 588L436 512Z" fill="#111111"/>
|
|
||||||
<circle cx="512" cy="512" r="32" fill="{GOLD}"/>
|
|
||||||
<circle cx="262" cy="390" r="24" fill="{CREAM}"/>
|
|
||||||
<circle cx="762" cy="634" r="24" fill="{CREAM}"/>'''
|
|
||||||
elif style == "dot_gate":
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="#101010"/>
|
|
||||||
<rect x="276" y="330" width="472" height="422" rx="132" fill="{CREAM}"/>
|
|
||||||
<rect x="386" y="440" width="252" height="312" rx="126" fill="#101010"/>
|
|
||||||
<circle cx="512" cy="260" r="62" fill="{GOLD}"/>
|
|
||||||
<rect x="450" y="308" width="124" height="196" rx="62" fill="{CREAM}"/>
|
|
||||||
<circle cx="512" cy="518" r="40" fill="{GOLD}"/>
|
|
||||||
<rect x="316" y="754" width="392" height="58" rx="29" fill="{CREAM}"/>'''
|
|
||||||
else:
|
|
||||||
body = f'''
|
|
||||||
<rect width="1024" height="1024" rx="160" fill="#121212"/>
|
|
||||||
<circle cx="396" cy="402" r="132" fill="{CREAM}"/>
|
|
||||||
<circle cx="628" cy="402" r="132" fill="{CREAM}"/>
|
|
||||||
<circle cx="396" cy="622" r="132" fill="{CREAM}"/>
|
|
||||||
<circle cx="628" cy="622" r="132" fill="{CREAM}"/>
|
|
||||||
<rect x="386" y="386" width="252" height="252" rx="72" fill="#121212"/>
|
|
||||||
<path d="M512 416L608 512L512 608L416 512Z" fill="{GOLD}"/>
|
|
||||||
<circle cx="396" cy="402" r="48" fill="{CORAL}"/>
|
|
||||||
<circle cx="628" cy="622" r="48" fill="{MINT}"/>
|
|
||||||
<circle cx="628" cy="402" r="28" fill="#121212"/>
|
|
||||||
<circle cx="396" cy="622" r="28" fill="#121212"/>'''
|
|
||||||
return f'''<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
{body}
|
|
||||||
</svg>
|
|
||||||
'''
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def build_contact_sheet(previews: list[tuple[str, str, Image.Image]]) -> Image.Image:
|
|
||||||
cell_size = 320
|
|
||||||
label_height = 60
|
|
||||||
gap = 28
|
|
||||||
columns = 3
|
|
||||||
rows = 2
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * (cell_size + label_height) + (rows + 1) * gap
|
|
||||||
sheet = Image.new("RGB", (width, height), "#eee9df")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
font = load_font(23)
|
|
||||||
|
|
||||||
for index, (_, title, preview) in enumerate(previews):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_size + label_height + gap)
|
|
||||||
thumbnail = preview.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=10,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
label = f"{index + 1:02d} {title}"
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=font)
|
|
||||||
return sheet
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
previews: list[tuple[str, str, Image.Image]] = []
|
|
||||||
|
|
||||||
for asset_id, title, style in VARIANTS:
|
|
||||||
preview = render_variant(style)
|
|
||||||
preview.save(OUTPUT_DIR / f"{asset_id}.png")
|
|
||||||
(OUTPUT_DIR / f"{asset_id}.svg").write_text(build_svg(style), encoding="utf-8")
|
|
||||||
previews.append((asset_id, title, preview))
|
|
||||||
|
|
||||||
contact_sheet = build_contact_sheet(previews)
|
|
||||||
contact_sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"output_dir": str(OUTPUT_DIR),
|
|
||||||
"files": [f"{asset_id}.png" for asset_id, _, _ in VARIANTS]
|
|
||||||
+ [f"{asset_id}.svg" for asset_id, _, _ in VARIANTS]
|
|
||||||
+ [CONTACT_SHEET_PATH.name],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
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));
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = (
|
|
||||||
REPO_ROOT / "public" / "branding" / "taonier-logo-hand-spirit-bold-color-concepts"
|
|
||||||
)
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-hand-spirit-bold-color-contact-sheet.png"
|
|
||||||
REFERENCE_IMAGE = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-hand-spirit-ref01-logo-refine-concepts"
|
|
||||||
/ "taonier-hand-spirit-ref01-logo-refine-01-flat-coral-cream.png"
|
|
||||||
)
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("REF 上轮01", REFERENCE_IMAGE),
|
|
||||||
("01 莓粉青 aqua", "taonier-hand-spirit-bold-color-01-berry-aqua-pop"),
|
|
||||||
("02 珊瑚丁香", "taonier-hand-spirit-bold-color-02-coral-lilac"),
|
|
||||||
("03 芒果松石", "taonier-hand-spirit-bold-color-03-mango-turquoise"),
|
|
||||||
("04 玫红薄荷", "taonier-hand-spirit-bold-color-04-neon-rose-mint"),
|
|
||||||
("05 罂粟蓝调", "taonier-hand-spirit-bold-color-05-poppy-blue"),
|
|
||||||
("06 紫桃撞色", "taonier-hand-spirit-bold-color-06-violet-peach"),
|
|
||||||
("07 双色强记忆", "taonier-hand-spirit-bold-color-07-flat-duotone"),
|
|
||||||
("08 亮彩头像", "taonier-hand-spirit-bold-color-08-app-icon-bright"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
for candidate in [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem_or_path: str | Path) -> Path | None:
|
|
||||||
if isinstance(stem_or_path, Path):
|
|
||||||
return stem_or_path if stem_or_path.exists() else None
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem_or_path}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_square(image_path: Path) -> Image.Image:
|
|
||||||
image = Image.open(image_path).convert("RGB")
|
|
||||||
if image.size == (1024, 1024):
|
|
||||||
return image
|
|
||||||
if image.width == image.height:
|
|
||||||
normalized = image.resize((1024, 1024), Image.Resampling.LANCZOS)
|
|
||||||
else:
|
|
||||||
contained = ImageOps.contain(image, (1024, 1024), Image.Resampling.LANCZOS)
|
|
||||||
normalized = Image.new("RGB", (1024, 1024), "#fffdf8")
|
|
||||||
x = (1024 - contained.width) // 2
|
|
||||||
y = (1024 - contained.height) // 2
|
|
||||||
normalized.paste(contained, (x, y))
|
|
||||||
if image_path.is_relative_to(OUTPUT_DIR):
|
|
||||||
normalized.save(image_path, quality=95)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 268
|
|
||||||
label_height = 54
|
|
||||||
test_height = 44
|
|
||||||
gap = 22
|
|
||||||
columns = 3
|
|
||||||
rows = 3
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f0ebe5")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(18)
|
|
||||||
test_font = load_font(13)
|
|
||||||
|
|
||||||
for index, (label, stem_or_path) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem_or_path)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = normalize_square(image_path)
|
|
||||||
sheet.paste(source.resize((cell_size, cell_size), Image.Resampling.LANCZOS), (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f7f3ed",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 52, test_y + 6))
|
|
||||||
sheet.paste(mono, (x + 104, test_y + 6))
|
|
||||||
draw.text((x + 150, test_y + 13), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-hand-spirit-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-hand-spirit-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 温柔托举灵体", "taonier-hand-spirit-01-gentle-hand-spirit"),
|
|
||||||
("02 分享掌形", "taonier-hand-spirit-02-sharing-palm"),
|
|
||||||
("03 青绿托线", "taonier-hand-spirit-03-teal-support"),
|
|
||||||
("04 拱形泥灵", "taonier-hand-spirit-04-arched-spirit"),
|
|
||||||
("05 轻玩递出", "taonier-hand-spirit-05-playful-offer"),
|
|
||||||
("06 黑白优先", "taonier-hand-spirit-06-monochrome-first"),
|
|
||||||
("07 头像可读", "taonier-hand-spirit-07-avatar-readable"),
|
|
||||||
("08 矢量定稿感", "taonier-hand-spirit-08-vector-ready"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
test_height = 46
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f0ebe5")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(20)
|
|
||||||
test_font = load_font(14)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f7f3ed",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 68, test_y + 7))
|
|
||||||
sheet.paste(mono, (x + 122, test_y + 7))
|
|
||||||
draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,442 +0,0 @@
|
|||||||
import { 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-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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: '陶泥儿',
|
|
||||||
coreBelief: '好玩会创造',
|
|
||||||
logoType: 'symbol/icon-only mark, no wordmark',
|
|
||||||
product:
|
|
||||||
'AI UGC 轻休闲小游戏创作与传播平台,用户像捏陶泥一样把脑洞、梗和灵感塑造成可分享的作品',
|
|
||||||
metaphor: '抽象化的手托举/递出一个软萌陶泥灵体',
|
|
||||||
intent: ['托举', '分享', '传递', '创作被捏成一个有生命感的小作品'],
|
|
||||||
spiritShape: '不规则半球形陶泥灵体,参考黑底白色半圆拱形轮廓,但不照抄',
|
|
||||||
audience: '女性用户友好、全年龄向、年轻明亮但不低幼',
|
|
||||||
material: '只保留陶泥温度,不追求泥土质感',
|
|
||||||
mustHave: [
|
|
||||||
'手必须高度抽象,像托举曲线或掌形基座',
|
|
||||||
'陶泥灵体必须是主角,软萌但不出现脸',
|
|
||||||
'画面传达分享/传递,而不是供奉/宗教/医疗',
|
|
||||||
'32px 可识别',
|
|
||||||
'黑白化仍成立',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const basePrompt = [
|
|
||||||
'Create an icon-only brand logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.',
|
|
||||||
'Brand core belief: fun creation. Users can turn imagination, memes, and ideas into playable AI/UGC casual game works and share them with others.',
|
|
||||||
'Main metaphor: an abstract hand gently holding up and offering a cute soft clay spirit. The logo should communicate creation, sharing, passing forward, and a small idea becoming a lovable playable object.',
|
|
||||||
'Logo type: abstract symbol/icon only. Not a mascot illustration, not an app-icon rounded-square background, not an emblem with text.',
|
|
||||||
'Composition: one highly simplified hand-like support curve or palm base below, holding one soft clay spirit above. The hand must be abstract, smooth, and logo-like, not realistic fingers.',
|
|
||||||
'Clay spirit shape: a cute irregular semi-dome / half-blob / rounded arch form, inspired by a simple white arched half-circle silhouette on dark background, but transformed into a friendly original brand symbol.',
|
|
||||||
'The spirit should be soft and lovable without a face. No eyes, no mouth, no expression, no limbs, no character body, no halo, no star, no spark.',
|
|
||||||
'The hand gesture should feel like sharing or gently presenting, not worship, religion, medical care, begging, or holding a food item.',
|
|
||||||
'Style: modern minimalist vector logo. Clean broad shapes, clear silhouette, fresh bright colors, very light clay warmth only. It must look good and recognizable at 32px favicon size.',
|
|
||||||
'Color direction: women-friendly and all-age fresh palette: coral orange, peach pink, cream white, clear soft teal or mint green. Use flat brand-color shapes, not candy rendering.',
|
|
||||||
'Avoid muted mud colors as the dominant palette. Avoid realistic clay texture, dirty clay, brick, pottery shard, mud pie, rough craft class object, and archaeology stamp feeling.',
|
|
||||||
'Food avoidance is critical: do not make the spirit look like bread, mochi, dumpling, cake, cookie, candy, jelly, donut, cream filling, sauce, dessert, or food packaging.',
|
|
||||||
'Shape avoidance: no square base, no rounded-square app background, no free ribbon, no swirl, no S or G letter feeling, no center star.',
|
|
||||||
'Composition: centered on a clean light background, generous safe area, no border, no UI, no watermark. Use simple readable silhouette first.',
|
|
||||||
'Validation targets: black-and-white version should still read clearly; the hand support and semi-dome clay spirit should be recognizable at 32px.',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-gentle-hand-spirit',
|
|
||||||
title: '温柔托举灵体',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: the clearest version. A simple cream abstract palm curve holds a coral-peach semi-dome clay spirit. Friendly, iconic, and readable.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-sharing-palm',
|
|
||||||
title: '分享掌形',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: sharing intention. The abstract hand is slightly forward-facing, like offering the clay spirit outward, but still very simplified and not realistic.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-teal-support',
|
|
||||||
title: '青绿托线',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: use a clear soft teal support curve as the hand and a warm peach clay spirit above. Strong color memory, no food look.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-arched-spirit',
|
|
||||||
title: '拱形泥灵',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: emphasize the irregular semi-dome clay spirit shape from the reference: simple arched top, flatter base, slightly organic, no face.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-playful-offer',
|
|
||||||
title: '轻玩递出',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: more playful and lively. The hand support suggests passing the spirit forward, with one broad curve only. Avoid decorative tiny details.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-monochrome-first',
|
|
||||||
title: '黑白优先',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: design for black-and-white survival first. Use strong positive and negative shapes so the hand and spirit remain readable without color.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-avatar-readable',
|
|
||||||
title: '头像可读',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: social avatar and favicon readability. Compact, bold silhouette, thicker hand curve, larger semi-dome spirit, no small parts.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-vector-ready',
|
|
||||||
title: '矢量定稿感',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: designer-ready vector concept. 2-3 flat shapes, crisp boundaries, distinctive hand-support and clay-spirit silhouette, minimal material cue.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-${variant.id}.${image.extension}`);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(outputDir, 'taonier-logo-hand-spirit-manifest.json');
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
brief: logoBrief,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,404 +0,0 @@
|
|||||||
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));
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = (
|
|
||||||
REPO_ROOT / "public" / "branding" / "taonier-logo-hand-spirit-muted-color-concepts"
|
|
||||||
)
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-hand-spirit-muted-color-contact-sheet.png"
|
|
||||||
REFERENCE_IMAGE = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-hand-spirit-ref01-logo-refine-concepts"
|
|
||||||
/ "taonier-hand-spirit-ref01-logo-refine-01-flat-coral-cream.png"
|
|
||||||
)
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("REF 上轮01", REFERENCE_IMAGE),
|
|
||||||
("01 雾玫鼠尾草", "taonier-hand-spirit-muted-color-01-dusty-rose-sage"),
|
|
||||||
("02 烟蓝杏橙", "taonier-hand-spirit-muted-color-02-smoke-blue-apricot"),
|
|
||||||
("03 雾紫陶土", "taonier-hand-spirit-muted-color-03-misty-lilac-clay"),
|
|
||||||
("04 黄油玫瑰茶", "taonier-hand-spirit-muted-color-04-butter-rose-tea"),
|
|
||||||
("05 陶蓝薄荷", "taonier-hand-spirit-muted-color-05-clay-blue-mint"),
|
|
||||||
("06 粉雾浆果", "taonier-hand-spirit-muted-color-06-powder-berry-cloud"),
|
|
||||||
("07 砂紫奶雾", "taonier-hand-spirit-muted-color-07-sand-violet"),
|
|
||||||
("08 低饱双色", "taonier-hand-spirit-muted-color-08-muted-duotone"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
for candidate in [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem_or_path: str | Path) -> Path | None:
|
|
||||||
if isinstance(stem_or_path, Path):
|
|
||||||
return stem_or_path if stem_or_path.exists() else None
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem_or_path}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_square(image_path: Path) -> Image.Image:
|
|
||||||
image = Image.open(image_path).convert("RGB")
|
|
||||||
if image.size == (1024, 1024):
|
|
||||||
return image
|
|
||||||
if image.width == image.height:
|
|
||||||
normalized = image.resize((1024, 1024), Image.Resampling.LANCZOS)
|
|
||||||
else:
|
|
||||||
contained = ImageOps.contain(image, (1024, 1024), Image.Resampling.LANCZOS)
|
|
||||||
normalized = Image.new("RGB", (1024, 1024), "#fffdf8")
|
|
||||||
x = (1024 - contained.width) // 2
|
|
||||||
y = (1024 - contained.height) // 2
|
|
||||||
normalized.paste(contained, (x, y))
|
|
||||||
if image_path.is_relative_to(OUTPUT_DIR):
|
|
||||||
normalized.save(image_path, quality=95)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 268
|
|
||||||
label_height = 54
|
|
||||||
test_height = 44
|
|
||||||
gap = 22
|
|
||||||
columns = 3
|
|
||||||
rows = 3
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f0ebe5")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(18)
|
|
||||||
test_font = load_font(13)
|
|
||||||
|
|
||||||
for index, (label, stem_or_path) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem_or_path)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = normalize_square(image_path)
|
|
||||||
sheet.paste(source.resize((cell_size, cell_size), Image.Resampling.LANCZOS), (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f7f3ed",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 52, test_y + 6))
|
|
||||||
sheet.paste(mono, (x + 104, test_y + 6))
|
|
||||||
draw.text((x + 150, test_y + 13), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
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-outline-eye-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 exact structural reference. Keep the same icon-only composition: a soft semi-dome spirit above a smooth abstract hand-like support curve.',
|
|
||||||
'Create a cuter, more logo-like refinement. Add a clean outline around the whole symbol, and add two pure matte black dot eyes to the upper semi-dome part only.',
|
|
||||||
'The eyes must belong to the upper half-circle spirit, not the lower support curve. No nose, no mouth, no eyebrows, no blush, no limbs, no character body, no star, no spark, no text.',
|
|
||||||
'Do not redesign the overall structure. Keep the same proportions, centered layout, and soft brand silhouette. Only make it more cute, more memorable, and more trademark-like.',
|
|
||||||
'The outline should feel clean, friendly, and brand-ready, not sticker-like, not cartoonish, not too thick unless the variant asks for it.',
|
|
||||||
'Use the same soft warm palette family as the reference, but allow subtle low-saturation tweaks to improve charm and contrast. Keep it elegant and youthful, not loud.',
|
|
||||||
'Keep 32px readability and black-white viability. It 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-thin-outline-small-eyes',
|
|
||||||
title: '细描边小眼',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: the most restrained cute version. Use a thin warm outline and small matte black dot eyes with calm spacing.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-medium-outline-round-eyes',
|
|
||||||
title: '中描边圆眼',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: medium outline thickness and slightly rounder dot eyes. Make the face read a touch more openly cute, but still minimal.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-bold-outline-higher-eyes',
|
|
||||||
title: '粗描边高眼',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: stronger bold outline and eyes placed a little higher on the upper dome, creating a sweeter peeking expression.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-warm-cocoa-outline',
|
|
||||||
title: '暖可可描边',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: use a warm cocoa or deep beige outline that makes the logo feel softer and more plush, with small centered black eyes.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-compact-avatar-cute',
|
|
||||||
title: '头像可爱款',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: compact avatar readability. Enlarge the upper dome slightly, keep the hand support bold, and make the eyes more visible without adding any mouth.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-black-white-first',
|
|
||||||
title: '黑白优先',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: black-and-white survival first. Make the outline and the eyes work clearly even if all color is removed. Very strong logo readability.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-soft-feminine-cute',
|
|
||||||
title: '柔和少女感',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: a softer feminine-cute version. Keep the outline elegant and the eyes gentle; the whole mark should feel like a friendly brand mascot symbol.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-vector-ready-cute',
|
|
||||||
title: '矢量定稿感',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: designer-ready vector concept. Clean crisp outline, balanced eye spacing, no decorative detail, very easy to trace into an SVG mark.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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-outline-eye-${variant.id}.${image.extension}`,
|
|
||||||
);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(
|
|
||||||
outputDir,
|
|
||||||
'taonier-hand-spirit-outline-eye-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: '在上轮 01 的基础上加入描边和黑点眼睛,让标志更可爱',
|
|
||||||
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',
|
|
||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = (
|
|
||||||
REPO_ROOT / "public" / "branding" / "taonier-logo-hand-spirit-outline-eye-concepts"
|
|
||||||
)
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-hand-spirit-outline-eye-contact-sheet.png"
|
|
||||||
REFERENCE_IMAGE = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-hand-spirit-ref01-logo-refine-concepts"
|
|
||||||
/ "taonier-hand-spirit-ref01-logo-refine-01-flat-coral-cream.png"
|
|
||||||
)
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("REF 上轮01", REFERENCE_IMAGE),
|
|
||||||
("01 细描边小眼", "taonier-hand-spirit-outline-eye-01-thin-outline-small-eyes"),
|
|
||||||
("02 中描边圆眼", "taonier-hand-spirit-outline-eye-02-medium-outline-round-eyes"),
|
|
||||||
("03 粗描边高眼", "taonier-hand-spirit-outline-eye-03-bold-outline-higher-eyes"),
|
|
||||||
("04 暖可可描边", "taonier-hand-spirit-outline-eye-04-warm-cocoa-outline"),
|
|
||||||
("05 头像可爱款", "taonier-hand-spirit-outline-eye-05-compact-avatar-cute"),
|
|
||||||
("06 黑白优先", "taonier-hand-spirit-outline-eye-06-black-white-first"),
|
|
||||||
("07 柔和少女感", "taonier-hand-spirit-outline-eye-07-soft-feminine-cute"),
|
|
||||||
("08 矢量定稿感", "taonier-hand-spirit-outline-eye-08-vector-ready-cute"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
for candidate in [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem_or_path: str | Path) -> Path | None:
|
|
||||||
if isinstance(stem_or_path, Path):
|
|
||||||
return stem_or_path if stem_or_path.exists() else None
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem_or_path}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_square(image_path: Path) -> Image.Image:
|
|
||||||
image = Image.open(image_path).convert("RGB")
|
|
||||||
if image.size == (1024, 1024):
|
|
||||||
return image
|
|
||||||
if image.width == image.height:
|
|
||||||
normalized = image.resize((1024, 1024), Image.Resampling.LANCZOS)
|
|
||||||
else:
|
|
||||||
contained = ImageOps.contain(image, (1024, 1024), Image.Resampling.LANCZOS)
|
|
||||||
normalized = Image.new("RGB", (1024, 1024), "#fffdf8")
|
|
||||||
x = (1024 - contained.width) // 2
|
|
||||||
y = (1024 - contained.height) // 2
|
|
||||||
normalized.paste(contained, (x, y))
|
|
||||||
if image_path.is_relative_to(OUTPUT_DIR):
|
|
||||||
normalized.save(image_path, quality=95)
|
|
||||||
return normalized
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 268
|
|
||||||
label_height = 54
|
|
||||||
test_height = 44
|
|
||||||
gap = 22
|
|
||||||
columns = 3
|
|
||||||
rows = 3
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f0ebe5")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(18)
|
|
||||||
test_font = load_font(13)
|
|
||||||
|
|
||||||
for index, (label, stem_or_path) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem_or_path)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = normalize_square(image_path)
|
|
||||||
sheet.paste(source.resize((cell_size, cell_size), Image.Resampling.LANCZOS), (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f7f3ed",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 52, test_y + 6))
|
|
||||||
sheet.paste(mono, (x + 104, test_y + 6))
|
|
||||||
draw.text((x + 150, test_y + 13), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,491 +0,0 @@
|
|||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-hand-spirit-ref01-logo-refine-concepts"
|
|
||||||
)
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-hand-spirit-ref01-logo-refine-contact-sheet.png"
|
|
||||||
REFERENCE_IMAGE = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-hand-spirit-concepts"
|
|
||||||
/ "taonier-hand-spirit-01-gentle-hand-spirit.png"
|
|
||||||
)
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("REF 原01", REFERENCE_IMAGE),
|
|
||||||
("01 扁平珊瑚奶白", "taonier-hand-spirit-ref01-logo-refine-01-flat-coral-cream"),
|
|
||||||
("02 暖陶精品色", "taonier-hand-spirit-ref01-logo-refine-02-warm-clay-premium"),
|
|
||||||
("03 青绿托举线", "taonier-hand-spirit-ref01-logo-refine-03-mint-support"),
|
|
||||||
("04 线面商标", "taonier-hand-spirit-ref01-logo-refine-04-outline-vector"),
|
|
||||||
("05 双形抽象", "taonier-hand-spirit-ref01-logo-refine-05-abstract-two-shape"),
|
|
||||||
("06 黑白优先", "taonier-hand-spirit-ref01-logo-refine-06-monochrome-first"),
|
|
||||||
("07 轻渐变精品", "taonier-hand-spirit-ref01-logo-refine-07-soft-gradient-premium"),
|
|
||||||
("08 头像强识别", "taonier-hand-spirit-ref01-logo-refine-08-compact-avatar"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem_or_path: str | Path) -> Path | None:
|
|
||||||
if isinstance(stem_or_path, Path):
|
|
||||||
return stem_or_path if stem_or_path.exists() else None
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem_or_path}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def composite_on(image: Image.Image, color: str) -> Image.Image:
|
|
||||||
rgba = image.convert("RGBA")
|
|
||||||
background = Image.new("RGBA", rgba.size, color)
|
|
||||||
background.alpha_composite(rgba)
|
|
||||||
return background.convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_square(image_path: Path) -> Image.Image:
|
|
||||||
image = Image.open(image_path).convert("RGBA")
|
|
||||||
if image.size == (1024, 1024):
|
|
||||||
normalized = image
|
|
||||||
elif image.width == image.height:
|
|
||||||
normalized = image.resize((1024, 1024), Image.Resampling.LANCZOS)
|
|
||||||
else:
|
|
||||||
contained = ImageOps.contain(image, (1024, 1024), Image.Resampling.LANCZOS)
|
|
||||||
normalized = Image.new("RGBA", (1024, 1024), (255, 253, 248, 255))
|
|
||||||
x = (1024 - contained.width) // 2
|
|
||||||
y = (1024 - contained.height) // 2
|
|
||||||
normalized.alpha_composite(contained, (x, y))
|
|
||||||
|
|
||||||
if image_path.is_relative_to(OUTPUT_DIR) and image.size != (1024, 1024):
|
|
||||||
normalized.convert("RGB").save(image_path, quality=95)
|
|
||||||
return composite_on(normalized, "#fffdf8")
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 268
|
|
||||||
label_height = 54
|
|
||||||
test_height = 44
|
|
||||||
gap = 22
|
|
||||||
columns = 3
|
|
||||||
rows = 3
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f0ebe5")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(18)
|
|
||||||
test_font = load_font(13)
|
|
||||||
|
|
||||||
for index, (label, stem_or_path) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem_or_path)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = normalize_square(image_path)
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f7f3ed",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 52, test_y + 6))
|
|
||||||
sheet.paste(mono, (x + 104, test_y + 6))
|
|
||||||
draw.text((x + 150, test_y + 13), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
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-hands-concepts',
|
|
||||||
);
|
|
||||||
const defaultTimeoutMs = 420000;
|
|
||||||
|
|
||||||
const concepts = [
|
|
||||||
{
|
|
||||||
id: 'taonier-hands-v2-cradle',
|
|
||||||
title: '掌心星核',
|
|
||||||
prompt:
|
|
||||||
'无文字扁平矢量 Logo,产品名“陶泥儿”。图形是上下两片抽象软掌,轻轻护住中央小星核,像把脑洞捏成作品。主流 App icon,简单、亲和、醒目、小尺寸清晰。珊瑚红、青绿、奶油白,最多 3 色。不要真实手指、播放三角、聊天气泡、笑脸、眼睛、花朵、褐色、文字、字母、3D。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-hands-v2-clap',
|
|
||||||
title: '合掌成型',
|
|
||||||
prompt:
|
|
||||||
'无文字扁平矢量 Logo,产品名“陶泥儿”。用上下两片圆润软形表现合掌捏合,中间一个小圆点正在成型,表达 AI 把灵感变成小游戏作品。图形完整、现代、亲和、可记忆。珊瑚红、薄荷青、奶白,最多 3 色。不要真实手掌、聊天气泡、播放键、笑脸、眼睛、花朵、褐色、碎元素、3D、文字。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-hands-v2-bowl',
|
|
||||||
title: '软掌托碗',
|
|
||||||
prompt:
|
|
||||||
'无文字扁平矢量 Logo,产品名“陶泥儿”。下方一片软掌像托碗,上方一片小软形轻压,中央浮出小星点,表达轻托脑洞、一捏成型。品牌感、主流、温暖、干净。青绿主形、珊瑚红辅助、奶白中心,最多 3 色。不要眼睛、嘴巴、聊天气泡、播放键、真实手、花朵、褐色、3D、文字。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-hands-v2-seal',
|
|
||||||
title: '双掌印记',
|
|
||||||
prompt:
|
|
||||||
'无文字扁平矢量 Logo,产品名“陶泥儿”。两片抽象软掌上下扣合,形成一个圆润印记,中间留出小星形负空间,像双手捏出的创意标记。简洁、亲和、高识别、适合 App icon。珊瑚红、奶油白、青绿,最多 3 色。不要真实手指、宗教手势、医疗标识、聊天气泡、播放三角、眼睛、花朵、褐色、3D、文字。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-hands-v2-pop',
|
|
||||||
title: '掌心开捏',
|
|
||||||
prompt:
|
|
||||||
'无文字扁平矢量 Logo,产品名“陶泥儿”。上下两片软掌像打开的胶囊,中央小星点从掌心弹出,表达脑洞被捏出来。年轻、亲和、醒目、主流娱乐创作 App 风格。亮珊瑚红、薄荷青、奶白,最多 3 色。不要聊天气泡、播放键、笑脸、眼睛、花朵、真实手指、褐色、碎元素、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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
import { 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-brief-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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: '陶泥儿',
|
|
||||||
logoType: 'symbol/icon-only mark, no wordmark',
|
|
||||||
product:
|
|
||||||
'AI UGC 轻休闲小游戏创作与传播平台,用户像捏陶泥一样把脑洞、梗和灵感塑造成作品',
|
|
||||||
personality: ['亲和', '精品', '创作感', '轻松', '年轻', '有传播记忆点'],
|
|
||||||
mustHave: [
|
|
||||||
'闭合不规则几何底盘',
|
|
||||||
'外轮廓由流畅曲线组成',
|
|
||||||
'整体是一个完整符号而不是自由飘带',
|
|
||||||
'32px 仍能识别',
|
|
||||||
'黑白化后仍成立',
|
|
||||||
'无中文、无英文、无字标',
|
|
||||||
],
|
|
||||||
avoid: [
|
|
||||||
'整体方形或圆角方块',
|
|
||||||
'中心星星或任何星形',
|
|
||||||
'自由飘带、旋涡、S/G 字母感',
|
|
||||||
'巧克力面包、甜点、饼干、糖果等食物感',
|
|
||||||
'砖块、土块、泥饼、陶片、考古印章',
|
|
||||||
'脸、表情、吉祥物、手、工具',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const basePrompt = [
|
|
||||||
'Create an icon-only brand logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.',
|
|
||||||
'Brand personality: friendly, premium, creative, lightweight, young, memorable, suitable for an AI UGC casual game creation platform.',
|
|
||||||
'Core metaphor: users shape imagination like clay. The logo should communicate soft creative material becoming a refined digital product symbol.',
|
|
||||||
'Logo type: abstract symbol/icon only. Not an emblem with text, not a mascot, not an app-icon rounded-square background.',
|
|
||||||
'Main element: one closed irregular geometric base shape made from smooth flowing curves. The outer contour must be closed, continuous, recognizable, organic, and vector-friendly.',
|
|
||||||
'The shape must not be a square, rounded square, circle, ellipse, simple blob, ribbon, swirl, S shape, G shape, open ring, or loose strip. It should feel like a refined custom symbol with 5-7 soft curve turns.',
|
|
||||||
'Internal design: use only 1-2 broad smooth curve partitions or negative-shape cuts to make the mark memorable. No center icon inserted. No star, no spark, no hole shaped like a star.',
|
|
||||||
'Style: modern minimalist vector logo with very subtle matte clay warmth. Clean edges, broad shapes, high contrast, no tiny details. It must look good and recognizable at 32px favicon size.',
|
|
||||||
'Color: warm ceramic white, light terracotta, clay orange, warm brown, with optional small low-saturation teal, indigo-gray, or dark mud gray accent. Avoid sweet candy colors. No glossy highlights.',
|
|
||||||
'Food avoidance is critical: the mark must not look like bread, chocolate bread, croissant, pastry, cookie, candy, donut, cream filling, sauce, baked dough, dessert, or food packaging.',
|
|
||||||
'Material avoidance: do not make it look like brick, dirt clod, mud pie, pottery shard, stone, archaeological stamp, or rough handmade craft class object.',
|
|
||||||
'Composition: centered on a clean light background, generous safe area, no border, no UI, no watermark. Use simple readable silhouette first.',
|
|
||||||
'Validation targets: black-and-white version should still read clearly; the large shape should be recognizable at 32px.',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-closed-curve-mark',
|
|
||||||
title: '闭合曲线标',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: the cleanest closed irregular curve mark. Use two large color areas separated by one smooth internal curve. Maximize 32px readability.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-friendly-geo-seed',
|
|
||||||
title: '亲和几何种',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: a friendly seed-like closed geometric base, but not a literal seed, not food. Rounded and approachable with one teal accent curve.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-premium-soft-contour',
|
|
||||||
title: '精品软轮廓',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: premium, calm, fewer colors. Strong outer contour with a dark mud-gray internal negative curve. Very logo-like, not illustrative.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-playful-closed-tile',
|
|
||||||
title: '轻玩闭合片',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: a more playful closed irregular tile with warm terracotta and ceramic white. The internal curve should suggest creation flow, not filling.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-monochrome-first',
|
|
||||||
title: '黑白优先',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: design as if it will be converted to black and white. Use bold positive and negative shapes; color only supports the structure.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-digital-clay-accent',
|
|
||||||
title: '数字陶泥点',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: include at most two tiny geometric accent dots or notches that imply AI/UGC, but they must not look like candy sprinkles or decorative confetti.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-compact-avatar-symbol',
|
|
||||||
title: '头像紧凑标',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: compact social-avatar readability. The closed contour should be slightly fuller and more iconic, but not a rounded-square app background.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-designer-vector-ready',
|
|
||||||
title: '矢量定稿感',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: make it look like a designer-ready vector concept: 2-3 flat shapes, crisp boundaries, distinctive closed outer contour, minimal material texture.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-logo-brief-${variant.id}.${image.extension}`);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(outputDir, 'taonier-logo-brief-manifest.json');
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
brief: logoBrief,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-brief-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-brief-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 闭合曲线标", "taonier-logo-brief-01-closed-curve-mark"),
|
|
||||||
("02 亲和几何种", "taonier-logo-brief-02-friendly-geo-seed"),
|
|
||||||
("03 精品软轮廓", "taonier-logo-brief-03-premium-soft-contour"),
|
|
||||||
("04 轻玩闭合片", "taonier-logo-brief-04-playful-closed-tile"),
|
|
||||||
("05 黑白优先", "taonier-logo-brief-05-monochrome-first"),
|
|
||||||
("06 数字陶泥点", "taonier-logo-brief-06-digital-clay-accent"),
|
|
||||||
("07 头像紧凑标", "taonier-logo-brief-07-compact-avatar-symbol"),
|
|
||||||
("08 矢量定稿感", "taonier-logo-brief-08-designer-vector-ready"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
test_height = 46
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#ede8de")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(20)
|
|
||||||
test_font = load_font(14)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fbfaf6",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f4f1ea",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 68, test_y + 7))
|
|
||||||
sheet.paste(mono, (x + 122, test_y + 7))
|
|
||||||
draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,107 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-mascot-symbol-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-mascot-symbol-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 抽象人形", "taonier-mascot-symbol-01-clay-humanoid"),
|
|
||||||
("02 陶泥精灵", "taonier-mascot-symbol-02-clay-sprite"),
|
|
||||||
("03 软萌怪物", "taonier-mascot-symbol-03-soft-monster"),
|
|
||||||
("04 抽象动物", "taonier-mascot-symbol-04-animal-abstract"),
|
|
||||||
("05 泥团小灵", "taonier-mascot-symbol-05-clay-orb-being"),
|
|
||||||
("06 轻玩小怪", "taonier-mascot-symbol-06-playful-creature"),
|
|
||||||
("07 头像可读", "taonier-mascot-symbol-07-avatar-readable"),
|
|
||||||
("08 矢量符号感", "taonier-mascot-symbol-08-vector-ready"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
test_height = 46
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f0ebe5")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(20)
|
|
||||||
test_font = load_font(14)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f7f3ed",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 68, test_y + 7))
|
|
||||||
sheet.paste(mono, (x + 122, test_y + 7))
|
|
||||||
draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,448 +0,0 @@
|
|||||||
import { 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-mascot-symbol-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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: '陶泥儿',
|
|
||||||
coreBelief: '好玩会创造',
|
|
||||||
logoType: 'symbol/icon-only mascot mark, no wordmark',
|
|
||||||
product:
|
|
||||||
'AI UGC 轻休闲小游戏创作与传播平台,用户像捏陶泥一样把脑洞、梗和灵感塑造成可分享的作品',
|
|
||||||
direction: '抽象吉祥物符号,从人形、精灵、怪物、动物等形态提炼',
|
|
||||||
audience: '女性用户友好、全年龄向、年轻明亮但不低幼',
|
|
||||||
mascotRules: [
|
|
||||||
'必须是 logo 符号级别,不是完整角色立绘',
|
|
||||||
'轮廓要有记忆点,32px 可读',
|
|
||||||
'表情最多极简两点或无表情',
|
|
||||||
'身体结构要高度抽象、可矢量化',
|
|
||||||
'保留一点陶泥被捏出的柔软感',
|
|
||||||
],
|
|
||||||
avoid: [
|
|
||||||
'复杂角色',
|
|
||||||
'儿童玩具感',
|
|
||||||
'怪物恐怖感',
|
|
||||||
'真实动物',
|
|
||||||
'食品感',
|
|
||||||
'文字',
|
|
||||||
'星星',
|
|
||||||
'写实泥土纹理',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const basePrompt = [
|
|
||||||
'Create an icon-only mascot logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.',
|
|
||||||
'Brand core belief: fun creation. Users can turn imagination, memes, and ideas into playable AI/UGC casual game works and share them with others.',
|
|
||||||
'Logo type: abstract mascot symbol only. It must be a simple brand mark, not a full character illustration, not a scene, not a sticker sheet, not an app-icon rounded-square background.',
|
|
||||||
'Mascot meaning: a soft clay-born little being that represents playful creation, shareable ideas, and a friendly AI/UGC companion.',
|
|
||||||
'Style: modern minimalist vector mascot mark, compact silhouette, broad simple shapes, fresh bright palette, very light clay warmth only. It must look good and recognizable at 32px favicon size.',
|
|
||||||
'Character abstraction: use a highly simplified head/body silhouette or creature glyph. No detailed anatomy. No clothing. No props. No complex limbs. Optional face should be extremely minimal, at most two small eyes; no mouth unless it is a tiny simple mark.',
|
|
||||||
'Shape language: soft, rounded, slightly irregular, as if gently pinched from clay, but clean and designer-ready. Friendly and memorable, not childish.',
|
|
||||||
'Color direction: women-friendly and all-age fresh palette: coral orange, peach pink, cream white, clear soft teal or mint green. Use flat brand-color shapes, not candy rendering.',
|
|
||||||
'Avoid realistic clay texture, dirty clay, brick, pottery shard, mud pie, rough craft class object, and archaeology stamp feeling.',
|
|
||||||
'Food avoidance: do not make it look like bread, mochi, dumpling, cake, cookie, candy, jelly, donut, dessert, or food packaging.',
|
|
||||||
'Avoid scary monster, aggressive teeth, claws, realistic animal, pet-shop icon, emoji face, toy mascot, chibi over-detailing, or generic blob.',
|
|
||||||
'No star, no spark, no halo, no magic wand, no hand, no pottery tool, no UI, no border, no watermark.',
|
|
||||||
'Composition: centered on a clean light background, generous safe area. Use simple readable silhouette first.',
|
|
||||||
'Validation targets: black-and-white version should still read clearly; the mascot silhouette should be recognizable at 32px.',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-clay-humanoid',
|
|
||||||
title: '抽象人形',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: abstract humanoid mascot. A tiny soft clay person-like glyph with rounded head and merged body, no limbs or very minimal arms, friendly but not childish.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-clay-sprite',
|
|
||||||
title: '陶泥精灵',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: clay sprite. A small semi-dome spirit with a gentle lifted silhouette, like a friendly creative helper, no wings, no magic stars, no fantasy clutter.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-soft-monster',
|
|
||||||
title: '软萌怪物',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: soft friendly monster glyph. Cute but not scary, no teeth, no claws, one distinctive head shape, perhaps tiny horn-like soft bumps but not devilish.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-animal-abstract',
|
|
||||||
title: '抽象动物',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: abstract animal-like mascot. Suggest a small rounded creature through ears or tail-like curves, but not a specific real animal and not pet logo.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-clay-orb-being',
|
|
||||||
title: '泥团小灵',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: orb-like clay being. A simple irregular rounded body with minimal face or no face, strong silhouette, playful creation companion.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-playful-creature',
|
|
||||||
title: '轻玩小怪',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: more playful creature mark. Dynamic but compact, one distinctive asymmetric curve, readable at 32px, still premium and not a toy brand.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-avatar-readable',
|
|
||||||
title: '头像可读',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: social avatar and favicon readability. Bold compact mascot head/body silhouette, minimal inner detail, high black-and-white clarity.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-vector-ready',
|
|
||||||
title: '矢量符号感',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: designer-ready vector mascot concept. 2-3 flat shapes, crisp boundaries, distinctive silhouette, minimal material cue, no illustration shading.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-mascot-symbol-${variant.id}.${image.extension}`);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(outputDir, 'taonier-logo-mascot-symbol-manifest.json');
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
brief: logoBrief,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-pair-ears-jar-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-pair-ears-jar-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 兔耳陶罐", "taonier-pair-ears-jar-01-rabbit-jar"),
|
|
||||||
("02 猫耳陶罐", "taonier-pair-ears-jar-02-cat-jar"),
|
|
||||||
("03 狐耳陶罐", "taonier-pair-ears-jar-03-fox-jar"),
|
|
||||||
("04 熊耳陶罐", "taonier-pair-ears-jar-04-bear-jar"),
|
|
||||||
("05 狗耳陶罐", "taonier-pair-ears-jar-05-dog-jar"),
|
|
||||||
("06 双耳组合", "taonier-pair-ears-jar-06-dual-ears"),
|
|
||||||
("07 高罐长耳", "taonier-pair-ears-jar-07-tall-jar"),
|
|
||||||
("08 商标定稿感", "taonier-pair-ears-jar-08-jar-mark"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
test_height = 46
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f0ebe5")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(20)
|
|
||||||
test_font = load_font(14)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f7f3ed",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 68, test_y + 7))
|
|
||||||
sheet.paste(mono, (x + 122, test_y + 7))
|
|
||||||
draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,450 +0,0 @@
|
|||||||
import { 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-pair-ears-jar-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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: '陶泥儿',
|
|
||||||
coreBelief: '好玩会创造',
|
|
||||||
logoType: 'symbol/icon-only mascot mark, no wordmark',
|
|
||||||
product:
|
|
||||||
'AI UGC 轻休闲小游戏创作与传播平台,用户把脑洞、梗和灵感塑造成可分享的作品',
|
|
||||||
direction:
|
|
||||||
'陶罐容器 + 只露出动物耳朵的小灵体,神秘又可爱,罐子无表情',
|
|
||||||
audience: '女性用户友好、全年龄向、年轻明亮但不低幼',
|
|
||||||
structureRules: [
|
|
||||||
'主形体是一个陶罐或陶罐容器,强调器皿感和包裹感',
|
|
||||||
'罐中只露出耳朵,不露完整脸部,不露完整身体',
|
|
||||||
'耳朵可以是兔、猫、狐狸、熊、狗等动物耳朵,但只能露耳朵',
|
|
||||||
'罐子可以带短手短脚,但不是必须;若有,也要极简抽象',
|
|
||||||
'整体必须是 logo 符号级别,不是完整插画角色',
|
|
||||||
],
|
|
||||||
avoid: [
|
|
||||||
'中文或英文字',
|
|
||||||
'表情元素',
|
|
||||||
'星星或闪光',
|
|
||||||
'手托举元素',
|
|
||||||
'写实陶瓷高光',
|
|
||||||
'脏泥土或砖块',
|
|
||||||
'面团、汤圆、甜点、面包、巧克力、糖果、布丁',
|
|
||||||
'完整动物脸',
|
|
||||||
'恐怖怪物、牙齿、爪子',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const basePrompt = [
|
|
||||||
'Create an icon-only mascot logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.',
|
|
||||||
'Core idea: a ceramic jar container with only animal ears peeking out from inside. The jar should feel mysterious and cute at the same time. Do not show a full face or full body, only ears.',
|
|
||||||
'Brand core: fun creation. The logo should feel like a tiny clay-born creative spirit that can hold playful ideas, memes, and shareable works.',
|
|
||||||
'Logo type: simple mascot symbol only. It must be a brand mark, not a full character illustration, not a scene, not a sticker, not a rounded-square app icon background.',
|
|
||||||
'Main structure: a closed or mostly closed ceramic jar, pot, or vessel with a clear rim and body silhouette. The jar should read as a container first, not as a cup, vase, bowl, bottle, or food package.',
|
|
||||||
'Hidden animal presence: only the ears emerge from the jar opening or upper body. The ears may be rabbit, cat, fox, bear, or dog ears depending on the variant, but no face, no eyes, no nose, no mouth, no full head. The ears must feel playful and alive, but still simple and logo-friendly.',
|
|
||||||
'Optional tiny feet or short hand nubs are allowed only if they help the silhouette; keep them extremely abstract and minimal.',
|
|
||||||
'Style: modern minimalist vector mascot logo, clean curves, flat brand-color shapes, premium but warm. Slight clay warmth through color and form only; no realistic texture.',
|
|
||||||
'Jar color direction: low-saturation ceramic tones such as beige, sand, warm gray, muted terracotta, pale taupe, clay pink, or smoky cream. Ear color direction: use natural animal colors that fit the variant, but keep them soft and not neon.',
|
|
||||||
'Avoid realistic ceramic glaze, muddy pottery, rough handmade class object, 3D render, ceramic shine, brick, food-like container, toy feeling, chibi over-detailing, or generic blob.',
|
|
||||||
'Food avoidance is critical: do not make it look like bread, mochi, dumpling, bun, cake, cookie, candy, pudding, chocolate, jelly, or dessert packaging.',
|
|
||||||
'Avoid any jar face or expression. No eyes, no smile, no blush, no emoji marks on the jar.',
|
|
||||||
'Avoid star, spark, halo, magic wand, hand, pottery tool, UI, border, or watermark.',
|
|
||||||
'Composition: centered on a clean light background with generous safe area. Strong readable silhouette first. Must still read at 32px and in black and white.',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-rabbit-jar',
|
|
||||||
title: '兔耳陶罐',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: rabbit ears. Long soft rabbit ears rise from the jar opening with a gentle curve, while the jar remains compact and premium.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-cat-jar',
|
|
||||||
title: '猫耳陶罐',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: cat ears. Small pointed cat ears peek from the jar opening, giving a slightly sly but still very cute feeling.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-fox-jar',
|
|
||||||
title: '狐耳陶罐',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: fox ears. Slender fox-like ears with a warm orange accent, a little more clever and playful than the rabbit version.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-bear-jar',
|
|
||||||
title: '熊耳陶罐',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: bear ears. Two small rounded bear ears emerging from the top, very soft and sleepy, with a sturdy jar silhouette.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-dog-jar',
|
|
||||||
title: '狗耳陶罐',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: dog ears. Slightly floppy dog ears peeking from the vessel, friendly and lively, but still only ears, no face.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-dual-ears',
|
|
||||||
title: '双耳组合',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: two different ear shapes on one jar, such as one rabbit ear and one cat ear, but still harmonized into a single mascot symbol.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-tall-jar',
|
|
||||||
title: '高罐长耳',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: taller jar silhouette with more vertical ears, so the ear read is clearer at favicon size and the vessel feels more iconic.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-jar-mark',
|
|
||||||
title: '商标定稿感',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: strongest trademark readability. Use a compact jar silhouette, very simple ears, minimal details, excellent black-and-white legibility.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-pair-ears-jar-${variant.id}.${image.extension}`);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(outputDir, 'taonier-logo-pair-ears-jar-manifest.json');
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
brief: logoBrief,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-peeking-head-jar-blackdot-eye-concepts"
|
|
||||||
)
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-peeking-head-jar-blackdot-eye-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 陶粉兔头", "taonier-peeking-head-jar-blackdot-eye-01-clay-rabbit"),
|
|
||||||
("02 灰陶猫头", "taonier-peeking-head-jar-blackdot-eye-02-ash-cat"),
|
|
||||||
("03 陶红狐头", "taonier-peeking-head-jar-blackdot-eye-03-terracotta-fox"),
|
|
||||||
("04 横纹熊头", "taonier-peeking-head-jar-blackdot-eye-04-striped-bear"),
|
|
||||||
("05 长颈狗头", "taonier-peeking-head-jar-blackdot-eye-05-long-neck-dog"),
|
|
||||||
("06 低矮鼠头", "taonier-peeking-head-jar-blackdot-eye-06-low-mouse"),
|
|
||||||
("07 偏心鹿头", "taonier-peeking-head-jar-blackdot-eye-07-asym-deer"),
|
|
||||||
("08 紧凑熊猫", "taonier-peeking-head-jar-blackdot-eye-08-compact-panda"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
test_height = 46
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f0ebe5")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(20)
|
|
||||||
test_font = load_font(14)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f7f3ed",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 68, test_y + 7))
|
|
||||||
sheet.paste(mono, (x + 122, test_y + 7))
|
|
||||||
draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
import { 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-peeking-head-jar-blackdot-eye-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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: '陶泥儿',
|
|
||||||
coreBelief: '好玩会创造',
|
|
||||||
logoType: 'symbol/icon-only mascot mark, no wordmark',
|
|
||||||
product:
|
|
||||||
'AI UGC 轻休闲小游戏创作与传播平台,用户把脑洞、梗和灵感塑造成可分享的作品',
|
|
||||||
direction:
|
|
||||||
'在保持“半头探出”的节奏下继续拓展,但眼睛必须是纯黑点无高光',
|
|
||||||
audience: '女性用户友好、全年龄向、年轻明亮但不低幼',
|
|
||||||
structureRules: [
|
|
||||||
'主形体仍然是陶罐容器,罐子负责陶器和包裹感',
|
|
||||||
'动物只露出耳朵、上半个脑袋和两只黑点眼睛',
|
|
||||||
'眼睛不能有高光、不能有白点反光、不能有玻璃感',
|
|
||||||
'不露鼻子、嘴巴、身体、爪子或完整动物脸',
|
|
||||||
'罐子绝对不能有表情元素',
|
|
||||||
],
|
|
||||||
avoid: [
|
|
||||||
'中文或英文字',
|
|
||||||
'罐子表情',
|
|
||||||
'动物嘴巴或鼻子',
|
|
||||||
'眼睛高光',
|
|
||||||
'白眼球高光',
|
|
||||||
'星星或闪光',
|
|
||||||
'手托举元素',
|
|
||||||
'写实陶瓷高光',
|
|
||||||
'脏泥土或砖块',
|
|
||||||
'面团、汤圆、甜点、面包、巧克力、糖果、布丁',
|
|
||||||
'完整动物身体',
|
|
||||||
'恐怖怪物、牙齿、爪子',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const basePrompt = [
|
|
||||||
'Create an icon-only mascot logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.',
|
|
||||||
'Core idea: a ceramic jar container with a small animal peeking out only to eye level. Show the animal ears, the upper half of the head, and two simple eyes. Do not show nose, mouth, lower face, paws, body, or full head.',
|
|
||||||
'The jar must remain the main brand shape. The animal head should feel hidden inside the jar, as if a playful idea is peeking out.',
|
|
||||||
'Brand core: fun creation. The logo should feel like a tiny clay-born creative spirit that can hold playful ideas, memes, and shareable works.',
|
|
||||||
'Logo type: simple mascot symbol only. It must be a brand mark, not a full character illustration, not a scene, not a sticker, not a rounded-square app icon background.',
|
|
||||||
'Main structure: a closed or mostly closed ceramic jar, pot, or vessel with a clear rim and body silhouette. The jar should read as a container first, not as a cup, vase, bowl, bottle, or food package.',
|
|
||||||
'Animal visibility: show only ears, top half of head, and two eye dots above or behind the jar rim. The rim may partially cover the lower half of the animal head. No animal mouth, no nose, no cheek blush, no paws, no body.',
|
|
||||||
'Eye policy: the eyes must be pure black dots. No highlights, no glossy sparkles, no white reflective spots, no glassy pupils, no shiny toy eyes.',
|
|
||||||
'Jar policy: the jar itself has no face or expression. No eyes, no smile, no blush, no emoji marks on the jar.',
|
|
||||||
'Style: modern minimalist vector mascot logo, clean curves, flat brand-color shapes, premium but warm. Slight clay warmth through color and form only; no realistic texture.',
|
|
||||||
'Jar color direction: low-saturation ceramic tones such as beige, sand, warm gray, muted terracotta, pale taupe, clay pink, smoky cream, or cool ash clay. Each variant should shift the jar silhouette and color slightly.',
|
|
||||||
'Animal color direction: use soft natural animal colors that fit the species, but keep them gentle and logo-friendly. The animal head should be more alive and colorful than the jar.',
|
|
||||||
'Avoid realistic ceramic glaze, muddy pottery, rough handmade class object, 3D render, ceramic shine, brick, food-like container, toy feeling, chibi over-detailing, or generic blob.',
|
|
||||||
'Food avoidance is critical: do not make it look like bread, mochi, dumpling, bun, cake, cookie, candy, pudding, chocolate, jelly, or dessert packaging.',
|
|
||||||
'Avoid star, spark, halo, magic wand, hand, pottery tool, UI, border, or watermark.',
|
|
||||||
'Composition: centered on a clean light background with generous safe area. Strong readable silhouette first. Must still read at 32px and in black and white.',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-clay-rabbit',
|
|
||||||
title: '陶粉兔头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: tall slim clay jar with rabbit ears and a cream rabbit head. Use pale clay beige jar and soft peach ear interiors, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-ash-cat',
|
|
||||||
title: '灰陶猫头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: squat ash-clay jar with cat ears and a gray-white cat head. Use muted ash beige jar and compact triangular ears, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-terracotta-fox',
|
|
||||||
title: '陶红狐头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: flared terracotta jar with fox ears and orange fox head. Use muted terracotta jar, cream face area, and warm orange ear tips, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-striped-bear',
|
|
||||||
title: '横纹熊头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: jar with subtle ceramic stripe bands, bear ears, and a cocoa-brown bear head. The eyes remain black dots only, no extra facial marks.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-long-neck-dog',
|
|
||||||
title: '长颈狗头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: long-neck jar with floppy dog ears and a tan dog head. Use a soft gray-beige jar and warm tan ears, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-low-mouse',
|
|
||||||
title: '低矮鼠头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: low wide jar with mouse ears and a pale gray mouse head. Add tiny pink ear interiors, eyes are black dots only, cute and slightly mischievous.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-asym-deer',
|
|
||||||
title: '偏心鹿头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: slightly asymmetrical jar with deer ears and a soft brown deer head. Keep the ears upright and slender, with a calm cream-beige jar, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-compact-panda',
|
|
||||||
title: '紧凑熊猫',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: compact trademark jar with panda ears and a black-and-cream panda head. Keep the silhouette bold and simple for strongest brand recognition, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-peeking-head-jar-blackdot-eye-${variant.id}.${image.extension}`,
|
|
||||||
);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(
|
|
||||||
outputDir,
|
|
||||||
'taonier-logo-peeking-head-jar-blackdot-eye-manifest.json',
|
|
||||||
);
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
brief: logoBrief,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-peeking-head-jar-broad-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-peeking-head-jar-broad-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 橄榄兔头", "taonier-peeking-head-jar-broad-01-olive-rabbit"),
|
|
||||||
("02 砂陶猫头", "taonier-peeking-head-jar-broad-02-sand-cat"),
|
|
||||||
("03 杏陶狐头", "taonier-peeking-head-jar-broad-03-apricot-fox"),
|
|
||||||
("04 双带熊头", "taonier-peeking-head-jar-broad-04-banded-bear"),
|
|
||||||
("05 细颈狗头", "taonier-peeking-head-jar-broad-05-necked-dog"),
|
|
||||||
("06 扁罐鼠头", "taonier-peeking-head-jar-broad-06-flat-mouse"),
|
|
||||||
("07 斜肩鹿头", "taonier-peeking-head-jar-broad-07-tilted-deer"),
|
|
||||||
("08 紧凑熊猫", "taonier-peeking-head-jar-broad-08-compact-panda"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
test_height = 46
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f0ebe5")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(20)
|
|
||||||
test_font = load_font(14)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f7f3ed",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 68, test_y + 7))
|
|
||||||
sheet.paste(mono, (x + 122, test_y + 7))
|
|
||||||
draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
import { 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-peeking-head-jar-broad-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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: '陶泥儿',
|
|
||||||
coreBelief: '好玩会创造',
|
|
||||||
logoType: 'symbol/icon-only mascot mark, no wordmark',
|
|
||||||
product:
|
|
||||||
'AI UGC 轻休闲小游戏创作与传播平台,用户把脑洞、梗和灵感塑造成可分享的作品',
|
|
||||||
direction:
|
|
||||||
'保持“半头探出”节奏,继续拓展更丰富的罐型、配色和动物类别,眼睛仍是黑点',
|
|
||||||
audience: '女性用户友好、全年龄向、年轻明亮但不低幼',
|
|
||||||
structureRules: [
|
|
||||||
'主形体仍然是陶罐容器,罐子负责陶器和包裹感',
|
|
||||||
'动物只露出耳朵、上半个脑袋和两只黑点眼睛',
|
|
||||||
'眼睛不能有高光、不能有白点反光、不能有玻璃感',
|
|
||||||
'不露鼻子、嘴巴、身体、爪子或完整动物脸',
|
|
||||||
'罐子绝对不能有表情元素',
|
|
||||||
],
|
|
||||||
avoid: [
|
|
||||||
'中文或英文字',
|
|
||||||
'罐子表情',
|
|
||||||
'动物嘴巴或鼻子',
|
|
||||||
'眼睛高光',
|
|
||||||
'白眼球高光',
|
|
||||||
'星星或闪光',
|
|
||||||
'手托举元素',
|
|
||||||
'写实陶瓷高光',
|
|
||||||
'脏泥土或砖块',
|
|
||||||
'面团、汤圆、甜点、面包、巧克力、糖果、布丁',
|
|
||||||
'完整动物身体',
|
|
||||||
'恐怖怪物、牙齿、爪子',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const basePrompt = [
|
|
||||||
'Create an icon-only mascot logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.',
|
|
||||||
'Core idea: a ceramic jar container with a small animal peeking out only to eye level. Show the animal ears, the upper half of the head, and two simple black-dot eyes. Do not show nose, mouth, lower face, paws, body, or full head.',
|
|
||||||
'The jar must remain the main brand shape. The animal head should feel hidden inside the jar, as if a playful idea is peeking out.',
|
|
||||||
'Brand core: fun creation. The logo should feel like a tiny clay-born creative spirit that can hold playful ideas, memes, and shareable works.',
|
|
||||||
'Logo type: simple mascot symbol only. It must be a brand mark, not a full character illustration, not a scene, not a sticker, not a rounded-square app icon background.',
|
|
||||||
'Main structure: a closed or mostly closed ceramic jar, pot, or vessel with a clear rim and body silhouette. The jar should read as a container first, not as a cup, vase, bowl, bottle, or food package.',
|
|
||||||
'Animal visibility: show only ears, top half of head, and two solid black eye dots above or behind the jar rim. The rim may partially cover the lower half of the animal head. No animal mouth, no nose, no cheek blush, no paws, no body.',
|
|
||||||
'Eye policy: the eyes must be pure matte black dots. No highlights, no glossy sparkles, no white reflective spots, no glassy pupils, no shiny toy eyes.',
|
|
||||||
'Jar policy: the jar itself has no face or expression. No eyes, no smile, no blush, no emoji marks on the jar.',
|
|
||||||
'Style: modern minimalist vector mascot logo, clean curves, flat brand-color shapes, premium but warm. Slight clay warmth through color and form only; no realistic texture.',
|
|
||||||
'Jar color direction: low-saturation ceramic tones such as beige, sand, warm gray, muted terracotta, pale taupe, clay pink, smoky cream, cool ash clay, or muted olive clay. Each variant should shift the jar silhouette and color slightly.',
|
|
||||||
'Animal color direction: use soft natural animal colors that fit the species, but keep them gentle and logo-friendly. The animal head should be more alive and colorful than the jar.',
|
|
||||||
'Avoid realistic ceramic glaze, muddy pottery, rough handmade class object, 3D render, ceramic shine, brick, food-like container, toy feeling, chibi over-detailing, or generic blob.',
|
|
||||||
'Food avoidance is critical: do not make it look like bread, mochi, dumpling, bun, cake, cookie, candy, pudding, chocolate, jelly, or dessert packaging.',
|
|
||||||
'Avoid star, spark, halo, magic wand, hand, pottery tool, UI, border, or watermark.',
|
|
||||||
'Composition: centered on a clean light background with generous safe area. Strong readable silhouette first. Must still read at 32px and in black and white.',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-olive-rabbit',
|
|
||||||
title: '橄榄兔头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: tall slim jar with a muted olive-clay body and rabbit ears. The rabbit head is cream colored with soft peach inner ears, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-sand-cat',
|
|
||||||
title: '砂陶猫头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: squat sand-colored jar with cat ears and a gray-white cat head. Make the rim compact and the body broad, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-apricot-fox',
|
|
||||||
title: '杏陶狐头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: flared apricot-terracotta jar with fox ears and a warm orange fox head. Use a cream face area and strong ear silhouette, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-banded-bear',
|
|
||||||
title: '双带熊头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: jar with two subtle ceramic bands, bear ears, and a cocoa-brown bear head. Keep the vessel sturdy and broad, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-necked-dog',
|
|
||||||
title: '细颈狗头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: tall narrow-neck jar with floppy dog ears and a tan dog head. Use a warm gray-beige jar and slightly longer ear shapes, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-flat-mouse',
|
|
||||||
title: '扁罐鼠头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: low flat jar with mouse ears and a pale gray mouse head. Add tiny pink ear interiors and a wider mouth rim, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-tilted-deer',
|
|
||||||
title: '斜肩鹿头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: slightly tilted jar with deer ears and a soft brown deer head. Use a calm cream-beige jar with a subtle shoulder shift, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-compact-panda',
|
|
||||||
title: '紧凑熊猫',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: compact trademark jar with panda ears and a black-and-cream panda head. Keep the silhouette bold, simple, and easy to read at 32px, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-peeking-head-jar-broad-${variant.id}.${image.extension}`,
|
|
||||||
);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(
|
|
||||||
outputDir,
|
|
||||||
'taonier-logo-peeking-head-jar-broad-manifest.json',
|
|
||||||
);
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
brief: logoBrief,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-peeking-head-jar-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-peeking-head-jar-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 兔头探出", "taonier-peeking-head-jar-01-rabbit-peek"),
|
|
||||||
("02 猫头探出", "taonier-peeking-head-jar-02-cat-peek"),
|
|
||||||
("03 狐头探出", "taonier-peeking-head-jar-03-fox-peek"),
|
|
||||||
("04 熊头探出", "taonier-peeking-head-jar-04-bear-peek"),
|
|
||||||
("05 狗头探出", "taonier-peeking-head-jar-05-dog-peek"),
|
|
||||||
("06 混合小灵", "taonier-peeking-head-jar-06-mixed-peek"),
|
|
||||||
("07 高罐探头", "taonier-peeking-head-jar-07-tall-peek"),
|
|
||||||
("08 商标探头", "taonier-peeking-head-jar-08-trademark-peek"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
test_height = 46
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f0ebe5")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(20)
|
|
||||||
test_font = load_font(14)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f7f3ed",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 68, test_y + 7))
|
|
||||||
sheet.paste(mono, (x + 122, test_y + 7))
|
|
||||||
draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-peeking-head-jar-expanded-concepts"
|
|
||||||
)
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-peeking-head-jar-expanded-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 高罐兔耳", "taonier-peeking-head-jar-expanded-01-tall-rabbit"),
|
|
||||||
("02 矮罐猫头", "taonier-peeking-head-jar-expanded-02-squat-cat"),
|
|
||||||
("03 阔口狐头", "taonier-peeking-head-jar-expanded-03-flared-fox"),
|
|
||||||
("04 双圈熊头", "taonier-peeking-head-jar-expanded-04-double-band-bear"),
|
|
||||||
("05 长颈狗头", "taonier-peeking-head-jar-expanded-05-long-neck-dog"),
|
|
||||||
("06 低矮鼠头", "taonier-peeking-head-jar-expanded-06-low-mouse"),
|
|
||||||
("07 偏心鹿头", "taonier-peeking-head-jar-expanded-07-asym-deer"),
|
|
||||||
("08 紧凑熊猫", "taonier-peeking-head-jar-expanded-08-compact-panda"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
test_height = 46
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f0ebe5")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(20)
|
|
||||||
test_font = load_font(14)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f7f3ed",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 68, test_y + 7))
|
|
||||||
sheet.paste(mono, (x + 122, test_y + 7))
|
|
||||||
draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,461 +0,0 @@
|
|||||||
import { 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-peeking-head-jar-expanded-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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: '陶泥儿',
|
|
||||||
coreBelief: '好玩会创造',
|
|
||||||
logoType: 'symbol/icon-only mascot mark, no wordmark',
|
|
||||||
product:
|
|
||||||
'AI UGC 轻休闲小游戏创作与传播平台,用户把脑洞、梗和灵感塑造成可分享的作品',
|
|
||||||
direction:
|
|
||||||
'在保持“半头探出”的节奏下,拓展罐型、口沿、底座、动物原色和耳型搭配',
|
|
||||||
audience: '女性用户友好、全年龄向、年轻明亮但不低幼',
|
|
||||||
structureRules: [
|
|
||||||
'主形体仍然是陶罐容器,罐子负责陶器和包裹感',
|
|
||||||
'动物只露出耳朵、上半个脑袋和两只眼睛',
|
|
||||||
'不露鼻子、嘴巴、身体、爪子或完整动物脸',
|
|
||||||
'罐子绝对不能有表情元素',
|
|
||||||
'整体必须是 logo 符号级别,不是完整插画角色',
|
|
||||||
],
|
|
||||||
avoid: [
|
|
||||||
'中文或英文字',
|
|
||||||
'罐子表情',
|
|
||||||
'动物嘴巴或鼻子',
|
|
||||||
'星星或闪光',
|
|
||||||
'手托举元素',
|
|
||||||
'写实陶瓷高光',
|
|
||||||
'脏泥土或砖块',
|
|
||||||
'面团、汤圆、甜点、面包、巧克力、糖果、布丁',
|
|
||||||
'完整动物身体',
|
|
||||||
'恐怖怪物、牙齿、爪子',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const basePrompt = [
|
|
||||||
'Create an icon-only mascot logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.',
|
|
||||||
'Core idea: a ceramic jar container with a small animal peeking out only to eye level. Show the animal ears, the upper half of the head, and two simple eyes. Do not show nose, mouth, lower face, paws, body, or full head.',
|
|
||||||
'The jar must remain the main brand shape. The animal head should feel hidden inside the jar, as if a playful idea is peeking out.',
|
|
||||||
'Brand core: fun creation. The logo should feel like a tiny clay-born creative spirit that can hold playful ideas, memes, and shareable works.',
|
|
||||||
'Logo type: simple mascot symbol only. It must be a brand mark, not a full character illustration, not a scene, not a sticker, not a rounded-square app icon background.',
|
|
||||||
'Main structure: a closed or mostly closed ceramic jar, pot, or vessel with a clear rim and body silhouette. The jar should read as a container first, not as a cup, vase, bowl, bottle, or food package.',
|
|
||||||
'Animal visibility: show only ears, top half of head, and two eye dots above or behind the jar rim. The rim may partially cover the lower half of the animal head. No animal mouth, no nose, no cheek blush, no paws, no body.',
|
|
||||||
'Jar policy: the jar itself has no face or expression. No eyes, no smile, no blush, no emoji marks on the jar.',
|
|
||||||
'Style: modern minimalist vector mascot logo, clean curves, flat brand-color shapes, premium but warm. Slight clay warmth through color and form only; no realistic texture.',
|
|
||||||
'Jar color direction: low-saturation ceramic tones such as beige, sand, warm gray, muted terracotta, pale taupe, clay pink, smoky cream, or cool ash clay. Each variant should shift the jar silhouette and color slightly.',
|
|
||||||
'Animal color direction: use soft natural animal colors that fit the species, but keep them gentle and logo-friendly. The animal head should be more alive and colorful than the jar.',
|
|
||||||
'Avoid realistic ceramic glaze, muddy pottery, rough handmade class object, 3D render, ceramic shine, brick, food-like container, toy feeling, chibi over-detailing, or generic blob.',
|
|
||||||
'Food avoidance is critical: do not make it look like bread, mochi, dumpling, bun, cake, cookie, candy, pudding, chocolate, jelly, or dessert packaging.',
|
|
||||||
'Avoid star, spark, halo, magic wand, hand, pottery tool, UI, border, or watermark.',
|
|
||||||
'Composition: centered on a clean light background with generous safe area. Strong readable silhouette first. Must still read at 32px and in black and white.',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-tall-rabbit',
|
|
||||||
title: '高罐兔耳',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: tall slim jar with soft rabbit ears and a cream rabbit head peeking out. Use pale beige jar and soft peach inner ears, elegant and light.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-squat-cat',
|
|
||||||
title: '矮罐猫头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: squat round jar with cat ears and a gray-white cat head. Use a warmer taupe jar and small triangular ears, compact and cozy.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-flared-fox',
|
|
||||||
title: '阔口狐头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: flared-rim jar with fox ears and orange fox head. Use muted terracotta jar, cream face area, and warm orange ear tips.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-double-band-bear',
|
|
||||||
title: '双圈熊头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: double-band jar with bear ears and a cocoa-brown bear head. The jar can have two subtle horizontal rings for ceramic rhythm.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-long-neck-dog',
|
|
||||||
title: '长颈狗头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: long-neck jar with floppy dog ears and a tan dog head. Use a soft gray-beige jar and warm tan ears, friendly and upright.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-low-mouse',
|
|
||||||
title: '低矮鼠头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: low wide jar with mouse ears and a pale gray mouse head. Add tiny pink ear interiors, cute and slightly mischievous.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-asym-deer',
|
|
||||||
title: '偏心鹿头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: slightly asymmetrical jar with deer ears and a soft brown deer head. Keep the ears upright and slender, with a calm cream-beige jar.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-compact-panda',
|
|
||||||
title: '紧凑熊猫',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: compact trademark jar with panda ears and a black-and-cream panda head. Keep the silhouette bold and simple for strongest brand recognition.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-peeking-head-jar-expanded-${variant.id}.${image.extension}`,
|
|
||||||
);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(
|
|
||||||
outputDir,
|
|
||||||
'taonier-logo-peeking-head-jar-expanded-manifest.json',
|
|
||||||
);
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
brief: logoBrief,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,452 +0,0 @@
|
|||||||
import { 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-peeking-head-jar-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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: '陶泥儿',
|
|
||||||
coreBelief: '好玩会创造',
|
|
||||||
logoType: 'symbol/icon-only mascot mark, no wordmark',
|
|
||||||
product:
|
|
||||||
'AI UGC 轻休闲小游戏创作与传播平台,用户把脑洞、梗和灵感塑造成可分享的作品',
|
|
||||||
direction:
|
|
||||||
'陶罐容器 + 小动物从罐中露出耳朵、半个脑袋和眼睛,神秘又可爱',
|
|
||||||
audience: '女性用户友好、全年龄向、年轻明亮但不低幼',
|
|
||||||
structureRules: [
|
|
||||||
'主形体仍然是陶罐容器,罐子负责陶器和包裹感',
|
|
||||||
'动物只露出耳朵、上半个脑袋和两只眼睛',
|
|
||||||
'不露鼻子、嘴巴、身体、爪子或完整动物脸',
|
|
||||||
'罐子绝对不能有表情元素',
|
|
||||||
'整体必须是 logo 符号级别,不是完整插画角色',
|
|
||||||
],
|
|
||||||
avoid: [
|
|
||||||
'中文或英文字',
|
|
||||||
'罐子表情',
|
|
||||||
'动物嘴巴或鼻子',
|
|
||||||
'星星或闪光',
|
|
||||||
'手托举元素',
|
|
||||||
'写实陶瓷高光',
|
|
||||||
'脏泥土或砖块',
|
|
||||||
'面团、汤圆、甜点、面包、巧克力、糖果、布丁',
|
|
||||||
'完整动物身体',
|
|
||||||
'恐怖怪物、牙齿、爪子',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const basePrompt = [
|
|
||||||
'Create an icon-only mascot logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.',
|
|
||||||
'Core idea: a ceramic jar container with a small animal peeking out only to eye level. Show the animal ears, the upper half of the head, and two simple eyes. Do not show nose, mouth, lower face, paws, body, or full head.',
|
|
||||||
'The jar must remain the main brand shape. The animal head should feel hidden inside the jar, as if a playful idea is peeking out.',
|
|
||||||
'Brand core: fun creation. The logo should feel like a tiny clay-born creative spirit that can hold playful ideas, memes, and shareable works.',
|
|
||||||
'Logo type: simple mascot symbol only. It must be a brand mark, not a full character illustration, not a scene, not a sticker, not a rounded-square app icon background.',
|
|
||||||
'Main structure: a closed or mostly closed ceramic jar, pot, or vessel with a clear rim and body silhouette. The jar should read as a container first, not as a cup, vase, bowl, bottle, or food package.',
|
|
||||||
'Animal visibility: show only ears, top half of head, and two eye dots above or behind the jar rim. The rim may partially cover the lower half of the animal head. No animal mouth, no nose, no cheek blush, no paws, no body.',
|
|
||||||
'Jar policy: the jar itself has no face or expression. No eyes, no smile, no blush, no emoji marks on the jar.',
|
|
||||||
'Optional tiny jar feet or short side nubs are allowed only if they help the silhouette; keep them extremely abstract and minimal.',
|
|
||||||
'Style: modern minimalist vector mascot logo, clean curves, flat brand-color shapes, premium but warm. Slight clay warmth through color and form only; no realistic texture.',
|
|
||||||
'Jar color direction: low-saturation ceramic tones such as beige, sand, warm gray, muted terracotta, pale taupe, clay pink, or smoky cream. Animal head and ears should use soft natural animal colors that fit the variant.',
|
|
||||||
'Avoid realistic ceramic glaze, muddy pottery, rough handmade class object, 3D render, ceramic shine, brick, food-like container, toy feeling, chibi over-detailing, or generic blob.',
|
|
||||||
'Food avoidance is critical: do not make it look like bread, mochi, dumpling, bun, cake, cookie, candy, pudding, chocolate, jelly, or dessert packaging.',
|
|
||||||
'Avoid star, spark, halo, magic wand, hand, pottery tool, UI, border, or watermark.',
|
|
||||||
'Composition: centered on a clean light background with generous safe area. Strong readable silhouette first. Must still read at 32px and in black and white.',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-rabbit-peek',
|
|
||||||
title: '兔头探出',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: rabbit. Long soft rabbit ears, upper half of rabbit head and two simple eyes peeking above the jar rim, gentle and premium.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-cat-peek',
|
|
||||||
title: '猫头探出',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: cat. Small triangular cat ears, upper half of cat head and two simple eyes peeking out, clever and cute, no whiskers.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-fox-peek',
|
|
||||||
title: '狐头探出',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: fox. Slender fox ears, warm orange upper head with two simple eyes, playful but not sharp or aggressive.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-bear-peek',
|
|
||||||
title: '熊头探出',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: bear. Rounded bear ears and rounded upper head, two simple eyes, cozy and calm, no muzzle.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-dog-peek',
|
|
||||||
title: '狗头探出',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: dog. Soft floppy dog ears and upper head peeking from the jar, friendly but not a pet logo, no nose or mouth.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-mixed-peek',
|
|
||||||
title: '混合小灵',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: ambiguous animal spirit. Ear shapes sit between rabbit and cat, upper head and eyes only, more original and less species-specific.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-tall-peek',
|
|
||||||
title: '高罐探头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: taller jar silhouette with animal head peeking to eye level. Make the jar and head relationship clear at favicon size.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-trademark-peek',
|
|
||||||
title: '商标探头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: strongest trademark readability. Compact jar, simple half-head, two eyes, very few details, excellent black-and-white legibility.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-peeking-head-jar-${variant.id}.${image.extension}`);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(outputDir, 'taonier-logo-peeking-head-jar-manifest.json');
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
brief: logoBrief,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-peeking-head-jar-new-animals-concepts"
|
|
||||||
)
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-peeking-head-jar-new-animals-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 水豚头", "taonier-peeking-head-jar-new-animals-01-capybara"),
|
|
||||||
("02 仓鼠头", "taonier-peeking-head-jar-new-animals-02-hamster"),
|
|
||||||
("03 考拉头", "taonier-peeking-head-jar-new-animals-03-koala"),
|
|
||||||
("04 水獭头", "taonier-peeking-head-jar-new-animals-04-otter"),
|
|
||||||
("05 松鼠头", "taonier-peeking-head-jar-new-animals-05-squirrel"),
|
|
||||||
("06 浣熊头", "taonier-peeking-head-jar-new-animals-06-raccoon"),
|
|
||||||
("07 小羊头", "taonier-peeking-head-jar-new-animals-07-lamb"),
|
|
||||||
("08 刺猬头", "taonier-peeking-head-jar-new-animals-08-hedgehog"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
test_height = 46
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f0ebe5")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(20)
|
|
||||||
test_font = load_font(14)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f7f3ed",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 68, test_y + 7))
|
|
||||||
sheet.paste(mono, (x + 122, test_y + 7))
|
|
||||||
draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,464 +0,0 @@
|
|||||||
import { 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-peeking-head-jar-new-animals-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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: '陶泥儿',
|
|
||||||
coreBelief: '好玩会创造',
|
|
||||||
logoType: 'symbol/icon-only mascot mark, no wordmark',
|
|
||||||
product:
|
|
||||||
'AI UGC 轻休闲小游戏创作与传播平台,用户把脑洞、梗和灵感塑造成可分享的作品',
|
|
||||||
direction:
|
|
||||||
'保持当前“半头探出”的状态,但把动物类型真正拓宽到新物种',
|
|
||||||
audience: '女性用户友好、全年龄向、年轻明亮但不低幼',
|
|
||||||
structureRules: [
|
|
||||||
'主形体仍然是陶罐容器,罐子负责陶器和包裹感',
|
|
||||||
'动物只露出耳朵、上半个脑袋和两只黑点眼睛',
|
|
||||||
'眼睛不能有高光、不能有白点反光、不能有玻璃感',
|
|
||||||
'不露鼻子、嘴巴、身体、爪子或完整动物脸',
|
|
||||||
'罐子绝对不能有表情元素',
|
|
||||||
],
|
|
||||||
avoid: [
|
|
||||||
'中文或英文字',
|
|
||||||
'罐子表情',
|
|
||||||
'动物嘴巴或鼻子',
|
|
||||||
'眼睛高光',
|
|
||||||
'白眼球高光',
|
|
||||||
'星星或闪光',
|
|
||||||
'手托举元素',
|
|
||||||
'写实陶瓷高光',
|
|
||||||
'脏泥土或砖块',
|
|
||||||
'面团、汤圆、甜点、面包、巧克力、糖果、布丁',
|
|
||||||
'完整动物身体',
|
|
||||||
'恐怖怪物、牙齿、爪子',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const basePrompt = [
|
|
||||||
'Create an icon-only mascot logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.',
|
|
||||||
'Core idea: a ceramic jar container with a small animal peeking out only to eye level. Show the animal ears, the upper half of the head, and two simple black-dot eyes. Do not show nose, mouth, lower face, paws, body, or full head.',
|
|
||||||
'The jar must remain the main brand shape. The animal head should feel hidden inside the jar, as if a playful idea is peeking out.',
|
|
||||||
'Brand core: fun creation. The logo should feel like a tiny clay-born creative spirit that can hold playful ideas, memes, and shareable works.',
|
|
||||||
'Logo type: simple mascot symbol only. It must be a brand mark, not a full character illustration, not a scene, not a sticker, not a rounded-square app icon background.',
|
|
||||||
'Main structure: a closed or mostly closed ceramic jar, pot, or vessel with a clear rim and body silhouette. The jar should read as a container first, not as a cup, vase, bowl, bottle, or food package.',
|
|
||||||
'Animal visibility: show only ears, top half of head, and two solid black eye dots above or behind the jar rim. The rim may partially cover the lower half of the animal head. No animal mouth, no nose, no cheek blush, no paws, no body.',
|
|
||||||
'Eye policy: the eyes must be pure matte black dots. No highlights, no glossy sparkles, no white reflective spots, no glassy pupils, no shiny toy eyes.',
|
|
||||||
'Jar policy: the jar itself has no face or expression. No eyes, no smile, no blush, no emoji marks on the jar.',
|
|
||||||
'Style: modern minimalist vector mascot logo, clean curves, flat brand-color shapes, premium but warm. Slight clay warmth through color and form only; no realistic texture.',
|
|
||||||
'Jar color direction: low-saturation ceramic tones such as beige, sand, warm gray, muted terracotta, pale taupe, clay pink, smoky cream, cool ash clay, or muted olive clay. Each variant should shift the jar silhouette and color slightly.',
|
|
||||||
'Animal color direction: use soft natural animal colors that fit the species, but keep them gentle and logo-friendly. The animal head should be more alive and colorful than the jar.',
|
|
||||||
'Avoid realistic ceramic glaze, muddy pottery, rough handmade class object, 3D render, ceramic shine, brick, food-like container, toy feeling, chibi over-detailing, or generic blob.',
|
|
||||||
'Food avoidance is critical: do not make it look like bread, mochi, dumpling, bun, cake, cookie, candy, pudding, chocolate, jelly, or dessert packaging.',
|
|
||||||
'Avoid star, spark, halo, magic wand, hand, pottery tool, UI, border, or watermark.',
|
|
||||||
'Composition: centered on a clean light background with generous safe area. Strong readable silhouette first. Must still read at 32px and in black and white.',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-capybara',
|
|
||||||
title: '水豚头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: capybara. Use a broad calm jar with a warm beige body and a capybara head peeking out. The capybara has simple rounded ears and a very gentle expression made only from black-dot eyes.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-hamster',
|
|
||||||
title: '仓鼠头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: hamster. Use a squat round jar with a pale sand body and a hamster head. Slightly fuller cheeks are allowed only as shape, but no mouth or nose; eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-koala',
|
|
||||||
title: '考拉头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: koala. Use a muted eucalyptus-gray jar and a gray-white koala head with round fuzzy ears. Keep the head soft and calm, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-otter',
|
|
||||||
title: '水獭头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: otter. Use a smooth river-stone jar and a warm brown otter head. The ears can be tiny and round, the head is compact and playful, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-squirrel',
|
|
||||||
title: '松鼠头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: squirrel. Use a light clay jar and a reddish-brown squirrel head with small upright ears. The head should feel energetic but still only half exposed, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-raccoon',
|
|
||||||
title: '浣熊头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: raccoon. Use a muted taupe jar and a gray raccoon head with a darker mask shape implied by color, but no nose or mouth; eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-lamb',
|
|
||||||
title: '小羊头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: lamb. Use a soft cream jar and a fluffy off-white lamb head with small curled ears. Keep the silhouette gentle and soft, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-hedgehog',
|
|
||||||
title: '刺猬头',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: hedgehog. Use a compact jar with a warm sand body and a hedgehog head hinted by a rounded spiky silhouette, but keep the spikes soft and logo-simple, eyes are black dots only.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-peeking-head-jar-new-animals-${variant.id}.${image.extension}`,
|
|
||||||
);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(
|
|
||||||
outputDir,
|
|
||||||
'taonier-logo-peeking-head-jar-new-animals-manifest.json',
|
|
||||||
);
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
brief: logoBrief,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-playful-bean-concepts"
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-playful-bean-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 清新豆形标", "taonier-playful-bean-01-fresh-bean-mark"),
|
|
||||||
("02 蜜桃软几何", "taonier-playful-bean-02-peach-soft-geometry"),
|
|
||||||
("03 青绿创作胚", "taonier-playful-bean-03-mint-creation-embryo"),
|
|
||||||
("04 女性向明亮款", "taonier-playful-bean-04-female-bright-mark"),
|
|
||||||
("05 全龄轻玩款", "taonier-playful-bean-05-all-age-play-mark"),
|
|
||||||
("06 黑白优先款", "taonier-playful-bean-06-monochrome-first"),
|
|
||||||
("07 头像小尺寸款", "taonier-playful-bean-07-avatar-readable"),
|
|
||||||
("08 矢量定稿感款", "taonier-playful-bean-08-vector-ready"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
test_height = 46
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f0ebe5")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(20)
|
|
||||||
test_font = load_font(14)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f7f3ed",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 68, test_y + 7))
|
|
||||||
sheet.paste(mono, (x + 122, test_y + 7))
|
|
||||||
draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,444 +0,0 @@
|
|||||||
import { 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-playful-bean-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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: '陶泥儿',
|
|
||||||
coreBelief: '好玩会创造',
|
|
||||||
logoType: 'symbol/icon-only mark, no wordmark',
|
|
||||||
product:
|
|
||||||
'AI UGC 轻休闲小游戏创作与传播平台,用户像捏陶泥一样把脑洞、梗和灵感塑造成可玩的作品',
|
|
||||||
coreMetaphor: '已经成形的可玩作品胚',
|
|
||||||
audience: '女性用户友好、全年龄向、年轻明亮但不低幼',
|
|
||||||
visualLanguage: '抽象但有玩性的软几何玩具感',
|
|
||||||
material: '只保留陶泥温度,不追求泥土质感',
|
|
||||||
shape: '闭合不规则圆润豆形,外轮廓流畅、亲和、有玩性',
|
|
||||||
colors: ['珊瑚橙', '蜜桃粉', '奶油白', '清透青绿', '少量暖黄或柔紫可选'],
|
|
||||||
mustHave: [
|
|
||||||
'无中文、无英文、无字标',
|
|
||||||
'无星星、无脸、无表情',
|
|
||||||
'无方形底盘',
|
|
||||||
'无食物感',
|
|
||||||
'32px 可识别',
|
|
||||||
'黑白化仍成立',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const basePrompt = [
|
|
||||||
'Create an icon-only brand logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.',
|
|
||||||
'Brand core belief: fun creation. Users can turn imagination, memes, and ideas into playable AI/UGC casual game works.',
|
|
||||||
'Logo meaning: an already-formed playable creation embryo. It should feel like a small friendly finished creative object, not a process diagram and not raw clay.',
|
|
||||||
'Logo type: abstract symbol/icon only. No wordmark, no mascot, no face, no app-icon rounded-square background.',
|
|
||||||
'Main element: one closed irregular rounded bean-like soft geometric shape. The outer contour must be smooth, friendly, memorable, complete, and vector-friendly.',
|
|
||||||
'The shape must not be a square, rounded square, circle, ellipse, simple blob, ribbon, swirl, S shape, G shape, open ring, loose strip, badge, stamp, or tile.',
|
|
||||||
'The symbol should be abstract but playful: it should feel like a soft geometric toy-like creation object, mature enough for a premium brand and not childish.',
|
|
||||||
'Internal design: optional 1-2 broad curved color fields, soft cut-ins, or abstract playful inlays. Do not show a transformation process. Do not add a center icon.',
|
|
||||||
'No star, no spark, no star-shaped negative space, no eyes, no mouth, no character body, no hand, no pottery tool.',
|
|
||||||
'Style: modern minimalist vector logo. Flat, crisp, simple broad shapes, very light clay warmth only. No realistic texture. It must look good and recognizable at 32px favicon size.',
|
|
||||||
'Color direction: fresh bright palette for women-friendly and all-age users: coral orange, peach pink, cream white, clear soft teal or mint green. Use brand-color flat shapes, not candy rendering.',
|
|
||||||
'Avoid muted mud colors as the dominant palette. Avoid dirty clay, brick, pottery shard, mud pie, rough craft class object, and archaeology stamp feeling.',
|
|
||||||
'Food avoidance is critical: do not make it look like bread, chocolate bread, pastry, cookie, candy, jelly, donut, cream filling, sauce, baked dough, dessert, fruit candy, or food packaging.',
|
|
||||||
'Composition: centered on a clean light background, generous safe area, no border, no UI, no watermark. Use simple readable silhouette first.',
|
|
||||||
'Validation targets: black-and-white version should still read clearly; the closed bean-like silhouette should be recognizable at 32px.',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-fresh-bean-mark',
|
|
||||||
title: '清新豆形标',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: the cleanest fresh bean mark. Use coral orange and cream white with a tiny soft teal accent. Strong closed irregular bean silhouette, very readable.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-peach-soft-geometry',
|
|
||||||
title: '蜜桃软几何',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: peach pink and coral soft geometry. Feminine-friendly but not cosmetic, not candy. One smooth inner color field supports the closed bean shape.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-mint-creation-embryo',
|
|
||||||
title: '青绿创作胚',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: clear mint or teal as the memory accent, with warm cream and coral. The mark should feel like a playable creation object, not a leaf or seed.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-female-bright-mark',
|
|
||||||
title: '女性向明亮款',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: brighter women-friendly palette, soft coral, peach, cream, and one clean mint accent. Keep it premium and avoid beauty-brand cliché.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-all-age-play-mark',
|
|
||||||
title: '全龄轻玩款',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: all-age casual play. More energetic and memorable, but still simple. Use two or three flat color fields, no small decorative details.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-monochrome-first',
|
|
||||||
title: '黑白优先款',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: design for black-and-white survival first. Bold positive and negative shapes, color only supports the structure. No delicate gradients.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-avatar-readable',
|
|
||||||
title: '头像小尺寸款',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: social avatar and favicon readability. Full, compact closed bean silhouette with one distinctive broad internal curve; no tiny dots.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-vector-ready',
|
|
||||||
title: '矢量定稿感款',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: designer-ready vector concept. 2-3 flat shapes, crisp boundaries, distinctive closed rounded-bean contour, minimal material cue.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-playful-bean-${variant.id}.${image.extension}`);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(outputDir, 'taonier-logo-playful-bean-manifest.json');
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
brief: logoBrief,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,544 +0,0 @@
|
|||||||
from collections import deque
|
|
||||||
import math
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageChops, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
REFERENCE_PATH = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-punch-hole-concepts"
|
|
||||||
/ "taonier-punch-color-inlay.png"
|
|
||||||
)
|
|
||||||
OUTPUT_DIR = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-ref04-locked-color-concepts"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def is_red(pixel):
|
|
||||||
r, g, b = pixel
|
|
||||||
return r > 160 and g < 155 and b < 145 and r - g > 45
|
|
||||||
|
|
||||||
|
|
||||||
def is_cyan(pixel):
|
|
||||||
r, g, b = pixel
|
|
||||||
return r < 120 and g > 125 and b > 125 and b - r > 65
|
|
||||||
|
|
||||||
|
|
||||||
def is_open_light(pixel):
|
|
||||||
r, g, b = pixel
|
|
||||||
lum = (r + g + b) // 3
|
|
||||||
return lum > 174 and max(pixel) - min(pixel) < 105 and not is_red(pixel) and not is_cyan(pixel)
|
|
||||||
|
|
||||||
|
|
||||||
def colorize(pixel, target, category):
|
|
||||||
r, g, b = pixel
|
|
||||||
lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255
|
|
||||||
if category == "dark":
|
|
||||||
factor = 0.88 + min(lum, 0.42) * 0.44
|
|
||||||
else:
|
|
||||||
factor = 0.9 + lum * 0.2
|
|
||||||
return tuple(max(0, min(255, round(channel * factor))) for channel in target)
|
|
||||||
|
|
||||||
|
|
||||||
def build_masks(image):
|
|
||||||
width, height = image.size
|
|
||||||
pixels = image.load()
|
|
||||||
open_mask = bytearray(width * height)
|
|
||||||
|
|
||||||
for y in range(height):
|
|
||||||
for x in range(width):
|
|
||||||
if is_open_light(pixels[x, y]):
|
|
||||||
open_mask[y * width + x] = 1
|
|
||||||
|
|
||||||
# 从画布边缘连通的浅色区域是外部背景;剩下的浅色闭合区域就是中孔。
|
|
||||||
external_mask = bytearray(width * height)
|
|
||||||
queue = deque()
|
|
||||||
for x in range(width):
|
|
||||||
for y in (0, height - 1):
|
|
||||||
index = y * width + x
|
|
||||||
if open_mask[index] and not external_mask[index]:
|
|
||||||
external_mask[index] = 1
|
|
||||||
queue.append((x, y))
|
|
||||||
for y in range(height):
|
|
||||||
for x in (0, width - 1):
|
|
||||||
index = y * width + x
|
|
||||||
if open_mask[index] and not external_mask[index]:
|
|
||||||
external_mask[index] = 1
|
|
||||||
queue.append((x, y))
|
|
||||||
|
|
||||||
while queue:
|
|
||||||
x, y = queue.popleft()
|
|
||||||
for next_x, next_y in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)):
|
|
||||||
if 0 <= next_x < width and 0 <= next_y < height:
|
|
||||||
index = next_y * width + next_x
|
|
||||||
if open_mask[index] and not external_mask[index]:
|
|
||||||
external_mask[index] = 1
|
|
||||||
queue.append((next_x, next_y))
|
|
||||||
|
|
||||||
hole_mask = bytearray(width * height)
|
|
||||||
red_mask = bytearray(width * height)
|
|
||||||
cyan_mask = bytearray(width * height)
|
|
||||||
dark_mask = bytearray(width * height)
|
|
||||||
for y in range(height):
|
|
||||||
for x in range(width):
|
|
||||||
index = y * width + x
|
|
||||||
pixel = pixels[x, y]
|
|
||||||
if open_mask[index] and not external_mask[index]:
|
|
||||||
hole_mask[index] = 1
|
|
||||||
elif not external_mask[index]:
|
|
||||||
if is_red(pixel):
|
|
||||||
red_mask[index] = 1
|
|
||||||
elif is_cyan(pixel):
|
|
||||||
cyan_mask[index] = 1
|
|
||||||
elif not open_mask[index]:
|
|
||||||
dark_mask[index] = 1
|
|
||||||
|
|
||||||
return {
|
|
||||||
"dark": dark_mask,
|
|
||||||
"red": red_mask,
|
|
||||||
"cyan": cyan_mask,
|
|
||||||
"hole": hole_mask,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def mask_bounds(mask, width, height):
|
|
||||||
xs = []
|
|
||||||
ys = []
|
|
||||||
for index, value in enumerate(mask):
|
|
||||||
if value:
|
|
||||||
xs.append(index % width)
|
|
||||||
ys.append(index // width)
|
|
||||||
return min(xs), min(ys), max(xs), max(ys)
|
|
||||||
|
|
||||||
|
|
||||||
def draw_center_content(size, hole_mask, variant):
|
|
||||||
width, height = size
|
|
||||||
hole_bounds = mask_bounds(hole_mask, width, height)
|
|
||||||
left, top, right, bottom = hole_bounds
|
|
||||||
center_x = (left + right) / 2
|
|
||||||
center_y = (top + bottom) / 2
|
|
||||||
hole_w = right - left
|
|
||||||
hole_h = bottom - top
|
|
||||||
|
|
||||||
scale = 4
|
|
||||||
layer = Image.new("RGBA", (width * scale, height * scale), (0, 0, 0, 0))
|
|
||||||
draw = ImageDraw.Draw(layer)
|
|
||||||
|
|
||||||
def box(cx, cy, w, h):
|
|
||||||
return [
|
|
||||||
int((cx - w / 2) * scale),
|
|
||||||
int((cy - h / 2) * scale),
|
|
||||||
int((cx + w / 2) * scale),
|
|
||||||
int((cy + h / 2) * scale),
|
|
||||||
]
|
|
||||||
|
|
||||||
def rounded(cx, cy, w, h, radius, fill):
|
|
||||||
draw.rounded_rectangle(box(cx, cy, w, h), radius=int(radius * scale), fill=fill)
|
|
||||||
|
|
||||||
def ellipse(cx, cy, w, h, fill):
|
|
||||||
draw.ellipse(box(cx, cy, w, h), fill=fill)
|
|
||||||
|
|
||||||
def star(cx, cy, outer, inner, fill):
|
|
||||||
points = []
|
|
||||||
for index in range(10):
|
|
||||||
angle = -90 + index * 36
|
|
||||||
radius = outer if index % 2 == 0 else inner
|
|
||||||
points.append(
|
|
||||||
(
|
|
||||||
int((cx + radius * math.cos(math.radians(angle))) * scale),
|
|
||||||
int((cy + radius * math.sin(math.radians(angle))) * scale),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
draw.polygon(points, fill=fill)
|
|
||||||
|
|
||||||
def sparkle(cx, cy, radius, fill, with_rays=False):
|
|
||||||
points = []
|
|
||||||
point_count = 128
|
|
||||||
for index in range(point_count):
|
|
||||||
theta = -math.pi / 2 + index * math.tau / point_count
|
|
||||||
pulse = abs(math.cos(2 * theta)) ** 4.2
|
|
||||||
current_radius = radius * (0.12 + 0.88 * pulse)
|
|
||||||
points.append(
|
|
||||||
(
|
|
||||||
int((cx + math.cos(theta) * current_radius * 0.78) * scale),
|
|
||||||
int((cy + math.sin(theta) * current_radius * 1.12) * scale),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
draw.polygon(points, fill=fill)
|
|
||||||
if not with_rays:
|
|
||||||
return
|
|
||||||
|
|
||||||
ray_color = fill
|
|
||||||
line_width = max(4, int(radius * 0.11 * scale))
|
|
||||||
cap = line_width // 2
|
|
||||||
|
|
||||||
def rounded_line(start, end):
|
|
||||||
draw.line(
|
|
||||||
(
|
|
||||||
int(start[0] * scale),
|
|
||||||
int(start[1] * scale),
|
|
||||||
int(end[0] * scale),
|
|
||||||
int(end[1] * scale),
|
|
||||||
),
|
|
||||||
fill=ray_color,
|
|
||||||
width=line_width,
|
|
||||||
)
|
|
||||||
for point in (start, end):
|
|
||||||
draw.ellipse(
|
|
||||||
(
|
|
||||||
int(point[0] * scale) - cap,
|
|
||||||
int(point[1] * scale) - cap,
|
|
||||||
int(point[0] * scale) + cap,
|
|
||||||
int(point[1] * scale) + cap,
|
|
||||||
),
|
|
||||||
fill=ray_color,
|
|
||||||
)
|
|
||||||
|
|
||||||
rounded_line((cx - radius * 1.48, cy - radius * 0.15), (cx - radius * 1.18, cy - radius * 0.08))
|
|
||||||
rounded_line((cx - radius * 1.35, cy + radius * 0.5), (cx - radius * 1.1, cy + radius * 0.3))
|
|
||||||
rounded_line((cx + radius * 1.12, cy - radius * 0.42), (cx + radius * 1.33, cy - radius * 0.64))
|
|
||||||
rounded_line((cx + radius * 1.25, cy + radius * 0.08), (cx + radius * 1.52, cy + radius * 0.15))
|
|
||||||
|
|
||||||
if variant == "cream_seed":
|
|
||||||
rounded(center_x, center_y, hole_w * 0.42, hole_h * 0.34, 44, (244, 216, 166, 255))
|
|
||||||
elif variant == "soft_dot":
|
|
||||||
rounded(center_x, center_y, hole_w * 0.32, hole_h * 0.28, 36, (250, 219, 157, 255))
|
|
||||||
elif variant == "double_piece":
|
|
||||||
rounded(center_x - hole_w * 0.08, center_y + hole_h * 0.01, hole_w * 0.24, hole_h * 0.22, 30, (249, 202, 174, 255))
|
|
||||||
rounded(center_x + hole_w * 0.13, center_y - hole_h * 0.02, hole_w * 0.22, hole_h * 0.21, 28, (143, 207, 205, 255))
|
|
||||||
elif variant == "tiny_kernel":
|
|
||||||
ellipse(center_x, center_y, hole_w * 0.26, hole_h * 0.24, (252, 223, 157, 255))
|
|
||||||
elif variant == "filled_core":
|
|
||||||
rounded(center_x, center_y, hole_w * 0.58, hole_h * 0.5, 58, (248, 231, 196, 255))
|
|
||||||
ellipse(center_x - hole_w * 0.09, center_y + hole_h * 0.02, hole_w * 0.13, hole_h * 0.12, (240, 93, 82, 255))
|
|
||||||
ellipse(center_x + hole_w * 0.1, center_y - hole_h * 0.02, hole_w * 0.13, hole_h * 0.12, (20, 183, 196, 255))
|
|
||||||
elif variant == "clay_pearl":
|
|
||||||
rounded(center_x, center_y, hole_w * 0.36, hole_h * 0.3, 40, (255, 226, 177, 255))
|
|
||||||
ellipse(center_x + hole_w * 0.06, center_y - hole_h * 0.05, hole_w * 0.08, hole_h * 0.07, (255, 246, 220, 180))
|
|
||||||
elif variant == "cream_star":
|
|
||||||
star(center_x, center_y, min(hole_w, hole_h) * 0.17, min(hole_w, hole_h) * 0.075, (255, 223, 154, 255))
|
|
||||||
elif variant == "small_star":
|
|
||||||
star(center_x, center_y, min(hole_w, hole_h) * 0.135, min(hole_w, hole_h) * 0.06, (255, 231, 177, 255))
|
|
||||||
elif variant == "soft_star_badge":
|
|
||||||
rounded(center_x, center_y, hole_w * 0.38, hole_h * 0.34, 42, (255, 239, 207, 255))
|
|
||||||
star(center_x, center_y, min(hole_w, hole_h) * 0.115, min(hole_w, hole_h) * 0.052, (238, 129, 80, 255))
|
|
||||||
elif variant == "coral_star":
|
|
||||||
star(center_x, center_y, min(hole_w, hole_h) * 0.15, min(hole_w, hole_h) * 0.067, (241, 108, 82, 255))
|
|
||||||
elif variant == "mint_star":
|
|
||||||
star(center_x, center_y, min(hole_w, hole_h) * 0.15, min(hole_w, hole_h) * 0.067, (78, 198, 183, 255))
|
|
||||||
elif variant == "soft_sparkle":
|
|
||||||
sparkle(center_x, center_y, min(hole_w, hole_h) * 0.18, (255, 205, 61, 255), True)
|
|
||||||
elif variant == "small_sparkle":
|
|
||||||
sparkle(center_x, center_y, min(hole_w, hole_h) * 0.145, (255, 214, 91, 255), True)
|
|
||||||
elif variant == "bright_sparkle":
|
|
||||||
sparkle(center_x, center_y, min(hole_w, hole_h) * 0.17, (255, 197, 43, 255), True)
|
|
||||||
elif variant == "quiet_sparkle":
|
|
||||||
sparkle(center_x, center_y, min(hole_w, hole_h) * 0.155, (255, 224, 139, 255), False)
|
|
||||||
|
|
||||||
layer = layer.resize(size, Image.Resampling.LANCZOS)
|
|
||||||
alpha = Image.frombytes("L", size, bytes(255 if value else 0 for value in hole_mask))
|
|
||||||
layer_alpha = layer.getchannel("A")
|
|
||||||
layer.putalpha(ImageChops.multiply(layer_alpha, alpha))
|
|
||||||
return layer
|
|
||||||
|
|
||||||
|
|
||||||
VARIANTS = [
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-locked-warm-ink",
|
|
||||||
"label": "01 warm",
|
|
||||||
"dark": (63, 58, 53),
|
|
||||||
"red": (243, 82, 69),
|
|
||||||
"cyan": (14, 183, 198),
|
|
||||||
"content": "cream_seed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-locked-blue-ink",
|
|
||||||
"label": "02 blue",
|
|
||||||
"dark": (30, 39, 72),
|
|
||||||
"red": (255, 89, 84),
|
|
||||||
"cyan": (28, 181, 207),
|
|
||||||
"content": "soft_dot",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-locked-plum-ink",
|
|
||||||
"label": "03 plum",
|
|
||||||
"dark": (69, 53, 72),
|
|
||||||
"red": (255, 98, 86),
|
|
||||||
"cyan": (34, 188, 198),
|
|
||||||
"content": "double_piece",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-locked-green-ink",
|
|
||||||
"label": "04 green",
|
|
||||||
"dark": (11, 83, 78),
|
|
||||||
"red": (255, 107, 88),
|
|
||||||
"cyan": (68, 209, 192),
|
|
||||||
"content": "tiny_kernel",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-locked-shrink-core",
|
|
||||||
"label": "05 fill",
|
|
||||||
"dark": (43, 43, 47),
|
|
||||||
"red": (239, 84, 75),
|
|
||||||
"cyan": (17, 178, 193),
|
|
||||||
"content": "filled_core",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-locked-soft-charcoal",
|
|
||||||
"label": "06 soft",
|
|
||||||
"dark": (82, 76, 68),
|
|
||||||
"red": (242, 105, 90),
|
|
||||||
"cyan": (37, 188, 195),
|
|
||||||
"content": "clay_pearl",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
STAR_OUTPUT_DIR = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-ref04-warm-star-concepts"
|
|
||||||
)
|
|
||||||
|
|
||||||
STAR_VARIANTS = [
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-warm-star-terracotta",
|
|
||||||
"label": "01 clay",
|
|
||||||
"dark": (121, 76, 54),
|
|
||||||
"red": (244, 86, 70),
|
|
||||||
"cyan": (15, 184, 198),
|
|
||||||
"content": "cream_star",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-warm-star-caramel",
|
|
||||||
"label": "02 caramel",
|
|
||||||
"dark": (142, 94, 51),
|
|
||||||
"red": (247, 91, 73),
|
|
||||||
"cyan": (13, 185, 196),
|
|
||||||
"content": "small_star",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-warm-star-cocoa",
|
|
||||||
"label": "03 cocoa",
|
|
||||||
"dark": (89, 64, 47),
|
|
||||||
"red": (240, 88, 72),
|
|
||||||
"cyan": (17, 181, 194),
|
|
||||||
"content": "soft_star_badge",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-warm-star-rust",
|
|
||||||
"label": "04 rust",
|
|
||||||
"dark": (111, 62, 54),
|
|
||||||
"red": (249, 93, 75),
|
|
||||||
"cyan": (15, 184, 198),
|
|
||||||
"content": "cream_star",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-warm-star-olive",
|
|
||||||
"label": "05 olive",
|
|
||||||
"dark": (92, 81, 48),
|
|
||||||
"red": (245, 94, 76),
|
|
||||||
"cyan": (25, 185, 187),
|
|
||||||
"content": "coral_star",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-warm-star-plum",
|
|
||||||
"label": "06 plum",
|
|
||||||
"dark": (95, 57, 66),
|
|
||||||
"red": (250, 94, 81),
|
|
||||||
"cyan": (26, 185, 197),
|
|
||||||
"content": "mint_star",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
SPARKLE_OUTPUT_DIR = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-ref04-warm-sparkle-concepts"
|
|
||||||
)
|
|
||||||
|
|
||||||
SPARKLE_V2_OUTPUT_DIR = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-ref04-warm-sparkle-v2-concepts"
|
|
||||||
)
|
|
||||||
|
|
||||||
PALETTE_TRANSFER_OUTPUT_DIR = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-ref04-palette-transfer"
|
|
||||||
)
|
|
||||||
|
|
||||||
SPARKLE_VARIANTS = [
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-warm-sparkle-terracotta",
|
|
||||||
"label": "01 clay",
|
|
||||||
"dark": (121, 76, 54),
|
|
||||||
"red": (244, 86, 70),
|
|
||||||
"cyan": (15, 184, 198),
|
|
||||||
"content": "soft_sparkle",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-warm-sparkle-rust",
|
|
||||||
"label": "02 rust",
|
|
||||||
"dark": (111, 62, 54),
|
|
||||||
"red": (249, 93, 75),
|
|
||||||
"cyan": (15, 184, 198),
|
|
||||||
"content": "soft_sparkle",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-warm-sparkle-caramel",
|
|
||||||
"label": "03 caramel",
|
|
||||||
"dark": (142, 94, 51),
|
|
||||||
"red": (247, 91, 73),
|
|
||||||
"cyan": (13, 185, 196),
|
|
||||||
"content": "small_sparkle",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-warm-sparkle-cocoa",
|
|
||||||
"label": "04 cocoa",
|
|
||||||
"dark": (89, 64, 47),
|
|
||||||
"red": (240, 88, 72),
|
|
||||||
"cyan": (17, 181, 194),
|
|
||||||
"content": "bright_sparkle",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-warm-sparkle-clay-quiet",
|
|
||||||
"label": "05 quiet",
|
|
||||||
"dark": (121, 76, 54),
|
|
||||||
"red": (244, 86, 70),
|
|
||||||
"cyan": (15, 184, 198),
|
|
||||||
"content": "quiet_sparkle",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-warm-sparkle-plum",
|
|
||||||
"label": "06 plum",
|
|
||||||
"dark": (95, 57, 66),
|
|
||||||
"red": (250, 94, 81),
|
|
||||||
"cyan": (26, 185, 197),
|
|
||||||
"content": "soft_sparkle",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
PALETTE_TRANSFER_VARIANTS = [
|
|
||||||
{
|
|
||||||
"id": "taonier-ref04-palette-transfer-warm-yellow-sparkle",
|
|
||||||
"label": "transfer",
|
|
||||||
"dark": (224, 162, 58),
|
|
||||||
"red": (255, 113, 132),
|
|
||||||
"cyan": (91, 213, 192),
|
|
||||||
"content": "soft_sparkle",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def apply_variant(reference, masks, variant):
|
|
||||||
image = reference.copy().convert("RGBA")
|
|
||||||
source = reference.convert("RGB")
|
|
||||||
width, height = source.size
|
|
||||||
source_pixels = source.load()
|
|
||||||
result_pixels = image.load()
|
|
||||||
|
|
||||||
for y in range(height):
|
|
||||||
for x in range(width):
|
|
||||||
index = y * width + x
|
|
||||||
pixel = source_pixels[x, y]
|
|
||||||
if masks["dark"][index]:
|
|
||||||
result_pixels[x, y] = (*colorize(pixel, variant["dark"], "dark"), 255)
|
|
||||||
elif masks["red"][index]:
|
|
||||||
result_pixels[x, y] = (*colorize(pixel, variant["red"], "accent"), 255)
|
|
||||||
elif masks["cyan"][index]:
|
|
||||||
result_pixels[x, y] = (*colorize(pixel, variant["cyan"], "accent"), 255)
|
|
||||||
|
|
||||||
content = draw_center_content(source.size, masks["hole"], variant["content"])
|
|
||||||
return Image.alpha_composite(image, content).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def build_contact_sheet(items, output_path):
|
|
||||||
thumb = 260
|
|
||||||
label_h = 34
|
|
||||||
pad = 18
|
|
||||||
cols = 4
|
|
||||||
rows = (len(items) + cols - 1) // cols
|
|
||||||
sheet_w = cols * thumb + (cols + 1) * pad
|
|
||||||
sheet_h = rows * (thumb + label_h) + (rows + 1) * pad
|
|
||||||
sheet = Image.new("RGB", (sheet_w, sheet_h), "#f7f3ea")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
try:
|
|
||||||
font = ImageFont.truetype("arial.ttf", 18)
|
|
||||||
except OSError:
|
|
||||||
font = ImageFont.load_default()
|
|
||||||
|
|
||||||
for index, (label, path) in enumerate(items):
|
|
||||||
image = Image.open(path).convert("RGB")
|
|
||||||
image.thumbnail((thumb, thumb), Image.Resampling.LANCZOS)
|
|
||||||
row, col = divmod(index, cols)
|
|
||||||
x = pad + col * (thumb + pad)
|
|
||||||
y = pad + row * (thumb + label_h + pad)
|
|
||||||
bg = Image.new("RGB", (thumb, thumb), "#fffaf1")
|
|
||||||
bg.paste(image, ((thumb - image.width) // 2, (thumb - image.height) // 2))
|
|
||||||
sheet.paste(bg, (x, y))
|
|
||||||
draw.rectangle((x, y, x + thumb - 1, y + thumb - 1), outline="#ded5c6", width=1)
|
|
||||||
bbox = draw.textbbox((0, 0), label, font=font)
|
|
||||||
draw.text((x + (thumb - (bbox[2] - bbox[0])) // 2, y + thumb + 8), label, fill="#211f1c", font=font)
|
|
||||||
|
|
||||||
sheet.save(output_path)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_set(output_dir, variants, contact_name):
|
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
reference = Image.open(REFERENCE_PATH).convert("RGB")
|
|
||||||
masks = build_masks(reference)
|
|
||||||
contact_items = [("REF-04", REFERENCE_PATH)]
|
|
||||||
|
|
||||||
for variant in variants:
|
|
||||||
output_path = output_dir / f"{variant['id']}.png"
|
|
||||||
apply_variant(reference, masks, variant).save(output_path)
|
|
||||||
contact_items.append((variant["label"], output_path))
|
|
||||||
|
|
||||||
build_contact_sheet(
|
|
||||||
contact_items,
|
|
||||||
output_dir / contact_name,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
generate_set(
|
|
||||||
OUTPUT_DIR,
|
|
||||||
VARIANTS,
|
|
||||||
"taonier-logo-ref04-locked-color-contact-sheet.png",
|
|
||||||
)
|
|
||||||
generate_set(
|
|
||||||
STAR_OUTPUT_DIR,
|
|
||||||
STAR_VARIANTS,
|
|
||||||
"taonier-logo-ref04-warm-star-contact-sheet.png",
|
|
||||||
)
|
|
||||||
generate_set(
|
|
||||||
SPARKLE_OUTPUT_DIR,
|
|
||||||
SPARKLE_VARIANTS,
|
|
||||||
"taonier-logo-ref04-warm-sparkle-contact-sheet.png",
|
|
||||||
)
|
|
||||||
generate_set(
|
|
||||||
SPARKLE_V2_OUTPUT_DIR,
|
|
||||||
SPARKLE_VARIANTS,
|
|
||||||
"taonier-logo-ref04-warm-sparkle-v2-contact-sheet.png",
|
|
||||||
)
|
|
||||||
generate_set(
|
|
||||||
PALETTE_TRANSFER_OUTPUT_DIR,
|
|
||||||
PALETTE_TRANSFER_VARIANTS,
|
|
||||||
"taonier-logo-ref04-palette-transfer-contact-sheet.png",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont, ImageOps
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-short-foot-creature-concepts"
|
|
||||||
)
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-short-foot-creature-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 弯角泥团", "taonier-short-foot-creature-01-curled-tip"),
|
|
||||||
("02 软芽泥团", "taonier-short-foot-creature-02-soft-sprout"),
|
|
||||||
("03 波浪小怪", "taonier-short-foot-creature-03-wave-tuft"),
|
|
||||||
("04 圆角小怪", "taonier-short-foot-creature-04-round-horn"),
|
|
||||||
("05 低趴泥团", "taonier-short-foot-creature-05-low-squat"),
|
|
||||||
("06 偏心灵体", "taonier-short-foot-creature-06-asymmetric-charm"),
|
|
||||||
("07 头像强识别", "taonier-short-foot-creature-07-avatar-bold"),
|
|
||||||
("08 商标轮廓", "taonier-short-foot-creature-08-vector-outline"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def find_image(stem: str) -> Path | None:
|
|
||||||
for extension in ("png", "webp", "jpg", "jpeg"):
|
|
||||||
candidate = OUTPUT_DIR / f"{stem}.{extension}"
|
|
||||||
if candidate.exists():
|
|
||||||
return candidate
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def bw_preview(image: Image.Image, size: int) -> Image.Image:
|
|
||||||
thumb = ImageOps.grayscale(image).resize((size, size), Image.Resampling.LANCZOS)
|
|
||||||
return ImageOps.autocontrast(thumb).convert("RGB")
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 300
|
|
||||||
label_height = 58
|
|
||||||
test_height = 46
|
|
||||||
gap = 24
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
cell_total_height = cell_size + label_height + test_height
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * cell_total_height + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f0ebe5")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
label_font = load_font(20)
|
|
||||||
test_font = load_font(14)
|
|
||||||
|
|
||||||
for index, (label, stem) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_total_height + gap)
|
|
||||||
|
|
||||||
image_path = find_image(stem)
|
|
||||||
if image_path is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = Image.open(image_path).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=label_font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=label_font)
|
|
||||||
|
|
||||||
test_y = y + cell_size + label_height
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, test_y, x + cell_size, test_y + test_height),
|
|
||||||
radius=8,
|
|
||||||
fill="#f7f3ed",
|
|
||||||
)
|
|
||||||
tiny = source.resize((32, 32), Image.Resampling.LANCZOS)
|
|
||||||
mono = bw_preview(source, 32)
|
|
||||||
sheet.paste(tiny, (x + 68, test_y + 7))
|
|
||||||
sheet.paste(mono, (x + 122, test_y + 7))
|
|
||||||
draw.text((x + 166, test_y + 14), "32px / BW", fill="#5c5148", font=test_font)
|
|
||||||
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,457 +0,0 @@
|
|||||||
import { 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-short-foot-creature-concepts',
|
|
||||||
);
|
|
||||||
const timeoutMsDefault = 180000;
|
|
||||||
|
|
||||||
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: '陶泥儿',
|
|
||||||
coreBelief: '好玩会创造',
|
|
||||||
logoType: 'symbol/icon-only mascot mark, no wordmark',
|
|
||||||
product:
|
|
||||||
'AI UGC 轻休闲小游戏创作与传播平台,用户把脑洞、梗和灵感塑造成可分享的作品',
|
|
||||||
direction:
|
|
||||||
'低重心短脚泥团小灵体 / 小怪物:参考图只用于造型,不继承写实陶瓷质感',
|
|
||||||
audience: '女性用户友好、全年龄向、年轻明亮但不低幼',
|
|
||||||
shapeRules: [
|
|
||||||
'主体是坐在地上的闭合泥团生物,像一个稳定的软陶泥胚',
|
|
||||||
'底部有 3-5 个短短的圆脚或脚趾状支点,但不能变成爪子',
|
|
||||||
'头顶可以有弯角、小尖、软芽、卷曲或捏起的造型,作为记忆点',
|
|
||||||
'整体必须是 logo 符号级别,不是完整角色插画',
|
|
||||||
'32px 下仍能看出低重心泥团、短脚和头顶造型',
|
|
||||||
],
|
|
||||||
avoid: [
|
|
||||||
'中文或英文字',
|
|
||||||
'星星或闪光',
|
|
||||||
'手托举元素',
|
|
||||||
'写实陶瓷高光',
|
|
||||||
'脏泥土或砖块',
|
|
||||||
'面团、汤圆、甜点、面包、巧克力、糖果、布丁',
|
|
||||||
'恐怖怪物、牙齿、爪子',
|
|
||||||
'儿童玩具、表情包贴纸',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const basePrompt = [
|
|
||||||
'Create an icon-only mascot logo symbol for the Chinese product named "陶泥儿"; do not render any Chinese, English, letters, numbers, wordmark, tagline, or text.',
|
|
||||||
'The reference idea is only shape language: a squat soft clay lump creature sitting on the ground, with several very short rounded feet and a distinctive top tuft or curved horn. Do not copy realistic ceramic rendering.',
|
|
||||||
'Brand core: fun creation. The mark should feel like a tiny clay-born creative spirit: soft, friendly, shareable, and able to turn ideas into playful AI/UGC casual game works.',
|
|
||||||
'Logo type: simple mascot symbol only. It must be a brand mark, not a full character illustration, not a scene, not a sticker, not a rounded-square app icon background.',
|
|
||||||
'Main silhouette: low center of gravity, closed mound body, slightly irregular but clean outline, broad base, 3 to 5 stubby rounded feet at the bottom. Feet must be short and cute, never claws, toes, paws, or realistic animal legs.',
|
|
||||||
'Top silhouette: one memorable soft sculpted feature on the head, such as a curled clay tip, small pinched sprout, rounded horn, or soft wave tuft. The top feature should be part of the same clay body, not a separate hat or accessory.',
|
|
||||||
'Face policy: face is optional; if used, only two tiny simple eye dots. No mouth, no blush, no emoji expression, no detailed face. The silhouette should work even without the face.',
|
|
||||||
'Style: modern minimalist vector mascot logo, flat brand-color shapes, clean curves, premium but warm. Slight clay warmth through color and form only; no realistic texture.',
|
|
||||||
'Color direction: earthy but fresh, using warm clay beige, terracotta, cream white, soft coral, and a very small teal or mint accent if helpful. Avoid dirty mud, brick red dominance, candy gradients, glossy dessert rendering.',
|
|
||||||
'Food avoidance is critical: do not make it look like mochi, dumpling, bun, bread, cake, cookie, candy, pudding, chocolate, jelly, or any edible mascot.',
|
|
||||||
'Avoid pottery class object, ceramic figurine, handmade toy, rough mud texture, archaeology stamp, realistic glaze, 3D clay render, or shiny ceramic highlight.',
|
|
||||||
'Avoid scary monster, teeth, claws, spikes, realistic animal, pet logo, chibi over-detailing, generic blob, star, spark, halo, magic wand, hand, pottery tool, UI, border, or watermark.',
|
|
||||||
'Composition: centered on clean light background, generous safe area, strong readable silhouette first. It should look recognizable at 32px and still work in black and white.',
|
|
||||||
];
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: '01-curled-tip',
|
|
||||||
title: '弯角泥团',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: a squat clay lump creature with one soft curled tip leaning gently forward, four tiny rounded feet, calm premium silhouette.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '02-soft-sprout',
|
|
||||||
title: '软芽泥团',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: a low mound creature with a pinched sprout-like top made from the same clay body, three short feet, fresh and memorable.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '03-wave-tuft',
|
|
||||||
title: '波浪小怪',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: a playful clay creature with a single wave-shaped top tuft, broad sitting base, 4 tiny feet, more dynamic but still logo-simple.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '04-round-horn',
|
|
||||||
title: '圆角小怪',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: a friendly abstract little monster with one rounded horn-like bump and a second smaller bump, stubby feet, no scary details.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '05-low-squat',
|
|
||||||
title: '低趴泥团',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: extra low and stable clay mound, wide base, five tiny rounded feet, top feature is a subtle pinched crest, very favicon-readable.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '06-asymmetric-charm',
|
|
||||||
title: '偏心灵体',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: asymmetrical friendly spirit mark, body leans slightly to one side, curled top balances the shape, short feet stay grounded.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '07-avatar-bold',
|
|
||||||
title: '头像强识别',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: bold social avatar readability, thick simple silhouette, two tiny eye dots allowed, top tuft and feet readable at 32px.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '08-vector-outline',
|
|
||||||
title: '商标轮廓',
|
|
||||||
prompt: [
|
|
||||||
...basePrompt,
|
|
||||||
'Variant focus: designer-ready vector mark. Use 2-3 flat shapes, crisp boundaries, very strong black-and-white silhouette, minimal inner detail.',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
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 buildVectorEngineImagesGenerationUrl(baseUrl) {
|
|
||||||
return baseUrl.endsWith('/v1')
|
|
||||||
? `${baseUrl}/images/generations`
|
|
||||||
: `${baseUrl}/v1/images/generations`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRequestBody(variant) {
|
|
||||||
return {
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
prompt: variant.prompt.join('\n'),
|
|
||||||
n: 1,
|
|
||||||
size: '1024x1024',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function generateOne(env, variant) {
|
|
||||||
const requestBody = buildRequestBody(variant);
|
|
||||||
const payload = await fetchJson(
|
|
||||||
buildVectorEngineImagesGenerationUrl(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 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-short-foot-creature-${variant.id}.${image.extension}`,
|
|
||||||
);
|
|
||||||
writeFileSync(outputPath, image.bytes);
|
|
||||||
return outputPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeManifest(files) {
|
|
||||||
const manifestPath = path.join(
|
|
||||||
outputDir,
|
|
||||||
'taonier-logo-short-foot-creature-manifest.json',
|
|
||||||
);
|
|
||||||
writeFileSync(
|
|
||||||
manifestPath,
|
|
||||||
`${JSON.stringify(
|
|
||||||
{
|
|
||||||
model: 'gpt-image-2-all',
|
|
||||||
size: '1024x1024',
|
|
||||||
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,
|
|
||||||
count: selectedVariants.length,
|
|
||||||
brief: logoBrief,
|
|
||||||
requests: selectedVariants.map((variant) => ({
|
|
||||||
id: variant.id,
|
|
||||||
title: variant.title,
|
|
||||||
body: buildRequestBody(variant),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
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 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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
OUTPUT_DIR = (
|
|
||||||
REPO_ROOT / "public" / "branding" / "taonier-logo-spiral-reference-concepts"
|
|
||||||
)
|
|
||||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-spiral-reference-contact-sheet.png"
|
|
||||||
|
|
||||||
ITEMS = [
|
|
||||||
("01 软泥旋合", "taonier-spiral-soft-squish.png"),
|
|
||||||
("02 糖果泥卷", "taonier-spiral-candy-roll.png"),
|
|
||||||
("03 星核涡标", "taonier-spiral-star-core.png"),
|
|
||||||
("04 Q弹泥涡", "taonier-spiral-bouncy-clay.png"),
|
|
||||||
("05 创作星涡", "taonier-spiral-creation-whirl.png"),
|
|
||||||
("06 旋合软标", "taonier-spiral-soft-token.png"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
cell_size = 330
|
|
||||||
label_height = 58
|
|
||||||
gap = 28
|
|
||||||
columns = 3
|
|
||||||
rows = 2
|
|
||||||
width = columns * cell_size + (columns + 1) * gap
|
|
||||||
height = rows * (cell_size + label_height) + (rows + 1) * gap
|
|
||||||
|
|
||||||
sheet = Image.new("RGB", (width, height), "#f6f2eb")
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
font = load_font(24)
|
|
||||||
|
|
||||||
for index, (label, filename) in enumerate(ITEMS):
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell_size + gap)
|
|
||||||
y = gap + row * (cell_size + label_height + gap)
|
|
||||||
|
|
||||||
source = Image.open(OUTPUT_DIR / filename).convert("RGB")
|
|
||||||
thumbnail = source.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
|
||||||
sheet.paste(thumbnail, (x, y))
|
|
||||||
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
|
||||||
radius=10,
|
|
||||||
fill="#fffdf8",
|
|
||||||
)
|
|
||||||
text_box = draw.textbbox((0, 0), label, font=font)
|
|
||||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), label, fill="#302a25", font=font)
|
|
||||||
|
|
||||||
sheet.save(CONTACT_SHEET_PATH, quality=95)
|
|
||||||
print(CONTACT_SHEET_PATH)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,315 +0,0 @@
|
|||||||
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,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
const repoRoot = process.cwd();
|
|
||||||
const outputDir = path.join(
|
|
||||||
repoRoot,
|
|
||||||
'public',
|
|
||||||
'branding',
|
|
||||||
'taonier-logo-squish-variants',
|
|
||||||
);
|
|
||||||
|
|
||||||
const variants = [
|
|
||||||
{
|
|
||||||
id: 'taonier-squish-berry-mint',
|
|
||||||
title: '莓果薄荷',
|
|
||||||
topStart: '#ff4778',
|
|
||||||
topEnd: '#ff6b5f',
|
|
||||||
bottomStart: '#12c9b7',
|
|
||||||
bottomEnd: '#16b899',
|
|
||||||
starStart: '#ffd54c',
|
|
||||||
starEnd: '#ffb82e',
|
|
||||||
accent: '#ffc545',
|
|
||||||
background: '#fffaf2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-squish-candy-pop',
|
|
||||||
title: '糖果桃青',
|
|
||||||
topStart: '#ff5fa2',
|
|
||||||
topEnd: '#ff8670',
|
|
||||||
bottomStart: '#2ed7c5',
|
|
||||||
bottomEnd: '#65d8f4',
|
|
||||||
starStart: '#ffe06f',
|
|
||||||
starEnd: '#ffbf4d',
|
|
||||||
accent: '#ffce5e',
|
|
||||||
background: '#fff8fb',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-squish-jelly-cream',
|
|
||||||
title: '奶油果冻',
|
|
||||||
topStart: '#ff758d',
|
|
||||||
topEnd: '#ff9a70',
|
|
||||||
bottomStart: '#42d6b5',
|
|
||||||
bottomEnd: '#7ce3c5',
|
|
||||||
starStart: '#fff07a',
|
|
||||||
starEnd: '#ffc955',
|
|
||||||
accent: '#ffd76a',
|
|
||||||
background: '#fffdf4',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-squish-bubble-bright',
|
|
||||||
title: '亮彩泡泡',
|
|
||||||
topStart: '#ff3f8f',
|
|
||||||
topEnd: '#ff6d6d',
|
|
||||||
bottomStart: '#00c2b8',
|
|
||||||
bottomEnd: '#00d69a',
|
|
||||||
starStart: '#fff15c',
|
|
||||||
starEnd: '#ffbe35',
|
|
||||||
accent: '#ffbd3c',
|
|
||||||
background: '#fdfcff',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-squish-sunny-coral',
|
|
||||||
title: '暖日珊瑚',
|
|
||||||
topStart: '#ff684f',
|
|
||||||
topEnd: '#ff8d67',
|
|
||||||
bottomStart: '#22c4a8',
|
|
||||||
bottomEnd: '#4dd9b5',
|
|
||||||
starStart: '#ffe36d',
|
|
||||||
starEnd: '#ffb948',
|
|
||||||
accent: '#ffc04a',
|
|
||||||
background: '#fff8ed',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'taonier-squish-neon-cute',
|
|
||||||
title: '霓虹可爱',
|
|
||||||
topStart: '#ff3d7f',
|
|
||||||
topEnd: '#ff4fb8',
|
|
||||||
bottomStart: '#00bfae',
|
|
||||||
bottomEnd: '#00d2ff',
|
|
||||||
starStart: '#fff16a',
|
|
||||||
starEnd: '#ffd13d',
|
|
||||||
accent: '#ffcf45',
|
|
||||||
background: '#fbfbff',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function buildLogoSvg(variant, includeLabel = false) {
|
|
||||||
const labelHeight = includeLabel ? 72 : 0;
|
|
||||||
const height = 1024 + labelHeight;
|
|
||||||
const labelMarkup = includeLabel
|
|
||||||
? `
|
|
||||||
<rect x="0" y="1024" width="1024" height="72" fill="#fffdf8"/>
|
|
||||||
<text x="512" y="1069" text-anchor="middle" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="36" fill="#302a25">${variant.title}</text>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="1024" height="${height}" viewBox="0 0 1024 ${height}" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="top" x1="250" y1="180" x2="770" y2="420" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="${variant.topStart}"/>
|
|
||||||
<stop offset="1" stop-color="${variant.topEnd}"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="bottom" x1="250" y1="620" x2="760" y2="850" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="${variant.bottomStart}"/>
|
|
||||||
<stop offset="1" stop-color="${variant.bottomEnd}"/>
|
|
||||||
</linearGradient>
|
|
||||||
<linearGradient id="star" x1="438" y1="452" x2="586" y2="590" gradientUnits="userSpaceOnUse">
|
|
||||||
<stop stop-color="${variant.starStart}"/>
|
|
||||||
<stop offset="1" stop-color="${variant.starEnd}"/>
|
|
||||||
</linearGradient>
|
|
||||||
<filter id="softShadow" x="140" y="120" width="760" height="790" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
|
||||||
<feDropShadow dx="0" dy="18" stdDeviation="16" flood-color="#231f20" flood-opacity="0.08"/>
|
|
||||||
</filter>
|
|
||||||
</defs>
|
|
||||||
<rect width="1024" height="${height}" fill="${variant.background}"/>
|
|
||||||
<g filter="url(#softShadow)">
|
|
||||||
<path d="M257.5 295.6C286.4 210.6 398.9 164 513.4 176.9C618.3 188.7 731.3 244.8 776.2 326.4C810.8 389.2 779.6 447.5 709.4 458.8C646.8 468.8 596.8 438.6 543.5 429.4C475.6 417.7 428.6 456.8 370.4 471.7C301.2 489.4 229.3 378.4 257.5 295.6Z" fill="url(#top)"/>
|
|
||||||
<path d="M251.2 672.8C273.2 589.1 366.4 562.5 427.4 617.9C469.5 656.1 503.6 712.6 565.5 721.3C621.9 729.2 660.5 692.6 712.6 673.7C776.6 650.4 839.4 693.4 821.8 762.9C798.2 856 672.2 910 543.4 889.5C424.4 870.6 328.6 812.7 275.9 743.4C259.1 721.4 244.5 698.4 251.2 672.8Z" fill="url(#bottom)"/>
|
|
||||||
</g>
|
|
||||||
<path d="M512 428C528.1 481.8 550.2 503.9 604 520C550.2 536.1 528.1 558.2 512 612C495.9 558.2 473.8 536.1 420 520C473.8 503.9 495.9 481.8 512 428Z" fill="url(#star)"/>
|
|
||||||
<path d="M396 489C405.9 498.8 417.4 503.7 431 504C417.4 504.3 405.9 509.2 396 519C386.1 509.2 374.6 504.3 361 504C374.6 503.7 386.1 498.8 396 489Z" fill="${variant.accent}" opacity="0.95"/>
|
|
||||||
<path d="M630 486C639.9 495.8 651.4 500.7 665 501C651.4 501.3 639.9 506.2 630 516C620.1 506.2 608.6 501.3 595 501C608.6 500.7 620.1 495.8 630 486Z" fill="${variant.accent}" opacity="0.95"/>
|
|
||||||
<circle cx="373" cy="557" r="11" fill="${variant.accent}" opacity="0.88"/>
|
|
||||||
<circle cx="650" cy="555" r="11" fill="${variant.accent}" opacity="0.88"/>
|
|
||||||
${labelMarkup}
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildContactSheetSvg() {
|
|
||||||
const cell = 320;
|
|
||||||
const label = 64;
|
|
||||||
const gap = 28;
|
|
||||||
const width = cell * 3 + gap * 4;
|
|
||||||
const height = (cell + label) * 2 + gap * 3;
|
|
||||||
const items = variants
|
|
||||||
.map((variant, index) => {
|
|
||||||
const row = Math.floor(index / 3);
|
|
||||||
const col = index % 3;
|
|
||||||
const x = gap + col * (cell + gap);
|
|
||||||
const y = gap + row * (cell + label + gap);
|
|
||||||
const logo = buildLogoSvg(variant)
|
|
||||||
.replace(/<\?xml[^>]+>\n/u, '')
|
|
||||||
.replace('<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">', `<svg x="${x}" y="${y}" width="${cell}" height="${cell}" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">`);
|
|
||||||
return `${logo}
|
|
||||||
<rect x="${x}" y="${y + cell}" width="${cell}" height="${label}" fill="#fffdf8"/>
|
|
||||||
<text x="${x + cell / 2}" y="${y + cell + 40}" text-anchor="middle" font-family="Microsoft YaHei, PingFang SC, Arial, sans-serif" font-size="22" fill="#302a25">${String(index + 1).padStart(2, '0')} ${variant.title}</text>`;
|
|
||||||
})
|
|
||||||
.join('\n');
|
|
||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<rect width="${width}" height="${height}" fill="#f6f2eb"/>
|
|
||||||
${items}
|
|
||||||
</svg>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(outputDir, { recursive: true });
|
|
||||||
|
|
||||||
for (const variant of variants) {
|
|
||||||
writeFileSync(
|
|
||||||
path.join(outputDir, `${variant.id}.svg`),
|
|
||||||
buildLogoSvg(variant),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
writeFileSync(
|
|
||||||
path.join(outputDir, 'taonier-squish-variants-contact-sheet.svg'),
|
|
||||||
buildContactSheetSvg(),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
{
|
|
||||||
ok: true,
|
|
||||||
outputDir,
|
|
||||||
files: [...variants.map((variant) => `${variant.id}.svg`), 'taonier-squish-variants-contact-sheet.svg'],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import colorsys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from PIL import Image, ImageDraw, ImageFont
|
|
||||||
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
SOURCE_PATH = (
|
|
||||||
REPO_ROOT
|
|
||||||
/ "public"
|
|
||||||
/ "branding"
|
|
||||||
/ "taonier-logo-magic-dot-concepts"
|
|
||||||
/ "taonier-magic-dot-squish.png"
|
|
||||||
)
|
|
||||||
OUTPUT_DIR = (
|
|
||||||
REPO_ROOT / "public" / "branding" / "taonier-logo-squish-recolor-variants"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
VARIANTS = [
|
|
||||||
{
|
|
||||||
"id": "taonier-squish-recolor-original-plus",
|
|
||||||
"title": "原版提亮",
|
|
||||||
"top": ("#ff3f74", "#ff5a8c"),
|
|
||||||
"bottom": ("#10c6b1", "#19d5b8"),
|
|
||||||
"star": ("#ffd249", "#ffc13c"),
|
|
||||||
"background": "#fffdf8",
|
|
||||||
"saturation": 1.04,
|
|
||||||
"value": 1.02,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-squish-recolor-candy-mint",
|
|
||||||
"title": "糖果薄荷",
|
|
||||||
"top": ("#ff5aa0", "#ff7786"),
|
|
||||||
"bottom": ("#1fd3c2", "#46e0cc"),
|
|
||||||
"star": ("#ffe071", "#ffc64b"),
|
|
||||||
"background": "#fffafd",
|
|
||||||
"saturation": 1.02,
|
|
||||||
"value": 1.05,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-squish-recolor-peach-jelly",
|
|
||||||
"title": "桃桃果冻",
|
|
||||||
"top": ("#ff6b8d", "#ff8b72"),
|
|
||||||
"bottom": ("#30cfb7", "#72dec5"),
|
|
||||||
"star": ("#ffe586", "#ffc75c"),
|
|
||||||
"background": "#fffaf2",
|
|
||||||
"saturation": 0.96,
|
|
||||||
"value": 1.07,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-squish-recolor-pop-bright",
|
|
||||||
"title": "亮彩出圈",
|
|
||||||
"top": ("#ff2f82", "#ff4faf"),
|
|
||||||
"bottom": ("#00c5b9", "#00d8e8"),
|
|
||||||
"star": ("#fff15a", "#ffc735"),
|
|
||||||
"background": "#fbfbff",
|
|
||||||
"saturation": 1.10,
|
|
||||||
"value": 1.03,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-squish-recolor-coral-soda",
|
|
||||||
"title": "珊瑚苏打",
|
|
||||||
"top": ("#ff674d", "#ff8372"),
|
|
||||||
"bottom": ("#17c5a9", "#55dabc"),
|
|
||||||
"star": ("#ffe26f", "#ffbe43"),
|
|
||||||
"background": "#fff9ee",
|
|
||||||
"saturation": 0.98,
|
|
||||||
"value": 1.05,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "taonier-squish-recolor-bubble-q",
|
|
||||||
"title": "泡泡Q感",
|
|
||||||
"top": ("#ff68ba", "#ff77a0"),
|
|
||||||
"bottom": ("#35d8c9", "#73e7d8"),
|
|
||||||
"star": ("#fff08c", "#ffd35c"),
|
|
||||||
"background": "#fffaff",
|
|
||||||
"saturation": 0.92,
|
|
||||||
"value": 1.09,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def hex_to_rgb(value: str) -> tuple[int, int, int]:
|
|
||||||
value = value.removeprefix("#")
|
|
||||||
return tuple(int(value[index : index + 2], 16) for index in (0, 2, 4))
|
|
||||||
|
|
||||||
|
|
||||||
def blend_rgb(
|
|
||||||
first: tuple[int, int, int], second: tuple[int, int, int], amount: float
|
|
||||||
) -> tuple[int, int, int]:
|
|
||||||
amount = max(0.0, min(1.0, amount))
|
|
||||||
return tuple(round(first[index] * (1 - amount) + second[index] * amount) for index in range(3))
|
|
||||||
|
|
||||||
|
|
||||||
def classify_pixel(red: int, green: int, blue: int) -> str | None:
|
|
||||||
hue, saturation, value = colorsys.rgb_to_hsv(red / 255, green / 255, blue / 255)
|
|
||||||
|
|
||||||
if value < 0.42 or saturation < 0.09:
|
|
||||||
return None
|
|
||||||
if red > 145 and green > 105 and blue < 170 and red > blue + 20 and green > blue + 16:
|
|
||||||
return "star"
|
|
||||||
if red > 140 and red > green + 18 and red > blue + 12 and (hue < 0.08 or hue > 0.9):
|
|
||||||
return "top"
|
|
||||||
if green > 105 and blue > 88 and green > red + 15 and blue > red + 6 and 0.36 <= hue <= 0.58:
|
|
||||||
return "bottom"
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def remap_color(
|
|
||||||
red: int,
|
|
||||||
green: int,
|
|
||||||
blue: int,
|
|
||||||
x: int,
|
|
||||||
y: int,
|
|
||||||
width: int,
|
|
||||||
height: int,
|
|
||||||
group: str,
|
|
||||||
variant: dict[str, object],
|
|
||||||
) -> tuple[int, int, int]:
|
|
||||||
hue, saturation, value = colorsys.rgb_to_hsv(red / 255, green / 255, blue / 255)
|
|
||||||
palette = variant[group]
|
|
||||||
assert isinstance(palette, tuple)
|
|
||||||
start = hex_to_rgb(palette[0])
|
|
||||||
end = hex_to_rgb(palette[1])
|
|
||||||
|
|
||||||
if group == "top":
|
|
||||||
gradient_position = 0.68 * (x / width) + 0.32 * (y / height)
|
|
||||||
elif group == "bottom":
|
|
||||||
gradient_position = 0.42 * (x / width) + 0.58 * (y / height)
|
|
||||||
else:
|
|
||||||
gradient_position = 0.18 * (x / width) + 0.82 * (y / height)
|
|
||||||
|
|
||||||
target = blend_rgb(start, end, gradient_position)
|
|
||||||
target_hue, target_saturation, target_value = colorsys.rgb_to_hsv(
|
|
||||||
target[0] / 255, target[1] / 255, target[2] / 255
|
|
||||||
)
|
|
||||||
|
|
||||||
saturation_boost = float(variant["saturation"])
|
|
||||||
value_boost = float(variant["value"])
|
|
||||||
new_saturation = max(0.0, min(1.0, target_saturation * saturation_boost))
|
|
||||||
# 原图本身有轻微明暗变化,这里保留它,让换色后仍然像同一个软泥形体。
|
|
||||||
new_value = max(0.0, min(1.0, target_value * (0.78 + value * 0.22) * value_boost))
|
|
||||||
recolored = colorsys.hsv_to_rgb(target_hue, new_saturation, new_value)
|
|
||||||
|
|
||||||
background = hex_to_rgb(str(variant["background"]))
|
|
||||||
edge_coverage = max(0.0, min(1.0, (saturation - 0.08) / 0.55))
|
|
||||||
if group == "star":
|
|
||||||
edge_coverage = max(0.0, min(1.0, (saturation - 0.06) / 0.5))
|
|
||||||
|
|
||||||
foreground = tuple(round(channel * 255) for channel in recolored)
|
|
||||||
return blend_rgb(background, foreground, edge_coverage)
|
|
||||||
|
|
||||||
|
|
||||||
def recolor_variant(source: Image.Image, variant: dict[str, object]) -> Image.Image:
|
|
||||||
image = source.convert("RGBA")
|
|
||||||
width, height = image.size
|
|
||||||
background = hex_to_rgb(str(variant["background"]))
|
|
||||||
result = Image.new("RGBA", image.size, (*background, 255))
|
|
||||||
|
|
||||||
source_pixels = image.load()
|
|
||||||
result_pixels = result.load()
|
|
||||||
for y in range(height):
|
|
||||||
for x in range(width):
|
|
||||||
red, green, blue, alpha = source_pixels[x, y]
|
|
||||||
if alpha == 0:
|
|
||||||
result_pixels[x, y] = (*background, 0)
|
|
||||||
continue
|
|
||||||
|
|
||||||
group = classify_pixel(red, green, blue)
|
|
||||||
if group is None:
|
|
||||||
if red > 238 and green > 238 and blue > 238:
|
|
||||||
result_pixels[x, y] = (*background, alpha)
|
|
||||||
else:
|
|
||||||
result_pixels[x, y] = (red, green, blue, alpha)
|
|
||||||
continue
|
|
||||||
|
|
||||||
new_red, new_green, new_blue = remap_color(
|
|
||||||
red, green, blue, x, y, width, height, group, variant
|
|
||||||
)
|
|
||||||
result_pixels[x, y] = (new_red, new_green, new_blue, alpha)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
|
||||||
candidates = [
|
|
||||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
|
||||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
|
||||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
|
||||||
]
|
|
||||||
for candidate in candidates:
|
|
||||||
if candidate.exists():
|
|
||||||
return ImageFont.truetype(str(candidate), size)
|
|
||||||
return ImageFont.load_default()
|
|
||||||
|
|
||||||
|
|
||||||
def paste_cell(
|
|
||||||
sheet: Image.Image,
|
|
||||||
image: Image.Image,
|
|
||||||
label: str,
|
|
||||||
index: int,
|
|
||||||
font: ImageFont.FreeTypeFont | ImageFont.ImageFont,
|
|
||||||
) -> None:
|
|
||||||
cell = 310
|
|
||||||
label_height = 54
|
|
||||||
gap = 28
|
|
||||||
columns = 4
|
|
||||||
row = index // columns
|
|
||||||
column = index % columns
|
|
||||||
x = gap + column * (cell + gap)
|
|
||||||
y = gap + row * (cell + label_height + gap)
|
|
||||||
|
|
||||||
draw = ImageDraw.Draw(sheet)
|
|
||||||
thumb = image.resize((cell, cell), Image.Resampling.LANCZOS)
|
|
||||||
sheet.alpha_composite(thumb, (x, y))
|
|
||||||
draw.rounded_rectangle(
|
|
||||||
(x, y + cell, x + cell, y + cell + label_height),
|
|
||||||
radius=10,
|
|
||||||
fill=(255, 253, 248, 255),
|
|
||||||
)
|
|
||||||
text = f"{index:02d} {label}"
|
|
||||||
text_box = draw.textbbox((0, 0), text, font=font)
|
|
||||||
text_x = x + (cell - (text_box[2] - text_box[0])) / 2
|
|
||||||
text_y = y + cell + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
|
||||||
draw.text((text_x, text_y), text, fill=(50, 42, 36, 255), font=font)
|
|
||||||
|
|
||||||
|
|
||||||
def build_contact_sheet(original: Image.Image, outputs: list[tuple[dict[str, object], Image.Image]]) -> Image.Image:
|
|
||||||
cell = 310
|
|
||||||
label_height = 54
|
|
||||||
gap = 28
|
|
||||||
columns = 4
|
|
||||||
rows = 2
|
|
||||||
width = columns * cell + (columns + 1) * gap
|
|
||||||
height = rows * (cell + label_height) + (rows + 1) * gap
|
|
||||||
sheet = Image.new("RGBA", (width, height), (246, 242, 235, 255))
|
|
||||||
font = load_font(22)
|
|
||||||
|
|
||||||
paste_cell(sheet, original.convert("RGBA"), "原参考", 0, font)
|
|
||||||
for index, (variant, image) in enumerate(outputs, start=1):
|
|
||||||
paste_cell(sheet, image, str(variant["title"]), index, font)
|
|
||||||
return sheet
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
source = Image.open(SOURCE_PATH)
|
|
||||||
outputs: list[tuple[dict[str, object], Image.Image]] = []
|
|
||||||
|
|
||||||
for variant in VARIANTS:
|
|
||||||
image = recolor_variant(source, variant)
|
|
||||||
image.save(OUTPUT_DIR / f"{variant['id']}.png")
|
|
||||||
outputs.append((variant, image))
|
|
||||||
|
|
||||||
contact_sheet = build_contact_sheet(source, outputs)
|
|
||||||
contact_sheet.convert("RGB").save(OUTPUT_DIR / "taonier-squish-recolor-contact-sheet.png")
|
|
||||||
|
|
||||||
print(
|
|
||||||
{
|
|
||||||
"ok": True,
|
|
||||||
"output_dir": str(OUTPUT_DIR),
|
|
||||||
"files": [f"{variant['id']}.png" for variant in VARIANTS]
|
|
||||||
+ ["taonier-squish-recolor-contact-sheet.png"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Reference in New Issue
Block a user