Files
Genarrative/scripts/generate-child-motion-demo-assets.mjs
五香丸子 d41f260a2a
Some checks failed
CI / verify (push) Has been cancelled
feat: add baby object match edutainment flow
2026-05-12 16:08:59 +08:00

1035 lines
32 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 { spawnSync } from 'node:child_process';
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 assetDir = path.join(repoRoot, 'public', 'child-motion-demo');
const intermediateDir = path.join(repoRoot, 'tmp', 'child-motion-demo-assets');
const defaultTimeoutMs = 180000;
const chromaKeyColor = '#ff00ff';
const layoutReferenceOutput = 'picture-book-stage-layout-v2.png';
const backgroundPrompt = [
'请生成一张横版儿童动作互动游戏舞台背景图,卡通绘本风格,温暖明亮。',
'画面下半部分必须是开阔柔软的草地地面,适合叠加半透明角色轮廓和地面圆圈指示环。',
'远处有柔和小山坡、树木、天空和浅色云朵,中心和下方前景保持干净开阔。',
'构图需要适配 16:9 横屏游戏舞台,左右和上下边缘可安全裁切,主体信息不要贴边。',
'风格像儿童绘本插画,柔和笔触,清新色彩,轻微纸张纹理,细节适中,不杂乱。',
'不要出现人物、动物、文字、按钮、UI、边框、水印、摄像头画面、真实照片质感。',
].join('');
const styleReferenceNote = [
'参考图仅用于统一卡通绘本草地舞台的色彩、笔触、纸张纹理和明亮童趣气质。',
'不要复制参考图构图,不要出现真实照片质感。',
].join('');
const layoutReferencePrompt = [
'请基于参考背景重新设计一张 16:9 儿童动作互动游戏热身关版式参考图,卡通绘本草地风格保持统一。',
'背景品质和明亮草地绘本质感沿用参考图,不要把背景做暗或做成科技风。',
'画面中心到下方中部保持开阔,留给半透明角色轮廓和地面椭圆指示环。',
'底部只放一条自然的前景草坪边缘,占舞台高度约 18% 到 22%,草叶比例真实可爱,不要拉伸成扁平色块。',
'顶部居中放一个小型横向 HUD 软纸条,占舞台宽度约 45% 到 52%,高度约 9% 到 12%,不要做成整屏顶部栏。',
'右下角放一个小型五格状态条,占舞台宽度约 28% 到 34%,高度约 6% 到 8%,不要压住角色脚下区域。',
'开始按钮占位使用小型胶囊按钮和轻盈托盘,整体不要超过舞台宽度 26%。',
'所有 UI 都是无文字、无图标的空白资源占位,边缘带少量草叶、水彩纸张纹理和浅蓝高光。',
'不要出现人物、动物、文字、数字、水印、摄像头画面、真实照片质感。',
].join('');
const chromaKeyNote = [
`背景必须是完全纯色、均匀一致的 ${chromaKeyColor} 品红色,用于后续去背。`,
'背景不能有阴影、渐变、纹理、地面、反光或光照变化。',
`主体中不要使用 ${chromaKeyColor} 或接近品红的颜色。`,
'主体边缘保持清晰,四周留出充足空白。',
'不要出现文字、水印、真实照片质感。',
].join('');
const noStretchNote = [
'资源自身必须按最终用途设计比例绘制,不要画成方形卡片再留大面积空白。',
'网页端会按资源原始比例等比缩放使用,不会把资源横向或纵向强行拉伸。',
'不要出现文字、数字、按钮文案、水印、真实照片质感。',
].join('');
const assetDefinitions = [
{
id: 'background',
output: 'picture-book-grass-stage.png',
size: '1536x1024',
prompt: backgroundPrompt,
transparent: false,
useBackgroundReference: false,
},
{
id: 'layout-reference-v2',
output: layoutReferenceOutput,
outputDirectory: 'intermediate',
size: '2048x1152',
prompt: layoutReferencePrompt,
transparent: false,
useBackgroundReference: true,
},
{
id: 'floor',
output: 'picture-book-foreground-grass-v2.png',
sourceOutput: 'picture-book-foreground-grass-v2-source.png',
size: '2048x768',
transparent: true,
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 2048,
canvasHeight: 640,
fit: 'cover-width',
fillWidth: 1.04,
anchorY: 'bottom',
padding: 18,
},
prompt: [
'请生成儿童动作互动游戏的底部前景草坪资源,不是完整背景。',
'主体是一条横向自然草地边缘,用于覆盖 16:9 舞台最下方约五分之一高度。',
'草坪顶部边缘有松散手绘草叶和少量浅色小花,底部更厚实,中心不要出现硬平台、椭圆地毯或 UI 栏。',
'整体应像绘本背景自然延伸出来的草地前景,比例宽而舒展,草叶不能被压扁或横向拉伸。',
'不要天空、远山、人物、角色、按钮、面板、边框。',
'风格必须和参考背景一致:明亮、温暖、卡通绘本、水彩笔触、轻微纸张纹理。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'ground-ring',
output: 'picture-book-ground-ring-v2.png',
sourceOutput: 'picture-book-ground-ring-v2-source.png',
size: '1536x512',
transparent: true,
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1200,
canvasHeight: 520,
fit: 'contain',
fillWidth: 0.92,
fillHeight: 0.78,
anchorY: 'center',
padding: 24,
},
prompt: [
'请生成一个儿童动作互动游戏地面椭圆指示环资产。',
'主体是单个透视椭圆环,直接设计成贴在草地地面上的椭圆,不要依赖网页后期压扁。',
'圆环由柔软草叶、水彩绿色描边和浅色高光组成,中心留空,边缘带轻微绘本手绘不规则感。',
'整体清爽、明亮、儿童绘本风,不要科技感,不要霓虹,不要金属材质。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'character-outline',
output: 'picture-book-character-outline-v2.png',
sourceOutput: 'picture-book-character-outline-v2-source.png',
size: '1024x1536',
transparent: true,
transparencyCleanup: 'character-outline',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1024,
canvasHeight: 1536,
fit: 'contain',
fillWidth: 0.78,
fillHeight: 0.9,
anchorY: 'bottom',
padding: 28,
},
prompt: [
'请生成一个儿童动作互动游戏的半透明角色轮廓指示器资产。',
'主体是正面站立的人形轮廓,儿童友好比例,无五官、无衣服细节、无性别特征,双臂自然微微张开。',
'视觉上像浅蓝绿色水彩发光描边加半透明白色填充,用于表示真实用户的位置剪影。',
'轮廓需要简洁清晰,适合缩放到游戏舞台中使用。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'hud-strip',
output: 'picture-book-hud-strip-v2.png',
sourceOutput: 'picture-book-hud-strip-v2-source.png',
size: '1536x512',
transparent: true,
transparencyCleanup: 'soft-panel',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 2200,
canvasHeight: 420,
fit: 'contain',
fillWidth: 0.96,
fillHeight: 0.92,
anchorY: 'center',
padding: 18,
},
prompt: [
'请生成儿童动作互动游戏顶部 HUD 软纸条资产,不是方形面板。',
'主体是一条细长横向顶部信息条,目标宽高比约 5:1像轻盈软纸丝带不要做成圆形徽章、方形卡片或厚重弹窗。',
'中间为浅米白到淡浅绿色水彩软纸区域,左右边缘可以有少量草叶装饰,但不能扩大成大圆端。',
'边缘有少量草叶、浅蓝高光和绘本纸张纹理,中心必须干净空白,方便网页叠加标题和进度。',
'形状轻盈,适合放在 16:9 舞台顶部居中,占画面宽度约一半,不要做成全宽导航栏或后台系统面板。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'calibration-strip',
output: 'picture-book-calibration-strip-v2.png',
sourceOutput: 'picture-book-calibration-strip-v2-source.png',
size: '1536x512',
transparent: true,
transparencyCleanup: 'soft-panel',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1800,
canvasHeight: 360,
fit: 'contain',
fillWidth: 0.96,
fillHeight: 0.9,
anchorY: 'center',
padding: 16,
},
prompt: [
'请生成儿童动作互动游戏右下角五格状态条资产,不是方形面板。',
'主体是横向小型状态条,内部有五个柔和小胶囊或五个浅色分隔留白区域,但不要写任何文字或数字。',
'整体用于舞台右下角,轻薄、不厚重,不压住角色脚下区域。',
'米白、淡浅绿和浅蓝水彩高光为主,边缘可以有少量草叶和纸张纹理,风格必须和参考背景一致。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'start-panel',
output: 'picture-book-start-panel-v2.png',
sourceOutput: 'picture-book-start-panel-v2-source.png',
size: '1024x512',
transparent: true,
transparencyCleanup: 'soft-panel',
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1280,
canvasHeight: 520,
fit: 'contain',
fillWidth: 0.88,
fillHeight: 0.88,
anchorY: 'center',
padding: 18,
},
prompt: [
'请生成儿童动作互动游戏开始按钮背后的轻盈托盘资产,不是完整弹窗。',
'主体是一个小型横向圆角软纸托盘,中心空白,适合只承载一个开始按钮。',
'边缘可以有少量草叶、浅蓝高光和淡绿色纸张纹理,整体要比 HUD 更小、更轻,不要做成大卡片。',
'不要文字、数字、图标或按钮文案。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
{
id: 'ui-button',
output: 'picture-book-ui-button-v2.png',
sourceOutput: 'picture-book-ui-button-v2-source.png',
size: '1024x512',
transparent: true,
useBackgroundReference: true,
useLayoutReference: true,
layoutNormalization: {
canvasWidth: 1300,
canvasHeight: 520,
fit: 'contain',
fillWidth: 0.86,
fillHeight: 0.76,
anchorY: 'center',
padding: 18,
},
prompt: [
'请生成一个儿童动作互动游戏主按钮背景资产。',
'主体是横向胶囊形按钮,无文字,绿色草地色为主,带浅蓝天空高光和柔和水彩纸张质感。',
'按钮中心保持干净,适合网页叠加“开始游戏”等文字。',
'整体要圆润、明亮、童趣、绘本感,不要科技感、金属感、真实照片质感。',
styleReferenceNote,
noStretchNote,
chromaKeyNote,
].join(''),
},
];
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('--')) {
const existing = args.get(raw);
if (Array.isArray(existing)) {
existing.push(next);
} else if (existing) {
args.set(raw, [existing, next]);
} else {
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 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, preferredPath) {
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 path.extname(preferredPath).replace(/^\./u, '') || 'png';
}
function toDataUrl(filePath) {
if (!existsSync(filePath)) {
return null;
}
const bytes = readFileSync(filePath);
const extension = inferExtensionFromBytes(bytes, filePath);
const mime = extension === 'jpg' ? 'image/jpeg' : `image/${extension}`;
return `data:${mime};base64,${bytes.toString('base64')}`;
}
function pushReferenceImage(body, filePath) {
const reference = toDataUrl(filePath);
if (!reference) {
return false;
}
body.image = [...(body.image || []), reference];
return true;
}
function buildRequestBody(asset, size) {
const body = {
model: 'gpt-image-2-all',
prompt: asset.prompt,
n: 1,
size: size || asset.size,
};
if (asset.useBackgroundReference) {
pushReferenceImage(
body,
path.join(assetDir, 'picture-book-grass-stage.png'),
);
}
if (asset.useLayoutReference) {
pushReferenceImage(
body,
path.join(intermediateDir, layoutReferenceOutput),
);
}
return body;
}
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);
}
}
function outputPathFor(asset) {
if (asset.outputDirectory === 'intermediate') {
return path.join(intermediateDir, asset.output);
}
return path.join(assetDir, asset.output);
}
function sourceOutputPathFor(asset) {
return path.join(intermediateDir, asset.sourceOutput || asset.output);
}
function opaqueSourceOutputPathFor(asset) {
return path.join(
intermediateDir,
`${path.basename(asset.sourceOutput || asset.output, path.extname(asset.sourceOutput || asset.output))}-rgb.png`,
);
}
function normalizeOutputPath(preferredPath, imageBytes) {
const actualExtension = inferExtensionFromBytes(imageBytes, preferredPath);
const outputPath =
path.extname(preferredPath).toLowerCase() === `.${actualExtension}`
? preferredPath
: path.join(
path.dirname(preferredPath),
`${path.basename(preferredPath, path.extname(preferredPath))}.${actualExtension}`,
);
return { actualExtension, outputPath };
}
function resolveCodexHome() {
if (process.env.CODEX_HOME) {
return process.env.CODEX_HOME;
}
if (process.env.USERPROFILE) {
return path.join(process.env.USERPROFILE, '.codex');
}
if (process.env.HOME) {
return path.join(process.env.HOME, '.codex');
}
return null;
}
function findChromaKeyHelper() {
const codexHome = resolveCodexHome();
if (!codexHome) {
return null;
}
const helper = path.join(
codexHome,
'skills',
'.system',
'imagegen',
'scripts',
'remove_chroma_key.py',
);
return existsSync(helper) ? helper : null;
}
function removeChromaKey(sourcePath, finalPath) {
const helper = findChromaKeyHelper();
if (!helper) {
throw new Error(
'Missing Codex imagegen remove_chroma_key.py helper for transparent assets',
);
}
const result = spawnSync(
'python',
[
helper,
'--input',
sourcePath,
'--out',
finalPath,
'--key-color',
chromaKeyColor,
'--auto-key',
'border',
'--soft-matte',
'--transparent-threshold',
'12',
'--opaque-threshold',
'220',
'--despill',
'--force',
],
{
cwd: repoRoot,
encoding: 'utf8',
},
);
if (result.status !== 0) {
throw new Error(
`remove_chroma_key.py failed: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function removeUiPanelChromaKey(sourcePath, finalPath) {
const script = [
'from PIL import Image, ImageFilter',
'import sys',
'source, out = sys.argv[1], sys.argv[2]',
'im = Image.open(source).convert("RGBA")',
'px = im.load()',
'w, h = im.size',
'corner = im.getpixel((0, 0))',
'key = corner[:3]',
'for y in range(h):',
' for x in range(w):',
' r, g, b, _ = px[x, y]',
' brightness = (r + g + b) / 3',
' dist = ((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2) ** 0.5',
' magenta_bias = r + b - 1.85 * g',
' if brightness < 42 or dist < 155 or (r > 185 and b > 150 and g < 190 and magenta_bias > 235):',
' alpha = 0',
' elif dist < 225:',
' alpha = int(max(0, min(255, (dist - 155) / 70 * 255)))',
' else:',
' alpha = 255',
' if alpha > 0 and r > g + 28 and b > g + 20:',
' r = min(r, g + 18)',
' b = min(b, g + 14)',
' px[x, y] = (r, g, b, alpha)',
'alpha = im.getchannel("A").filter(ImageFilter.GaussianBlur(0.45))',
'im.putalpha(alpha)',
'im.save(out)',
].join('\n');
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to clean UI panel transparency: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function removeCharacterOutlineChromaKey(sourcePath, finalPath) {
const script = [
'from PIL import Image, ImageFilter',
'import sys',
'source, out = sys.argv[1], sys.argv[2]',
'im = Image.open(source).convert("RGBA")',
'px = im.load()',
'w, h = im.size',
'for y in range(h):',
' for x in range(w):',
' r, g, b, _ = px[x, y]',
' magenta_strength = min(r, b) - g',
' magenta_bg = r > 180 and b > 170 and g < 145 and magenta_strength > 70',
' hot_bg = r > 225 and b > 205 and g < 190 and magenta_strength > 55',
' if magenta_bg or hot_bg:',
' alpha = 0',
' else:',
' alpha = 255',
' if alpha > 0 and r > g + 35 and b > g + 22:',
' r = min(r, g + 24)',
' b = min(b, g + 20)',
' px[x, y] = (r, g, b, alpha)',
'alpha = im.getchannel("A").filter(ImageFilter.GaussianBlur(0.35))',
'im.putalpha(alpha)',
'im.save(out)',
].join('\n');
const result = spawnSync('python', ['-c', script, sourcePath, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to clean character outline transparency: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function normalizeTransparentAsset(finalPath, layoutNormalization) {
if (!layoutNormalization) {
return;
}
const script = [
'from PIL import Image',
'import sys',
'source, out = sys.argv[1], sys.argv[2]',
'canvas_w = int(sys.argv[3])',
'canvas_h = int(sys.argv[4])',
'fit = sys.argv[5]',
'fill_w = float(sys.argv[6])',
'fill_h = float(sys.argv[7])',
'anchor_y = sys.argv[8]',
'padding = int(sys.argv[9])',
'im = Image.open(source).convert("RGBA")',
'alpha = im.getchannel("A").point(lambda a: 255 if a > 8 else 0)',
'bbox = alpha.getbbox()',
'if bbox is None:',
' im.save(out)',
' raise SystemExit(0)',
'left, top, right, bottom = bbox',
'left = max(0, left - padding)',
'top = max(0, top - padding)',
'right = min(im.width, right + padding)',
'bottom = min(im.height, bottom + padding)',
'subject = im.crop((left, top, right, bottom))',
'target_w = max(1, int(canvas_w * fill_w))',
'target_h = max(1, int(canvas_h * fill_h))',
'scale_w = target_w / subject.width',
'scale_h = target_h / subject.height',
'scale = max(scale_w, scale_h) if fit == "cover-width" else min(scale_w, scale_h)',
'new_w = max(1, int(subject.width * scale))',
'new_h = max(1, int(subject.height * scale))',
'subject = subject.resize((new_w, new_h), Image.Resampling.LANCZOS)',
'if new_w > canvas_w:',
' crop_left = max(0, (new_w - canvas_w) // 2)',
' subject = subject.crop((crop_left, 0, crop_left + canvas_w, new_h))',
' new_w = canvas_w',
'if new_h > canvas_h:',
' if anchor_y == "bottom":',
' crop_top = new_h - canvas_h',
' elif anchor_y == "top":',
' crop_top = 0',
' else:',
' crop_top = max(0, (new_h - canvas_h) // 2)',
' subject = subject.crop((0, crop_top, new_w, crop_top + canvas_h))',
' new_h = canvas_h',
'canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))',
'x = (canvas_w - new_w) // 2',
'if anchor_y == "bottom":',
' y = canvas_h - new_h',
'elif anchor_y == "top":',
' y = 0',
'else:',
' y = (canvas_h - new_h) // 2',
'canvas.alpha_composite(subject, (x, y))',
'canvas.save(out)',
].join('\n');
const result = spawnSync(
'python',
[
'-c',
script,
finalPath,
finalPath,
String(layoutNormalization.canvasWidth),
String(layoutNormalization.canvasHeight),
layoutNormalization.fit || 'contain',
String(layoutNormalization.fillWidth || 0.92),
String(layoutNormalization.fillHeight || 0.92),
layoutNormalization.anchorY || 'center',
String(layoutNormalization.padding || 0),
],
{
cwd: repoRoot,
encoding: 'utf8',
},
);
if (result.status !== 0) {
throw new Error(
`Failed to normalize transparent asset canvas: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function scrubChromaFringe(finalPath) {
const script = [
'from PIL import Image',
'import sys',
'path = sys.argv[1]',
'im = Image.open(path).convert("RGBA")',
'px = im.load()',
'w, h = im.size',
'for y in range(h):',
' for x in range(w):',
' r, g, b, a = px[x, y]',
' if a == 0:',
' continue',
' magenta_bias = min(r, b) - g',
' is_magenta_edge = r > 135 and b > 135 and magenta_bias > 24 and abs(r - b) < 92',
' if is_magenta_edge and a < 90:',
' px[x, y] = (r, g, b, 0)',
' continue',
' if is_magenta_edge:',
' neutral = max(g, min(248, int((r + b + g) / 3)))',
' r = min(r, neutral + 18)',
' b = min(b, neutral + 16)',
' g = max(g, min(neutral, 230))',
' px[x, y] = (r, g, b, a)',
'im.save(path)',
].join('\n');
const result = spawnSync('python', ['-c', script, finalPath], {
cwd: repoRoot,
encoding: 'utf8',
});
if (result.status !== 0) {
throw new Error(
`Failed to scrub chroma fringe: ${(result.stderr || result.stdout).trim()}`,
);
}
}
function writeOpaquePng(sourcePath, outputPath) {
const result = spawnSync(
'python',
[
'-c',
[
'from PIL import Image',
'import sys',
'Image.open(sys.argv[1]).convert("RGB").save(sys.argv[2])',
].join('; '),
sourcePath,
outputPath,
],
{
cwd: repoRoot,
encoding: 'utf8',
},
);
if (result.status !== 0) {
throw new Error(
`Failed to normalize transparent source before chroma key removal: ${(result.stderr || result.stdout).trim()}`,
);
}
}
async function generateAsset(asset, env, size, force) {
const finalPath = outputPathFor(asset);
if (!force && existsSync(finalPath)) {
return {
id: asset.id,
ok: true,
skipped: true,
file: finalPath,
};
}
if (args.has('--postprocess-only')) {
if (!asset.transparent) {
return {
id: asset.id,
ok: true,
skipped: true,
file: finalPath,
};
}
const sourcePath = sourceOutputPathFor(asset);
if (!existsSync(sourcePath)) {
throw new Error(`Missing source image for postprocess-only: ${sourcePath}`);
}
mkdirSync(assetDir, { recursive: true });
mkdirSync(intermediateDir, { recursive: true });
const opaqueSourcePath = opaqueSourceOutputPathFor(asset);
writeOpaquePng(sourcePath, opaqueSourcePath);
if (asset.transparencyCleanup === 'soft-panel') {
removeUiPanelChromaKey(opaqueSourcePath, finalPath);
} else if (asset.transparencyCleanup === 'character-outline') {
removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
} else {
removeChromaKey(opaqueSourcePath, finalPath);
}
normalizeTransparentAsset(finalPath, asset.layoutNormalization);
scrubChromaFringe(finalPath);
return {
id: asset.id,
ok: true,
file: finalPath,
sourceFile: sourcePath,
postprocessedOnly: true,
};
}
const requestBody = buildRequestBody(asset, 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 ${asset.id}`);
}
mkdirSync(assetDir, { recursive: true });
mkdirSync(intermediateDir, { recursive: true });
const preferredPath = asset.transparent
? sourceOutputPathFor(asset)
: finalPath;
const { actualExtension, outputPath } = normalizeOutputPath(
preferredPath,
imageBytes,
);
writeFileSync(outputPath, imageBytes);
if (asset.transparent) {
const opaqueSourcePath = opaqueSourceOutputPathFor(asset);
writeOpaquePng(outputPath, opaqueSourcePath);
if (asset.transparencyCleanup === 'soft-panel') {
removeUiPanelChromaKey(opaqueSourcePath, finalPath);
} else if (asset.transparencyCleanup === 'character-outline') {
removeCharacterOutlineChromaKey(opaqueSourcePath, finalPath);
} else {
removeChromaKey(opaqueSourcePath, finalPath);
}
normalizeTransparentAsset(finalPath, asset.layoutNormalization);
scrubChromaFringe(finalPath);
}
return {
id: asset.id,
ok: true,
file: asset.transparent ? finalPath : outputPath,
sourceFile: asset.transparent ? outputPath : undefined,
size: requestBody.size,
extension: actualExtension,
source: urls[0] ? 'url' : 'b64_json',
usedReferenceImage: Boolean(requestBody.image),
};
}
function normalizeSelection(value) {
if (!value) {
return [];
}
return Array.isArray(value) ? value : [value];
}
function selectAssets() {
const selectedIds = new Set([
...normalizeSelection(args.get('--asset')),
...normalizeSelection(args.get('--only')),
]);
if (selectedIds.size === 0) {
return assetDefinitions;
}
return assetDefinitions.filter((asset) => selectedIds.has(asset.id));
}
function dryRun(selectedAssets, size) {
console.log(
JSON.stringify(
{
mode: 'dry-run',
assets: selectedAssets.map((asset) => {
const body = buildRequestBody(asset, size);
return {
id: asset.id,
outputPath: outputPathFor(asset),
sourceOutputPath: asset.transparent
? sourceOutputPathFor(asset)
: undefined,
transparent: asset.transparent,
body: {
...body,
image: body.image ? ['<local style reference image>'] : undefined,
},
};
}),
},
null,
2,
),
);
}
const selectedAssets = selectAssets();
const unknownAssetRequested =
selectedAssets.length === 0 &&
(args.has('--asset') || args.has('--only'));
if (unknownAssetRequested) {
console.error(
JSON.stringify({
ok: false,
error: 'No matching child motion demo asset id',
availableIds: assetDefinitions.map((asset) => asset.id),
}),
);
process.exit(1);
}
const size = args.has('--size') ? String(args.get('--size')) : undefined;
if (args.has('--dry-run') || !args.has('--live')) {
dryRun(selectedAssets, size);
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 force = Boolean(args.get('--force'));
const results = [];
for (const asset of selectedAssets) {
results.push(await generateAsset(asset, env, size, force));
}
console.log(
JSON.stringify(
{
ok: true,
results,
},
null,
2,
),
);