| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271 |
- // scripts/scene.mjs — 工序⑤:场景图(从关键帧拆分)
- //
- // 为什么要拆出「场景图」:AI 图生视频最稳的首帧是「先干净环境、再放人」。
- // 直接文生图出「环境+人物」的图,模型要在一次推理里同时解决空间、光线、人脸三件事,
- // 结果往往是背景每镜都不一样、人物和背景光照打架。拆成两步就稳得多:
- //
- // 场景图(无人/纯环境,文生图)
- // ↓ 以场景图为参考 + 人物资产多视图
- // 关键帧(有人物,图生图多图参考)
- //
- // 相邻镜头通过 prevScene 注入一致性约束(同一空间结构/光源方向/色温)。
- import { existsSync, mkdirSync } from 'node:fs';
- import { join, resolve } from 'node:path';
- import {
- parseArgs, readJSON, writeJSON, paths, ensureWork, log, humanSize, fileBytes,
- CostLedger, buildScenePrompt, buildSceneKeyframePrompt, DEFAULT_ANCHOR, resolveSize,
- } from '../lib/index.mjs';
- import { getProvider } from '../lib/providers.mjs';
- /** 把返回里的费用明细渲染成台账样式(避免重新 new 一个空台账) */
- function ledgerSummary(cost) {
- const lines = ['费用台账 · 场景图 / 关键帧'];
- for (const e of cost.entries || []) {
- lines.push(` ¥${Number(e.amount).toFixed(2)} [${e.actual != null ? '实扣' : '预估'}] ${e.kind}${e.detail ? ` · ${e.detail}` : ''}`);
- }
- lines.push(` 合计 ¥${Number(cost.total).toFixed(2)}`);
- return lines.join('\n');
- }
- /**
- * 场景图 / 关键帧尺寸。
- *
- * 两条不同约束(实测踩坑):
- * · 场景图走 text2imgV3 → 面积 ∈ [512², 2048²],1K 档可用 1664×936
- * · 关键帧走 getImgV4 → 面积必须 ≥ 1024²,所以最低就是 2560×1440(没有 1K 档)
- * 直接传小尺寸不会在提交时报错,要等轮询才回 'height or width invalid',所以用 resolveSize 取推荐值。
- */
- export function aspectToJimengSize(aspect = '16:9', tier = '2k', endpoint = 'text2imgV3') {
- return resolveSize(endpoint, aspect, endpoint === 'imgV4' ? '2k' : tier);
- }
- /**
- * 为分镜逐镜头生成「场景图」+「关键帧」。
- *
- * @param {object} o
- * @param {object} o.storyboard
- * @param {string} [o.characterSheet] 人物多视图路径(第④步产物)——有它才不换脸
- * @param {string} [o.sceneDir] 场景图输出目录(默认 work/scene)
- * @param {string} [o.frameDir] 关键帧输出目录(默认 work/frames)
- * @param {number} [o.shotId] 只处理某一镜
- * @param {number} [o.limit] 只处理前 n 镜
- * @param {'1k'|'2k'} [o.tier]
- * @param {boolean} [o.sceneOnly] 只出场景图,不出关键帧
- * @param {boolean} [o.frameOnly] 只出关键帧(沿用已有场景图)
- */
- export async function generateScenes(o = {}) {
- const {
- storyboard,
- characterSheet = '',
- sceneDir = join(resolve(paths.work), 'scene'),
- frameDir = paths.frames,
- shotId,
- limit,
- tier = '1k',
- sceneOnly = false,
- frameOnly = false,
- force = false,
- style = '',
- appearanceAnchor = '',
- } = o;
- if (!storyboard) throw new Error('generateScenes 需要 storyboard');
- mkdirSync(sceneDir, { recursive: true });
- mkdirSync(frameDir, { recursive: true });
- const aspect = storyboard.aspect || '16:9';
- const sceneSize = aspectToJimengSize(aspect, tier, 'text2imgV3');
- const frameSize = aspectToJimengSize(aspect, tier, 'imgV4');
- const anchor = appearanceAnchor || storyboard.appearanceAnchor || DEFAULT_ANCHOR.song;
- const styleKw = style || storyboard.styleKeywords || '';
- const provider = getProvider('jimeng');
- const ledger = new CostLedger('场景图 / 关键帧');
- let shots = storyboard.shots.filter((s) => !shotId || s.id === Number(shotId));
- if (limit) shots = shots.slice(0, Number(limit));
- const hasChar = !!characterSheet && existsSync(characterSheet);
- if (!hasChar && !sceneOnly) {
- log.warn(`未提供人物资产多视图(--character),关键帧将不含身份锁定 —— 跨镜头大概率换脸`);
- log.dim('建议先跑:fmode-mv character-sheet --desc "..." --limit 4');
- }
- const results = [];
- let prevSceneDesc = '';
- for (const shot of shots) {
- const name = `shot-${String(shot.id).padStart(2, '0')}`;
- const sceneFile = join(sceneDir, `${name}.png`);
- const frameFile = join(frameDir, `${name}.png`);
- // 相邻镜头一致性约束:把上一镜的场景描述复述进本镜 prompt
- const continuityDesc = prevSceneDesc || shot.continuity || '';
- // ---------- 场景图(无人) ----------
- let sceneOk = false;
- if (!frameOnly) {
- if (existsSync(sceneFile) && !force) {
- log.dim(`[${name}] 场景图已存在,跳过`);
- sceneOk = true;
- } else {
- const p = buildScenePrompt(shot, { style: styleKw, prevScene: continuityDesc, aspect });
- log.info(`[${name}] 场景图(无人)${sceneSize.width}×${sceneSize.height}`);
- try {
- await provider.text2img({
- prompt: p,
- width: sceneSize.width,
- height: sceneSize.height,
- outFile: sceneFile,
- ledger,
- });
- sceneOk = true;
- log.ok(`→ ${sceneFile}(${humanSize(fileBytes(sceneFile))})`);
- results.push({ id: shot.id, kind: 'scene', file: sceneFile, status: 'ok', bytes: fileBytes(sceneFile), prompt: p });
- } catch (e) {
- log.err(`[${name}] 场景图失败:${String(e.message).split('\n')[0]}`);
- results.push({ id: shot.id, kind: 'scene', file: null, status: 'failed', error: e.message, prompt: p });
- }
- }
- } else {
- sceneOk = existsSync(sceneFile);
- }
- // 记录本镜场景描述,供下一镜做连续性约束
- prevSceneDesc = shot.scenePrompt || shot.description || '';
- if (sceneOnly) continue;
- // ---------- 关键帧(有人物,图生图多图参考) ----------
- if (existsSync(frameFile) && !force) {
- log.dim(`[${name}] 关键帧已存在,跳过`);
- results.push({ id: shot.id, kind: 'frame', file: frameFile, status: 'skipped', bytes: fileBytes(frameFile) });
- continue;
- }
- const refs = [sceneFile, hasChar ? characterSheet : null].filter((p) => p && existsSync(p));
- if (!refs.length) {
- log.err(`[${name}] 没有可用参考图(场景图缺失且未提供人物资产),跳过`);
- results.push({ id: shot.id, kind: 'frame', file: null, status: 'failed', error: '无参考图' });
- continue;
- }
- const p = buildSceneKeyframePrompt(shot, { appearanceAnchor: anchor, style: styleKw, aspect });
- log.info(`[${name}] 关键帧(参考 ${refs.length} 图:${sceneOk ? '场景图' : ''}${hasChar ? '+人物多视图' : ''})${frameSize.width}×${frameSize.height}`);
- try {
- await provider.img2img({
- prompt: p,
- refImagePaths: refs,
- width: frameSize.width,
- height: frameSize.height,
- scale: 0.5,
- outFile: frameFile,
- ledger,
- });
- log.ok(`→ ${frameFile}(${humanSize(fileBytes(frameFile))})`);
- results.push({ id: shot.id, kind: 'frame', file: frameFile, status: 'ok', bytes: fileBytes(frameFile), refs, prompt: p });
- } catch (e) {
- log.err(`[${name}] 关键帧失败:${String(e.message).split('\n')[0]}`);
- results.push({ id: shot.id, kind: 'frame', file: null, status: 'failed', error: e.message, refs, prompt: p });
- }
- }
- const out = {
- aspect,
- sceneSize,
- frameSize,
- tier,
- characterSheet: hasChar ? characterSheet : null,
- appearanceAnchor: anchor,
- styleKeywords: styleKw,
- results,
- ok: results.filter((r) => r.status === 'ok').length,
- failed: results.filter((r) => r.status === 'failed').length,
- skipped: results.filter((r) => r.status === 'skipped').length,
- cost: { total: ledger.total, entries: ledger.entries },
- _meta: { createdBy: 'fmode-mv/scene', generatedAt: new Date().toISOString() },
- };
- writeJSON(join(sceneDir, 'scenes.json'), out);
- return out;
- }
- export async function main(argv = process.argv.slice(2)) {
- const { flags } = parseArgs(argv);
- if (flags.help || !flags.from) {
- console.log(`
- fmode-mv scene — 场景图(工序⑤,从关键帧拆分)
- 做法:
- ① 场景图(无人/纯环境) —— 文生图,为每个镜头铺环境
- ② 关键帧(有人物) —— 图生图多图参考:场景图 + 人物资产多视图
- 相邻镜头通过「上一镜场景描述」注入一致性约束(同光源方向/同色温/同镜头感)
- 用法:
- fmode-mv scene --from work/storyboard.json --character out/character/4-多视图.png
- fmode-mv scene --from work/storyboard.json --shot 1 --limit 2 # 试跑
- fmode-mv scene --from work/storyboard.json --scene-only # 只出场景图
- fmode-mv scene --from work/storyboard.json --frame-only # 只出关键帧(沿用场景图)
- 选项:
- --from <file> 分镜 JSON(templates/storyboard.schema.json 结构)
- --character <file> 人物资产多视图(第④步产物)★强烈建议传,否则会换脸
- --shot <id> 只处理某一镜
- --limit <n> 只处理前 n 镜(试跑省钱)
- --tier 1k|2k 尺寸档位(默认 1k,省钱;2k 更清晰但更贵)
- --scene-dir <dir> 场景图目录(默认 work/scene)
- --frame-dir <dir> 关键帧目录(默认 work/frames)
- --anchor "..." 覆盖人物外观锚点(英文)
- --style "..." 追加风格关键词
- --force 覆盖已存在产物
- --scene-only / --frame-only
- 产出:
- work/scene/shot-XX.png(无人环境)
- work/frames/shot-XX.png(有人物关键帧,供 video 工序消费)
- work/scene/scenes.json(含 prompt / 费用台账)
- 费用: 每镜 2 次即梦调用(场景 ¥0.20 + 关键帧 ¥0.22)≈ ¥0.42/镜
- `);
- return 0;
- }
- const from = String(flags.from);
- if (!existsSync(from)) throw new Error(`分镜文件不存在:${from}`);
- const storyboard = readJSON(from);
- ensureWork();
- log.step(`生成场景图 + 关键帧:${storyboard.title || ''}(共 ${storyboard.shots.length} 镜)`);
- const r = await generateScenes({
- storyboard,
- characterSheet: typeof flags.character === 'string' ? flags.character : '',
- sceneDir: String(flags['scene-dir'] || join(paths.work, 'scene')),
- frameDir: String(flags['frame-dir'] || paths.frames),
- shotId: flags.shot ? Number(flags.shot) : undefined,
- limit: flags.limit ? Number(flags.limit) : undefined,
- tier: flags.tier || '1k',
- sceneOnly: !!flags['scene-only'],
- frameOnly: !!flags['frame-only'],
- force: !!flags.force,
- style: typeof flags.style === 'string' ? flags.style : '',
- appearanceAnchor: typeof flags.anchor === 'string' ? flags.anchor : '',
- });
- log.raw('');
- log.raw(r.cost.total ? ledgerSummary(r.cost) : ' 费用台账 · 无新调用(全部跳过)');
- log.key('结果', `成功 ${r.ok} / 跳过 ${r.skipped} / 失败 ${r.failed}`);
- log.key('场景图', join(paths.work, 'scene'));
- log.key('关键帧', paths.frames);
- return r.failed > 0 && r.ok === 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);
- });
- }
|