fix: stabilize match3d demo discovery
This commit is contained in:
345
scripts/export-match3d-resource-pipeline-postprocess.py
Normal file
345
scripts/export-match3d-resource-pipeline-postprocess.py
Normal file
@@ -0,0 +1,345 @@
|
||||
import argparse
|
||||
import json
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
UI_ORDER = ["back", "settings", "tile", "remove", "match", "shuffle"]
|
||||
VIEW_COUNT = 5
|
||||
ITEM_COUNT = 20
|
||||
GRID_SIZE = 10
|
||||
|
||||
|
||||
def green_screen_score(pixel):
|
||||
red, green, blue, alpha = pixel
|
||||
if alpha == 0:
|
||||
return 1.0
|
||||
red = float(red)
|
||||
green = float(green)
|
||||
blue = float(blue)
|
||||
green_lead = green - max(red, blue)
|
||||
if green < 96.0 or green_lead <= 18.0:
|
||||
return 0.0
|
||||
green_ratio = green / max(red + blue, 1.0)
|
||||
if green_ratio <= 0.9:
|
||||
return 0.0
|
||||
return max(
|
||||
0.0,
|
||||
min(
|
||||
1.0,
|
||||
((green - 96.0) / 128.0) * 0.34
|
||||
+ ((green_lead - 18.0) / 120.0) * 0.46
|
||||
+ ((green_ratio - 0.9) / 2.4) * 0.20,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def white_screen_score(pixel):
|
||||
red, green, blue, alpha = pixel
|
||||
if alpha == 0:
|
||||
return 1.0
|
||||
red = float(red)
|
||||
green = float(green)
|
||||
blue = float(blue)
|
||||
max_channel = max(red, green, blue)
|
||||
min_channel = min(red, green, blue)
|
||||
average = (red + green + blue) / 3.0
|
||||
if average < 188.0 or min_channel < 168.0:
|
||||
return 0.0
|
||||
spread = max_channel - min_channel
|
||||
neutrality = 1.0 - max(0.0, min(1.0, (spread - 6.0) / 34.0))
|
||||
brightness = max(0.0, min(1.0, (average - 188.0) / 55.0))
|
||||
floor = max(0.0, min(1.0, (min_channel - 168.0) / 60.0))
|
||||
return max(0.0, min(1.0, neutrality * (brightness * 0.85 + floor * 0.15)))
|
||||
|
||||
|
||||
def apply_green_screen_alpha(source):
|
||||
image = source.convert("RGBA")
|
||||
pixels = image.load()
|
||||
width, height = image.size
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
red, green, blue, alpha = pixels[x, y]
|
||||
score = green_screen_score((red, green, blue, alpha))
|
||||
if score >= 0.82:
|
||||
pixels[x, y] = (red, green, blue, 0)
|
||||
elif score >= 0.34:
|
||||
next_alpha = int(round(alpha * (1.0 - min(1.0, score * 1.08))))
|
||||
if next_alpha < 10:
|
||||
next_alpha = 0
|
||||
pixels[x, y] = (red, green, blue, next_alpha)
|
||||
return image
|
||||
|
||||
|
||||
def make_background_opaque(source):
|
||||
image = source.convert("RGBA")
|
||||
width, height = image.size
|
||||
edge_pixels = []
|
||||
pixels = image.load()
|
||||
for x in range(width):
|
||||
edge_pixels.append(pixels[x, 0])
|
||||
edge_pixels.append(pixels[x, height - 1])
|
||||
for y in range(1, max(1, height - 1)):
|
||||
edge_pixels.append(pixels[0, y])
|
||||
edge_pixels.append(pixels[width - 1, y])
|
||||
weighted = [0, 0, 0, 0]
|
||||
for red, green, blue, alpha in edge_pixels:
|
||||
if alpha < 32:
|
||||
continue
|
||||
weighted[0] += red * alpha
|
||||
weighted[1] += green * alpha
|
||||
weighted[2] += blue * alpha
|
||||
weighted[3] += alpha
|
||||
matte = (
|
||||
tuple(channel // weighted[3] for channel in weighted[:3])
|
||||
if weighted[3] > 0
|
||||
else (246, 243, 236)
|
||||
)
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
red, green, blue, alpha = pixels[x, y]
|
||||
if alpha == 255:
|
||||
continue
|
||||
inv = 255 - alpha
|
||||
pixels[x, y] = (
|
||||
(red * alpha + matte[0] * inv + 127) // 255,
|
||||
(green * alpha + matte[1] * inv + 127) // 255,
|
||||
(blue * alpha + matte[2] * inv + 127) // 255,
|
||||
255,
|
||||
)
|
||||
return image
|
||||
|
||||
|
||||
def visible(pixel, threshold=36):
|
||||
return pixel[3] >= threshold
|
||||
|
||||
|
||||
def detect_components(image, alpha_threshold=36):
|
||||
width, height = image.size
|
||||
pixels = image.load()
|
||||
visited = bytearray(width * height)
|
||||
min_area = max(16, min(800, (width * height) // 12000))
|
||||
components = []
|
||||
for start in range(width * height):
|
||||
if visited[start]:
|
||||
continue
|
||||
sx = start % width
|
||||
sy = start // width
|
||||
if not visible(pixels[sx, sy], alpha_threshold):
|
||||
visited[start] = 1
|
||||
continue
|
||||
queue = deque([(sx, sy)])
|
||||
visited[start] = 1
|
||||
min_x = max_x = sx
|
||||
min_y = max_y = sy
|
||||
area = 0
|
||||
while queue:
|
||||
x, y = queue.pop()
|
||||
area += 1
|
||||
min_x = min(min_x, x)
|
||||
max_x = max(max_x, x)
|
||||
min_y = min(min_y, y)
|
||||
max_y = max(max_y, y)
|
||||
for nx, ny in ((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)):
|
||||
if nx < 0 or ny < 0 or nx >= width or ny >= height:
|
||||
continue
|
||||
index = ny * width + nx
|
||||
if visited[index]:
|
||||
continue
|
||||
visited[index] = 1
|
||||
if visible(pixels[nx, ny], alpha_threshold):
|
||||
queue.append((nx, ny))
|
||||
if area >= min_area:
|
||||
components.append(
|
||||
{
|
||||
"x": min_x,
|
||||
"y": min_y,
|
||||
"width": max_x - min_x + 1,
|
||||
"height": max_y - min_y + 1,
|
||||
"area": area,
|
||||
}
|
||||
)
|
||||
return sort_components_by_original_position(components)
|
||||
|
||||
|
||||
def sort_components_by_original_position(components):
|
||||
if not components:
|
||||
return []
|
||||
average_height = sum(component["height"] for component in components) / len(components)
|
||||
row_tolerance = max(2.0, average_height * 0.65)
|
||||
rows = []
|
||||
for component in sorted(components, key=lambda item: (item["y"], item["x"])):
|
||||
center_y = component["y"] + component["height"] / 2.0
|
||||
target_row = None
|
||||
for row in rows:
|
||||
row_center = sum(item["y"] + item["height"] / 2.0 for item in row) / len(row)
|
||||
if abs(row_center - center_y) <= row_tolerance:
|
||||
target_row = row
|
||||
break
|
||||
if target_row is None:
|
||||
rows.append([component])
|
||||
else:
|
||||
target_row.append(component)
|
||||
sorted_components = []
|
||||
for row in rows:
|
||||
sorted_components.extend(sorted(row, key=lambda item: item["x"]))
|
||||
return sorted_components
|
||||
|
||||
|
||||
def trim_visible_bounds(image):
|
||||
width, height = image.size
|
||||
pixels = image.load()
|
||||
min_x = width
|
||||
min_y = height
|
||||
max_x = -1
|
||||
max_y = -1
|
||||
visible_count = 0
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
if not visible(pixels[x, y], 12):
|
||||
continue
|
||||
visible_count += 1
|
||||
min_x = min(min_x, x)
|
||||
min_y = min(min_y, y)
|
||||
max_x = max(max_x, x)
|
||||
max_y = max(max_y, y)
|
||||
min_visible = max(10, min(120, (width * height) // 540))
|
||||
if visible_count < min_visible or max_x <= min_x or max_y <= min_y:
|
||||
return image
|
||||
return image.crop((min_x, min_y, max_x + 1, max_y + 1))
|
||||
|
||||
|
||||
def crop_region(image, component):
|
||||
x = component["x"]
|
||||
y = component["y"]
|
||||
width = component["width"]
|
||||
height = component["height"]
|
||||
return trim_visible_bounds(image.crop((x, y, x + width, y + height)))
|
||||
|
||||
|
||||
def fallback_grid_slice(image, item_count=ITEM_COUNT):
|
||||
width, height = image.size
|
||||
slices = []
|
||||
items_per_row = GRID_SIZE // VIEW_COUNT
|
||||
for item_index in range(item_count):
|
||||
row = item_index // items_per_row
|
||||
start_col = (item_index % items_per_row) * VIEW_COUNT
|
||||
for view_index in range(VIEW_COUNT):
|
||||
col = start_col + view_index
|
||||
x0 = col * width // GRID_SIZE
|
||||
x1 = (col + 1) * width // GRID_SIZE
|
||||
y0 = row * height // GRID_SIZE
|
||||
y1 = (row + 1) * height // GRID_SIZE
|
||||
cell = image.crop((x0, y0, x1, y1))
|
||||
slices.append((item_index, view_index, trim_visible_bounds(cell)))
|
||||
return slices
|
||||
|
||||
|
||||
def save_ui_slices(image, out_dir):
|
||||
components = detect_components(image, 36)
|
||||
slices_dir = out_dir / "03-ui-slices"
|
||||
slices_dir.mkdir(parents=True, exist_ok=True)
|
||||
regions = []
|
||||
for index, component in enumerate(components[: len(UI_ORDER)]):
|
||||
label = UI_ORDER[index]
|
||||
output = slices_dir / f"{index + 1:02d}-{label}.png"
|
||||
crop_region(image, component).save(output)
|
||||
regions.append({**component, "label": label, "file": str(output)})
|
||||
return {
|
||||
"detectedCount": len(components),
|
||||
"usedCount": len(regions),
|
||||
"regions": regions,
|
||||
}
|
||||
|
||||
|
||||
def save_item_slices(image, out_dir):
|
||||
components = detect_components(image, 36)
|
||||
slices_dir = out_dir / "07-item-slices"
|
||||
slices_dir.mkdir(parents=True, exist_ok=True)
|
||||
expected = ITEM_COUNT * VIEW_COUNT
|
||||
use_components = len(components) >= expected
|
||||
if use_components:
|
||||
source_slices = [
|
||||
(index // VIEW_COUNT, index % VIEW_COUNT, crop_region(image, component))
|
||||
for index, component in enumerate(components[:expected])
|
||||
]
|
||||
else:
|
||||
source_slices = fallback_grid_slice(image)
|
||||
|
||||
items = []
|
||||
for item_index in range(ITEM_COUNT):
|
||||
item_dir = slices_dir / f"item-{item_index + 1:02d}"
|
||||
item_dir.mkdir(parents=True, exist_ok=True)
|
||||
views = []
|
||||
for _, view_index, crop in [
|
||||
entry for entry in source_slices if entry[0] == item_index
|
||||
]:
|
||||
output = item_dir / f"view-{view_index + 1:02d}.png"
|
||||
crop.save(output)
|
||||
views.append({"viewIndex": view_index + 1, "file": str(output)})
|
||||
items.append({"itemIndex": item_index + 1, "views": views})
|
||||
return {
|
||||
"method": "alpha-components" if use_components else "fallback-grid",
|
||||
"detectedCount": len(components),
|
||||
"expectedCount": expected,
|
||||
"items": items,
|
||||
"regions": components[:expected],
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--out-dir", required=True)
|
||||
args = parser.parse_args()
|
||||
out_dir = Path(args.out_dir).resolve()
|
||||
|
||||
level_scene = Image.open(out_dir / "01-level-scene.raw.png").convert("RGBA")
|
||||
ui_raw = Image.open(out_dir / "02-ui-spritesheet.raw.png").convert("RGBA")
|
||||
background_raw = Image.open(out_dir / "04-background.raw.png").convert("RGBA")
|
||||
item_raw = Image.open(out_dir / "06-item-spritesheet.raw.png").convert("RGBA")
|
||||
|
||||
ui_transparent = apply_green_screen_alpha(ui_raw)
|
||||
item_transparent = apply_green_screen_alpha(item_raw)
|
||||
background_opaque = make_background_opaque(background_raw)
|
||||
|
||||
ui_transparent.save(out_dir / "02-ui-spritesheet.transparent.png")
|
||||
background_opaque.save(out_dir / "05-background.opaque.png")
|
||||
item_transparent.save(out_dir / "06-item-spritesheet.transparent.png")
|
||||
|
||||
ui_manifest = save_ui_slices(ui_transparent, out_dir)
|
||||
item_manifest = save_item_slices(item_transparent, out_dir)
|
||||
|
||||
manifest = {
|
||||
"levelScene": {
|
||||
"file": str(out_dir / "01-level-scene.raw.png"),
|
||||
"size": level_scene.size,
|
||||
},
|
||||
"uiSpritesheet": {
|
||||
"rawFile": str(out_dir / "02-ui-spritesheet.raw.png"),
|
||||
"transparentFile": str(out_dir / "02-ui-spritesheet.transparent.png"),
|
||||
"size": ui_transparent.size,
|
||||
**ui_manifest,
|
||||
},
|
||||
"background": {
|
||||
"rawFile": str(out_dir / "04-background.raw.png"),
|
||||
"opaqueFile": str(out_dir / "05-background.opaque.png"),
|
||||
"size": background_opaque.size,
|
||||
},
|
||||
"itemSpritesheet": {
|
||||
"rawFile": str(out_dir / "06-item-spritesheet.raw.png"),
|
||||
"transparentFile": str(out_dir / "06-item-spritesheet.transparent.png"),
|
||||
"size": item_transparent.size,
|
||||
**item_manifest,
|
||||
},
|
||||
}
|
||||
(out_dir / "08-export-manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(json.dumps({"ok": True, "manifest": str(out_dir / "08-export-manifest.json")}, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
378
scripts/export-match3d-resource-pipeline.mjs
Normal file
378
scripts/export-match3d-resource-pipeline.mjs
Normal file
@@ -0,0 +1,378 @@
|
||||
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);
|
||||
});
|
||||
Reference in New Issue
Block a user