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