| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274 |
- // scripts/character-sheet.mjs — 工序④:人物资产多视图(character sheet)
- //
- // ★ 本工序补的是技能仓最大的缺口:人物跨镜头「换脸」。
- // 做法照抄 KB `_kb/07-即梦API工具箱/脚本/生成-人物资产v4流水线.js` 的成熟流程:
- //
- // ① 定妆照 文生图 3 张候选(3:4) → 择优(拉普拉斯锐度)
- // ② 上半身 图生图(参考①)×2 候选 → 择优
- // ③ 全身 图生图(参考②)×2 候选 → 择优
- // ④ 多视图 图生图(参考③)×2 候选,9:16 → 择优
- //
- // 产出:<out>/character/{1-定妆照,2-上半身,3-全身,4-多视图}.png
- // <out>/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<object>} 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 <dir> 输出根目录(默认 ./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);
- });
- }
|