鉴影 ICE-GL-CA001 3eceff49c2 D181: 角色一致性修复 + pack引擎v2升级
- 修复: 林昊正面/侧面不一致 → 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
2026-07-10 16:33:56 +08:00

388 lines
12 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.

#!/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 };