chore: remove redundant asset generation scripts

This commit is contained in:
kdletters
2026-05-22 16:35:35 +08:00
parent b89d4940a9
commit 2ba4691bc0
65 changed files with 0 additions and 20353 deletions
-145
View File
@@ -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,
),
);
-321
View File
@@ -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