keyframe.mjs 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177
  1. // scripts/keyframe.mjs — 场景 & 人物关键帧(provider: image)
  2. //
  3. // ✅ 通道可用(实测 gpt-image-2.5-sunburst 返回 b64_json)。
  4. // 关键点:人物一致性 —— 每个含人物的镜头都必须复述 appearanceAnchor。
  5. import { existsSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'node:fs';
  6. import { join } from 'node:path';
  7. import {
  8. parseArgs, readJSON, writeJSON, paths, ensureWork, log, invoke, humanSize,
  9. } from '../lib/index.mjs';
  10. import { buildKeyframePrompt, aspectToSize, DEFAULT_ANCHOR } from '../lib/prompts.mjs';
  11. /**
  12. * 生成关键帧。
  13. * @param {object} o
  14. * @param {object} o.storyboard 分镜(可选)
  15. * @param {string} [o.prompt] 裸 prompt(无分镜时)
  16. * @param {number} [o.shotId] 只生成某一镜
  17. * @param {number} [o.limit] 最多生成几镜
  18. * @param {string} [o.outDir]
  19. */
  20. export async function generateKeyframes(o = {}) {
  21. const {
  22. storyboard,
  23. prompt,
  24. shotId,
  25. limit,
  26. outDir = paths.frames,
  27. aspect = storyboard?.aspect || '16:9',
  28. tier = 'standard',
  29. style = '',
  30. model,
  31. anchorOverride,
  32. dryRun = false,
  33. } = o;
  34. mkdirSync(outDir, { recursive: true });
  35. const anchor = anchorOverride || storyboard?.appearanceAnchor || DEFAULT_ANCHOR.song;
  36. const styleKw = style || storyboard?.styleKeywords || '';
  37. const size = aspectToSize(aspect, tier);
  38. let jobs;
  39. if (storyboard) {
  40. jobs = storyboard.shots.filter((s) => !shotId || s.id === Number(shotId));
  41. if (limit) jobs = jobs.slice(0, Number(limit));
  42. } else {
  43. if (!prompt) throw new Error('需要 --prompt 或 --from <storyboard.json>');
  44. jobs = [{ id: 0, imagePrompt: prompt, description: prompt, type: 'broll', hasCharacter: false }];
  45. }
  46. const results = [];
  47. for (const shot of jobs) {
  48. const fullPrompt = buildKeyframePrompt(shot, { appearanceAnchor: anchor, style: styleKw, aspect });
  49. const name = shot.id ? `shot-${String(shot.id).padStart(2, '0')}` : 'keyframe';
  50. const file = join(outDir, `${name}.png`);
  51. log.info(`[${name}] ${String(shot.description || '').slice(0, 60)}`);
  52. if (dryRun) {
  53. log.dim(`prompt: ${fullPrompt.slice(0, 160)}…`);
  54. results.push({ id: shot.id, file: null, status: 'pending', prompt: fullPrompt, size });
  55. continue;
  56. }
  57. try {
  58. const res = await invoke('image', { prompt: fullPrompt, size, n: 1, model });
  59. const buf = res.buffers[0];
  60. writeFileSync(file, buf);
  61. log.ok(`→ ${file}(${humanSize(buf.length)},${size})`);
  62. results.push({
  63. id: shot.id,
  64. file,
  65. bytes: buf.length,
  66. size,
  67. model: res.model,
  68. prompt: fullPrompt,
  69. status: 'ok',
  70. });
  71. } catch (e) {
  72. log.err(`[${name}] 生成失败:${e.message.split('\n')[0]}`);
  73. results.push({ id: shot.id, file: null, status: 'failed', error: e.message, prompt: fullPrompt });
  74. if (jobs.length === 1) throw e;
  75. }
  76. }
  77. const okCount = results.filter((r) => r.status === 'ok').length;
  78. const failCount = results.filter((r) => r.status === 'failed').length;
  79. writeJSON(join(outDir, 'keyframes.json'), {
  80. count: results.length,
  81. ok: okCount,
  82. failed: failCount,
  83. pending: results.filter((r) => r.status === 'pending').length,
  84. aspect,
  85. size,
  86. appearanceAnchor: anchor,
  87. styleKeywords: styleKw,
  88. frames: results,
  89. _meta: { createdBy: 'fmode-mv/keyframe', generatedAt: new Date().toISOString() },
  90. });
  91. return { results, okCount, failCount, outDir };
  92. }
  93. export async function main(argv = process.argv.slice(2)) {
  94. const { _, flags } = parseArgs(argv);
  95. if (flags.help || (!flags.from && !flags.prompt)) {
  96. console.log(`
  97. fmode-mv keyframe — 场景 & 人物关键帧(image 通道,✅ 可用)
  98. 用法:
  99. fmode-mv keyframe --from work/storyboard.json # 按分镜批量生成
  100. fmode-mv keyframe --from work/storyboard.json --shot 1 # 只生成第 1 镜
  101. fmode-mv keyframe --from work/storyboard.json --limit 4 # 只生成前 4 镜
  102. fmode-mv keyframe --prompt "傍晚城市天台,主角侧影,霓虹初上" --name rooftop
  103. 选项:
  104. --from <file> 分镜 JSON
  105. --shot <id> 只生成指定镜头
  106. --limit <n> 最多生成 n 镜
  107. --prompt "..." 裸 prompt(不依赖分镜)
  108. --out <dir> 输出目录(默认 work/frames)
  109. --aspect <r> 16:9 | 9:16 | 1:1(默认取分镜)
  110. --tier standard|large 尺寸档位(默认 standard)
  111. --style "..." 追加风格关键词(英文更稳)
  112. --anchor "..." 覆盖人物外观锚点(英文)
  113. --model <name> 覆盖图像模型(默认 gpt-image-2.5-sunburst)
  114. --dry-run 只打印将发送的 prompt,不实际生成(不产生费用)
  115. 产出:
  116. work/frames/shot-XX.png + work/frames/keyframes.json
  117. `);
  118. return 0;
  119. }
  120. ensureWork();
  121. let storyboard = null;
  122. if (flags.from) {
  123. const from = String(flags.from);
  124. if (!existsSync(from)) throw new Error(`分镜文件不存在:${from}`);
  125. storyboard = readJSON(from);
  126. log.step(`生成关键帧:${storyboard.title || ''}(共 ${storyboard.shots.length} 镜)`);
  127. } else {
  128. log.step('生成关键帧(单张)');
  129. }
  130. const r = await generateKeyframes({
  131. storyboard,
  132. prompt: flags.prompt ? String(flags.prompt) : undefined,
  133. shotId: flags.shot ? Number(flags.shot) : undefined,
  134. limit: flags.limit ? Number(flags.limit) : undefined,
  135. outDir: typeof flags.out === 'string' ? flags.out : paths.frames,
  136. aspect: flags.aspect,
  137. tier: flags.tier || 'standard',
  138. style: flags.style,
  139. model: flags.model,
  140. anchorOverride: flags.anchor,
  141. dryRun: !!flags['dry-run'],
  142. });
  143. if (flags['dry-run']) {
  144. log.warn(`dry-run:${r.results.length} 个 prompt 已生成,未调用图像通道(无费用)`);
  145. } else {
  146. log.ok(`完成:成功 ${r.okCount} / 失败 ${r.failCount}`);
  147. const files = readdirSync(paths.frames).filter((f) => f.endsWith('.png'));
  148. let total = 0;
  149. for (const f of files) total += statSync(join(paths.frames, f)).size;
  150. log.key('目录', `${paths.frames}(${files.length} 张,共 ${humanSize(total)})`);
  151. }
  152. return r.failCount > 0 && r.okCount === 0 ? 1 : 0;
  153. }
  154. if (import.meta.url === `file://${process.argv[1]}`) {
  155. main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
  156. console.error(`✗ ${e.message}`);
  157. process.exit(1);
  158. });
  159. }