storyboard.mjs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. // scripts/storyboard.mjs — 分镜脚本
  2. //
  3. // 输入:work/song.json(产线A)或口播稿(产线B)
  4. // 输出:work/storyboard.json,结构见 templates/storyboard.schema.json
  5. //
  6. // 分镜是整条链路的枢纽:后面的 keyframe / video / lipsync / assemble 全部读它。
  7. import {
  8. parseArgs, writeJSON, readJSON, paths, ensureWork, log, chatJSON,
  9. } from '../lib/index.mjs';
  10. import {
  11. buildStoryboardPrompt, buildTalkStoryboardPrompt, DEFAULT_ANCHOR,
  12. } from '../lib/prompts.mjs';
  13. const SYSTEM_A = `你是顶级音乐 MV 导演兼 AI 视频提示词工程师,服务于「全程 AI 生成、无实拍」的 MV 制作。
  14. 你的核心职责是:把一首歌拆成一个可被 AI 逐个执行的镜头清单,并保证跨镜头的人物/场景一致性。
  15. 只输出 JSON,不要任何解释文字与 markdown 围栏。`;
  16. const SYSTEM_B = `你是顶级口播讲解视频导演兼 AI 视频提示词工程师,服务于「全程 AI 生成、无实拍」的讲解视频。
  17. 你的核心职责是:把口播稿拆成镜头清单,让每一句口播都有对应的、语义相关的画面,并保持讲者形象一致。
  18. 只输出 JSON,不要任何解释文字与 markdown 围栏。`;
  19. const SCHEMA_A = `严格输出如下 JSON(字段名不可改):
  20. {
  21. "title": "与歌曲同名",
  22. "aspect": "16:9",
  23. "totalDuration": 180,
  24. "appearanceAnchor": "英文,主角外貌锚点(性别/年龄/发型/服装/气质),逐镜头复述用",
  25. "styleKeywords": "英文,全片统一影像风格关键词",
  26. "shots": [
  27. {
  28. "id": 1,
  29. "type": "broll | perf",
  30. "start": 0,
  31. "duration": 6,
  32. "section": "Intro",
  33. "description": "中文画面描述",
  34. "imagePrompt": "英文,完整可独立投喂文生图的提示词",
  35. "videoPrompt": "英文,图生视频的运镜/动作描述",
  36. "lipSync": false,
  37. "subtitle": "该镜头对应的歌词(用于字幕,可留空)",
  38. "notes": "可选,中文备注"
  39. }
  40. ]
  41. }`;
  42. const SCHEMA_B = `严格输出如下 JSON(字段名不可改):
  43. {
  44. "title": "标题",
  45. "aspect": "16:9",
  46. "totalDuration": 90,
  47. "appearanceAnchor": "英文,讲者外貌锚点",
  48. "styleKeywords": "英文,全片统一影像风格关键词",
  49. "shots": [
  50. {
  51. "id": 1,
  52. "type": "talking | broll",
  53. "start": 0,
  54. "duration": 8,
  55. "section": "开场钩子",
  56. "narration": "该镜头对应的口播原文",
  57. "subtitle": "字幕文本(≤20字)",
  58. "description": "中文画面描述",
  59. "imagePrompt": "英文,完整可独立投喂文生图的提示词",
  60. "videoPrompt": "英文,图生视频运镜描述",
  61. "lipSync": false,
  62. "notes": ""
  63. }
  64. ]
  65. }`;
  66. /** 归一化分镜,保证下游字段齐全 */
  67. export function normalizeStoryboard(sb, { aspect = '16:9', anchor = '', minShot = 3, maxShot = 10 } = {}) {
  68. const shots = (sb.shots || []).map((s, i) => {
  69. const dur = Number(s.duration) || 6;
  70. const start = s.start !== undefined && s.start !== null ? Number(s.start) : null;
  71. return {
  72. id: Number(s.id) || i + 1,
  73. type: s.type === 'perf' || s.type === 'talking' ? s.type : 'broll',
  74. start,
  75. duration: dur,
  76. section: s.section || '',
  77. description: s.description || s.prompt || '',
  78. imagePrompt: s.imagePrompt || s.description || '',
  79. videoPrompt: s.videoPrompt || 'subtle natural motion, slow cinematic push in',
  80. lipSync: !!s.lipSync && ['perf', 'talking'].includes(s.type === 'perf' ? 'perf' : s.type),
  81. subtitle: s.subtitle ?? s.narration ?? '',
  82. narration: s.narration ?? '',
  83. section: s.section || '',
  84. notes: s.notes || '',
  85. };
  86. });
  87. // 时长约束:AI 图生视频单段现实上限约 8~10 秒,模型常给出 30s+ 的不可能值。
  88. // 这里按规格(每段 5~8 秒)夹取,并如实记录被夹取的镜头,绝不偷偷改。
  89. let clamped = 0;
  90. for (const s of shots) {
  91. const before = s.duration;
  92. s.duration = Math.min(maxShot, Math.max(minShot, s.duration));
  93. if (Math.abs(s.duration - before) > 0.01) {
  94. s._durationClampedFrom = before;
  95. clamped++;
  96. }
  97. }
  98. // 补齐 start,保证时间轴连续
  99. let t = 0;
  100. for (const s of shots) {
  101. if (s.start === null || Number.isNaN(s.start)) s.start = +t.toFixed(3);
  102. t = s.start + s.duration;
  103. }
  104. const totalDuration = shots.length ? +(shots[shots.length - 1].start + shots[shots.length - 1].duration).toFixed(3) : 0;
  105. return {
  106. title: sb.title || '',
  107. aspect: sb.aspect || aspect,
  108. totalDuration,
  109. appearanceAnchor: sb.appearanceAnchor || anchor,
  110. styleKeywords: sb.styleKeywords || '',
  111. shots,
  112. _meta: {
  113. createdBy: 'fmode-mv/storyboard',
  114. generatedAt: new Date().toISOString(),
  115. durationClamped: clamped,
  116. },
  117. };
  118. }
  119. /** 产线A:从歌曲生成 MV 分镜 */
  120. export async function buildStoryboardFromSong(song, opts = {}) {
  121. const { shotCount = 8, aspect = '16:9', perShot = 6, model, project = '' } = opts;
  122. const prompt = buildStoryboardPrompt({ song, shotCount, aspect, perShot, project }) + '\n\n' + SCHEMA_A;
  123. const res = await chatJSON({
  124. model,
  125. system: SYSTEM_A,
  126. messages: [{ role: 'user', content: prompt }],
  127. temperature: 0.85,
  128. max_tokens: 12000,
  129. });
  130. const sb = normalizeStoryboard(res.json, { aspect });
  131. sb._meta = { ...sb._meta, model: res.model, maxTokensUsed: res.maxTokensUsed, repaired: !!res.repaired };
  132. return sb;
  133. }
  134. /** 产线B:从口播稿生成讲解分镜 */
  135. export async function buildStoryboardFromScript(script, opts = {}) {
  136. const { durationSec = 90, aspect = '16:9', shotCount = 8, model, project = '', hostAnchor = '' } = opts;
  137. const prompt =
  138. buildTalkStoryboardPrompt({ script, durationSec, aspect, shotCount, project, hostAnchor }) + '\n\n' + SCHEMA_B;
  139. const res = await chatJSON({
  140. model,
  141. system: SYSTEM_B,
  142. messages: [{ role: 'user', content: prompt }],
  143. temperature: 0.8,
  144. max_tokens: 12000,
  145. });
  146. const sb = normalizeStoryboard(res.json, { aspect, anchor: DEFAULT_ANCHOR.talk });
  147. sb._meta = { ...sb._meta, model: res.model, maxTokensUsed: res.maxTokensUsed, repaired: !!res.repaired };
  148. return sb;
  149. }
  150. export async function main(argv = process.argv.slice(2)) {
  151. const { _, flags } = parseArgs(argv);
  152. if (flags.help || (!_.length && !flags.from && !flags.script)) {
  153. console.log(`
  154. fmode-mv storyboard — 分镜脚本(chat 通道)
  155. 产线A(歌曲 MV):
  156. fmode-mv storyboard --from work/song.json [--shots 8] [--aspect 16:9] [--per-shot 6]
  157. 产线B(口播视频):
  158. fmode-mv storyboard --script work/script.md [--duration 90] [--shots 8]
  159. 选项:
  160. --from <file> 歌曲 JSON(产线A)
  161. --script <file> 口播稿文本文件(产线B)
  162. --shots <n> 镜头数(默认 8)
  163. --per-shot <s> 单镜头秒数,仅产线A(默认 6)
  164. --duration <s> 口播总时长,仅产线B(默认 90)
  165. --aspect <r> 16:9 | 9:16 | 1:1(默认 16:9)
  166. --project "..." 项目背景,帮助模型贴合语境
  167. --model <name> 覆盖 LLM 模型
  168. --out <file> 输出(默认 work/storyboard.json)
  169. --json 只打印 JSON
  170. `);
  171. return 0;
  172. }
  173. ensureWork();
  174. const aspect = flags.aspect || '16:9';
  175. let sb;
  176. if (flags.script) {
  177. const { readFileSync } = await import('node:fs');
  178. const script = readFileSync(String(flags.script), 'utf8');
  179. if (!script.trim()) throw new Error(`口播稿为空:${flags.script}`);
  180. log.step('生成口播分镜(产线B)');
  181. log.key('口播稿', `${script.length} 字`);
  182. sb = await buildStoryboardFromScript(script, {
  183. durationSec: Number(flags.duration) || 90,
  184. aspect,
  185. shotCount: Number(flags.shots) || 8,
  186. project: flags.project || '',
  187. model: flags.model,
  188. });
  189. } else {
  190. const from = typeof flags.from === 'string' ? flags.from : paths.song;
  191. const song = readJSON(from);
  192. if (!song) throw new Error(`未找到歌曲文件 ${from}。先跑:fmode-mv lyrics "主题"`);
  193. log.step(`生成 MV 分镜(产线A):《${song.title}》`);
  194. sb = await buildStoryboardFromSong(song, {
  195. shotCount: Number(flags.shots) || 8,
  196. aspect,
  197. perShot: Number(flags.per_shot) || 6,
  198. project: flags.project || '',
  199. model: flags.model,
  200. });
  201. }
  202. const outFile = typeof flags.out === 'string' ? flags.out : paths.storyboard;
  203. writeJSON(outFile, sb);
  204. const { writeFileSync } = await import('node:fs');
  205. const { join } = await import('node:path');
  206. writeFileSync(join(paths.work, 'storyboard.md'), renderStoryboardMd(sb), 'utf8');
  207. if (flags.json) {
  208. console.log(JSON.stringify(sb, null, 2));
  209. return 0;
  210. }
  211. console.log(renderStoryboardMd(sb));
  212. log.ok(`已写入 ${outFile} 与 work/storyboard.md`);
  213. if (sb._meta?.durationClamped) {
  214. log.warn(
  215. `${sb._meta.durationClamped} 个镜头的时长超出合理区间(${3}~${10}s)已被夹取 —— ` +
  216. 'AI 图生视频单段现实上限约 8~10 秒,模型给出的几十秒镜头无法直接生成。'
  217. );
  218. }
  219. const perfs = sb.shots.filter((s) => s.lipSync).length;
  220. const brolls = sb.shots.filter((s) => !s.lipSync).length;
  221. log.key('镜头', `${sb.shots.length} 个(空镜/场景 ${brolls} + 唇形 ${perfs})`);
  222. log.key('总时长', `${sb.totalDuration}s`);
  223. if (!sb.appearanceAnchor) log.warn('缺少 appearanceAnchor(人物一致性锚点),跨镜头可能换脸');
  224. return 0;
  225. }
  226. export function renderStoryboardMd(sb) {
  227. const lines = [];
  228. lines.push(`# 分镜表 · ${sb.title}`);
  229. lines.push('');
  230. lines.push(`- 画面比例:${sb.aspect}`);
  231. lines.push(`- 总时长:${sb.totalDuration}s`);
  232. lines.push(`- 影像风格:${sb.styleKeywords || '-'}`);
  233. lines.push(`- 人物锚点:${sb.appearanceAnchor || '-'}`);
  234. lines.push('');
  235. lines.push('| # | 时间码 | 时长 | 类型 | 段落 | 画面 | 唇形 |');
  236. lines.push('|---|--------|------|------|------|------|------|');
  237. for (const s of sb.shots) {
  238. const tc = `${fmt(s.start)}–${fmt(s.start + s.duration)}`;
  239. lines.push(
  240. `| ${s.id} | ${tc} | ${s.duration}s | ${s.type} | ${s.section || '-'} | ${oneLine(s.description)} | ${s.lipSync ? '✓' : ''} |`
  241. );
  242. }
  243. lines.push('');
  244. lines.push('## 镜头明细');
  245. for (const s of sb.shots) {
  246. lines.push('');
  247. lines.push(`### #${s.id} · ${fmt(s.start)}–${fmt(s.start + s.duration)} · ${s.type}`);
  248. lines.push(`- 画面:${s.description}`);
  249. if (s.narration) lines.push(`- 口播:${s.narration}`);
  250. if (s.subtitle) lines.push(`- 字幕:${s.subtitle}`);
  251. lines.push(`- 图 prompt:\`${s.imagePrompt}\``);
  252. lines.push(`- 运镜 prompt:\`${s.videoPrompt}\``);
  253. }
  254. return lines.join('\n') + '\n';
  255. }
  256. function fmt(sec) {
  257. const s = Math.max(0, Number(sec) || 0);
  258. const m = Math.floor(s / 60);
  259. const r = (s % 60).toFixed(1).padStart(4, '0');
  260. return `${String(m).padStart(2, '0')}:${r}`;
  261. }
  262. function oneLine(s, n = 34) {
  263. const t = String(s || '').replace(/[\r\n|]/g, ' ').trim();
  264. return t.length > n ? t.slice(0, n) + '…' : t;
  265. }
  266. if (import.meta.url === `file://${process.argv[1]}`) {
  267. main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
  268. console.error(`✗ ${e.message}`);
  269. process.exit(1);
  270. });
  271. }