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()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user