Files
Genarrative/scripts/generate-edutainment-toca-world-map-concepts.mjs

423 lines
13 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 { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import path from 'node:path';
const repoRoot = process.cwd();
const outDir = path.join(
repoRoot,
'output',
'imagegen',
'edutainment-toca-world-map-concepts-20260523',
);
const styleReferencePath = path.join(
repoRoot,
'public',
'child-motion-demo',
'picture-book-grass-stage.png',
);
const defaultTimeoutMs = 1000000;
const commonPrompt = [
'横屏 16:9 寓教于乐板块玩法入口概念图。',
'参考 Toca Life World 的地图组织方式:一个横向展开的大世界,允许向左和向右以“一屏一屏”的单位继续延伸和探索。',
'画面必须像儿童绘本插画,明亮、干净、温暖、圆润、手绘感强,保留 Genarrative 寓教于乐现有的草地、水彩、纸张纹理和童趣气质。',
'地图里要有清晰的道路、步道、河流、桥、分区节点和地标,但不要复制 Toca 的 logo、角色、字体、UI、建筑外形或配色。',
'每个玩法入口都要像一个可单独进入的区域,区域之间既连贯又能独立辨认,适合后续做横向滑动或分屏探索。',
'左右两侧都要有明显的延展感,边缘不能封死,重要场景不要都挤在画面中心。',
'中心只保留一个轻量主枢纽,左右各留出半屏到一屏的可继续探索空间。',
'不要出现文字、数字、字母、按钮文案、UI 面板、logo、水印、真实照片感、暗黑科技风、过度复杂的人物表情、现成商业 IP 角色。',
].join('');
const concepts = [
{
id: 'edutainment-toca-world-01-river-town',
title: '河道城镇带',
prompt: [
commonPrompt,
'版式方向:一条横向城镇带沿着河道和主路展开,左边是果园与识物区,中间是创作和主枢纽区,右边是音乐广场、自然观察和运动区。',
'每个区域像独立小镇街区,房屋和设施分布在道路两侧,远处山坡和天空继续延伸,整体感觉像可以一直向左右滑动。',
'左侧入口更偏自然与认知,右侧入口更偏探索与表演,中间保留一个安静广场作为聚合点。',
].join(''),
},
{
id: 'edutainment-toca-world-02-mountain-arc',
title: '山脊长廊',
prompt: [
commonPrompt,
'版式方向:一条山脊和环山公路贯穿左右,地图像被拉长的山地长廊,城市、树林、草坡和小店沿山脊展开。',
'左屏是水果农场和画画工坊,中屏是社区广场和拼图桥,右屏是音乐小剧场、动物观察和冒险步道。',
'地形要有明显起伏,但道路必须连续、可走、可向两侧继续延伸,像同一世界的不同屏幕段落。',
].join(''),
},
{
id: 'edutainment-toca-world-03-island-chain',
title: '岛链世界',
prompt: [
commonPrompt,
'版式方向:多个小岛通过桥梁、栈道和缆车相连,像一条横向的岛链,每个岛都像一屏可探索区域。',
'左侧岛是识物果园岛,中间岛是绘本创作岛和主广场,右侧岛是运动草地岛、音乐舞台岛和自然探索岛。',
'每个岛的边缘都要暗示下一岛的延伸,水面和道路自然把画面向左向右打开,不要封闭成一个圆形世界。',
].join(''),
},
{
id: 'edutainment-toca-world-04-city-park-spine',
title: '城市公园脊',
prompt: [
commonPrompt,
'版式方向:一条城市公园主脊从左到右穿过整个画面,主脊两侧是不同功能街区,像儿童版的世界地图主干道。',
'左边是安静学习区和果园,中央是综合广场、画画棚和拼图桥,右边是音乐广场、运动场、动物观测点和森林边界。',
'整体更接近 Toca 式“世界菜单”感,但美术要更像绘本插画,不要扁平矢量 UI也不要把地图画成球体。',
].join(''),
},
];
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 generationUrl(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 inferExtensionFromBytes(bytes) {
if (bytes.subarray(0, 8).equals(Buffer.from('\x89PNG\r\n\x1A\n', 'binary'))) {
return 'png';
}
if (bytes.subarray(0, 3).equals(Buffer.from([0xff, 0xd8, 0xff]))) {
return 'jpg';
}
if (
bytes.subarray(0, 4).toString('ascii') === 'RIFF' &&
bytes.subarray(8, 12).toString('ascii') === 'WEBP'
) {
return 'webp';
}
return 'png';
}
function inferExtensionFromContentType(contentType) {
const normalized = contentType.split(';')[0]?.trim().toLowerCase();
if (normalized === 'image/png') {
return 'png';
}
if (normalized === 'image/webp') {
return 'webp';
}
if (normalized === 'image/jpeg') {
return 'jpg';
}
return 'png';
}
function toDataUrl(filePath) {
if (!existsSync(filePath)) {
return null;
}
const bytes = readFileSync(filePath);
const extension = inferExtensionFromBytes(bytes);
const mime = extension === 'jpg' ? 'image/jpeg' : `image/${extension}`;
return `data:${mime};base64,${bytes.toString('base64')}`;
}
function buildRequestBody(concept, size, hasStyleReference) {
const body = {
model: 'gpt-image-2-all',
prompt: concept.prompt,
n: 1,
size,
};
if (hasStyleReference) {
const ref = toDataUrl(styleReferencePath);
if (ref) {
body.image = [ref];
}
}
return body;
}
function buildDryRunRequestBody(concept, size, hasStyleReference) {
return {
model: 'gpt-image-2-all',
prompt: concept.prompt,
n: 1,
size,
imageReferenceCount: hasStyleReference ? 1 : 0,
};
}
async function fetchJson(url, options, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(url, {
...options,
signal: abortController.signal,
});
const text = await response.text();
if (!response.ok) {
throw new Error(`VectorEngine ${response.status}: ${text.slice(0, 600)}`);
}
return JSON.parse(text);
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`VectorEngine request timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timer);
}
}
async function downloadUrl(url, timeoutMs) {
const abortController = new AbortController();
const timer = setTimeout(() => abortController.abort(), timeoutMs);
try {
const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) {
throw new Error(`download ${response.status}`);
}
return {
bytes: Buffer.from(await response.arrayBuffer()),
extension: inferExtensionFromContentType(
response.headers.get('content-type') || '',
),
};
} catch (error) {
if (error?.name === 'AbortError') {
throw new Error(`Generated image download timed out after ${timeoutMs}ms`);
}
throw error;
} finally {
clearTimeout(timer);
}
}
function outputPathFor(concept, extension = 'png') {
return path.join(outDir, `${concept.id}.${extension}`);
}
function findExistingOutputPath(concept) {
for (const extension of ['png', 'jpg', 'jpeg', 'webp']) {
const candidate = outputPathFor(concept, extension);
if (existsSync(candidate)) {
return candidate;
}
}
return null;
}
async function generateOne(env, concept, size) {
const payload = await fetchJson(
generationUrl(env.baseUrl),
{
method: 'POST',
headers: {
Authorization: `Bearer ${env.apiKey}`,
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(buildRequestBody(concept, size, existsSync(styleReferencePath))),
},
env.timeoutMs,
);
const urls = [];
const b64Images = [];
collectStringsByKey(payload, 'url', urls);
collectStringsByKey(payload, 'image', urls);
collectStringsByKey(payload, 'image_url', urls);
collectStringsByKey(payload, 'b64_json', b64Images);
let image;
const imageUrl = [...new Set(urls)].find((url) => /^https?:\/\//u.test(url));
if (imageUrl) {
image = await downloadUrl(imageUrl, env.timeoutMs);
} else if (b64Images[0]) {
const bytes = Buffer.from(b64Images[0], 'base64');
image = {
bytes,
extension: inferExtensionFromBytes(bytes),
};
} else {
throw new Error(`VectorEngine returned no image for ${concept.id}`);
}
mkdirSync(outDir, { recursive: true });
const outputPath = outputPathFor(concept, image.extension);
writeFileSync(outputPath, image.bytes);
return outputPath;
}
const args = new Map();
for (let index = 2; index < process.argv.length; index += 1) {
const raw = process.argv[index];
if (raw.startsWith('--')) {
const next = process.argv[index + 1];
if (next && !next.startsWith('--')) {
args.set(raw, next);
index += 1;
} else {
args.set(raw, true);
}
}
}
const size = String(args.get('--size') || '2048x1152');
const dryRun = args.has('--dry-run') || !args.has('--live');
const selectedIds = String(args.get('--only') || '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const selectedConcepts = concepts.filter(
(concept) => selectedIds.length === 0 || selectedIds.includes(concept.id),
);
const hasStyleReference = existsSync(styleReferencePath);
if (dryRun) {
console.log(
JSON.stringify(
{
mode: 'dry-run',
outDir,
size,
hasStyleReference,
count: selectedConcepts.length,
requests: selectedConcepts.map((concept) => ({
id: concept.id,
title: concept.title,
body: buildDryRunRequestBody(concept, size, hasStyleReference),
})),
},
null,
2,
),
);
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 files = [];
const generatedFileById = new Map();
for (const concept of selectedConcepts) {
console.log(`Generating ${concept.id}...`);
const file = await generateOne(env, concept, size);
files.push(file);
generatedFileById.set(concept.id, file);
}
const metadataFiles = concepts
.map((concept) => {
const file = generatedFileById.get(concept.id) ?? findExistingOutputPath(concept);
if (!file) {
return null;
}
return {
id: concept.id,
title: concept.title,
file,
prompt: concept.prompt,
};
})
.filter(Boolean);
writeFileSync(
path.join(outDir, 'generation-metadata.json'),
JSON.stringify(
{
model: 'gpt-image-2-all',
size,
generatedAt: new Date().toISOString(),
styleReference: hasStyleReference ? styleReferencePath : null,
generatedIds: selectedConcepts.map((concept) => concept.id),
files: metadataFiles,
},
null,
2,
),
);
console.log(JSON.stringify({ ok: true, count: files.length, files }, null, 2));