feat: add Hai Pig zodiac video workflow
This commit is contained in:
297
scripts/generate-hai-pig-zodiac-assets.mjs
Normal file
297
scripts/generate-hai-pig-zodiac-assets.mjs
Normal file
@@ -0,0 +1,297 @@
|
||||
#!/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);
|
||||
}
|
||||
Reference in New Issue
Block a user