scene.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271
  1. // scripts/scene.mjs — 工序⑤:场景图(从关键帧拆分)
  2. //
  3. // 为什么要拆出「场景图」:AI 图生视频最稳的首帧是「先干净环境、再放人」。
  4. // 直接文生图出「环境+人物」的图,模型要在一次推理里同时解决空间、光线、人脸三件事,
  5. // 结果往往是背景每镜都不一样、人物和背景光照打架。拆成两步就稳得多:
  6. //
  7. // 场景图(无人/纯环境,文生图)
  8. // ↓ 以场景图为参考 + 人物资产多视图
  9. // 关键帧(有人物,图生图多图参考)
  10. //
  11. // 相邻镜头通过 prevScene 注入一致性约束(同一空间结构/光源方向/色温)。
  12. import { existsSync, mkdirSync } from 'node:fs';
  13. import { join, resolve } from 'node:path';
  14. import {
  15. parseArgs, readJSON, writeJSON, paths, ensureWork, log, humanSize, fileBytes,
  16. CostLedger, buildScenePrompt, buildSceneKeyframePrompt, DEFAULT_ANCHOR, resolveSize,
  17. } from '../lib/index.mjs';
  18. import { getProvider } from '../lib/providers.mjs';
  19. /** 把返回里的费用明细渲染成台账样式(避免重新 new 一个空台账) */
  20. function ledgerSummary(cost) {
  21. const lines = ['费用台账 · 场景图 / 关键帧'];
  22. for (const e of cost.entries || []) {
  23. lines.push(` ¥${Number(e.amount).toFixed(2)} [${e.actual != null ? '实扣' : '预估'}] ${e.kind}${e.detail ? ` · ${e.detail}` : ''}`);
  24. }
  25. lines.push(` 合计 ¥${Number(cost.total).toFixed(2)}`);
  26. return lines.join('\n');
  27. }
  28. /**
  29. * 场景图 / 关键帧尺寸。
  30. *
  31. * 两条不同约束(实测踩坑):
  32. * · 场景图走 text2imgV3 → 面积 ∈ [512², 2048²],1K 档可用 1664×936
  33. * · 关键帧走 getImgV4 → 面积必须 ≥ 1024²,所以最低就是 2560×1440(没有 1K 档)
  34. * 直接传小尺寸不会在提交时报错,要等轮询才回 'height or width invalid',所以用 resolveSize 取推荐值。
  35. */
  36. export function aspectToJimengSize(aspect = '16:9', tier = '2k', endpoint = 'text2imgV3') {
  37. return resolveSize(endpoint, aspect, endpoint === 'imgV4' ? '2k' : tier);
  38. }
  39. /**
  40. * 为分镜逐镜头生成「场景图」+「关键帧」。
  41. *
  42. * @param {object} o
  43. * @param {object} o.storyboard
  44. * @param {string} [o.characterSheet] 人物多视图路径(第④步产物)——有它才不换脸
  45. * @param {string} [o.sceneDir] 场景图输出目录(默认 work/scene)
  46. * @param {string} [o.frameDir] 关键帧输出目录(默认 work/frames)
  47. * @param {number} [o.shotId] 只处理某一镜
  48. * @param {number} [o.limit] 只处理前 n 镜
  49. * @param {'1k'|'2k'} [o.tier]
  50. * @param {boolean} [o.sceneOnly] 只出场景图,不出关键帧
  51. * @param {boolean} [o.frameOnly] 只出关键帧(沿用已有场景图)
  52. */
  53. export async function generateScenes(o = {}) {
  54. const {
  55. storyboard,
  56. characterSheet = '',
  57. sceneDir = join(resolve(paths.work), 'scene'),
  58. frameDir = paths.frames,
  59. shotId,
  60. limit,
  61. tier = '1k',
  62. sceneOnly = false,
  63. frameOnly = false,
  64. force = false,
  65. style = '',
  66. appearanceAnchor = '',
  67. } = o;
  68. if (!storyboard) throw new Error('generateScenes 需要 storyboard');
  69. mkdirSync(sceneDir, { recursive: true });
  70. mkdirSync(frameDir, { recursive: true });
  71. const aspect = storyboard.aspect || '16:9';
  72. const sceneSize = aspectToJimengSize(aspect, tier, 'text2imgV3');
  73. const frameSize = aspectToJimengSize(aspect, tier, 'imgV4');
  74. const anchor = appearanceAnchor || storyboard.appearanceAnchor || DEFAULT_ANCHOR.song;
  75. const styleKw = style || storyboard.styleKeywords || '';
  76. const provider = getProvider('jimeng');
  77. const ledger = new CostLedger('场景图 / 关键帧');
  78. let shots = storyboard.shots.filter((s) => !shotId || s.id === Number(shotId));
  79. if (limit) shots = shots.slice(0, Number(limit));
  80. const hasChar = !!characterSheet && existsSync(characterSheet);
  81. if (!hasChar && !sceneOnly) {
  82. log.warn(`未提供人物资产多视图(--character),关键帧将不含身份锁定 —— 跨镜头大概率换脸`);
  83. log.dim('建议先跑:fmode-mv character-sheet --desc "..." --limit 4');
  84. }
  85. const results = [];
  86. let prevSceneDesc = '';
  87. for (const shot of shots) {
  88. const name = `shot-${String(shot.id).padStart(2, '0')}`;
  89. const sceneFile = join(sceneDir, `${name}.png`);
  90. const frameFile = join(frameDir, `${name}.png`);
  91. // 相邻镜头一致性约束:把上一镜的场景描述复述进本镜 prompt
  92. const continuityDesc = prevSceneDesc || shot.continuity || '';
  93. // ---------- 场景图(无人) ----------
  94. let sceneOk = false;
  95. if (!frameOnly) {
  96. if (existsSync(sceneFile) && !force) {
  97. log.dim(`[${name}] 场景图已存在,跳过`);
  98. sceneOk = true;
  99. } else {
  100. const p = buildScenePrompt(shot, { style: styleKw, prevScene: continuityDesc, aspect });
  101. log.info(`[${name}] 场景图(无人)${sceneSize.width}×${sceneSize.height}`);
  102. try {
  103. await provider.text2img({
  104. prompt: p,
  105. width: sceneSize.width,
  106. height: sceneSize.height,
  107. outFile: sceneFile,
  108. ledger,
  109. });
  110. sceneOk = true;
  111. log.ok(`→ ${sceneFile}(${humanSize(fileBytes(sceneFile))})`);
  112. results.push({ id: shot.id, kind: 'scene', file: sceneFile, status: 'ok', bytes: fileBytes(sceneFile), prompt: p });
  113. } catch (e) {
  114. log.err(`[${name}] 场景图失败:${String(e.message).split('\n')[0]}`);
  115. results.push({ id: shot.id, kind: 'scene', file: null, status: 'failed', error: e.message, prompt: p });
  116. }
  117. }
  118. } else {
  119. sceneOk = existsSync(sceneFile);
  120. }
  121. // 记录本镜场景描述,供下一镜做连续性约束
  122. prevSceneDesc = shot.scenePrompt || shot.description || '';
  123. if (sceneOnly) continue;
  124. // ---------- 关键帧(有人物,图生图多图参考) ----------
  125. if (existsSync(frameFile) && !force) {
  126. log.dim(`[${name}] 关键帧已存在,跳过`);
  127. results.push({ id: shot.id, kind: 'frame', file: frameFile, status: 'skipped', bytes: fileBytes(frameFile) });
  128. continue;
  129. }
  130. const refs = [sceneFile, hasChar ? characterSheet : null].filter((p) => p && existsSync(p));
  131. if (!refs.length) {
  132. log.err(`[${name}] 没有可用参考图(场景图缺失且未提供人物资产),跳过`);
  133. results.push({ id: shot.id, kind: 'frame', file: null, status: 'failed', error: '无参考图' });
  134. continue;
  135. }
  136. const p = buildSceneKeyframePrompt(shot, { appearanceAnchor: anchor, style: styleKw, aspect });
  137. log.info(`[${name}] 关键帧(参考 ${refs.length} 图:${sceneOk ? '场景图' : ''}${hasChar ? '+人物多视图' : ''})${frameSize.width}×${frameSize.height}`);
  138. try {
  139. await provider.img2img({
  140. prompt: p,
  141. refImagePaths: refs,
  142. width: frameSize.width,
  143. height: frameSize.height,
  144. scale: 0.5,
  145. outFile: frameFile,
  146. ledger,
  147. });
  148. log.ok(`→ ${frameFile}(${humanSize(fileBytes(frameFile))})`);
  149. results.push({ id: shot.id, kind: 'frame', file: frameFile, status: 'ok', bytes: fileBytes(frameFile), refs, prompt: p });
  150. } catch (e) {
  151. log.err(`[${name}] 关键帧失败:${String(e.message).split('\n')[0]}`);
  152. results.push({ id: shot.id, kind: 'frame', file: null, status: 'failed', error: e.message, refs, prompt: p });
  153. }
  154. }
  155. const out = {
  156. aspect,
  157. sceneSize,
  158. frameSize,
  159. tier,
  160. characterSheet: hasChar ? characterSheet : null,
  161. appearanceAnchor: anchor,
  162. styleKeywords: styleKw,
  163. results,
  164. ok: results.filter((r) => r.status === 'ok').length,
  165. failed: results.filter((r) => r.status === 'failed').length,
  166. skipped: results.filter((r) => r.status === 'skipped').length,
  167. cost: { total: ledger.total, entries: ledger.entries },
  168. _meta: { createdBy: 'fmode-mv/scene', generatedAt: new Date().toISOString() },
  169. };
  170. writeJSON(join(sceneDir, 'scenes.json'), out);
  171. return out;
  172. }
  173. export async function main(argv = process.argv.slice(2)) {
  174. const { flags } = parseArgs(argv);
  175. if (flags.help || !flags.from) {
  176. console.log(`
  177. fmode-mv scene — 场景图(工序⑤,从关键帧拆分)
  178. 做法:
  179. ① 场景图(无人/纯环境) —— 文生图,为每个镜头铺环境
  180. ② 关键帧(有人物) —— 图生图多图参考:场景图 + 人物资产多视图
  181. 相邻镜头通过「上一镜场景描述」注入一致性约束(同光源方向/同色温/同镜头感)
  182. 用法:
  183. fmode-mv scene --from work/storyboard.json --character out/character/4-多视图.png
  184. fmode-mv scene --from work/storyboard.json --shot 1 --limit 2 # 试跑
  185. fmode-mv scene --from work/storyboard.json --scene-only # 只出场景图
  186. fmode-mv scene --from work/storyboard.json --frame-only # 只出关键帧(沿用场景图)
  187. 选项:
  188. --from <file> 分镜 JSON(templates/storyboard.schema.json 结构)
  189. --character <file> 人物资产多视图(第④步产物)★强烈建议传,否则会换脸
  190. --shot <id> 只处理某一镜
  191. --limit <n> 只处理前 n 镜(试跑省钱)
  192. --tier 1k|2k 尺寸档位(默认 1k,省钱;2k 更清晰但更贵)
  193. --scene-dir <dir> 场景图目录(默认 work/scene)
  194. --frame-dir <dir> 关键帧目录(默认 work/frames)
  195. --anchor "..." 覆盖人物外观锚点(英文)
  196. --style "..." 追加风格关键词
  197. --force 覆盖已存在产物
  198. --scene-only / --frame-only
  199. 产出:
  200. work/scene/shot-XX.png(无人环境)
  201. work/frames/shot-XX.png(有人物关键帧,供 video 工序消费)
  202. work/scene/scenes.json(含 prompt / 费用台账)
  203. 费用: 每镜 2 次即梦调用(场景 ¥0.20 + 关键帧 ¥0.22)≈ ¥0.42/镜
  204. `);
  205. return 0;
  206. }
  207. const from = String(flags.from);
  208. if (!existsSync(from)) throw new Error(`分镜文件不存在:${from}`);
  209. const storyboard = readJSON(from);
  210. ensureWork();
  211. log.step(`生成场景图 + 关键帧:${storyboard.title || ''}(共 ${storyboard.shots.length} 镜)`);
  212. const r = await generateScenes({
  213. storyboard,
  214. characterSheet: typeof flags.character === 'string' ? flags.character : '',
  215. sceneDir: String(flags['scene-dir'] || join(paths.work, 'scene')),
  216. frameDir: String(flags['frame-dir'] || paths.frames),
  217. shotId: flags.shot ? Number(flags.shot) : undefined,
  218. limit: flags.limit ? Number(flags.limit) : undefined,
  219. tier: flags.tier || '1k',
  220. sceneOnly: !!flags['scene-only'],
  221. frameOnly: !!flags['frame-only'],
  222. force: !!flags.force,
  223. style: typeof flags.style === 'string' ? flags.style : '',
  224. appearanceAnchor: typeof flags.anchor === 'string' ? flags.anchor : '',
  225. });
  226. log.raw('');
  227. log.raw(r.cost.total ? ledgerSummary(r.cost) : ' 费用台账 · 无新调用(全部跳过)');
  228. log.key('结果', `成功 ${r.ok} / 跳过 ${r.skipped} / 失败 ${r.failed}`);
  229. log.key('场景图', join(paths.work, 'scene'));
  230. log.key('关键帧', paths.frames);
  231. return r.failed > 0 && r.ok === 0 ? 1 : 0;
  232. }
  233. if (import.meta.url === `file://${process.argv[1]}`) {
  234. main()
  235. .then((c) => process.exit(Number(c) || 0))
  236. .catch((e) => {
  237. console.error(`✗ ${e.message}`);
  238. if (process.env.DEBUG) console.error(e.stack);
  239. process.exit(1);
  240. });
  241. }