// scripts/character-sheet.mjs — 工序④:人物资产多视图(character sheet) // // ★ 本工序补的是技能仓最大的缺口:人物跨镜头「换脸」。 // 做法照抄 KB `_kb/07-即梦API工具箱/脚本/生成-人物资产v4流水线.js` 的成熟流程: // // ① 定妆照 文生图 3 张候选(3:4) → 择优(拉普拉斯锐度) // ② 上半身 图生图(参考①)×2 候选 → 择优 // ③ 全身 图生图(参考②)×2 候选 → 择优 // ④ 多视图 图生图(参考③)×2 候选,9:16 → 择优 // // 产出:/character/{1-定妆照,2-上半身,3-全身,4-多视图}.png // /character/character-sheet.json(每步 prompt / workId / 费用 / 锐度明细) // // 多视图版式用 KB 的 P5_PROMPT(上 1/3 双头像特写,下 2/3 服装正面放大 + 背面全身)。 // 后续所有「含人物」的镜头都拿多视图当参考图,人物一致性就锁住了。 import { existsSync, mkdirSync, copyFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { parseArgs, writeJSON, log, humanSize, pickSharpest, renderPick, fileBytes, CostLedger, ENDPOINTS, PORTRAIT_PROMPTS, buildCharacterBase, extractOutfit, } from '../lib/index.mjs'; import { getProvider } from '../lib/providers.mjs'; /** 四步流水线的默认版式(照抄 KB 的人物资产 v4/v5 比例) */ export const STEP_SIZES = { '1-定妆照': { width: 1728, height: 2304 }, // 3:4 '2-上半身': { width: 1728, height: 2304 }, // 3:4 '3-全身': { width: 1440, height: 2560 }, // 9:16 '4-多视图': { width: 1440, height: 2560 }, // 9:16 }; export const STEP_COUNTS = { '1-定妆照': 3, '2-上半身': 2, '3-全身': 2, '4-多视图': 2 }; /** * 跑完人物资产流水线。 * @param {object} o * @param {string} o.description 人物设定描述(中文,外貌/年龄/服装/风格) * @param {string} [o.outDir] 输出根目录(默认 out/) * @param {number} [o.counts] 每步候选倍数(--single 时全为 1,省钱) * @param {number} [o.limit] 只跑到第几步(1~4),用于试跑省钱 * @returns {Promise} character-sheet 结果 */ export async function buildCharacterSheet(o = {}) { const { description, outDir = join(resolve('.'), 'out'), single = false, limit = 4, dryRun = false, reuse = true, } = o; if (!description || !String(description).trim()) { throw new Error('character-sheet 需要人物设定描述(--desc "...")'); } const charDir = join(outDir, 'character'); const candDir = join(charDir, 'candidates'); mkdirSync(candDir, { recursive: true }); const ledger = new CostLedger('人物资产 character-sheet'); const provider = getProvider('jimeng'); // 步骤定义:每步的 prompt / 参考图来自上一步 / 尺寸 / 候选数 const base = buildCharacterBase(description); // 服装从用户描述里抽;抽不出来就用兜底,并明确告知(不静默) const outfitFallback = '白色T恤 + 波点半身裙'; const outfit = extractOutfit(description, ''); const outfitFromUser = !!outfit; const outfitUsed = outfit || outfitFallback; if (!outfitFromUser) { log.warn(`未能从描述里识别出服装 → 沿用兜底「${outfitFallback}」`); log.dim('建议在 --desc 里明确写「身穿…」,例如:--desc "25岁东亚女性,身穿米色针织衫和长裤"'); } else { log.info(`服装(来自你的描述):${outfitUsed}`); } const steps = [ { name: '1-定妆照', mode: 'text2img', prompt: PORTRAIT_PROMPTS.step1(base), ref: null }, { name: '2-上半身', mode: 'img2img', prompt: PORTRAIT_PROMPTS.step2(outfitUsed), ref: '1-定妆照' }, { name: '3-全身', mode: 'img2img', prompt: PORTRAIT_PROMPTS.step3(outfitUsed), ref: '2-上半身' }, { name: '4-多视图', mode: 'img2img', prompt: PORTRAIT_PROMPTS.step4(outfitUsed), ref: '3-全身' }, ].slice(0, Math.max(1, Math.min(4, limit))); const records = []; let prevBest = null; for (const step of steps) { const size = STEP_SIZES[step.name]; const want = single ? 1 : STEP_COUNTS[step.name]; const finalPath = join(charDir, `${step.name}.png`); log.step(`人物资产 · ${step.name}(${step.mode},${size.width}×${size.height},${want} 张候选)`); // 幂等:成品已存在且允许复用 → 直接跳过(省钱) if (reuse && existsSync(finalPath) && !o.force) { log.dim(`${finalPath} 已存在,跳过(--force 重跑)`); const prev = records.find((r) => r.step === step.ref); prevBest = prev?.selected || finalPath; records.push({ step: step.name, status: 'skipped', file: finalPath, bytes: fileBytes(finalPath), prompt: step.prompt }); continue; } if (dryRun) { log.dim(`prompt: ${String(step.prompt).slice(0, 140)}…`); records.push({ step: step.name, status: 'pending', file: null, prompt: step.prompt, size }); continue; } // 收集候选图路径 const candidates = []; const workIds = []; for (let i = 1; i <= want; i++) { const candPath = join(candDir, `${step.name}-候选${i}.png`); if (reuse && existsSync(candPath) && !o.force) { log.dim(`候选 ${i} 已存在,跳过`); candidates.push(candPath); continue; } try { if (step.mode === 'text2img') { await provider.text2img({ prompt: step.prompt, width: size.width, height: size.height, outFile: candPath, ledger, }); } else { if (!prevBest || !existsSync(prevBest)) { throw new Error(`缺少上一步的参考图(${step.ref})`); } await provider.img2img({ prompt: step.prompt, refImagePaths: [prevBest], width: size.width, height: size.height, scale: 0.6, outFile: candPath, ledger, }); } candidates.push(candPath); log.ok(`候选 ${i} → ${candPath}(${humanSize(fileBytes(candPath))})`); } catch (e) { // 单张失败不中断整步,但必须留痕 log.err(`候选 ${i} 生成失败:${String(e.message).split('\n')[0]}`); records.push({ step: step.name, status: 'candidate-failed', candidate: i, error: e.message }); } } if (!candidates.length) { log.err(`${step.name} 全部候选失败,流水线中止`); records.push({ step: step.name, status: 'failed', error: '所有候选生成失败', prompt: step.prompt }); break; } // 择优:拉普拉斯锐度(PIL 优先,ffmpeg 兜底) const pick = await pickSharpest(candidates); if (!pick.ok) { log.warn(`自动择优不可用(${pick.error})→ 沿用第一张候选(不编造锐度数据)`); } else { log.info(renderPick(pick, candidates)); } prevBest = pick.best || candidates[0]; // 落成规范文件名(后续镜头引用的就是它) copyFileSync(prevBest, finalPath); log.ok(`选定 → ${finalPath}(${humanSize(fileBytes(finalPath))})`); records.push({ step: step.name, status: 'ok', mode: step.mode, prompt: step.prompt, size, candidates: candidates.map((c, i) => ({ index: i + 1, file: c, bytes: fileBytes(c), sharpness: pick.scores?.[i]?.score ?? null, })), selected: finalPath, selectedFrom: prevBest, pickMethod: pick.method, file: finalPath, bytes: fileBytes(finalPath), workIds, }); } const result = { description, base: base, outfit: outfitUsed, outfitFromUser, steps: records, cost: { total: ledger.total, entries: ledger.entries.map((e) => ({ kind: e.kind, detail: e.detail, amount: e.amount, source: e.actual != null ? 'actual' : 'estimate' })) }, ledger: ledger.summary(), _meta: { createdBy: 'fmode-mv/character-sheet', generatedAt: new Date().toISOString() }, }; writeJSON(join(charDir, 'character-sheet.json'), result); return result; } export async function main(argv = process.argv.slice(2)) { const { flags } = parseArgs(argv); if (flags.help || !flags.desc) { console.log(` fmode-mv character-sheet — 人物资产多视图(工序④) 用法: fmode-mv character-sheet --desc "25岁东亚女性,栗棕色长发,白色T恤+波点裙,清纯慵懒" fmode-mv character-sheet --desc "..." --limit 1 # 只跑第①步定妆照(最省) fmode-mv character-sheet --desc "..." --single # 每步只出 1 张候选(省钱,不择优) fmode-mv character-sheet --desc "..." --dry-run # 只打印 prompt,不生成(零费用) fmode-mv character-sheet --desc "..." --force # 覆盖已有产物重跑 选项: --desc "..." 人物设定描述(外貌/年龄/服装/风格),必填 --out 输出根目录(默认 ./out) --limit <1-4> 只跑到第 N 步(1=定妆照 2=上半身 3=全身 4=多视图,默认 4) --single 每步只生成 1 张(跳过择优) --force 覆盖已存在产物 --dry-run 只打印 prompt,不调用通道(零费用) 流程(通道:即梦 ✅): ① 定妆照 文生图 3 张(3:4) ─择优→ ② 上半身 图生图 ×2(3:4) ─择优→ ③ 全身 图生图 ×2(9:16) ─择优→ ④ 多视图 图生图 ×2(9:16,上1/3双头像+下2/3服装) 产出: out/character/1-定妆照.png … 4-多视图.png out/character/candidates/ 全部候选(含锐度分数) out/character/character-sheet.json 预估费用(每步全部候选都生成): 3+2+2+2 = 9 张 × ¥0.22 ≈ ¥1.98(--limit 1 时仅 ¥0.66) ⚠️ 多视图(④)是后续所有含人物镜头的参考图 —— 有了它,跨镜头才不换脸。 `); return 0; } log.raw(''); log.raw(' 人物资产 character-sheet · 纯 AI 生成(即梦通道)'); log.raw(' ────────────────────────────────────────────────'); const r = await buildCharacterSheet({ description: String(flags.desc), outDir: String(flags.out || join(resolve('.'), 'out')), limit: Number(flags.limit) || 4, single: !!flags.single, dryRun: !!flags['dry-run'], force: !!flags.force, }); log.raw(''); log.raw(r.ledger); const okSteps = r.steps.filter((s) => s.status === 'ok' || s.status === 'skipped').length; log.key('完成步骤', `${okSteps}/${r.steps.length}`); log.key('输出目录', join(String(flags.out || join(resolve('.'), 'out')), 'character')); return okSteps === 0 ? 1 : 0; } if (import.meta.url === `file://${process.argv[1]}`) { main() .then((c) => process.exit(Number(c) || 0)) .catch((e) => { console.error(`✗ ${e.message}`); if (process.env.DEBUG) console.error(e.stack); process.exit(1); }); }