// 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 分镜 JSON(取字幕与时长) --clips 片段目录(默认 work/clips) --music 主音乐轨 --voice 配音轨 --bgm 背景乐轨(自动压到 0.18 音量) --music-volume 音乐音量(默认 1.0) --voice-volume 配音音量(默认 1.0) --bgm-volume 背景乐音量(默认 0.18) --beat-sync 按音乐节拍对齐镜头(能量包络节拍检测) --no-subtitle 不生成/不烧入字幕 --srt 使用现成 SRT --out 输出(默认 out/final.mp4) --fade-out 片尾音频淡出(默认 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 };