388 lines
12 KiB
JavaScript
Raw Permalink Normal View History

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