| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308 |
- // scripts/assemble.mjs — 剪辑合成成片(本地 ffmpeg,✅ 可用)
- //
- // 职责:
- // 1. 按分镜把 work/clips/ 的片段拼成完整画面轨,可卡节拍对齐
- // 2. 生成字幕 SRT 并烧进画面
- // 3. 混音(音乐 / 人声 / 配音,带淡入淡出)
- // 4. 响度归一化后输出到 out/
- import { existsSync, mkdirSync, readdirSync, writeFileSync } from 'node:fs';
- import { join } from 'node:path';
- import {
- parseArgs, readJSON, writeJSON, paths, ensureWork, log, humanSize,
- } from '../lib/index.mjs';
- import {
- checkFfmpeg, run, probe, concat, muxAudio, burnSubtitles, detectBeats, alignToBeats,
- normalizeLoudness, silence, fileSize,
- } from '../lib/ffmpeg.mjs';
- // ==================== 字幕 ====================
- /** 秒 → SRT 时间戳 00:00:01,250 */
- export function srtTime(sec) {
- const s = Math.max(0, Number(sec) || 0);
- const h = Math.floor(s / 3600);
- const m = Math.floor((s % 3600) / 60);
- const ss = Math.floor(s % 60);
- const ms = Math.round((s - Math.floor(s)) * 1000);
- const p = (n, w = 2) => String(n).padStart(w, '0');
- return `${p(h)}:${p(m)}:${p(ss)},${p(ms, 3)}`;
- }
- /** 从分镜生成 SRT 内容 */
- export function buildSRT(storyboard, { maxCharsPerLine = 18, minDuration = 0.8 } = {}) {
- const cues = [];
- for (const s of storyboard.shots) {
- const raw = String(s.subtitle || s.narration || s.section || '').trim();
- if (!raw) continue;
- // 长句切分为多行、必要时拆成多条字幕
- const chunks = splitCue(raw, maxCharsPerLine);
- const per = Math.max(minDuration, s.duration / chunks.length);
- chunks.forEach((c, i) => {
- cues.push({
- start: s.start + i * per,
- end: Math.min(s.start + s.duration, s.start + (i + 1) * per),
- text: c,
- });
- });
- }
- return cues.map((c, i) => `${i + 1}\n${srtTime(c.start)} --> ${srtTime(c.end)}\n${c.text}\n`).join('\n');
- }
- function splitCue(text, maxChars) {
- const clean = text.replace(/\s+/g, ' ').trim();
- if (clean.length <= maxChars) return [clean];
- const parts = [];
- for (const seg of clean.split(/[,。!?;,.!?;]/).filter(Boolean)) {
- let cur = '';
- for (const ch of seg) {
- cur += ch;
- if (cur.length >= maxChars) {
- parts.push(cur);
- cur = '';
- }
- }
- if (cur) parts.push(cur);
- }
- return parts.length ? parts : [clean.slice(0, maxChars)];
- }
- // ==================== 主流程 ====================
- export async function assemble(o = {}) {
- checkFfmpeg();
- const {
- storyboard,
- clipsDir = paths.clips,
- outFile = join(paths.out, 'final.mp4'),
- music, // 主音乐轨(产线A)
- voice, // 配音轨(产线B)
- bgm, // 背景乐(产线B 可选)
- musicVolume = 1.0,
- voiceVolume = 1.0,
- bgmVolume = 0.18,
- burnSub = true,
- subtitleFile,
- beatSync = false,
- fadeOut = 2.0,
- aspect,
- fps = 30,
- } = o;
- if (!existsSync(clipsDir)) throw new Error(`片段目录不存在:${clipsDir}。先跑 fmode-mv video`);
- mkdirSync(paths.out, { recursive: true });
- // ---------- 1. 收集片段 ----------
- const want = new Set((storyboard?.shots || []).map((s) => `shot-${String(s.id).padStart(2, '0')}`));
- let clips = readdirSync(clipsDir)
- .filter((f) => f.endsWith('.mp4'))
- .filter((f) => !want.size || want.has(f.replace(/\.(mp4|fallback\.mp4)$/, '').replace(/\.fallback$/, '')))
- .sort();
- // 排序:优先 shot-XX.mp4,缺失时用 shot-XX.fallback.mp4 顶上
- const byShot = new Map();
- for (const f of clips) {
- const m = f.match(/^shot-(\d+)(\.fallback)?\.mp4$/);
- if (!m) continue;
- const id = Number(m[1]);
- const isFallback = !!m[2];
- const cur = byShot.get(id);
- if (!cur || (cur.isFallback && !isFallback)) byShot.set(id, { file: join(clipsDir, f), isFallback });
- }
- let ordered = [...byShot.entries()].sort((a, b) => a[0] - b[0]).map(([id, v]) => ({ id, ...v }));
- if (!ordered.length) throw new Error(`在 ${clipsDir} 没找到 shot-*.mp4 片段。先跑 fmode-mv video`);
- log.step(`合成成片(${ordered.length} 段)`);
- const fallbackCount = ordered.filter((c) => c.isFallback).length;
- if (fallbackCount) log.warn(`${fallbackCount}/${ordered.length} 段来自 Ken Burns 兜底(非真视频生成)`);
- // ---------- 2. 卡点对齐(可选) ----------
- let beatInfo = null;
- if (beatSync && music && existsSync(music)) {
- log.info('检测节拍(能量包络法)…');
- beatInfo = await detectBeats(music);
- if (!beatInfo.beats.length) {
- log.warn('未检出节拍点(纯音/弱起拍素材常见),跳过卡点对齐,按分镜原时长拼接');
- log.dim('提示:卡点需要有明显鼓点/瞬态的音频素材(如真实编曲成品)');
- } else {
- log.ok(`BPM ≈ ${beatInfo.bpm || '未知'},检出 ${beatInfo.beats.length} 个节拍点`);
- if (storyboard?.shots) {
- storyboard.shots = alignToBeats(beatInfo, storyboard.shots);
- log.ok('分镜时长已吸附到节拍点');
- }
- }
- }
- // ---------- 3. 拼接画面 ----------
- const [w, h] = sizeOf(aspect || storyboard?.aspect || '16:9');
- const videoOnly = join(paths.work, 'video-only.mp4');
- await concat(ordered.map((c) => c.file), videoOnly, { width: w, height: h, fps });
- const vInfo = await probe(videoOnly);
- log.ok(`画面轨:${videoOnly}(${vInfo.duration.toFixed(1)}s,${vInfo.width}x${vInfo.height},${humanSize(fileSize(videoOnly))})`);
- // ---------- 4. 字幕 ----------
- let srtPath = null;
- if (burnSub && storyboard?.shots?.length) {
- const content = buildSRT(storyboard);
- if (content.trim()) {
- srtPath = subtitleFile || join(paths.work, 'subtitles.srt');
- writeFileSync(srtPath, content, 'utf8');
- log.ok(`字幕:${srtPath}(${content.split('\n\n').filter(Boolean).length} 条)`);
- } else {
- log.warn('分镜没有 subtitle/narration 文本,跳过字幕');
- }
- }
- // ---------- 5. 混音 ----------
- let stage = videoOnly;
- const audioTracks = [];
- if (music && existsSync(music)) audioTracks.push({ file: music, volume: musicVolume, label: '音乐' });
- if (voice && existsSync(voice)) audioTracks.push({ file: voice, volume: voiceVolume, label: '配音' });
- if (bgm && existsSync(bgm)) audioTracks.push({ file: bgm, volume: bgmVolume, label: '背景乐' });
- let mixed = null;
- if (audioTracks.length === 1) {
- const t = audioTracks[0];
- mixed = join(paths.work, 'audio-mixed.m4a');
- await run(
- ['-y', '-i', t.file, '-af', `volume=${t.volume}`, '-c:a', 'aac', '-b:a', '192k', mixed],
- { label: 'volume' }
- );
- log.ok(`音轨:仅 ${t.label}(音量 ${t.volume})`);
- } else if (audioTracks.length > 1) {
- mixed = join(paths.work, 'audio-mixed.m4a');
- const args = ['-y'];
- for (const t of audioTracks) args.push('-i', t.file);
- const fc =
- audioTracks.map((t, i) => `[${i}:a]volume=${t.volume}[a${i}]`).join(';') +
- ';' +
- audioTracks.map((_, i) => `[a${i}]`).join('') +
- `amix=inputs=${audioTracks.length}:duration=longest:normalize=0[aout]`;
- await run([...args, '-filter_complex', fc, '-map', '[aout]', '-c:a', 'aac', '-b:a', '192k', mixed], { label: 'amix' });
- log.ok(`音轨:${audioTracks.map((t) => `${t.label}(${t.volume})`).join(' + ')}`);
- } else {
- log.warn('未提供任何音轨(--music / --voice / --bgm),成片将无声音');
- }
- // ---------- 6. 合成 + 字幕 ----------
- let merged = mixed ? join(paths.work, 'merged.mp4') : stage;
- if (mixed) {
- await muxAudio(videoOnly, mixed, merged, { duration: vInfo.duration, fadeOut });
- }
- if (srtPath) {
- const burned = join(paths.work, 'subtitled.mp4');
- try {
- await burnSubtitles(merged, srtPath, burned, { fontSize: Math.round(h / 36), marginV: Math.round(h / 26) });
- merged = burned;
- log.ok('字幕已烧入画面');
- } catch (e) {
- log.warn(`字幕烧入失败(成片不带字幕,SRT 仍在 ${srtPath}):${e.message.split('\n')[0]}`);
- }
- }
- // ---------- 7. 响度归一化 ----------
- let finalOut = outFile;
- try {
- await normalizeLoudness(merged, outFile);
- log.ok(`响度归一化完成(I=-14 LUFS)`);
- } catch (e) {
- log.warn(`响度归一化失败,直接输出:${e.message.split('\n')[0]}`);
- await run(['-y', '-i', merged, '-c', 'copy', outFile], { label: 'copy(final)' });
- }
- const fInfo = await probe(finalOut);
- const bytes = fileSize(finalOut);
- writeJSON(join(paths.out, 'manifest.json'), {
- outFile: finalOut,
- duration: fInfo.duration,
- size: bytes,
- resolution: `${fInfo.width}x${fInfo.height}`,
- clips: ordered.map((c) => ({ id: c.id, file: c.file, fallback: c.isFallback })),
- fallbackCount,
- beatSync: beatInfo && beatInfo.beats.length ? { bpm: beatInfo.bpm, beats: beatInfo.beats.length, applied: true } : null,
- subtitles: srtPath,
- audioTracks: audioTracks.map((t) => ({ label: t.label, volume: t.volume })),
- _meta: { createdBy: 'fmode-mv/assemble', generatedAt: new Date().toISOString() },
- });
- log.step('成片');
- log.key('文件', finalOut);
- log.key('时长', `${fInfo.duration.toFixed(1)}s`);
- log.key('分辨率', `${fInfo.width}x${fInfo.height} @ ${fInfo.fps || fps}fps`);
- log.key('大小', humanSize(bytes));
- if (fallbackCount) log.warn(`提示:${fallbackCount} 段为兜底运镜(非真 AI 图生视频)`);
- return { outFile: finalOut, info: fInfo, fallbackCount };
- }
- function sizeOf(aspect) {
- return aspect === '9:16' ? [720, 1280] : aspect === '1:1' ? [960, 960] : [1280, 720];
- }
- export async function main(argv = process.argv.slice(2)) {
- const { _, flags } = parseArgs(argv);
- if (flags.help) {
- console.log(`
- fmode-mv assemble — 剪辑合成成片(本地 ffmpeg,✅ 可用)
- 用法:
- # 产线A:MV(音乐为主音轨,卡点对齐)
- fmode-mv assemble --from work/storyboard.json --music work/audio/music.mp3 --beat-sync
- # 产线B:口播(配音为主音轨 + 轻背景乐)
- fmode-mv assemble --from work/storyboard.json --voice work/audio/voiceover.mp3 --bgm work/audio/bgm.mp3
- 选项:
- --from <file> 分镜 JSON(取字幕与时长)
- --clips <dir> 片段目录(默认 work/clips)
- --music <file> 主音乐轨
- --voice <file> 配音轨
- --bgm <file> 背景乐轨(自动压到 0.18 音量)
- --music-volume <n> 音乐音量(默认 1.0)
- --voice-volume <n> 配音音量(默认 1.0)
- --bgm-volume <n> 背景乐音量(默认 0.18)
- --beat-sync 按音乐节拍对齐镜头(能量包络节拍检测)
- --no-subtitle 不生成/不烧入字幕
- --srt <file> 使用现成 SRT
- --out <file> 输出(默认 out/final.mp4)
- --fade-out <sec> 片尾音频淡出(默认 2.0)
- 产出:
- out/final.mp4 + out/manifest.json(含兜底片段数、BPM、音轨信息)
- `);
- return 0;
- }
- ensureWork();
- const sb = flags.from ? readJSON(String(flags.from)) : null;
- if (flags.from && !sb) throw new Error(`分镜文件不存在:${flags.from}`);
- const r = await assemble({
- storyboard: sb,
- clipsDir: String(flags.clips || paths.clips),
- outFile: String(flags.out || join(paths.out, 'final.mp4')),
- music: typeof flags.music === 'string' ? flags.music : undefined,
- voice: typeof flags.voice === 'string' ? flags.voice : undefined,
- bgm: typeof flags.bgm === 'string' ? flags.bgm : undefined,
- musicVolume: Number(flags['music-volume'] ?? 1.0),
- voiceVolume: Number(flags['voice-volume'] ?? 1.0),
- bgmVolume: Number(flags['bgm-volume'] ?? 0.18),
- burnSub: !flags['no-subtitle'],
- subtitleFile: typeof flags.srt === 'string' ? flags.srt : undefined,
- beatSync: !!flags['beat-sync'],
- fadeOut: Number(flags['fade-out'] ?? 2.0),
- aspect: typeof flags.aspect === 'string' ? flags.aspect : undefined,
- });
- return 0;
- }
- if (import.meta.url === `file://${process.argv[1]}`) {
- main().then((c) => process.exit(Number(c) || 0)).catch((e) => {
- console.error(`✗ ${e.message}`);
- process.exit(1);
- });
- }
- export { silence, fileSize };
|