- 修复: 林昊正面/侧面不一致 → seedream_i2i 图生图重绘 - 新建: engines/char-hero-design-packer/char-hero-design-packer.js - 主视角T2I → 记录seed → 其余视角I2I参考同一张脸 + 锁定seed - 版本化输出 + manifest.json + --dry-run 预览 - 更新: cli.py cmd_pack 支持 .js 引擎 + --project --views --dry-run - 新增: EP01-BREAKDOWN-V3.hdlp, EP01-PARSED.hdlp
388 lines
12 KiB
JavaScript
388 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* char-hero-design-packer.js — 角色资产打包引擎 v2.0
|
||
*
|
||
* 核心改进:
|
||
* 1. 主视角先生成 → 记录 seed
|
||
* 2. 其余视角用主视角作为图生图参考 → 锁定同一 seed
|
||
* 3. 版本化输出目录
|
||
* 4. manifest.json 记录所有种子和参考链
|
||
*
|
||
* 用法:
|
||
* node char-hero-design-packer.js --character CHAR-001 --project deep-sea-voyage
|
||
* node char-hero-design-packer.js --character CHAR-001 --project deep-sea-voyage --dry-run
|
||
*/
|
||
|
||
const https = require('https');
|
||
const http = require('http');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { execSync } = require('child_process');
|
||
|
||
// ===== 配置 =====
|
||
|
||
const MODEL = 'doubao-seedream-4-0-250828';
|
||
const SIZE = '1440x2560';
|
||
const COST_PER_IMAGE = 0.21; // ¥
|
||
|
||
// 视角定义
|
||
const VIEW_CONFIGS = {
|
||
front: {
|
||
label: '正面',
|
||
suffix: '_正面',
|
||
promptAddon: 'full body shot, facing camera directly, looking at viewer',
|
||
mode: 't2i' // 第一张用文生图
|
||
},
|
||
side: {
|
||
label: '侧面',
|
||
suffix: '_侧面',
|
||
promptAddon: 'side profile view, turned 90 degrees to the right, profile silhouette visible',
|
||
mode: 'i2i' // 用于主视角参考
|
||
},
|
||
hand: {
|
||
label: '手部',
|
||
suffix: '_手部',
|
||
promptAddon: 'close-up shot focusing on hands, callused worker hands with engine oil stains, hands in foreground',
|
||
mode: 'i2i'
|
||
},
|
||
threeQuarter: {
|
||
label: '¾面',
|
||
suffix: '¾面',
|
||
promptAddon: 'three-quarter view, angled slightly to the right, facing partly away from camera, cinematic portrait',
|
||
mode: 'i2i'
|
||
}
|
||
};
|
||
|
||
// ===== API 调用 =====
|
||
|
||
function getArkKey() {
|
||
const envPaths = [
|
||
path.join(__dirname, '..', '..', '.env'),
|
||
path.join(__dirname, '..', '..', '..', '.env'),
|
||
'D:/WorkBuddy/cang-ying/.env'
|
||
];
|
||
for (const p of envPaths) {
|
||
if (fs.existsSync(p)) {
|
||
const content = fs.readFileSync(p, 'utf8');
|
||
const m = content.match(/ARK_API_KEY=(.+)/);
|
||
if (m) return m[1].trim();
|
||
}
|
||
}
|
||
throw new Error('ARK_API_KEY 未找到,请检查 .env 配置');
|
||
}
|
||
|
||
function apiCall(payload) {
|
||
return new Promise((resolve, reject) => {
|
||
const body = JSON.stringify(payload);
|
||
const req = https.request({
|
||
hostname: 'ark.cn-beijing.volces.com',
|
||
path: '/api/v3/images/generations',
|
||
method: 'POST',
|
||
headers: {
|
||
'Authorization': `Bearer ${getArkKey()}`,
|
||
'Content-Type': 'application/json',
|
||
'Content-Length': Buffer.byteLength(body)
|
||
}
|
||
}, (res) => {
|
||
let data = '';
|
||
res.on('data', c => data += c);
|
||
res.on('end', () => {
|
||
try {
|
||
const result = JSON.parse(data);
|
||
if (result.error) reject(new Error(JSON.stringify(result.error)));
|
||
else resolve(result);
|
||
} catch (e) { reject(new Error(data)); }
|
||
});
|
||
});
|
||
req.on('error', reject);
|
||
req.write(body);
|
||
req.end();
|
||
});
|
||
}
|
||
|
||
function t2i(prompt, seed) {
|
||
const payload = { model: MODEL, prompt, size: SIZE, n: 1 };
|
||
if (seed) payload.seed = seed;
|
||
return apiCall(payload);
|
||
}
|
||
|
||
function i2i(prompt, imagePath, seed) {
|
||
const imgBase64 = fs.readFileSync(imagePath).toString('base64');
|
||
const ext = imagePath.endsWith('.png') ? 'png' : 'jpeg';
|
||
const payload = {
|
||
model: MODEL,
|
||
prompt,
|
||
image: `data:image/${ext};base64,${imgBase64}`,
|
||
size: SIZE,
|
||
n: 1
|
||
};
|
||
if (seed) payload.seed = seed;
|
||
return apiCall(payload);
|
||
}
|
||
|
||
function downloadImage(url, outputPath) {
|
||
return new Promise((resolve, reject) => {
|
||
const proto = url.startsWith('https') ? https : http;
|
||
const file = fs.createWriteStream(outputPath);
|
||
proto.get(url, (res) => {
|
||
res.pipe(file);
|
||
file.on('finish', () => { file.close(); resolve(outputPath); });
|
||
}).on('error', (e) => { fs.unlink(outputPath, () => {}); reject(e); });
|
||
});
|
||
}
|
||
|
||
// ===== 剧本解析 =====
|
||
|
||
function findCharacterInBreakdown(charId, project, breakdownPath) {
|
||
// 优先用指定路径
|
||
if (breakdownPath && fs.existsSync(breakdownPath)) {
|
||
const content = fs.readFileSync(breakdownPath, 'utf8');
|
||
return parseCharFromHdlp(content, charId);
|
||
}
|
||
|
||
// 自动搜索项目目录
|
||
const base = path.join(__dirname, '..', '..', 'projects', project);
|
||
if (!fs.existsSync(base)) throw new Error(`项目目录不存在: ${base}`);
|
||
|
||
const files = fs.readdirSync(base).filter(f => f.includes('BREAKDOWN') && f.endsWith('.hdlp'));
|
||
// 优先 V3 > V2 > V1
|
||
files.sort((a, b) => {
|
||
const getV = f => { const m = f.match(/V(\d+)/); return m ? parseInt(m[1]) : 0; };
|
||
return getV(b) - getV(a);
|
||
});
|
||
|
||
for (const f of files) {
|
||
const content = fs.readFileSync(path.join(base, f), 'utf8');
|
||
const result = parseCharFromHdlp(content, charId);
|
||
if (result) return result;
|
||
}
|
||
|
||
throw new Error(`角色 ${charId} 未在项目的 BREAKDOWN 中找到`);
|
||
}
|
||
|
||
function parseCharFromHdlp(content, charId) {
|
||
// 解析 CHAR 表格
|
||
const lines = content.split('\n');
|
||
let inCharTable = false;
|
||
let headers = [];
|
||
|
||
for (const line of lines) {
|
||
if (line.includes('CHAR - 角色资产表') || line.includes('CHAR -')) {
|
||
inCharTable = true;
|
||
continue;
|
||
}
|
||
if (inCharTable && (line.startsWith('### ') || line.startsWith('## '))) {
|
||
inCharTable = false;
|
||
continue;
|
||
}
|
||
if (!inCharTable) continue;
|
||
|
||
// 表头行
|
||
if (line.includes('| ID |') || line.includes('| ID | 名称 |')) {
|
||
headers = line.split('|').map(h => h.trim()).filter(Boolean);
|
||
continue;
|
||
}
|
||
if (line.includes('| :---')) continue; // 分隔行
|
||
|
||
// 数据行
|
||
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
||
if (cells.length >= 4 && cells[0] === charId) {
|
||
return {
|
||
id: cells[0],
|
||
name: cells[1] || '',
|
||
description: cells[3] || cells[2] || '',
|
||
englishPrompt: cells[4] || cells[3] || '',
|
||
rawLine: line
|
||
};
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ===== 主流程 =====
|
||
|
||
async function packCharacter(charId, project, options = {}) {
|
||
const {
|
||
breakdownPath = null,
|
||
views = ['front', 'side', 'hand'],
|
||
dryRun = false
|
||
} = options;
|
||
|
||
// 1. 查找角色描述
|
||
console.error(`📖 查找角色 ${charId}...`);
|
||
const charInfo = findCharacterInBreakdown(charId, project, breakdownPath);
|
||
console.error(` 名称: ${charInfo.name}`);
|
||
console.error(` 描述: ${charInfo.description.substring(0, 80)}...`);
|
||
|
||
// 2. 准备输出目录
|
||
const outputDir = path.join(
|
||
__dirname, '..', '..', 'outputs', 'pack', project, charId,
|
||
`v${new Date().toISOString().slice(0,10).replace(/-/g,'')}`
|
||
);
|
||
if (!dryRun && !fs.existsSync(outputDir)) {
|
||
fs.mkdirSync(outputDir, { recursive: true });
|
||
}
|
||
|
||
console.error(`📁 输出目录: ${outputDir}`);
|
||
console.error();
|
||
|
||
const basePrompt = charInfo.englishPrompt;
|
||
|
||
// 3. 干运行模式
|
||
if (dryRun) {
|
||
console.error('🔍 === 干运行预览 ===');
|
||
const viewList = views.map(v => {
|
||
const cfg = VIEW_CONFIGS[v];
|
||
return {
|
||
view: v,
|
||
label: cfg.label,
|
||
mode: cfg.mode,
|
||
prompt: `${basePrompt} ${cfg.promptAddon}`,
|
||
ref: cfg.mode === 'i2i' ? '<FRONT_MASTER>' : 'none',
|
||
cost: COST_PER_IMAGE
|
||
};
|
||
});
|
||
console.error(` 角色: ${charId} ${charInfo.name}`);
|
||
console.error(` 视角度数: ${views.length} (${views.map(v => VIEW_CONFIGS[v].label).join(', ')})`);
|
||
console.error(` 总成本: ¥${(views.length * COST_PER_IMAGE).toFixed(2)}`);
|
||
console.error(` 成本明细:`);
|
||
viewList.forEach(v => console.error(` ${v.label} (${v.mode}): ¥${COST_PER_IMAGE.toFixed(2)}`));
|
||
console.error();
|
||
return { dryRun: true, charInfo, views: viewList, cost: views.length * COST_PER_IMAGE };
|
||
}
|
||
|
||
// 4. 首次视角用 T2I(主视角)
|
||
let masterImage = null;
|
||
let masterSeed = null;
|
||
let masterViewConfig = null;
|
||
const results = {};
|
||
|
||
// 找第一个 t2i 视角
|
||
for (const v of views) {
|
||
const cfg = VIEW_CONFIGS[v];
|
||
if (cfg && cfg.mode === 't2i') {
|
||
masterViewConfig = { view: v, cfg };
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!masterViewConfig) {
|
||
// 所有视角都是 i2i,先做一个 T2I 主视角
|
||
masterViewConfig = { view: 'front', cfg: VIEW_CONFIGS.front };
|
||
}
|
||
|
||
console.error(`🎨 步骤1: 生成主视角 (${masterViewConfig.cfg.label})...`);
|
||
const masterPrompt = `${basePrompt} ${masterViewConfig.cfg.promptAddon}`;
|
||
console.error(` 提示词: ${masterPrompt.substring(0, 100)}...`);
|
||
|
||
const masterResult = await t2i(masterPrompt);
|
||
if (masterResult.error) throw new Error(JSON.stringify(masterResult.error));
|
||
|
||
masterSeed = masterResult.data?.[0]?.seed;
|
||
const masterUrl = masterResult.data?.[0]?.url;
|
||
console.error(` Seed: ${masterSeed || 'N/A'}`);
|
||
|
||
// 下载主视角
|
||
const masterFile = path.join(outputDir, `${charId}${VIEW_CONFIGS.front.suffix}.png`);
|
||
await downloadImage(masterUrl, masterFile);
|
||
masterImage = masterFile;
|
||
console.error(` ✅ ${masterFile}`);
|
||
|
||
results.front = { seed: masterSeed, path: masterFile, mode: 't2i', cost: COST_PER_IMAGE };
|
||
|
||
// 5. 其他视角用 I2I + 锁定 seed
|
||
const remainingViews = views.filter(v => {
|
||
const cfg = VIEW_CONFIGS[v];
|
||
return cfg && cfg.mode !== 't2i';
|
||
});
|
||
|
||
for (const view of remainingViews) {
|
||
const cfg = VIEW_CONFIGS[view];
|
||
console.error(`🎨 步骤: 生成${cfg.label} (I2I,参考=正面)...`);
|
||
|
||
const viewPrompt = `${basePrompt} ${cfg.promptAddon}`;
|
||
console.error(` 提示词: ${viewPrompt.substring(0, 100)}...`);
|
||
console.error(` 参考图: ${masterImage}`);
|
||
if (masterSeed) console.error(` 锁定Seed: ${masterSeed}`);
|
||
|
||
const result = await i2i(viewPrompt, masterImage, masterSeed);
|
||
if (result.error) throw new Error(JSON.stringify(result.error));
|
||
|
||
const viewSeed = result.data?.[0]?.seed;
|
||
const viewUrl = result.data?.[0]?.url;
|
||
console.error(` Seed: ${viewSeed || 'N/A'}`);
|
||
|
||
const viewFile = path.join(outputDir, `${charId}${cfg.suffix}.png`);
|
||
await downloadImage(viewUrl, viewFile);
|
||
console.error(` ✅ ${viewFile}`);
|
||
|
||
results[view] = { seed: viewSeed, path: viewFile, mode: 'i2i', refFrom: 'front', cost: COST_PER_IMAGE };
|
||
}
|
||
|
||
// 6. 写入 manifest
|
||
const totalCost = Object.values(results).reduce((s, r) => s + r.cost, 0);
|
||
const manifest = {
|
||
characterId: charId,
|
||
characterName: charInfo.name,
|
||
project,
|
||
model: MODEL,
|
||
generatedAt: new Date().toISOString(),
|
||
masterSeed,
|
||
totalCost,
|
||
views: results
|
||
};
|
||
|
||
const manifestPath = path.join(outputDir, 'manifest.json');
|
||
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
||
console.error(`📋 Manifest: ${manifestPath}`);
|
||
console.error(`💰 总成本: ¥${totalCost.toFixed(2)}`);
|
||
|
||
return { dryRun: false, manifest, outputDir };
|
||
}
|
||
|
||
// ===== CLI =====
|
||
|
||
async function main() {
|
||
const args = process.argv.slice(2);
|
||
const getArg = (name) => {
|
||
const idx = args.indexOf(name);
|
||
return idx !== -1 ? args[idx + 1] : null;
|
||
};
|
||
|
||
const character = getArg('--character');
|
||
const project = getArg('--project');
|
||
const breakdown = getArg('--breakdown');
|
||
const viewsStr = getArg('--views');
|
||
const dryRun = args.includes('--dry-run');
|
||
const generateAll = args.includes('--generate-all');
|
||
|
||
if (!character) {
|
||
console.log('用法: node char-hero-design-packer.js --character CHAR-001 --project <项目名> [--views front,side,hand] [--dry-run]');
|
||
process.exit(1);
|
||
}
|
||
|
||
// 默认视图
|
||
const views = viewsStr ? viewsStr.split(',').map(v => v.trim()) : ['front', 'side', 'hand'];
|
||
const proj = project || 'default';
|
||
|
||
try {
|
||
const result = await packCharacter(character, proj, {
|
||
breakdownPath: breakdown,
|
||
views,
|
||
dryRun
|
||
});
|
||
if (!dryRun) {
|
||
console.log(JSON.stringify(result.manifest, null, 2));
|
||
}
|
||
} catch (e) {
|
||
console.error(`❌ 错误: ${e.message}`);
|
||
process.exit(1);
|
||
}
|
||
}
|
||
|
||
if (require.main === module) {
|
||
main();
|
||
}
|
||
|
||
module.exports = { packCharacter, findCharacterInBreakdown };
|