Files
ai-toy-patent-workflow/scripts/generate-hai-pig-zodiac-assets.mjs

298 lines
12 KiB
JavaScript
Raw Permalink 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.
#!/usr/bin/env node
import { createWriteStream, readFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import path from 'node:path';
import { Readable } from 'node:stream';
import { fileURLToPath } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = parseArgs(process.argv.slice(2));
const sessionId = args.session || args._[0];
if (!sessionId) fail('Usage: node scripts/generate-hai-pig-zodiac-assets.mjs --session <sessionId> [--env deploy/.env.production]');
const env = {
...readEnvFile(path.join(root, args.env || '.env.local')),
...process.env,
};
const apiKey = env.OPENAI_API_KEY;
if (!apiKey) fail('OPENAI_API_KEY missing');
const apiBase = env.GPT_API_BASE || 'https://api.openai.com/v1';
const model = env.GPT_IMAGE_EDIT_MODEL || env.GPT_IMAGE_MODEL || 'gpt-image-2';
const fresh = Boolean(args.fresh);
const selectedTemplateIds = String(args.templates || '')
.split(',')
.map(item => item.trim())
.filter(Boolean);
const sessionPath = path.join(root, 'data', 'sessions', `${sessionId}.json`);
const packDir = path.join(root, 'data', 'packs');
const ZODIAC_ASSETS = [
{
templateId: 'zodiac_armor_grid',
view: 'zodiac-grid',
title: '十二生肖装甲全阵列',
description: '同一亥猪基型扩展出的十二生肖装甲配色和模块组合。',
aspectRatio: '16:9',
size: '1536x1024',
prompt: '12 variants grid, same base robot, different zodiac armor modules and colors',
},
{
templateId: 'zodiac_hero_lineup',
view: 'zodiac-hero',
title: '亥猪主角十二生肖集合',
description: '亥猪作为主角,其他生肖作为同一基础机甲的装甲变化。',
aspectRatio: '16:9',
size: '1536x1024',
prompt: 'hero lineup, Hai Pig main character in center, eleven zodiac armor variants around',
},
{
templateId: 'zodiac_material_palette',
view: 'zodiac-material',
title: '十二生肖短绒软壳材质方案',
description: '女性向软萌机甲材质、色彩与局部模块组合方案。',
aspectRatio: '16:9',
size: '1536x1024',
prompt: 'soft plush and soft shell material palette board for 12 zodiac armor variants',
},
{
templateId: 'zodiac_module_variants',
view: 'zodiac-modules',
title: '十二生肖可变模块设计',
description: '头顶冠盖、侧面模块、肩部、手脚装甲和纹样的可变区域方案。',
aspectRatio: '16:9',
size: '1536x1024',
prompt: 'design board of changeable crown, side module, shoulder, arm and foot armor modules',
},
];
await fs.mkdir(packDir, { recursive: true });
const session = JSON.parse(await fs.readFile(sessionPath, 'utf8'));
const pack = ensureMarketingPack(session);
const anchorUrl = findAnchor(session);
const anchorPath = localPathFromImageUrl(anchorUrl);
if (!anchorPath) fail(`Cannot resolve local anchor image: ${anchorUrl}`);
const activeSpecs = selectedTemplateIds.length
? ZODIAC_ASSETS.filter(spec => selectedTemplateIds.includes(spec.templateId))
: ZODIAC_ASSETS;
const generated = [];
for (const spec of activeSpecs) {
const existing = pack.assets.find(asset => asset.templateId === spec.templateId);
if (existing && !fresh) {
generated.push({ templateId: spec.templateId, status: 'skipped', url: existing.url });
continue;
}
const dataUrl = await generateEdit({
prompt: zodiacPrompt(spec),
anchorPath,
size: spec.size,
});
const assetId = `${spec.templateId}_${randomHex(3)}`;
const filename = `${pack.id}_${assetId}.png`;
const filePath = path.join(packDir, filename);
await writeDataUrl(dataUrl, filePath);
const asset = {
id: assetId,
templateId: spec.templateId,
kind: 'marketing',
view: spec.view,
title: spec.title,
description: spec.description,
url: `/api/img/packs/${filename}`,
prompt: spec.prompt,
status: 'draft',
version: 'v01',
aspectRatio: spec.aspectRatio,
required: false,
createdAt: Date.now(),
anchorImageUrl: anchorUrl,
derivationLevel: 2,
meta: {
provider: 'gpt',
model,
packLabel: '宣发包',
generatedBy: 'generate-hai-pig-zodiac-assets',
},
};
if (existing) {
const index = pack.assets.findIndex(item => item.templateId === spec.templateId);
pack.assets[index] = asset;
} else {
pack.assets.push(asset);
}
generated.push({ templateId: spec.templateId, status: 'generated', url: asset.url });
await fs.writeFile(sessionPath, `${JSON.stringify(session, null, 2)}\n`);
}
await fs.writeFile(sessionPath, `${JSON.stringify(session, null, 2)}\n`);
console.log(JSON.stringify({ sessionId, packId: pack.id, generated }, null, 2));
function zodiacPrompt(spec) {
return [
'基于参考图中的“有你家族 · 亥猪”AI 陪伴机甲角色生成新的设计图板。',
'最重要:基础设计不能变。每一个形象都必须保留同一个短胖站立机甲身体、白色圆润头盔、橙色弧形 visor 情绪屏、深灰面部底层、头顶竖向 MEEY 识别条、胸前圆形 M 徽章、斜挎黑色能量肩带、灰色方形扣件、橙色功能扣、侧面圆形模块、短手短脚、黑灰脚底。',
'主题:十二生肖外观系统不是十二个动物机器人,而是同一个亥猪基型机甲的十二套可换装甲外观。亥猪是主角和母体,其它生肖只是装甲变化。',
'可变区域:头顶冠盖、侧面模块、肩部装甲、手臂装饰、腿脚边缘、表面纹样、配色、短绒/软壳材料分区。',
'禁止:猪鼻子、猪尾巴、写实猪耳、猪蹄、真实动物头、外凸兽耳、鹿角、牛角、龙角、爪、翅膀、尾巴、四足身体、武器、重装战斗机甲、第三方 IP、水印、价格、海报文案、错误尺寸标注。',
'所有生肖差异只能是贴合头盔的平面冠盖色块、平面纹样、柔软护甲片、侧面圆形模块贴片和配色变化;不能出现从头盔外伸出来的器官或尖锐装饰。',
'尺寸感:全部保持 40cm+ AI 陪伴机器人摆件体量,正面宽约 28cm、侧面深约 22cm不能像迷你挂件。',
'材质方向:女性用户友好,白色软壳与短绒毛绒复合触感,局部深灰结构件和高识别色点缀,干净、酷、温暖,不幼稚。',
'配色建议:子鼠银蓝、丑牛象牙灰+铜金、寅虎白黑+琥珀橙、卯兔月白+樱粉、辰龙珠光白+青绿、巳蛇白+薄荷绿、午马白+赤红、未羊奶白+薰衣草、申猴白+电光黄、酉鸡白+玫瑰金、戌狗白+海军蓝、亥猪白+橙色主视觉。',
`画面要求:${spec.prompt}。16:9 横向产品设计板,白底或极浅灰棚拍,干净高级,产品可用于内部评审;尽量不生成文字标签,如果必须出现文字也要极少且不影响主体。`,
].join('\n');
}
function ensureMarketingPack(sessionValue) {
const existing = sessionValue.packs?.find(item => item.kind === 'marketing');
if (existing) return existing;
const selected = sessionValue.images?.find(image => image.status === 'selected') || sessionValue.images?.[0];
const now = Date.now();
const pack = {
id: `pack_marketing_${now.toString(36)}_${randomHex(3)}`,
kind: 'marketing',
sessionId,
sourceImageId: selected?.id || 'source',
sourceImageUrl: selected?.url || sessionValue.characterSpec?.sourceImageUrl || '',
characterSpec: sessionValue.characterSpec,
assets: [],
manifestId: `manifest_marketing_${now.toString(36)}`,
createdAt: now,
version: 'v01',
status: 'complete',
};
sessionValue.packs = [...(sessionValue.packs || []), pack];
return pack;
}
function findAnchor(sessionValue) {
const preferredTemplateIds = [
'mkt_white_front',
'patent_front',
'prod_front_spec',
];
for (const templateId of preferredTemplateIds) {
const url = findAssetUrl(sessionValue, templateId);
if (url) return url;
}
if (sessionValue.characterSpec?.cleanReferenceImageUrl) return sessionValue.characterSpec.cleanReferenceImageUrl;
const selected = sessionValue.images?.find(image => image.status === 'selected') || sessionValue.images?.[0];
if (selected?.url) return selected.url;
fail('No anchor image found');
}
function findAssetUrl(sessionValue, templateId) {
for (const item of sessionValue.packs || []) {
const asset = item.assets?.find(candidate => candidate.templateId === templateId);
if (asset?.url) return asset.url;
}
return undefined;
}
function localPathFromImageUrl(url) {
const match = String(url).match(/^\/api\/img\/(generated|selected|refs|packs|anchors|uploads)\/([^/?#]+)$/);
if (!match) return null;
const dirs = {
generated: 'generated',
selected: 'selected',
refs: 'refs',
packs: 'packs',
anchors: 'anchors',
uploads: 'uploads',
};
return path.join(root, 'data', dirs[match[1]], decodeURIComponent(match[2]));
}
async function generateEdit(opts) {
const source = await fs.readFile(opts.anchorPath);
const form = new FormData();
form.set('model', model);
form.set('prompt', opts.prompt);
form.set('size', opts.size || '1536x1024');
form.set('image', new Blob([source], { type: 'image/png' }), path.basename(opts.anchorPath));
const res = await fetch(`${apiBase}/images/edits`, {
method: 'POST',
headers: { authorization: `Bearer ${apiKey}` },
body: form,
});
const rawText = await res.text();
if (!res.ok) throw new Error(`GPT image edit ${res.status}: ${rawText}`);
const raw = JSON.parse(rawText);
const first = raw.data?.[0];
if (first?.b64_json) return `data:image/png;base64,${first.b64_json}`;
if (first?.url) {
const image = await fetch(first.url);
if (!image.ok) throw new Error(`download image ${image.status}: ${await image.text()}`);
const type = image.headers.get('content-type')?.split(';')[0] || 'image/png';
const tempPath = path.join(packDir, `tmp_${Date.now()}_${randomHex(2)}.png`);
await streamToFile(image.body, tempPath);
const buffer = await fs.readFile(tempPath);
await fs.unlink(tempPath);
return `data:${type};base64,${buffer.toString('base64')}`;
}
throw new Error('GPT image edit response missing image');
}
async function writeDataUrl(dataUrl, filePath) {
const match = dataUrl.match(/^data:[^;]+;base64,(.+)$/);
if (!match) throw new Error('invalid image data URL');
await fs.writeFile(filePath, Buffer.from(match[1], 'base64'));
}
async function streamToFile(body, filePath) {
if (!body) throw new Error('response body missing');
await new Promise((resolve, reject) => {
const stream = createWriteStream(filePath);
Readable.fromWeb(body).pipe(stream);
stream.on('finish', resolve);
stream.on('error', reject);
});
}
function parseArgs(argv) {
const parsed = { _: [] };
for (let i = 0; i < argv.length; i += 1) {
const item = argv[i];
if (!item.startsWith('--')) {
parsed._.push(item);
continue;
}
const key = item.slice(2);
const next = argv[i + 1];
if (!next || next.startsWith('--')) {
parsed[key] = true;
} else {
parsed[key] = next;
i += 1;
}
}
return parsed;
}
function readEnvFile(filePath) {
try {
const text = readFileSync(filePath, 'utf8');
const out = {};
for (const line of text.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const idx = trimmed.indexOf('=');
if (idx < 0) continue;
out[trimmed.slice(0, idx)] = trimmed.slice(idx + 1).replace(/^["']|["']$/g, '');
}
return out;
} catch {
return {};
}
}
function randomHex(bytes) {
return Array.from(crypto.getRandomValues(new Uint8Array(bytes))).map(byte => byte.toString(16).padStart(2, '0')).join('');
}
function fail(message) {
console.error(message);
process.exit(1);
}