Add VectorEngine image generation config and raise request timeouts (env + scripts) from 180000 to 1000000ms. Introduce a reusable CreativeImageInputPanel component with tests and wire up mobile keyboard-focus helpers; update generation views and related tests (CustomWorldGenerationView, BarkBattle editor, Match3D, Puzzle flows). Improve API error handling / VectorEngine request guidance (packages/shared http.ts and docs), and apply multiple backend/frontend fixes for puzzle/match3d/prompt handling. Also include extensive docs and decision-log updates describing UI/UX decisions and verification steps.
1494 lines
55 KiB
JavaScript
1494 lines
55 KiB
JavaScript
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 = 1000000;
|
||
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(''),
|
||
},
|
||
{
|
||
id: 'wave-cat-head-guide',
|
||
output: 'picture-book-wave-cat-head-guide-v1.png',
|
||
sourceOutput: 'picture-book-wave-cat-head-guide-v1-source.png',
|
||
size: '1024x1024',
|
||
transparent: true,
|
||
useBackgroundReference: true,
|
||
useLayoutReference: true,
|
||
layoutNormalization: {
|
||
canvasWidth: 1024,
|
||
canvasHeight: 1024,
|
||
fit: 'contain',
|
||
fillWidth: 0.76,
|
||
fillHeight: 0.76,
|
||
anchorY: 'center',
|
||
padding: 22,
|
||
},
|
||
prompt: [
|
||
'请生成儿童动作互动游戏招手提示中央使用的卡通猫猫头资产,只画猫猫头,不要身体和爪子。',
|
||
'主体是一只原创绘本卡通猫猫头,圆润、亲切、表情开心,适合夹在左右两只猫爪中间作为挥手引导。',
|
||
'猫头可以是浅米白、淡橘、柔和浅棕和浅蓝绿色高光,轮廓清晰,五官简洁可爱,不能像真实照片或具体 IP 角色。',
|
||
'资产需要轻盈半透明、水彩纸张质感,缩小后仍能清楚看出猫脸和耳朵,边缘不要有复杂毛发。',
|
||
'整体风格必须和参考背景一致:明亮、温暖、卡通绘本、草地游戏舞台气质。',
|
||
'不要文字、数字、按钮、面板、人物、全身动物、品牌符号、水印、真实照片质感、厚重阴影或科技感。',
|
||
styleReferenceNote,
|
||
noStretchNote,
|
||
chromaKeyNote,
|
||
].join(''),
|
||
},
|
||
{
|
||
id: 'wave-cat-paw-guide',
|
||
output: 'picture-book-wave-cat-paw-guide-v1.png',
|
||
sourceOutput: 'picture-book-wave-cat-paw-guide-v1-source.png',
|
||
size: '1024x1024',
|
||
transparent: true,
|
||
useBackgroundReference: true,
|
||
useLayoutReference: true,
|
||
layoutNormalization: {
|
||
canvasWidth: 1024,
|
||
canvasHeight: 1024,
|
||
fit: 'contain',
|
||
fillWidth: 0.82,
|
||
fillHeight: 0.82,
|
||
anchorY: 'center',
|
||
padding: 22,
|
||
},
|
||
prompt: [
|
||
'请生成儿童动作互动游戏的挥手引导猫爪资产,只画一只猫爪和一小段前臂,用于网页左右镜像复用。',
|
||
'主体是一段从画面下方斜向上伸出的柔软卡通猫前臂,末端是圆润猫爪,不展示手指细节,爪垫可以用几个浅色圆形简化表达。',
|
||
'猫爪需要像儿童绘本里的玩偶圆爪,简洁可爱,适合放在猫猫头左右两侧做左右摆动动画。',
|
||
'资产需要半透明、轻盈,轮廓清晰,缩小后仍能看出前臂和猫爪;边缘不要复杂毛发,不要尖爪。',
|
||
'颜色使用浅米白、淡橘、柔和草绿色和浅蓝绿色水彩高光,风格和参考背景一致,明亮、温暖、卡通绘本、轻微纸张纹理。',
|
||
'不要文字、数字、按钮、面板、人物全身、完整动物、真实照片质感、厚重阴影或科技感。',
|
||
styleReferenceNote,
|
||
noStretchNote,
|
||
chromaKeyNote,
|
||
].join(''),
|
||
},
|
||
{
|
||
id: 'wave-cat-head-guide-v2',
|
||
output: 'picture-book-wave-cat-head-guide-v2.png',
|
||
sourceOutput: 'picture-book-wave-cat-head-guide-v2-source.png',
|
||
size: '1024x1024',
|
||
transparent: true,
|
||
useBackgroundReference: true,
|
||
useLayoutReference: true,
|
||
layoutNormalization: {
|
||
canvasWidth: 1024,
|
||
canvasHeight: 1024,
|
||
fit: 'contain',
|
||
fillWidth: 0.72,
|
||
fillHeight: 0.72,
|
||
anchorY: 'center',
|
||
padding: 24,
|
||
},
|
||
prompt: [
|
||
'请重新设计一版儿童动作互动游戏招手提示中央使用的原创绘本卡通猫猫头资产,只画猫猫头,不要身体和爪子。',
|
||
'主体是一只圆润的小猫头,像贴在游戏舞台中央的柔软绘本贴纸,轮廓大而简洁,表情开心、友好、轻轻张嘴微笑。',
|
||
'五官必须更简化:大眼睛、短鼻子、小嘴巴、短胡须即可;不要长胡须伸出太远,不要复杂毛发,不要真实猫毛细节。',
|
||
'色彩使用浅奶油白、淡橘和少量浅草绿或天空蓝高光,整体更轻、更通透,适合叠在明亮草地舞台上。',
|
||
'边缘是柔和水彩描边和轻微纸张纹理,缩小到舞台中央后仍能一眼看出是可爱的猫猫头。',
|
||
'不要文字、数字、按钮、面板、人物、全身动物、品牌符号、水印、真实照片质感、厚重阴影或科技感。',
|
||
styleReferenceNote,
|
||
noStretchNote,
|
||
chromaKeyNote,
|
||
].join(''),
|
||
},
|
||
{
|
||
id: 'wave-cat-paw-guide-v2',
|
||
output: 'picture-book-wave-cat-paw-guide-v2.png',
|
||
sourceOutput: 'picture-book-wave-cat-paw-guide-v2-source.png',
|
||
size: '1024x1024',
|
||
transparent: true,
|
||
useBackgroundReference: true,
|
||
useLayoutReference: true,
|
||
layoutNormalization: {
|
||
canvasWidth: 1024,
|
||
canvasHeight: 1024,
|
||
fit: 'contain',
|
||
fillWidth: 0.74,
|
||
fillHeight: 0.78,
|
||
anchorY: 'center',
|
||
padding: 24,
|
||
},
|
||
prompt: [
|
||
'请重新设计一版儿童动作互动游戏挥手引导猫爪资产,只画一只圆润猫爪和很短一段前臂,用于网页左右镜像复用。',
|
||
'主体是大号圆猫爪,爪面朝向观众,爪垫用一个浅粉色大圆垫和几个浅粉色小圆垫简化表达,前臂只保留短短一截,不要画成长手臂。',
|
||
'猫爪要像儿童绘本贴纸或软玩具爪子,轮廓饱满、简洁、可爱,适合放在猫猫头左右两侧做挥动动画。',
|
||
'色彩与猫猫头统一:浅奶油白、淡橘、柔和浅粉或淡桃色爪垫和少量浅草绿或天空蓝高光;整体半透明、轻盈、无厚重阴影。',
|
||
'爪垫必须保持明亮柔和,禁止黑色、灰色、深棕色、深色阴影或高反差硬边。',
|
||
'缩小后必须清楚看出猫爪轮廓和爪垫;不要尖爪、不要手指细节、不要真实皮肤或真实毛发质感。',
|
||
'不要文字、数字、按钮、面板、人物全身、完整动物、真实照片质感、厚重阴影或科技感。',
|
||
styleReferenceNote,
|
||
noStretchNote,
|
||
chromaKeyNote,
|
||
].join(''),
|
||
},
|
||
{
|
||
id: 'ground-ring-v3',
|
||
output: 'picture-book-ground-ring-v3.png',
|
||
sourceOutput: 'picture-book-ground-ring-v3-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: [
|
||
'请重新设计儿童动作互动游戏地面位置指示环资产,用于放在绿色草地上,必须和草皮明显区分。',
|
||
'主体是单个贴在地面上的透视椭圆指示环,不是完整背景,不要依赖网页后期压扁。',
|
||
'样式像浅蓝天空色和暖黄色软垫组成的绘本地贴:外圈为浅蓝白水彩描边,内圈有柔和暖黄色或奶油色高光,中心留空透明。',
|
||
'圆环边缘可以有少量星星光点、短虚线或纸贴边,但不要用大面积绿色草叶作为主体,避免和草地混在一起。',
|
||
'禁止使用粉紫色、品红色、紫色外圈、玫红光晕或任何接近 #ff00ff 的颜色;这些颜色会被当成透明背景删除。',
|
||
'除纯色品红背景外,主体只能使用浅蓝、白色、奶油黄、暖黄色、浅橙和极少量浅草绿。',
|
||
'整体要明亮、温暖、儿童绘本风,和草地舞台统一但有清楚视觉对比;不要科技感,不要霓虹,不要金属材质。',
|
||
styleReferenceNote,
|
||
noStretchNote,
|
||
chromaKeyNote,
|
||
].join(''),
|
||
},
|
||
{
|
||
id: 'wave-cat-torso-guide-v3',
|
||
output: 'picture-book-wave-cat-torso-guide-v3.png',
|
||
sourceOutput: 'picture-book-wave-cat-torso-guide-v3-source.png',
|
||
size: '1024x1024',
|
||
transparent: true,
|
||
useBackgroundReference: true,
|
||
useLayoutReference: true,
|
||
layoutNormalization: {
|
||
canvasWidth: 1024,
|
||
canvasHeight: 1024,
|
||
fit: 'contain',
|
||
fillWidth: 0.62,
|
||
fillHeight: 0.58,
|
||
anchorY: 'bottom',
|
||
padding: 24,
|
||
},
|
||
prompt: [
|
||
'请为输入图中的橘白绘本猫猫头补充一个可单独叠放在头部下方的猫猫上半身胸口资产。',
|
||
'只画短短的上半身胸口、脖子下沿、圆润肩膀和一点点短前肢根部;不要画头、耳朵、眼睛、嘴巴、胡须、完整爪子、腿或脚。',
|
||
'主体必须是橘白小猫身体,色彩和输入图一致:浅奶油白为主,淡橘色斑纹点缀,柔和浅棕描边,少量浅草绿或天空蓝高光。',
|
||
'形状像儿童绘本贴纸里的圆润上半身,底部自然截断,适合网页叠在猫头下面形成半身猫猫。',
|
||
'两侧不要伸出长手臂,左右猫爪会由网页单独叠加。',
|
||
'禁止黑色、黑白猫、大面积深色毛、真实毛发、尖锐漫画黑线、高反差阴影。',
|
||
'不要文字、数字、按钮、面板、人物、完整动物、品牌符号、水印、真实照片质感、厚重阴影或科技感。',
|
||
styleReferenceNote,
|
||
noStretchNote,
|
||
chromaKeyNote,
|
||
].join(''),
|
||
},
|
||
{
|
||
id: 'wave-cat-body-guide-v4',
|
||
output: 'picture-book-wave-cat-body-guide-v4.png',
|
||
sourceOutput: 'picture-book-wave-cat-body-guide-v4-source.png',
|
||
size: '1024x1024',
|
||
transparent: true,
|
||
useBackgroundReference: true,
|
||
useLayoutReference: true,
|
||
useWaveCatHeadReference: true,
|
||
layoutNormalization: {
|
||
canvasWidth: 1024,
|
||
canvasHeight: 1024,
|
||
fit: 'contain',
|
||
fillWidth: 0.78,
|
||
fillHeight: 0.88,
|
||
anchorY: 'bottom',
|
||
padding: 24,
|
||
},
|
||
prompt: [
|
||
'请按参考结构重新绘制儿童动作互动游戏中央招手提示的猫咪身体主体资源。',
|
||
'主体结构参考用户草图:正面半身猫咪,圆猫头在上方,两个三角耳朵,头下方接一个简单圆润躯干,躯干到胸口和腰部一半为止。',
|
||
'本资源只包含猫头、耳朵、脖子、躯干和肩部连接点,不要画任何手臂、前臂、手掌、猫爪、腿或脚;左右肩膀两侧要留出手臂接入空间。',
|
||
'角色必须是橘白猫:主体毛色 80% 为浅奶油白和温暖淡橘色,少量浅棕描边;只能有小面积深棕眼睛和细线五官。',
|
||
'五官简洁可爱:大眼睛、短鼻子、小嘴巴、短胡须;躯干为浅奶油白和淡橘色斑纹,边缘柔和水彩描边。',
|
||
'整体像儿童绘本贴纸,半透明、轻盈,缩小到舞台中央后仍能看清猫头和半身结构。',
|
||
'禁止画手臂或猫爪,禁止黑色、灰色、黑白猫、奶牛猫、虎斑深色块、大面积深棕毛、真实毛发、尖锐漫画黑线、高反差阴影、文字、数字、按钮、面板、水印和真实照片质感。',
|
||
styleReferenceNote,
|
||
noStretchNote,
|
||
chromaKeyNote,
|
||
].join(''),
|
||
},
|
||
{
|
||
id: 'wave-cat-arm-guide-v4',
|
||
output: 'picture-book-wave-cat-arm-guide-v4.png',
|
||
sourceOutput: 'picture-book-wave-cat-arm-guide-v4-source.png',
|
||
size: '1024x1024',
|
||
transparent: true,
|
||
useBackgroundReference: true,
|
||
useLayoutReference: true,
|
||
layoutNormalization: {
|
||
canvasWidth: 1024,
|
||
canvasHeight: 1024,
|
||
fit: 'contain',
|
||
fillWidth: 0.7,
|
||
fillHeight: 0.82,
|
||
anchorY: 'bottom',
|
||
padding: 24,
|
||
},
|
||
prompt: [
|
||
'请按参考结构重新绘制儿童动作互动游戏猫咪挥手动画用的单侧手臂资源。',
|
||
'只画一条橘白猫咪手臂:从肩膀连接处开始,弯曲向上,包含上臂、前臂和末端圆猫爪,整体像用户草图中单侧向上挥动的弯曲手臂。',
|
||
'资源需要适合网页左右镜像复用:默认绘制一条从画面下方肩部连接点向上弯到画面左上方的手臂,肩部连接点在资源下方内侧,方便 CSS 设置旋转轴。',
|
||
'猫爪末端是圆润猫爪,爪垫浅粉或淡桃色,不要尖爪;手臂粗细均匀、短而可爱,不要画成长人类手臂。',
|
||
'角色必须是橘白猫手臂:主体毛色 80% 为浅奶油白和温暖淡橘色,淡橘斑纹点缀,柔和浅棕描边,爪垫浅粉或淡桃色。',
|
||
'整体像儿童绘本贴纸,半透明、轻盈,边缘清晰,缩小后仍能看出弯曲手臂和圆猫爪。',
|
||
'不要画猫头、躯干、另一只手臂、完整动物、腿、脚、文字、数字、按钮、面板、水印、真实照片质感、黑色、灰色、黑白毛、黑灰重阴影或深色大面积毛。',
|
||
styleReferenceNote,
|
||
noStretchNote,
|
||
chromaKeyNote,
|
||
].join(''),
|
||
},
|
||
{
|
||
id: 'wave-cat-body-guide-v5',
|
||
output: 'picture-book-wave-cat-body-guide-v5.png',
|
||
sourceOutput: 'picture-book-wave-cat-body-guide-v5-source.png',
|
||
size: '1024x1024',
|
||
transparent: true,
|
||
transparencyCleanup: 'cat-guide',
|
||
useBackgroundReference: true,
|
||
useLayoutReference: true,
|
||
useWaveCatHeadReference: true,
|
||
layoutNormalization: {
|
||
canvasWidth: 1024,
|
||
canvasHeight: 1024,
|
||
fit: 'contain',
|
||
fillWidth: 0.72,
|
||
fillHeight: 0.88,
|
||
anchorY: 'bottom',
|
||
padding: 22,
|
||
},
|
||
prompt: [
|
||
'请按用户参考结构重新绘制儿童动作互动游戏中央招手提示的猫咪身体主体资源,用作动画底座。主体必须是正面纸偶结构:一个大圆猫头、两个三角耳朵、头下方连接短脖子和圆润半身躯干,画到上半身和腰部一半即可。',
|
||
'本资源只包含猫头、耳朵、五官、脖子、躯干、圆润肩膀和两侧肩部连接点;绝对不要画任何手臂、前臂、手掌、猫爪、小手、小脚、腿、脚或尾巴。左右肩膀外侧需要留出干净的手臂接入空间,方便网页单独叠加手臂动画。',
|
||
'请保持输入猫猫头的暖橘白绘本风格:头顶和耳朵外侧有淡橘色块,脸和肚子为浅奶油白,少量浅橘斑纹,五官只用小面积深棕眼睛和暖棕细线。',
|
||
'所有描边必须是柔和暖棕或浅橘棕,不要使用纯黑描边;资源自身保持清晰不透明,网页会统一设置半透明效果,不要在图片里主动降低主体透明度。',
|
||
'整体像儿童绘本贴纸或可动纸偶底座,结构简单、比例可爱,缩小到舞台中央后仍能看清大猫头、小身体和肩部挂点。',
|
||
'禁止画手臂或猫爪,禁止黑色、灰色、黑白猫、奶牛猫、虎斑深色块、大面积深棕毛、真实毛发、尖锐漫画黑线、高反差阴影、文字、数字、按钮、面板、水印和真实照片质感。',
|
||
styleReferenceNote,
|
||
noStretchNote,
|
||
chromaKeyNote,
|
||
].join(''),
|
||
},
|
||
{
|
||
id: 'wave-cat-arm-guide-v5',
|
||
output: 'picture-book-wave-cat-arm-guide-v5.png',
|
||
sourceOutput: 'picture-book-wave-cat-arm-guide-v5-source.png',
|
||
size: '1024x1024',
|
||
transparent: true,
|
||
transparencyCleanup: 'cat-guide',
|
||
useBackgroundReference: true,
|
||
useLayoutReference: true,
|
||
useWaveCatHeadReference: true,
|
||
layoutNormalization: {
|
||
canvasWidth: 1024,
|
||
canvasHeight: 1024,
|
||
fit: 'contain',
|
||
fillWidth: 0.58,
|
||
fillHeight: 0.86,
|
||
anchorY: 'bottom',
|
||
padding: 20,
|
||
},
|
||
prompt: [
|
||
'请按用户参考结构重新绘制儿童动作互动游戏猫咪挥手动画用的单侧手臂手部资源。只画一条猫咪手臂:从底部肩膀连接点开始,包含短上臂、弯曲前臂和末端圆猫爪,像可动纸偶的一条独立手臂。',
|
||
'默认绘制一条向左上方举起的手臂,肩膀连接点在画面底部偏内侧,圆猫爪在画面上方;资源需要适合网页左右镜像复用和围绕肩膀连接点旋转摆动。',
|
||
'猫爪用类似多啦A梦圆手的圆润简化形状,不展示手指细节,不要尖爪;爪面可以有浅粉或淡桃色圆形爪垫。手臂短而可爱,比例像小猫上肢,不要画成人类长手臂。',
|
||
'请保持输入猫猫头的暖橘白绘本风格:手臂主体为浅奶油白和淡橘色,少量浅橘斑纹,爪垫浅粉或淡桃色,柔和暖棕描边。',
|
||
'所有描边必须是柔和暖棕或浅橘棕,不要使用纯黑描边;资源自身保持清晰不透明,网页会统一设置半透明效果,不要在图片里主动降低主体透明度。',
|
||
'不要画猫头、躯干、另一只手臂、完整动物、腿、脚、文字、数字、按钮、面板、水印、真实照片质感、黑色、灰色、黑白毛、黑灰重阴影或深色大面积毛。',
|
||
styleReferenceNote,
|
||
noStretchNote,
|
||
chromaKeyNote,
|
||
].join(''),
|
||
},
|
||
{
|
||
id: 'wave-cat-body-guide-v6',
|
||
output: 'picture-book-wave-cat-body-guide-v6.png',
|
||
sourceOutput: 'picture-book-wave-cat-body-guide-v6-source.png',
|
||
size: '1024x1024',
|
||
transparent: true,
|
||
transparencyCleanup: 'cat-guide',
|
||
useBackgroundReference: true,
|
||
useLayoutReference: true,
|
||
useWaveCatHeadReference: true,
|
||
layoutNormalization: {
|
||
canvasWidth: 1024,
|
||
canvasHeight: 1024,
|
||
fit: 'contain',
|
||
fillWidth: 0.68,
|
||
fillHeight: 0.86,
|
||
anchorY: 'bottom',
|
||
padding: 22,
|
||
},
|
||
prompt: [
|
||
'请重新绘制儿童动作互动游戏中央招手提示的猫咪身体主体资源,严格按可动纸偶拆件结构生成。主体只有一只正面橘白猫:大圆猫头、两个三角耳朵、短脖子、梨形半身躯干,底部自然截断。',
|
||
'身体两侧只允许出现圆润肩膀轮廓和一个很小的肩部连接圆点或肩窝标记;绝对不要画伸出的手臂、前臂、手掌、猫爪、小手、小脚、腿、脚或尾巴。肩膀外侧必须留空,后续网页会单独叠加两条手臂。',
|
||
'猫咪造型参考输入猫猫头的暖橘白配色:头顶、耳朵外侧和身体侧边为淡橘色,脸和肚子为浅奶油白,少量浅橘斑纹;五官使用暖棕细线和小面积深棕眼睛。',
|
||
'请避免粉色大背景、避免主体外侧彩色光晕,主体贴纸外轮廓之外必须直接是纯色背景;线条为柔和暖棕或浅橘棕,不要纯黑粗描边。',
|
||
'资源自身保持清晰不透明,半透明效果由网页 CSS 控制;整体像儿童绘本可动纸偶底座,缩小后仍能看清大猫头、短身体、肩部连接点。',
|
||
'禁止手臂、爪子、小手、脚、尾巴;禁止黑色、灰色、黑白猫、奶牛猫、虎斑深色块、大面积深棕毛、真实毛发、尖锐漫画黑线、高反差阴影、文字、数字、按钮、面板、水印和真实照片质感。',
|
||
styleReferenceNote,
|
||
noStretchNote,
|
||
chromaKeyNote,
|
||
].join(''),
|
||
},
|
||
{
|
||
id: 'wave-cat-arm-guide-v6',
|
||
output: 'picture-book-wave-cat-arm-guide-v6.png',
|
||
sourceOutput: 'picture-book-wave-cat-arm-guide-v6-source.png',
|
||
size: '1024x1024',
|
||
transparent: true,
|
||
transparencyCleanup: 'cat-guide',
|
||
useBackgroundReference: true,
|
||
useLayoutReference: true,
|
||
useWaveCatHeadReference: true,
|
||
layoutNormalization: {
|
||
canvasWidth: 1024,
|
||
canvasHeight: 1024,
|
||
fit: 'contain',
|
||
fillWidth: 0.74,
|
||
fillHeight: 0.88,
|
||
anchorY: 'bottom',
|
||
padding: 20,
|
||
},
|
||
prompt: [
|
||
'请重新绘制儿童动作互动游戏猫咪挥手动画用的单侧手臂资源,严格作为可动纸偶拆件。只画一条橘白猫手臂:底部是肩膀连接端,向左上方弯曲,末端是一只简化圆猫手。',
|
||
'猫手必须像多啦A梦式圆手或软玩具圆爪:一个完整圆润手掌,不画手指,不画黑色或深色爪垫,不画粉色爪垫点,不画尖爪。手臂短而厚实,像小猫上肢,不要成人类长手臂。',
|
||
'资源必须适合网页左右镜像复用和围绕肩部连接点旋转:肩膀连接端在画面底部偏内侧,圆手在画面上方,四周留透明空白。',
|
||
'颜色参考输入猫猫头:浅奶油白和淡橘色为主体,少量浅橘斑纹,柔和暖棕或浅橘棕描边;不要纯黑粗描边。',
|
||
'请避免粉色大背景、避免主体外侧彩色光晕,主体贴纸外轮廓之外必须直接是纯色背景。资源自身保持清晰不透明,半透明效果由网页 CSS 控制。',
|
||
'不要画猫头、躯干、另一只手臂、完整动物、腿、脚、文字、数字、按钮、面板、水印、真实照片质感、黑色、灰色、黑白毛、黑灰重阴影或深色大面积毛。',
|
||
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),
|
||
);
|
||
}
|
||
if (asset.useWaveCatHeadReference) {
|
||
pushReferenceImage(
|
||
body,
|
||
path.join(assetDir, 'picture-book-wave-cat-head-guide-v2.png'),
|
||
);
|
||
}
|
||
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 removeCatGuideChromaKey(sourcePath, finalPath) {
|
||
const script = [
|
||
'from collections import deque',
|
||
'from PIL import Image',
|
||
'import sys',
|
||
'source, out = sys.argv[1], sys.argv[2]',
|
||
'im = Image.open(source).convert("RGBA")',
|
||
'px = im.load()',
|
||
'w, h = im.size',
|
||
'corner_samples = [im.getpixel((0, 0)), im.getpixel((w - 1, 0)), im.getpixel((0, h - 1)), im.getpixel((w - 1, h - 1))]',
|
||
'key = tuple(sorted([p[i] for p in corner_samples])[len(corner_samples) // 2] for i in range(3))',
|
||
'def is_magenta_bg(r, g, b):',
|
||
' if r > 170 and b > 145 and g < 185 and min(r, b) - g > 36:',
|
||
' return True',
|
||
' return r > 140 and b > 90 and r > g + 35 and b > g + 10',
|
||
'def is_bg_candidate(x, y):',
|
||
' r, g, b, a = px[x, y]',
|
||
' if a <= 10:',
|
||
' return True',
|
||
' dist = ((r - key[0]) ** 2 + (g - key[1]) ** 2 + (b - key[2]) ** 2) ** 0.5',
|
||
' if is_magenta_bg(r, g, b):',
|
||
' return True',
|
||
' if key[0] < 32 and key[1] < 32 and key[2] < 32:',
|
||
' return dist < 34 and max(r, g, b) < 55',
|
||
' if key[0] > 225 and key[1] > 225 and key[2] > 225:',
|
||
' return dist < 34 and min(r, g, b) > 210',
|
||
' return dist < 72',
|
||
'visited = bytearray(w * h)',
|
||
'queue = deque()',
|
||
'def push(x, y):',
|
||
' if x < 0 or y < 0 or x >= w or y >= h:',
|
||
' return',
|
||
' index = y * w + x',
|
||
' if visited[index] or not is_bg_candidate(x, y):',
|
||
' return',
|
||
' visited[index] = 1',
|
||
' queue.append((x, y))',
|
||
'for x in range(w):',
|
||
' push(x, 0)',
|
||
' push(x, h - 1)',
|
||
'for y in range(h):',
|
||
' push(0, y)',
|
||
' push(w - 1, y)',
|
||
'while queue:',
|
||
' x, y = queue.popleft()',
|
||
' push(x + 1, y)',
|
||
' push(x - 1, y)',
|
||
' push(x, y + 1)',
|
||
' push(x, y - 1)',
|
||
'for _ in range(3):',
|
||
' extra = []',
|
||
' for y in range(1, h - 1):',
|
||
' for x in range(1, w - 1):',
|
||
' index = y * w + x',
|
||
' if visited[index] or not is_bg_candidate(x, y):',
|
||
' continue',
|
||
' touches_bg = any(visited[(y + dy) * w + x + dx] for dy in (-1, 0, 1) for dx in (-1, 0, 1) if dx or dy)',
|
||
' if touches_bg:',
|
||
' extra.append(index)',
|
||
' if not extra:',
|
||
' break',
|
||
' for index in extra:',
|
||
' visited[index] = 1',
|
||
'for y in range(h):',
|
||
' for x in range(w):',
|
||
' r, g, b, a = px[x, y]',
|
||
' if visited[y * w + x]:',
|
||
' px[x, y] = (r, g, b, 0)',
|
||
' else:',
|
||
' if a <= 10:',
|
||
' a = 255',
|
||
' px[x, y] = (r, g, b, a)',
|
||
'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 cat guide 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 if (asset.transparencyCleanup === 'cat-guide') {
|
||
removeCatGuideChromaKey(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 if (asset.transparencyCleanup === 'cat-guide') {
|
||
removeCatGuideChromaKey(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,
|
||
),
|
||
);
|