Files
Genarrative/scripts/generate-match3d-style-references.mjs
2026-05-13 00:28:07 +08:00

327 lines
9.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Buffer } from 'node:buffer';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(scriptDir, '..');
const defaultOutDir = path.join(repoRoot, 'public', 'match3d-style-references');
const defaultTimeoutMs = 180000;
const styleTemplates = [
{
id: 'flat-icon',
title: '扁平图标',
prompt:
'扁平矢量游戏道具图标风格,干净色块,正面视角,深色清晰轮廓,移动端休闲游戏素材,可读性很强。',
},
{
id: 'cel-cartoon',
title: '赛璐璐卡通',
prompt:
'赛璐璐卡通游戏道具风格,明亮配色,清晰线稿,硬边阴影,边缘干净,像轻松休闲手游里的 2D 素材。',
},
{
id: 'pixel-retro',
title: '像素复古',
prompt:
'复古像素游戏道具素材风格,有限色板,清晰像素边缘,主体轮廓稳定,像 32-bit 休闲游戏图标。',
},
{
id: 'watercolor',
title: '手绘水彩',
prompt:
'手绘水彩游戏道具风格,柔和纸张纹理,透明叠色,边缘轻微晕染,但主体剪影仍然清楚。',
},
{
id: 'sticker-outline',
title: '贴纸描边',
prompt:
'贴纸描边游戏道具素材风格,粗白边,深色外轮廓,柔和投影,色彩活泼,适合休闲消除游戏。',
},
{
id: 'painterly-icon',
title: '厚涂图标',
prompt:
'厚涂游戏道具图标风格,细腻笔触,明确体积光影,中心构图,清晰剪影,适合高品质 2D 道具素材。',
},
];
const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) {
const raw = process.argv[index];
if (!raw.startsWith('--')) {
continue;
}
const next = process.argv[index + 1];
if (next && !next.startsWith('--')) {
args.set(raw, next);
index += 1;
} else {
args.set(raw, true);
}
}
function readDotenv(fileName) {
const filePath = path.join(repoRoot, fileName);
if (!existsSync(filePath)) {
return {};
}
const values = {};
for (const line of readFileSync(filePath, 'utf8').split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
continue;
}
const match = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u.exec(trimmed);
if (!match) {
continue;
}
let value = match[2].trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
values[match[1]] = value;
}
return values;
}
function resolveEnv() {
const loaded = {
...readDotenv('.env.example'),
...readDotenv('.env.local'),
...readDotenv('.env.secrets.local'),
...process.env,
};
return {
baseUrl: String(loaded.VECTOR_ENGINE_BASE_URL || '')
.trim()
.replace(/\/+$/u, ''),
apiKey: String(loaded.VECTOR_ENGINE_API_KEY || '').trim(),
timeoutMs: Number.parseInt(
String(loaded.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
10,
),
};
}
function buildVectorEngineImagesGenerationUrl(baseUrl) {
return baseUrl.endsWith('/v1')
? `${baseUrl}/images/generations`
: `${baseUrl}/v1/images/generations`;
}
function buildPrompt(template) {
return [
'请生成一张 1:1 方形抓大鹅入口 2D 素材风格参考图。',
'画面是一组 5 个小型游戏道具样张,题材统一为水果、甜点、玩具和宝石的混合展示。',
`整体风格:${template.prompt}`,
'要求:每个道具都是独立 2D 素材示例,主体集中,轮廓清晰,适合被切成抓大鹅局内物品素材。',
'构图:浅色干净背景,散点排列,留有呼吸感,不要九宫格边框,不要 UI 面板,不要按钮。',
'避免文字、水印、logo、教程标注、真实照片、复杂场景、人物、动物、3D 模型视口、明显透视地面、厚重阴影。',
].join('');
}
function collectStringsByKey(value, targetKey, output) {
if (Array.isArray(value)) {
value.forEach((entry) => collectStringsByKey(entry, targetKey, output));
return;
}
if (!value || typeof value !== 'object') {
return;
}
for (const [key, nested] of Object.entries(value)) {
if (key === targetKey) {
if (typeof nested === 'string' && nested.trim()) {
output.push(nested.trim());
}
if (Array.isArray(nested)) {
nested.forEach((entry) => {
if (typeof entry === 'string' && entry.trim()) {
output.push(entry.trim());
}
});
}
}
collectStringsByKey(nested, targetKey, output);
}
}
function extractImageUrls(payload) {
const urls = [];
collectStringsByKey(payload, 'url', urls);
collectStringsByKey(payload, 'image', urls);
collectStringsByKey(payload, 'image_url', urls);
return [...new Set(urls)].filter((url) => /^https?:\/\//u.test(url));
}
function extractBase64Images(payload) {
const values = [];
collectStringsByKey(payload, 'b64_json', values);
return values;
}
async function fetchWithTimeout(url, options, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: abortController.signal,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
}
return text;
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timer);
}
}
async function downloadImage(url, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) {
throw new Error(`download ${response.status}`);
}
return Buffer.from(await response.arrayBuffer());
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(
`Generated image download timed out after ${timeoutMs}ms`,
);
}
throw error;
} finally {
clearTimeout(timer);
}
}
async function generateOne(env, template, outDir, size) {
const requestBody = {
model: 'gpt-image-2-all',
prompt: buildPrompt(template),
n: 1,
size,
};
const payloadText = await fetchWithTimeout(
buildVectorEngineImagesGenerationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(requestBody),
},
env.timeoutMs,
);
const payload = JSON.parse(payloadText);
const urls = extractImageUrls(payload);
const base64Images = extractBase64Images(payload);
const imageBytes = urls[0]
? await downloadImage(urls[0], env.timeoutMs)
: base64Images[0]
? Buffer.from(base64Images[0], 'base64')
: null;
if (!imageBytes) {
throw new Error(`VectorEngine returned no image for ${template.id}`);
}
mkdirSync(outDir, { recursive: true });
const outPath = path.join(outDir, `${template.id}.png`);
writeFileSync(outPath, imageBytes);
return {
file: outPath,
source: urls[0] ? 'url' : 'b64_json',
};
}
const dryRun = args.has('--dry-run') || !args.has('--live');
const outDir = path.resolve(String(args.get('--out-dir') || defaultOutDir));
const size = String(args.get('--size') || '1024x1024');
const onlyIds = String(args.get('--only') || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const templates = styleTemplates.filter(
(template) => !onlyIds.length || onlyIds.includes(template.id),
);
if (dryRun) {
console.log(
JSON.stringify(
{
mode: 'dry-run',
outDir,
count: templates.length,
requests: templates.map((template) => ({
id: template.id,
title: template.title,
body: {
model: 'gpt-image-2-all',
prompt: buildPrompt(template),
n: 1,
size,
},
})),
},
null,
2,
),
);
process.exit(0);
}
const env = resolveEnv();
if (!env.baseUrl || !env.apiKey) {
console.error(
JSON.stringify({
ok: false,
error: 'Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY',
hasBaseUrl: Boolean(env.baseUrl),
hasApiKey: Boolean(env.apiKey),
}),
);
process.exit(1);
}
const generated = [];
for (const template of templates) {
console.log(`Generating ${template.id}...`);
generated.push({
id: template.id,
...(await generateOne(env, template, outDir, size)),
});
}
console.log(
JSON.stringify(
{
ok: true,
count: generated.length,
files: generated,
},
null,
2,
),
);