feat: refresh creation config and visual assets
This commit is contained in:
+46
-15
@@ -675,7 +675,17 @@ class DevRunner {
|
||||
}
|
||||
|
||||
if (!this.options.skipPublish) {
|
||||
await this.publishSpacetimeModule();
|
||||
try {
|
||||
await this.publishSpacetimeModule();
|
||||
} catch (error) {
|
||||
if (isSpacetimePublishPermissionError(error)) {
|
||||
console.warn(
|
||||
`[dev:spacetime] 本地发布被当前 identity 拒绝,保留已启动的 standalone: ${error.message}`,
|
||||
);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,20 +779,11 @@ class DevRunner {
|
||||
const env = {...this.baseEnv};
|
||||
this.prepareMigrationBootstrapSecret(env);
|
||||
|
||||
const args = [
|
||||
'publish',
|
||||
this.options.database,
|
||||
'--server',
|
||||
this.state.spacetimeServer,
|
||||
'--module-path',
|
||||
modulePath,
|
||||
'--build-options=--debug',
|
||||
];
|
||||
|
||||
if (!this.options.preserveDatabase) {
|
||||
args.push('-c=on-conflict');
|
||||
}
|
||||
args.push('--yes');
|
||||
const args = buildSpacetimePublishArgs({
|
||||
database: this.options.database,
|
||||
preserveDatabase: this.options.preserveDatabase,
|
||||
server: this.state.spacetimeServer,
|
||||
});
|
||||
|
||||
console.log(`[dev:spacetime] 发布模块: ${this.options.database}`);
|
||||
await runForeground('spacetime', args, {
|
||||
@@ -1459,10 +1460,40 @@ function normalizePath(path) {
|
||||
return path.replace(/\\/gu, '/');
|
||||
}
|
||||
|
||||
function buildSpacetimePublishArgs({database, server, preserveDatabase}) {
|
||||
const args = [
|
||||
'publish',
|
||||
database,
|
||||
'--server',
|
||||
server,
|
||||
'--module-path',
|
||||
modulePath,
|
||||
'--build-options=--debug',
|
||||
];
|
||||
|
||||
if (!preserveDatabase) {
|
||||
args.push('-c=on-conflict');
|
||||
}
|
||||
|
||||
args.push('--yes', '--no-config');
|
||||
return args;
|
||||
}
|
||||
|
||||
function isSpacetimePublishPermissionError(error) {
|
||||
const message = String(error?.message ?? error ?? '');
|
||||
return (
|
||||
message.includes('Pre-publish check failed with status 403 Forbidden') ||
|
||||
message.includes('not authorized to perform action on database') ||
|
||||
message.includes('is not authorized to perform action on database')
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DevRunner,
|
||||
buildSpacetimePublishArgs,
|
||||
createDevServerSpawnOptions,
|
||||
createWatchConfigs,
|
||||
isSpacetimePublishPermissionError,
|
||||
parseArgs,
|
||||
shouldAcceptWatchEvent,
|
||||
};
|
||||
|
||||
@@ -6,8 +6,10 @@ import {afterEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
import {
|
||||
DevRunner,
|
||||
buildSpacetimePublishArgs,
|
||||
createDevServerSpawnOptions,
|
||||
createWatchConfigs,
|
||||
isSpacetimePublishPermissionError,
|
||||
parseArgs,
|
||||
shouldAcceptWatchEvent,
|
||||
} from './dev.mjs';
|
||||
@@ -210,6 +212,34 @@ describe('dev scheduler watch routing', () => {
|
||||
});
|
||||
|
||||
describe('dev scheduler spacetime refresh', () => {
|
||||
test('本地发布 403 时识别为身份权限问题,避免误杀 standalone', () => {
|
||||
const error = new Error(
|
||||
'Pre-publish check failed with status 403 Forbidden: c200... is not authorized to perform action on database c200...: update database',
|
||||
);
|
||||
|
||||
expect(isSpacetimePublishPermissionError(error)).toBe(true);
|
||||
expect(isSpacetimePublishPermissionError(new Error('No database target matches'))).toBe(false);
|
||||
});
|
||||
|
||||
test('发布 spacetime-module 时忽略 spacetime.json 以免覆盖显式数据库', () => {
|
||||
const args = buildSpacetimePublishArgs({
|
||||
database: 'xushi-p4wfr',
|
||||
preserveDatabase: false,
|
||||
server: 'http://127.0.0.1:3101',
|
||||
});
|
||||
|
||||
expect(args).toContain('--no-config');
|
||||
expect(args).toEqual(
|
||||
expect.arrayContaining([
|
||||
'publish',
|
||||
'xushi-p4wfr',
|
||||
'--server',
|
||||
'http://127.0.0.1:3101',
|
||||
'-c=on-conflict',
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('手动刷新 spacetime 只重新发布模块,不重启 standalone 进程', async () => {
|
||||
const {explicitOptions, options} = parseArgs([], {});
|
||||
const runner = new DevRunner(options, {}, explicitOptions);
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
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));
|
||||
@@ -0,0 +1,79 @@
|
||||
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()
|
||||
@@ -0,0 +1,330 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,295 @@
|
||||
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()
|
||||
@@ -0,0 +1,77 @@
|
||||
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()
|
||||
@@ -0,0 +1,318 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,297 @@
|
||||
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()
|
||||
@@ -0,0 +1,323 @@
|
||||
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()
|
||||
@@ -0,0 +1,87 @@
|
||||
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()
|
||||
@@ -0,0 +1,420 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
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()
|
||||
@@ -0,0 +1,415 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,493 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,144 @@
|
||||
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()
|
||||
@@ -0,0 +1,71 @@
|
||||
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()
|
||||
@@ -0,0 +1,330 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
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()
|
||||
@@ -0,0 +1,419 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
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()
|
||||
@@ -0,0 +1,422 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
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()
|
||||
@@ -0,0 +1,418 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, '..');
|
||||
const outputDir = path.join(
|
||||
repoRoot,
|
||||
'public',
|
||||
'branding',
|
||||
'taonier-logo-flow-concepts',
|
||||
);
|
||||
const timeoutMsDefault = 180000;
|
||||
|
||||
const args = new Map();
|
||||
for (let index = 2; index < process.argv.length; index += 1) {
|
||||
const raw = process.argv[index];
|
||||
if (!raw.startsWith('--')) {
|
||||
continue;
|
||||
}
|
||||
const next = process.argv[index + 1];
|
||||
if (next && !next.startsWith('--')) {
|
||||
args.set(raw, next);
|
||||
index += 1;
|
||||
} else {
|
||||
args.set(raw, true);
|
||||
}
|
||||
}
|
||||
|
||||
const basePrompt = [
|
||||
'请生成一枚全新的「陶泥儿」产品商标图形标概念稿,但画面中绝对不要出现任何中文字、英文字母、数字、符号字样或品牌字标。',
|
||||
'产品定位:精品 AI UGC 轻休闲小游戏创作与传播平台,用户可以像捏陶泥一样把脑洞、梗和灵感塑造成小游戏或趣味内容。',
|
||||
'这次必须彻底放弃方形底和中心星星:不要整体方形、不要圆角方块、不要 App 图标底板、不要中间星形、不要五角星、不要四角星、不要闪光、不要中心挖孔星。',
|
||||
'核心隐喻:连续的陶泥曲线。用一整块顺滑、亲和、可塑的陶泥大结构来表达「脑洞被揉开、灵感流动、内容成型」,图形要像一个完整的流动符号,而不是底板加中心图案。',
|
||||
'主结构:由平滑曲线组成的大轮廓,可以像柔软泥流、脑洞涟漪、捏合的回环、温和的旋拧、单线团块或开放结。整体必须连贯、圆润、有亲和力,不能分裂成多个碎片。',
|
||||
'风格:现代扁平矢量商标,轻微哑光陶泥质感;边缘干净、曲线饱满、负形明确,适合商标、App 图标、社区头像和启动页。',
|
||||
'配色:暖陶白、浅陶土、陶土橙、暖棕为主,可加入少量低饱和孔雀青或深泥灰作为曲线内侧阴影或小连接点。颜色要温暖、有吸引力,但不要糖果色。',
|
||||
'识别度:远看必须能记住一个独特的大曲线轮廓;不要靠中心图案识别。缩小到 64px 时仍能看出整体曲线姿态。',
|
||||
'亲和力:整体像可以被揉捏的柔软小世界,有陪伴感和创作感,但不要做成脸、表情、角色或吉祥物。',
|
||||
'禁止:方形底、圆角方块、中心星星、任何星形、闪光、徽章、印章、砖块、泥饼、石头、陶片、饼干、糖果、手、陶艺工具、笔刷、复杂场景、按钮、UI、边框、水印、文字。',
|
||||
];
|
||||
|
||||
const variants = [
|
||||
{
|
||||
id: '01-soft-ribbon-loop',
|
||||
title: '柔泥回环',
|
||||
prompt: [
|
||||
...basePrompt,
|
||||
'本张重点:一条宽厚柔软的陶泥带形成开放回环,像脑洞被揉开。轮廓连续,内部负形自然,不出现星形或方形底。',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '02-clay-wave-knot',
|
||||
title: '陶泥波结',
|
||||
prompt: [
|
||||
...basePrompt,
|
||||
'本张重点:像一个温和波浪结,由 2 条互相捏合的平滑曲线组成,但必须视觉上像一个整体符号,不要碎片化。',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '03-imagination-ripple',
|
||||
title: '脑洞涟漪',
|
||||
prompt: [
|
||||
...basePrompt,
|
||||
'本张重点:用一整块陶泥曲面形成涟漪状大轮廓,中间负形像柔和水滴或脑洞入口,不能像星星。',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '04-friendly-clay-comet',
|
||||
title: '亲和泥流',
|
||||
prompt: [
|
||||
...basePrompt,
|
||||
'本张重点:亲和泥流。整体像一个流动的陶泥小世界,前端圆润、尾部自然收束,有动势但不尖锐。',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '05-single-stroke-blob',
|
||||
title: '单笔团块',
|
||||
prompt: [
|
||||
...basePrompt,
|
||||
'本张重点:单笔成型。像用一笔连续曲线捏出的陶泥团,结构极简但有记忆点,适合后续矢量化。',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '06-two-tone-soft-flow',
|
||||
title: '双色软流',
|
||||
prompt: [
|
||||
...basePrompt,
|
||||
'本张重点:双色曲线。暖陶白主体配少量孔雀青或陶土橙内侧曲线,让图形更吸引人,但不能变成多片拼贴。',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '07-open-clay-orbit',
|
||||
title: '开放泥环',
|
||||
prompt: [
|
||||
...basePrompt,
|
||||
'本张重点:开放式泥环。不是闭合圆,也不是旋涡旧稿,而是一枚有缺口和呼吸感的平滑陶泥环形符号。',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '08-brand-flow-glyph',
|
||||
title: '品牌曲线符',
|
||||
prompt: [
|
||||
...basePrompt,
|
||||
'本张重点:最终品牌符号潜力。减少材质和细节,用 1 到 2 个大曲线色块形成独特、亲和、可注册的图形标。',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function readDotenv(fileName) {
|
||||
const filePath = path.join(repoRoot, fileName);
|
||||
if (!existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const values = {};
|
||||
for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/u)) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
let value = match[2].trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
values[match[1]] = value;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function resolveEnv() {
|
||||
const loaded = {
|
||||
...readDotenv('.env.example'),
|
||||
...readDotenv('.env.local'),
|
||||
...readDotenv('.env.secrets.local'),
|
||||
...process.env,
|
||||
};
|
||||
return {
|
||||
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
|
||||
.trim()
|
||||
.replace(/\/+$/u, ''),
|
||||
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
|
||||
timeoutMs: Number.parseInt(
|
||||
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || timeoutMsDefault),
|
||||
10,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function buildVectorEngineImagesGenerationUrl(baseUrl) {
|
||||
return baseUrl.endsWith('/v1')
|
||||
? `${baseUrl}/images/generations`
|
||||
: `${baseUrl}/v1/images/generations`;
|
||||
}
|
||||
|
||||
function buildRequestBody(variant) {
|
||||
return {
|
||||
model: 'gpt-image-2-all',
|
||||
prompt: variant.prompt.join('\n'),
|
||||
n: 1,
|
||||
size: '1024x1024',
|
||||
};
|
||||
}
|
||||
|
||||
function collectStringsByKey(value, targetKey, output) {
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((entry) => collectStringsByKey(entry, targetKey, output));
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const [key, nested] of Object.entries(value)) {
|
||||
if (key === targetKey) {
|
||||
if (typeof nested === 'string' && nested.trim()) {
|
||||
output.push(nested.trim());
|
||||
}
|
||||
if (Array.isArray(nested)) {
|
||||
nested.forEach((entry) => {
|
||||
if (typeof entry === 'string' && entry.trim()) {
|
||||
output.push(entry.trim());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
collectStringsByKey(nested, targetKey, output);
|
||||
}
|
||||
}
|
||||
|
||||
function extractImageUrls(payload) {
|
||||
const urls = [];
|
||||
collectStringsByKey(payload, 'url', urls);
|
||||
collectStringsByKey(payload, 'image', urls);
|
||||
collectStringsByKey(payload, 'image_url', urls);
|
||||
return [...new Set(urls)].filter((url) => /^https?:\/\//u.test(url));
|
||||
}
|
||||
|
||||
function extractBase64Images(payload) {
|
||||
const values = [];
|
||||
collectStringsByKey(payload, 'b64_json', values);
|
||||
return values;
|
||||
}
|
||||
|
||||
function inferExtensionFromContentType(contentType) {
|
||||
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
|
||||
if (normalized === 'image/png') {
|
||||
return 'png';
|
||||
}
|
||||
if (normalized === 'image/webp') {
|
||||
return 'webp';
|
||||
}
|
||||
if (normalized === 'image/gif') {
|
||||
return 'gif';
|
||||
}
|
||||
return 'jpg';
|
||||
}
|
||||
|
||||
function inferExtensionFromBytes(bytes) {
|
||||
if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) {
|
||||
return 'png';
|
||||
}
|
||||
if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) {
|
||||
return 'jpg';
|
||||
}
|
||||
if (
|
||||
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
bytes.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'webp';
|
||||
}
|
||||
return 'png';
|
||||
}
|
||||
|
||||
async function fetchJson(url, options, timeoutMs) {
|
||||
const abortController = new AbortController();
|
||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
|
||||
}
|
||||
return JSON.parse(text);
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadUrl(url, timeoutMs) {
|
||||
const abortController = new AbortController();
|
||||
const timer = setTimeout(() => abortController.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { signal: abortController.signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`download ${response.status}`);
|
||||
}
|
||||
const bytes = Buffer.from(await response.arrayBuffer());
|
||||
return {
|
||||
bytes,
|
||||
extension: inferExtensionFromContentType(
|
||||
response.headers.get('content-type') || 'image/jpeg',
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error(`Generated image download timed out after ${timeoutMs}ms`);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function generateOne(env, variant) {
|
||||
const requestBody = buildRequestBody(variant);
|
||||
const payload = await fetchJson(
|
||||
buildVectorEngineImagesGenerationUrl(env.baseUrl),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${env.apiKey}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
},
|
||||
env.timeoutMs,
|
||||
);
|
||||
|
||||
const urls = extractImageUrls(payload);
|
||||
const b64Images = extractBase64Images(payload);
|
||||
let image;
|
||||
if (urls[0]) {
|
||||
image = await downloadUrl(urls[0], env.timeoutMs);
|
||||
} else if (b64Images[0]) {
|
||||
const bytes = Buffer.from(b64Images[0], 'base64');
|
||||
image = {
|
||||
bytes,
|
||||
extension: inferExtensionFromBytes(bytes),
|
||||
};
|
||||
} else {
|
||||
throw new Error(`VectorEngine returned no image for ${variant.id}`);
|
||||
}
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const outputPath = path.join(outputDir, `taonier-flow-${variant.id}.${image.extension}`);
|
||||
writeFileSync(outputPath, image.bytes);
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
function writeManifest(files) {
|
||||
const manifestPath = path.join(outputDir, 'taonier-logo-flow-manifest.json');
|
||||
writeFileSync(
|
||||
manifestPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
model: 'gpt-image-2-all',
|
||||
size: '1024x1024',
|
||||
generatedAt: new Date().toISOString(),
|
||||
creativeDirection: {
|
||||
name: '陶泥儿连续曲线亲和图形标',
|
||||
textPolicy: 'no Chinese, no English, no wordmark',
|
||||
correction:
|
||||
'no square base, no center star, use one continuous friendly clay curve structure',
|
||||
motif: '柔泥回环、脑洞涟漪、开放泥环、品牌曲线符',
|
||||
},
|
||||
variants: variants.map((variant) => {
|
||||
const file = files.find((item) => path.basename(item).includes(variant.id));
|
||||
return {
|
||||
id: variant.id,
|
||||
title: variant.title,
|
||||
file: file ? path.basename(file) : null,
|
||||
prompt: variant.prompt.join('\n'),
|
||||
};
|
||||
}),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
const dryRun = args.has('--dry-run') || !args.has('--live');
|
||||
const limit = Number.parseInt(String(args.get('--limit') || variants.length), 10);
|
||||
const selectedVariants = variants.slice(0, limit);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
mode: 'dry-run',
|
||||
outputDir,
|
||||
count: selectedVariants.length,
|
||||
requests: selectedVariants.map((variant) => ({
|
||||
id: variant.id,
|
||||
title: variant.title,
|
||||
body: buildRequestBody(variant),
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const env = resolveEnv();
|
||||
if (!env.baseUrl || !env.apiKey) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
|
||||
hasBaseUrl: Boolean(env.baseUrl),
|
||||
hasApiKey: Boolean(env.apiKey),
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const generated = [];
|
||||
for (const variant of selectedVariants) {
|
||||
console.log(`Generating ${variant.id} ${variant.title}...`);
|
||||
generated.push(await generateOne(env, variant));
|
||||
}
|
||||
|
||||
const manifestPath = writeManifest(generated);
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
ok: true,
|
||||
count: generated.length,
|
||||
files: generated,
|
||||
manifest: manifestPath,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,300 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
OUTPUT_DIR = REPO_ROOT / "public" / "branding" / "taonier-logo-geometric-concepts"
|
||||
CONTACT_SHEET_PATH = OUTPUT_DIR / "taonier-logo-geometric-contact-sheet.png"
|
||||
|
||||
SIZE = 1024
|
||||
SCALE = 4
|
||||
|
||||
INK = "#151515"
|
||||
CREAM = "#fff7e6"
|
||||
GOLD = "#ffd25d"
|
||||
CORAL = "#ff6a5f"
|
||||
MINT = "#29c9ad"
|
||||
BLUE = "#2f6bff"
|
||||
|
||||
VARIANTS = [
|
||||
("taonier-geometric-offset-core", "偏心泥孔", "offset_core"),
|
||||
("taonier-geometric-mold-chip", "模芯切片", "mold_chip"),
|
||||
("taonier-geometric-pinched-tile", "捏痕方标", "pinched_tile"),
|
||||
("taonier-geometric-dual-plate", "双片合模", "dual_plate"),
|
||||
("taonier-geometric-dot-gate", "泥点入口", "dot_gate"),
|
||||
("taonier-geometric-work-knot", "作品结点", "work_knot"),
|
||||
]
|
||||
|
||||
|
||||
def hex_to_rgb(value: str) -> tuple[int, int, int]:
|
||||
value = value.removeprefix("#")
|
||||
return tuple(int(value[index : index + 2], 16) for index in (0, 2, 4))
|
||||
|
||||
|
||||
def s(value: float) -> int:
|
||||
return round(value * SCALE)
|
||||
|
||||
|
||||
def rgba(value: str) -> tuple[int, int, int, int]:
|
||||
red, green, blue = hex_to_rgb(value)
|
||||
return red, green, blue, 255
|
||||
|
||||
|
||||
def regular_polygon(cx: float, cy: float, radius: float, sides: int, rotation: float) -> list[tuple[int, int]]:
|
||||
points = []
|
||||
for index in range(sides):
|
||||
angle = rotation + math.tau * index / sides
|
||||
points.append((s(cx + math.cos(angle) * radius), s(cy + math.sin(angle) * radius)))
|
||||
return points
|
||||
|
||||
|
||||
def rounded_rectangle(
|
||||
draw: ImageDraw.ImageDraw,
|
||||
box: tuple[float, float, float, float],
|
||||
radius: float,
|
||||
fill: str,
|
||||
) -> None:
|
||||
draw.rounded_rectangle(tuple(s(value) for value in box), radius=s(radius), fill=hex_to_rgb(fill))
|
||||
|
||||
|
||||
def circle(draw: ImageDraw.ImageDraw, cx: float, cy: float, radius: float, fill: str) -> None:
|
||||
draw.ellipse((s(cx - radius), s(cy - radius), s(cx + radius), s(cy + radius)), fill=hex_to_rgb(fill))
|
||||
|
||||
|
||||
def draw_offset_core(draw: ImageDraw.ImageDraw) -> None:
|
||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#111111"))
|
||||
rounded_rectangle(draw, (236, 236, 788, 788), 148, CREAM)
|
||||
circle(draw, 610, 456, 116, "#111111")
|
||||
circle(draw, 610, 456, 48, GOLD)
|
||||
rounded_rectangle(draw, (268, 612, 536, 718), 53, "#111111")
|
||||
rounded_rectangle(draw, (294, 638, 500, 690), 26, CREAM)
|
||||
circle(draw, 352, 370, 34, "#111111")
|
||||
circle(draw, 352, 370, 17, GOLD)
|
||||
|
||||
|
||||
def draw_mold_chip(draw: ImageDraw.ImageDraw) -> None:
|
||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#101418"))
|
||||
draw.polygon(
|
||||
[
|
||||
(s(278), s(230)),
|
||||
(s(734), s(230)),
|
||||
(s(828), s(330)),
|
||||
(s(828), s(694)),
|
||||
(s(706), s(794)),
|
||||
(s(278), s(794)),
|
||||
(s(196), s(708)),
|
||||
(s(196), s(322)),
|
||||
],
|
||||
fill=hex_to_rgb(CREAM),
|
||||
)
|
||||
circle(draw, 512, 512, 144, "#101418")
|
||||
circle(draw, 512, 512, 62, GOLD)
|
||||
rounded_rectangle(draw, (224, 280, 518, 370), 45, CORAL)
|
||||
rounded_rectangle(draw, (574, 654, 796, 736), 41, MINT)
|
||||
|
||||
|
||||
def draw_pinched_tile(draw: ImageDraw.ImageDraw) -> None:
|
||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#14100d"))
|
||||
rounded_rectangle(draw, (232, 250, 792, 774), 170, CREAM)
|
||||
circle(draw, 232, 512, 94, "#14100d")
|
||||
circle(draw, 792, 512, 94, "#14100d")
|
||||
draw.polygon(regular_polygon(512, 512, 104, 4, math.pi / 4), fill=hex_to_rgb("#14100d"))
|
||||
circle(draw, 512, 512, 38, GOLD)
|
||||
rounded_rectangle(draw, (420, 300, 604, 358), 29, CORAL)
|
||||
rounded_rectangle(draw, (420, 666, 604, 724), 29, MINT)
|
||||
|
||||
|
||||
def draw_dual_plate(draw: ImageDraw.ImageDraw) -> None:
|
||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#111111"))
|
||||
rounded_rectangle(draw, (214, 316, 790, 464), 74, CORAL)
|
||||
rounded_rectangle(draw, (234, 560, 810, 708), 74, MINT)
|
||||
draw.polygon(regular_polygon(512, 512, 138, 4, math.pi / 4), fill=hex_to_rgb(CREAM))
|
||||
draw.polygon(regular_polygon(512, 512, 76, 4, math.pi / 4), fill=hex_to_rgb("#111111"))
|
||||
circle(draw, 512, 512, 32, GOLD)
|
||||
circle(draw, 262, 390, 24, CREAM)
|
||||
circle(draw, 762, 634, 24, CREAM)
|
||||
|
||||
|
||||
def draw_dot_gate(draw: ImageDraw.ImageDraw) -> None:
|
||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#101010"))
|
||||
rounded_rectangle(draw, (276, 330, 748, 752), 132, CREAM)
|
||||
rounded_rectangle(draw, (386, 440, 638, 752), 126, "#101010")
|
||||
circle(draw, 512, 260, 62, GOLD)
|
||||
rounded_rectangle(draw, (450, 308, 574, 504), 62, CREAM)
|
||||
circle(draw, 512, 518, 40, GOLD)
|
||||
rounded_rectangle(draw, (316, 754, 708, 812), 29, CREAM)
|
||||
|
||||
|
||||
def draw_work_knot(draw: ImageDraw.ImageDraw) -> None:
|
||||
draw.rectangle((0, 0, s(SIZE), s(SIZE)), fill=hex_to_rgb("#121212"))
|
||||
circle(draw, 396, 402, 132, CREAM)
|
||||
circle(draw, 628, 402, 132, CREAM)
|
||||
circle(draw, 396, 622, 132, CREAM)
|
||||
circle(draw, 628, 622, 132, CREAM)
|
||||
rounded_rectangle(draw, (386, 386, 638, 638), 72, "#121212")
|
||||
draw.polygon(regular_polygon(512, 512, 96, 4, math.pi / 4), fill=hex_to_rgb(GOLD))
|
||||
circle(draw, 396, 402, 48, CORAL)
|
||||
circle(draw, 628, 622, 48, MINT)
|
||||
circle(draw, 628, 402, 28, "#121212")
|
||||
circle(draw, 396, 622, 28, "#121212")
|
||||
|
||||
|
||||
DRAWERS = {
|
||||
"offset_core": draw_offset_core,
|
||||
"mold_chip": draw_mold_chip,
|
||||
"pinched_tile": draw_pinched_tile,
|
||||
"dual_plate": draw_dual_plate,
|
||||
"dot_gate": draw_dot_gate,
|
||||
"work_knot": draw_work_knot,
|
||||
}
|
||||
|
||||
|
||||
def render_variant(style: str) -> Image.Image:
|
||||
image = Image.new("RGB", (SIZE * SCALE, SIZE * SCALE), hex_to_rgb("#111111"))
|
||||
draw = ImageDraw.Draw(image)
|
||||
DRAWERS[style](draw)
|
||||
return image.resize((SIZE, SIZE), Image.Resampling.LANCZOS)
|
||||
|
||||
|
||||
def build_svg(style: str) -> str:
|
||||
# PNG 是当前评审主物料,SVG 保留为后续设计师重绘的结构草图。
|
||||
if style == "offset_core":
|
||||
body = f'''
|
||||
<rect width="1024" height="1024" rx="160" fill="#111111"/>
|
||||
<rect x="236" y="236" width="552" height="552" rx="148" fill="{CREAM}"/>
|
||||
<circle cx="610" cy="456" r="116" fill="#111111"/>
|
||||
<circle cx="610" cy="456" r="48" fill="{GOLD}"/>
|
||||
<rect x="268" y="612" width="268" height="106" rx="53" fill="#111111"/>
|
||||
<rect x="294" y="638" width="206" height="52" rx="26" fill="{CREAM}"/>
|
||||
<circle cx="352" cy="370" r="34" fill="#111111"/>
|
||||
<circle cx="352" cy="370" r="17" fill="{GOLD}"/>'''
|
||||
elif style == "mold_chip":
|
||||
body = f'''
|
||||
<rect width="1024" height="1024" rx="160" fill="#101418"/>
|
||||
<path d="M278 230H734L828 330V694L706 794H278L196 708V322Z" fill="{CREAM}"/>
|
||||
<circle cx="512" cy="512" r="144" fill="#101418"/>
|
||||
<circle cx="512" cy="512" r="62" fill="{GOLD}"/>
|
||||
<rect x="224" y="280" width="294" height="90" rx="45" fill="{CORAL}"/>
|
||||
<rect x="574" y="654" width="222" height="82" rx="41" fill="{MINT}"/>'''
|
||||
elif style == "pinched_tile":
|
||||
body = f'''
|
||||
<rect width="1024" height="1024" rx="160" fill="#14100d"/>
|
||||
<rect x="232" y="250" width="560" height="524" rx="170" fill="{CREAM}"/>
|
||||
<circle cx="232" cy="512" r="94" fill="#14100d"/>
|
||||
<circle cx="792" cy="512" r="94" fill="#14100d"/>
|
||||
<path d="M512 408L616 512L512 616L408 512Z" fill="#14100d"/>
|
||||
<circle cx="512" cy="512" r="38" fill="{GOLD}"/>
|
||||
<rect x="420" y="300" width="184" height="58" rx="29" fill="{CORAL}"/>
|
||||
<rect x="420" y="666" width="184" height="58" rx="29" fill="{MINT}"/>'''
|
||||
elif style == "dual_plate":
|
||||
body = f'''
|
||||
<rect width="1024" height="1024" rx="160" fill="#111111"/>
|
||||
<rect x="214" y="316" width="576" height="148" rx="74" fill="{CORAL}"/>
|
||||
<rect x="234" y="560" width="576" height="148" rx="74" fill="{MINT}"/>
|
||||
<path d="M512 374L650 512L512 650L374 512Z" fill="{CREAM}"/>
|
||||
<path d="M512 436L588 512L512 588L436 512Z" fill="#111111"/>
|
||||
<circle cx="512" cy="512" r="32" fill="{GOLD}"/>
|
||||
<circle cx="262" cy="390" r="24" fill="{CREAM}"/>
|
||||
<circle cx="762" cy="634" r="24" fill="{CREAM}"/>'''
|
||||
elif style == "dot_gate":
|
||||
body = f'''
|
||||
<rect width="1024" height="1024" rx="160" fill="#101010"/>
|
||||
<rect x="276" y="330" width="472" height="422" rx="132" fill="{CREAM}"/>
|
||||
<rect x="386" y="440" width="252" height="312" rx="126" fill="#101010"/>
|
||||
<circle cx="512" cy="260" r="62" fill="{GOLD}"/>
|
||||
<rect x="450" y="308" width="124" height="196" rx="62" fill="{CREAM}"/>
|
||||
<circle cx="512" cy="518" r="40" fill="{GOLD}"/>
|
||||
<rect x="316" y="754" width="392" height="58" rx="29" fill="{CREAM}"/>'''
|
||||
else:
|
||||
body = f'''
|
||||
<rect width="1024" height="1024" rx="160" fill="#121212"/>
|
||||
<circle cx="396" cy="402" r="132" fill="{CREAM}"/>
|
||||
<circle cx="628" cy="402" r="132" fill="{CREAM}"/>
|
||||
<circle cx="396" cy="622" r="132" fill="{CREAM}"/>
|
||||
<circle cx="628" cy="622" r="132" fill="{CREAM}"/>
|
||||
<rect x="386" y="386" width="252" height="252" rx="72" fill="#121212"/>
|
||||
<path d="M512 416L608 512L512 608L416 512Z" fill="{GOLD}"/>
|
||||
<circle cx="396" cy="402" r="48" fill="{CORAL}"/>
|
||||
<circle cx="628" cy="622" r="48" fill="{MINT}"/>
|
||||
<circle cx="628" cy="402" r="28" fill="#121212"/>
|
||||
<circle cx="396" cy="622" r="28" fill="#121212"/>'''
|
||||
return f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
{body}
|
||||
</svg>
|
||||
'''
|
||||
|
||||
|
||||
def load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
candidates = [
|
||||
Path("C:/Windows/Fonts/msyh.ttc"),
|
||||
Path("C:/Windows/Fonts/simhei.ttf"),
|
||||
Path("C:/Windows/Fonts/simsun.ttc"),
|
||||
]
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
return ImageFont.truetype(str(candidate), size)
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def build_contact_sheet(previews: list[tuple[str, str, Image.Image]]) -> Image.Image:
|
||||
cell_size = 320
|
||||
label_height = 60
|
||||
gap = 28
|
||||
columns = 3
|
||||
rows = 2
|
||||
width = columns * cell_size + (columns + 1) * gap
|
||||
height = rows * (cell_size + label_height) + (rows + 1) * gap
|
||||
sheet = Image.new("RGB", (width, height), "#eee9df")
|
||||
draw = ImageDraw.Draw(sheet)
|
||||
font = load_font(23)
|
||||
|
||||
for index, (_, title, preview) in enumerate(previews):
|
||||
row = index // columns
|
||||
column = index % columns
|
||||
x = gap + column * (cell_size + gap)
|
||||
y = gap + row * (cell_size + label_height + gap)
|
||||
thumbnail = preview.resize((cell_size, cell_size), Image.Resampling.LANCZOS)
|
||||
sheet.paste(thumbnail, (x, y))
|
||||
draw.rounded_rectangle(
|
||||
(x, y + cell_size, x + cell_size, y + cell_size + label_height),
|
||||
radius=10,
|
||||
fill="#fffdf8",
|
||||
)
|
||||
label = f"{index + 1:02d} {title}"
|
||||
text_box = draw.textbbox((0, 0), label, font=font)
|
||||
text_x = x + (cell_size - (text_box[2] - text_box[0])) / 2
|
||||
text_y = y + cell_size + (label_height - (text_box[3] - text_box[1])) / 2 - 2
|
||||
draw.text((text_x, text_y), label, fill="#302a25", font=font)
|
||||
return sheet
|
||||
|
||||
|
||||
def main() -> None:
|
||||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
previews: list[tuple[str, str, Image.Image]] = []
|
||||
|
||||
for asset_id, title, style in VARIANTS:
|
||||
preview = render_variant(style)
|
||||
preview.save(OUTPUT_DIR / f"{asset_id}.png")
|
||||
(OUTPUT_DIR / f"{asset_id}.svg").write_text(build_svg(style), encoding="utf-8")
|
||||
previews.append((asset_id, title, preview))
|
||||
|
||||
contact_sheet = build_contact_sheet(previews)
|
||||
contact_sheet.save(CONTACT_SHEET_PATH, quality=95)
|
||||
print(
|
||||
{
|
||||
"ok": True,
|
||||
"output_dir": str(OUTPUT_DIR),
|
||||
"files": [f"{asset_id}.png" for asset_id, _, _ in VARIANTS]
|
||||
+ [f"{asset_id}.svg" for asset_id, _, _ in VARIANTS]
|
||||
+ [CONTACT_SHEET_PATH.name],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user