379 lines
12 KiB
JavaScript
379 lines
12 KiB
JavaScript
import {Buffer} from 'node:buffer';
|
||
import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
|
||
import path from 'node:path';
|
||
import {spawnSync} from 'node:child_process';
|
||
|
||
import {mergeApiServerEnv} from './dev-utils.mjs';
|
||
|
||
const repoRoot = process.cwd();
|
||
const defaultTimeoutMs = 1_000_000;
|
||
const defaultTheme = '海底糖果集市';
|
||
const uiLabels = ['back', 'settings', 'tile', 'remove', 'match', 'shuffle'];
|
||
|
||
function parseArgs(argv) {
|
||
const args = {
|
||
live: false,
|
||
theme: defaultTheme,
|
||
outDir: '',
|
||
};
|
||
for (let index = 2; index < argv.length; index += 1) {
|
||
const raw = argv[index];
|
||
if (raw === '--live') {
|
||
args.live = true;
|
||
continue;
|
||
}
|
||
if (raw === '--theme') {
|
||
args.theme = String(argv[index + 1] ?? defaultTheme);
|
||
index += 1;
|
||
continue;
|
||
}
|
||
if (raw === '--out-dir') {
|
||
args.outDir = String(argv[index + 1] ?? '');
|
||
index += 1;
|
||
}
|
||
}
|
||
return args;
|
||
}
|
||
|
||
function timestamp() {
|
||
const now = new Date();
|
||
const pad = (value) => String(value).padStart(2, '0');
|
||
return [
|
||
now.getFullYear(),
|
||
pad(now.getMonth() + 1),
|
||
pad(now.getDate()),
|
||
'-',
|
||
pad(now.getHours()),
|
||
pad(now.getMinutes()),
|
||
pad(now.getSeconds()),
|
||
].join('');
|
||
}
|
||
|
||
function resolveEnv() {
|
||
const env = mergeApiServerEnv(repoRoot, process.env);
|
||
return {
|
||
baseUrl: String(env.VECTOR_ENGINE_BASE_URL || '').trim().replace(/\/+$/u, ''),
|
||
apiKey: String(env.VECTOR_ENGINE_API_KEY || '').trim(),
|
||
timeoutMs: Number.parseInt(
|
||
String(env.VECTOR_ENGINE_IMAGE_REQUEST_TIMEOUT_MS || defaultTimeoutMs),
|
||
10,
|
||
),
|
||
};
|
||
}
|
||
|
||
function generationUrl(baseUrl) {
|
||
return baseUrl.endsWith('/v1')
|
||
? `${baseUrl}/images/generations`
|
||
: `${baseUrl}/v1/images/generations`;
|
||
}
|
||
|
||
function editUrl(baseUrl) {
|
||
return baseUrl.endsWith('/v1')
|
||
? `${baseUrl}/images/edits`
|
||
: `${baseUrl}/v1/images/edits`;
|
||
}
|
||
|
||
function promptWithNegative(prompt, negative) {
|
||
const normalizedPrompt = prompt.trim();
|
||
const normalizedNegative = String(negative ?? '').trim();
|
||
return normalizedNegative
|
||
? `${normalizedPrompt}\n避免:${normalizedNegative}`
|
||
: normalizedPrompt;
|
||
}
|
||
|
||
function buildLevelScenePrompt(theme) {
|
||
const normalizedTheme = String(theme || defaultTheme).trim() || defaultTheme;
|
||
return [
|
||
'生成抓大鹅游戏关卡画面,要求画面中所有元素精致且风格高度一致,画面中所有UI细节饱满精致、完成度高、顶级游戏品质',
|
||
'',
|
||
'抓大鹅主题描述:',
|
||
normalizedTheme,
|
||
'',
|
||
'画面元素:',
|
||
'返回按钮位于顶部左上角,顶部中间显示关卡标题“第1关 重庆火锅”和倒计时时间,右上角显示设置按钮',
|
||
'画面中间是一个和主题匹配的容器,宽度与画面宽度同宽,紧贴画面横向边缘',
|
||
'底部还有三个道具按钮分别为“移出”、“凑齐”、“打乱”',
|
||
].join('\n');
|
||
}
|
||
|
||
function buildUiSpritesheetPrompt() {
|
||
return '提取画面中的UI元素,将返回按钮、设置按钮、方格素材(不含边框,仅保留一个)、移出按钮、凑齐按钮、打乱按钮的顺序从上到下从左到右整理成纯绿色绿幕背景spritesheet。背景必须是统一纯绿色绿幕(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。UI 素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色题材只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。';
|
||
}
|
||
|
||
function buildBackgroundPrompt() {
|
||
return '移除画面中的所有UI组件和容器中的内含物,完整保留容器和背景,补全被UI覆盖的背景内容';
|
||
}
|
||
|
||
function buildItemSpritesheetPrompt() {
|
||
return '固定生成10行*10列spritesheet图,统一纯绿色绿幕背景(高饱和亮绿色,接近 #00FF00),背景平整无纹理、无渐变、无阴影、无场景内容,后端会在生图后将绿幕扣成透明并把透明背景 PNG 存到 OSS。素材间距严格均匀分布,任意两个素材间距相同,物品来自参考图中画面中心容器中的2D素材。每一行包含两种物品,每种物品的五个不同形态。物品素材自身不得使用接近 #00FF00 的高饱和纯绿;绿色物品只能使用深绿、橄榄绿、金绿或蓝绿,并用清晰描边与绿幕区分。严禁出现两种高相似度的物品';
|
||
}
|
||
|
||
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());
|
||
} else 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';
|
||
}
|
||
|
||
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());
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
async function imageBytesFromPayload(payload, env) {
|
||
const urls = [];
|
||
const b64Images = [];
|
||
collectStringsByKey(payload, 'url', urls);
|
||
collectStringsByKey(payload, 'image', urls);
|
||
collectStringsByKey(payload, 'image_url', urls);
|
||
collectStringsByKey(payload, 'b64_json', b64Images);
|
||
|
||
const imageUrl = [...new Set(urls)].find((url) => /^https?:\/\//u.test(url));
|
||
if (imageUrl) {
|
||
return downloadUrl(imageUrl, env.timeoutMs);
|
||
}
|
||
if (b64Images[0]) {
|
||
return Buffer.from(b64Images[0], 'base64');
|
||
}
|
||
throw new Error('VectorEngine returned no image');
|
||
}
|
||
|
||
async function generateImage(env, {prompt, negativePrompt, size, outPath}) {
|
||
const body = {
|
||
model: 'gpt-image-2',
|
||
prompt: promptWithNegative(prompt, negativePrompt),
|
||
n: 1,
|
||
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(body),
|
||
},
|
||
env.timeoutMs,
|
||
);
|
||
const bytes = await imageBytesFromPayload(payload, env);
|
||
writeFileSync(outPath, bytes);
|
||
return {
|
||
outPath,
|
||
extension: inferExtensionFromBytes(bytes),
|
||
bytes: bytes.length,
|
||
};
|
||
}
|
||
|
||
async function editImage(env, {prompt, negativePrompt, size, referencePath, outPath}) {
|
||
const referenceBytes = readFileSync(referencePath);
|
||
const form = new FormData();
|
||
form.append('model', 'gpt-image-2');
|
||
form.append('prompt', promptWithNegative(prompt, negativePrompt));
|
||
form.append('n', '1');
|
||
form.append('size', size);
|
||
form.append(
|
||
'image',
|
||
new Blob([referenceBytes], {type: 'image/png'}),
|
||
path.basename(referencePath),
|
||
);
|
||
const payload = await fetchJson(
|
||
editUrl(env.baseUrl),
|
||
{
|
||
method: 'POST',
|
||
headers: {
|
||
Authorization: `Bearer ${env.apiKey}`,
|
||
Accept: 'application/json',
|
||
},
|
||
body: form,
|
||
},
|
||
env.timeoutMs,
|
||
);
|
||
const bytes = await imageBytesFromPayload(payload, env);
|
||
writeFileSync(outPath, bytes);
|
||
return {
|
||
outPath,
|
||
extension: inferExtensionFromBytes(bytes),
|
||
bytes: bytes.length,
|
||
};
|
||
}
|
||
|
||
function runPostprocess(outDir) {
|
||
const postprocessPath = path.join(repoRoot, 'scripts', 'export-match3d-resource-pipeline-postprocess.py');
|
||
const result = spawnSync('python', [postprocessPath, '--out-dir', outDir], {
|
||
cwd: repoRoot,
|
||
encoding: 'utf8',
|
||
});
|
||
if (result.stdout) {
|
||
process.stdout.write(result.stdout);
|
||
}
|
||
if (result.stderr) {
|
||
process.stderr.write(result.stderr);
|
||
}
|
||
if (result.status !== 0) {
|
||
throw new Error(`postprocess failed with code ${result.status}`);
|
||
}
|
||
}
|
||
|
||
async function main() {
|
||
const args = parseArgs(process.argv);
|
||
const outDir = path.resolve(
|
||
repoRoot,
|
||
args.outDir || path.join('output', `match3d-resource-pipeline-${timestamp()}`),
|
||
);
|
||
mkdirSync(outDir, {recursive: true});
|
||
|
||
const prompts = {
|
||
theme: args.theme,
|
||
levelScenePrompt: buildLevelScenePrompt(args.theme),
|
||
uiSpritesheetPrompt: buildUiSpritesheetPrompt(),
|
||
backgroundPrompt: buildBackgroundPrompt(),
|
||
itemSpritesheetPrompt: buildItemSpritesheetPrompt(),
|
||
uiLabels,
|
||
};
|
||
writeFileSync(
|
||
path.join(outDir, '00-prompts.json'),
|
||
`${JSON.stringify(prompts, null, 2)}\n`,
|
||
'utf8',
|
||
);
|
||
|
||
if (!args.live) {
|
||
console.log(
|
||
JSON.stringify(
|
||
{
|
||
mode: 'dry-run',
|
||
outDir,
|
||
message: '加 --live 才会真实调用 VectorEngine。',
|
||
prompts,
|
||
},
|
||
null,
|
||
2,
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
|
||
const env = resolveEnv();
|
||
if (!env.baseUrl || !env.apiKey) {
|
||
throw new Error('Missing VECTOR_ENGINE_BASE_URL or VECTOR_ENGINE_API_KEY');
|
||
}
|
||
|
||
console.log(`[match3d-export] 1/4 生成关卡整图 -> ${outDir}`);
|
||
const levelScene = await generateImage(env, {
|
||
prompt: prompts.levelScenePrompt,
|
||
negativePrompt: '水印、教程浮层、菜单、广告、真实手机外框、浏览器 UI',
|
||
size: '1024x1536',
|
||
outPath: path.join(outDir, '01-level-scene.raw.png'),
|
||
});
|
||
|
||
console.log('[match3d-export] 2/4 并发生成 UI 图集、背景图、物品图集');
|
||
const [ui, background, items] = await Promise.all([
|
||
editImage(env, {
|
||
prompt: prompts.uiSpritesheetPrompt,
|
||
negativePrompt: '整页背景、中心物品、容器内物品、重复按钮、文字说明、白底、纯色底、网格线',
|
||
size: '1024x1024',
|
||
referencePath: levelScene.outPath,
|
||
outPath: path.join(outDir, '02-ui-spritesheet.raw.png'),
|
||
}),
|
||
editImage(env, {
|
||
prompt: prompts.backgroundPrompt,
|
||
negativePrompt: '返回按钮、设置按钮、倒计时、标题文字、道具按钮、物品、容器内含物、菜单、教程浮层',
|
||
size: '1024x1536',
|
||
referencePath: levelScene.outPath,
|
||
outPath: path.join(outDir, '04-background.raw.png'),
|
||
}),
|
||
editImage(env, {
|
||
prompt: prompts.itemSpritesheetPrompt,
|
||
negativePrompt: '文字、水印、UI、边框、网格线、标签、人物手部、复杂背景、非绿幕背景、白色背景、灰色背景、渐变背景、纹理背景',
|
||
// 中文注释:这里按当前后端 normalize_image_size("2k") 的实际请求尺寸复现。
|
||
size: '1536x1024',
|
||
referencePath: levelScene.outPath,
|
||
outPath: path.join(outDir, '06-item-spritesheet.raw.png'),
|
||
}),
|
||
]);
|
||
|
||
writeFileSync(
|
||
path.join(outDir, '00-generation-results.json'),
|
||
`${JSON.stringify({levelScene, ui, background, items}, null, 2)}\n`,
|
||
'utf8',
|
||
);
|
||
|
||
console.log('[match3d-export] 3/4 执行绿幕透明化、背景不透明化和连通域切片');
|
||
runPostprocess(outDir);
|
||
|
||
console.log('[match3d-export] 4/4 完成');
|
||
console.log(JSON.stringify({ok: true, outDir}, null, 2));
|
||
}
|
||
|
||
main().catch((error) => {
|
||
console.error(`[match3d-export] failed: ${error?.stack || error}`);
|
||
process.exit(1);
|
||
});
|