assemble.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. // scripts/assemble.mjs — 剪辑合成成片(本地 ffmpeg,✅ 可用)
  2. //
  3. // 职责:
  4. // 1. 按分镜把 work/clips/ 的片段拼成完整画面轨,可卡节拍对齐
  5. // 2. 生成字幕 SRT 并烧进画面
  6. // 3. 混音(音乐 / 人声 / 配音,带淡入淡出)
  7. // 4. 响度归一化后输出到 out/
  8. import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
  9. import { join } from 'node:path';
  10. import {
  11. parseArgs, readJSON, writeJSON, paths, ensureWork, log, humanSize,
  12. } from '../lib/index.mjs';
  13. import {
  14. checkFfmpeg, run, probe, concat, muxAudio, burnSubtitles, detectBeats, alignToBeats,
  15. normalizeLoudness, silence, fileSize,
  16. } from '../lib/ffmpeg.mjs';
  17. // ==================== 字幕 ====================
  18. /** 秒 → SRT 时间戳 00:00:01,250 */
  19. export function srtTime(sec) {
  20. const s = Math.max(0, Number(sec) || 0);
  21. const h = Math.floor(s / 3600);
  22. const m = Math.floor((s % 3600) / 60);
  23. const ss = Math.floor(s % 60);
  24. const ms = Math.round((s - Math.floor(s)) * 1000);
  25. const p = (n, w = 2) => String(n).padStart(w, '0');
  26. return `${p(h)}:${p(m)}:${p(ss)},${p(ms, 3)}`;
  27. }
  28. /** 从分镜生成 SRT 内容 */
  29. export function buildSRT(storyboard, { maxCharsPerLine = 18, minDuration = 0.8 } = {}) {
  30. const cues = [];
  31. for (const s of storyboard.shots) {
  32. const raw = String(s.subtitle || s.narration || s.section || '').trim();
  33. if (!raw) continue;
  34. // 长句切分为多行、必要时拆成多条字幕
  35. const chunks = splitCue(raw, maxCharsPerLine);
  36. const per = Math.max(minDuration, s.duration / chunks.length);
  37. chunks.forEach((c, i) => {
  38. cues.push({
  39. start: s.start + i * per,
  40. end: Math.min(s.start + s.duration, s.start + (i + 1) * per),
  41. text: c,
  42. });
  43. });
  44. }
  45. return cues.map((c, i) => `${i + 1}\n${srtTime(c.start)} --> ${srtTime(c.end)}\n${c.text}\n`).join('\n');
  46. }
  47. function splitCue(text, maxChars) {
  48. const clean = text.replace(/\s+/g, ' ').trim();
  49. if (clean.length <= maxChars) return [clean];
  50. const parts = [];
  51. for (const seg of clean.split(/[,。!?;,.!?;]/).filter(Boolean)) {
  52. let cur = '';
  53. for (const ch of seg) {
  54. cur += ch;
  55. if (cur.length >= maxChars) {
  56. parts.push(cur);
  57. cur = '';
  58. }
  59. }
  60. if (cur) parts.push(cur);
  61. }
  62. return parts.length ? parts : [clean.slice(0, maxChars)];
  63. }
  64. // ==================== 主流程 ====================
  65. export async function assemble(o = {}) {
  66. checkFfmpeg();
  67. const {
  68. storyboard,
  69. clipsDir = paths.clips,
  70. outFile = join(paths.out, 'final.mp4'),
  71. music, // 主音乐轨(产线A)
  72. voice, // 配音轨(产线B)
  73. bgm, // 背景乐(产线B 可选)
  74. musicVolume = 1.0,
  75. voiceVolume = 1.0,
  76. bgmVolume = 0.18,
  77. burnSub = true,
  78. subtitleFile,
  79. beatSync = false,
  80. fadeOut = 2.0,
  81. aspect,
  82. fps = 30,
  83. } = o;
  84. if (!existsSync(clipsDir)) throw new Error(`片段目录不存在:${clipsDir}。先跑 fmode-mv video`);
  85. mkdirSync(paths.out, { recursive: true });
  86. // ---------- 1. 收集片段 ----------
  87. const want = new Set((storyboard?.shots || []).map((s) => `shot-${String(s.id).padStart(2, '0')}`));
  88. let clips = readdirSync(clipsDir)
  89. .filter((f) => f.endsWith('.mp4'))
  90. .filter((f) => !want.size || want.has(f.replace(/\.(mp4|fallback\.mp4)$/, '').replace(/\.fallback$/, '')))
  91. .sort();
  92. // 排序:优先 shot-XX.mp4,缺失时用 shot-XX.fallback.mp4 顶上
  93. const byShot = new Map();
  94. for (const f of clips) {
  95. const m = f.match(/^shot-(\d+)(\.fallback)?\.mp4$/);
  96. if (!m) continue;
  97. const id = Number(m[1]);
  98. const isFallback = !!m[2];
  99. const cur = byShot.get(id);
  100. if (!cur || (cur.isFallback && !isFallback)) byShot.set(id, { file: join(clipsDir, f), isFallback });
  101. }
  102. let ordered = [...byShot.entries()].sort((a, b) => a[0] - b[0]).map(([id, v]) => ({ id, ...v }));
  103. if (!ordered.length) throw new Error(`在 ${clipsDir} 没找到 shot-*.mp4 片段。先跑 fmode-mv video`);
  104. log.step(`合成成片(${ordered.length} 段)`);
  105. const fallbackCount = ordered.filter((c) => c.isFallback).length;
  106. if (fallbackCount) log.warn(`${fallbackCount}/${ordered.length} 段来自 Ken Burns 兜底(非真视频生成)`);
  107. // ---------- 2. 卡点对齐(可选) ----------
  108. let beatInfo = null;
  109. if (beatSync && music && existsSync(music)) {
  110. log.info('检测节拍(能量包络法)…');
  111. beatInfo = await detectBeats(music);
  112. if (!beatInfo.beats.length) {
  113. log.warn('未检出节拍点(纯音/弱起拍素材常见),跳过卡点对齐,按分镜原时长拼接');
  114. log.dim('提示:卡点需要有明显鼓点/瞬态的音频素材(如真实编曲成品)');
  115. } else {
  116. log.ok(`BPM ≈ ${beatInfo.bpm || '未知'},检出 ${beatInfo.beats.length} 个节拍点`);
  117. if (storyboard?.shots) {
  118. storyboard.shots = alignToBeats(beatInfo, storyboard.shots);
  119. log.ok('分镜时长已吸附到节拍点');
  120. }
  121. }
  122. }
  123. // ---------- 3. 拼接画面 ----------
  124. const [w, h] = sizeOf(aspect || storyboard?.aspect || '16:9');
  125. const videoOnly = join(paths.work, 'video-only.mp4');
  126. await concat(ordered.map((c) => c.file), videoOnly, { width: w, height: h, fps });
  127. const vInfo = await probe(videoOnly);
  128. log.ok(`画面轨:${videoOnly}(${vInfo.duration.toFixed(1)}s,${vInfo.width}x${vInfo.height},${humanSize(fileSize(videoOnly))})`);
  129. // ---------- 4. 字幕 ----------
  130. let srtPath = null;
  131. if (burnSub && storyboard?.shots?.length) {
  132. const content = buildSRT(storyboard);
  133. if (content.trim()) {
  134. srtPath = subtitleFile || join(paths.work, 'subtitles.srt');
  135. writeFileSync(srtPath, content, 'utf8');
  136. log.ok(`字幕:${srtPath}(${content.split('\n\n').filter(Boolean).length} 条)`);
  137. } else {
  138. log.warn('分镜没有 subtitle/narration 文本,跳过字幕');
  139. }
  140. }
  141. // ---------- 5. 混音 ----------
  142. let stage = videoOnly;
  143. const audioTracks = [];
  144. if (music && existsSync(music)) audioTracks.push({ file: music, volume: musicVolume, label: '音乐' });
  145. if (voice && existsSync(voice)) audioTracks.push({ file: voice, volume: voiceVolume, label: '配音' });
  146. if (bgm && existsSync(bgm)) audioTracks.push({ file: bgm, volume: bgmVolume, label: '背景乐' });
  147. let mixed = null;
  148. if (audioTracks.length === 1) {
  149. const t = audioTracks[0];
  150. mixed = join(paths.work, 'audio-mixed.m4a');
  151. await run(
  152. ['-y', '-i', t.file, '-af', `volume=${t.volume}`, '-c:a', 'aac', '-b:a', '192k', mixed],
  153. { label: 'volume' }
  154. );
  155. log.ok(`音轨:仅 ${t.label}(音量 ${t.volume})`);
  156. } else if (audioTracks.length > 1) {
  157. mixed = join(paths.work, 'audio-mixed.m4a');
  158. const args = ['-y'];
  159. for (const t of audioTracks) args.push('-i', t.file);
  160. const fc =
  161. audioTracks.map((t, i) => `[${i}:a]volume=${t.volume}[a${i}]`).join(';') +
  162. ';' +
  163. audioTracks.map((_, i) => `[a${i}]`).join('') +
  164. `amix=inputs=${audioTracks.length}:duration=longest:normalize=0[aout]`;
  165. await run([...args, '-filter_complex', fc, '-map', '[aout]', '-c:a', 'aac', '-b:a', '192k', mixed], { label: 'amix' });
  166. log.ok(`音轨:${audioTracks.map((t) => `${t.label}(${t.volume})`).join(' + ')}`);
  167. } else {
  168. log.warn('未提供任何音轨(--music / --voice / --bgm),成片将无声音');
  169. }
  170. // ---------- 6. 合成 + 字幕 ----------
  171. let merged = mixed ? join(paths.work, 'merged.mp4') : stage;
  172. if (mixed) {
  173. await muxAudio(videoOnly, mixed, merged, { duration: vInfo.duration, fadeOut });
  174. }
  175. if (srtPath) {
  176. const burned = join(paths.work, 'subtitled.mp4');
  177. try {
  178. await burnSubtitles(merged, srtPath, burned, { fontSize: Math.round(h / 36), marginV: Math.round(h / 26) });
  179. merged = burned;
  180. log.ok('字幕已烧入画面');
  181. } catch (e) {
  182. log.warn(`字幕烧入失败(成片不带字幕,SRT 仍在 ${srtPath}):${e.message.split('\n')[0]}`);
  183. }
  184. }
  185. // ---------- 7. 响度归一化 ----------
  186. let finalOut = outFile;
  187. try {
  188. await normalizeLoudness(merged, outFile);
  189. log.ok(`响度归一化完成(I=-14 LUFS)`);
  190. } catch (e) {
  191. log.warn(`响度归一化失败,直接输出:${e.message.split('\n')[0]}`);
  192. await run(['-y', '-i', merged, '-c', 'copy', outFile], { label: 'copy(final)' });
  193. }
  194. const fInfo = await probe(finalOut);
  195. const bytes = fileSize(finalOut);
  196. writeJSON(join(paths.out, 'manifest.json'), {
  197. outFile: finalOut,
  198. duration: fInfo.duration,
  199. size: bytes,
  200. resolution: `${fInfo.width}x${fInfo.height}`,
  201. clips: ordered.map((c) => ({ id: c.id, file: c.file, fallback: c.isFallback })),
  202. fallbackCount,
  203. beatSync: beatInfo && beatInfo.beats.length ? { bpm: beatInfo.bpm, beats: beatInfo.beats.length, applied: true } : null,
  204. subtitles: srtPath,
  205. audioTracks: audioTracks.map((t) => ({ label: t.label, volume: t.volume })),
  206. _meta: { createdBy: 'fmode-mv/assemble', generatedAt: new Date().toISOString() },
  207. });
  208. log.step('成片');
  209. log.key('文件', finalOut);
  210. log.key('时长', `${fInfo.duration.toFixed(1)}s`);
  211. log.key('分辨率', `${fInfo.width}x${fInfo.height} @ ${fInfo.fps || fps}fps`);
  212. log.key('大小', humanSize(bytes));
  213. if (fallbackCount) log.warn(`提示:${fallbackCount} 段为兜底运镜(非真 AI 图生视频)`);
  214. return { outFile: finalOut, info: fInfo, fallbackCount };
  215. }
  216. function sizeOf(aspect) {
  217. return aspect === '9:16' ? [720, 1280] : aspect === '1:1' ? [960, 960] : [1280, 720];
  218. }
  219. export async function main(argv = process.argv.slice(2)) {
  220. const { _, flags } = parseArgs(argv);
  221. if (flags.help) {
  222. console.log(`
  223. fmode-mv assemble — 剪辑合成成片(本地 ffmpeg,✅ 可用)
  224. 用法:
  225. # 产线A:MV(音乐为主音轨,卡点对齐)
  226. fmode-mv assemble --from work/storyboard.json --music work/audio/music.mp3 --beat-sync
  227. # 产线B:口播(配音为主音轨 + 轻背景乐)
  228. fmode-mv assemble --from work/storyboard.json --voice work/audio/voiceover.mp3 --bgm work/audio/bgm.mp3
  229. 选项:
  230. --from <file> 分镜 JSON(取字幕与时长)
  231. --clips <dir> 片段目录(默认 work/clips)
  232. --music <file> 主音乐轨
  233. --voice <file> 配音轨
  234. --bgm <file> 背景乐轨(自动压到 0.18 音量)
  235. --music-volume <n> 音乐音量(默认 1.0)
  236. --voice-volume <n> 配音音量(默认 1.0)
  237. --bgm-volume <n> 背景乐音量(默认 0.18)
  238. --beat-sync 按音乐节拍对齐镜头(能量包络节拍检测)
  239. --no-subtitle 不生成/不烧入字幕
  240. --srt <file> 使用现成 SRT
  241. --out <file> 输出(默认 out/final.mp4)
  242. --fade-out <sec> 片尾音频淡出(默认 2.0)
  243. 产出:
  244. out/final.mp4 + out/manifest.json(含兜底片段数、BPM、音轨信息)
  245. `);
  246. return 0;
  247. }
  248. ensureWork();
  249. const sb = flags.from ? readJSON(String(flags.from)) : null;
  250. if (flags.from && !sb) throw new Error(`分镜文件不存在:${flags.from}`);
  251. const r = await assemble({
  252. storyboard: sb,
  253. clipsDir: String(flags.clips || paths.clips),
  254. outFile: String(flags.out || join(paths.out, 'final.mp4')),
  255. music: typeof flags.music === 'string' ? flags.music : undefined,
  256. voice: typeof flags.voice === 'string' ? flags.voice : undefined,
  257. bgm: typeof flags.bgm === 'string' ? flags.bgm : undefined,
  258. musicVolume: Number(flags['music-volume'] ?? 1.0),
  259. voiceVolume: Number(flags['voice-volume'] ?? 1.0),
  260. bgmVolume: Number(flags['bgm-volume'] ?? 0.18),
  261. burnSub: !flags['no-subtitle'],
  262. subtitleFile: typeof flags.srt === 'string' ? flags.srt : undefined,
  263. beatSync: !!flags['beat-sync'],
  264. fadeOut: Number(flags['fade-out'] ?? 2.0),
  265. aspect: typeof flags.aspect === 'string' ? flags.aspect : undefined,
  266. });
  267. return 0;
  268. }
  269. if (import.meta.url === `file://${process.argv[1]}`) {
  270. main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
  271. console.error(`✗ ${e.message}`);
  272. process.exit(1);
  273. });
  274. }
  275. export { silence, fileSize };