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 ? [''] : 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, ), );