// lib/ffmpeg.mjs — ffmpeg / ffprobe 封装 // // 只用系统 ffmpeg(/usr/bin/ffmpeg)。零 npm 依赖。 // 所有对外部命令的调用都走 run(),统一超时、统一错误信息(带 stderr 尾部)。 import { spawn, spawnSync } from 'node:child_process'; import { mkdirSync, existsSync, writeFileSync, readdirSync, statSync } from 'node:fs'; import { dirname, join, resolve, basename } from 'node:path'; import { safeEnv } from './config.mjs'; export const FFMPEG = process.env.FFMPEG_BIN || 'ffmpeg'; export const FFPROBE = process.env.FFPROBE_BIN || 'ffprobe'; /** 检查 ffmpeg / ffprobe 是否可用 */ export function checkFfmpeg() { const out = {}; for (const [name, bin] of [ ['ffmpeg', FFMPEG], ['ffprobe', FFPROBE], ]) { const r = spawnSync(bin, ['-version'], { encoding: 'utf8', timeout: 15_000 }); out[name] = r.status === 0 ? { available: true, version: (r.stdout || '').split('\n')[0].trim() } : { available: false, version: null, error: (r.stderr || '').split('\n')[0] }; } return out; } export function requireFfmpeg() { const c = checkFfmpeg(); if (!c.ffmpeg.available || !c.ffprobe.available) { throw new Error( '缺少系统依赖 ffmpeg / ffprobe。\n' + ' Ubuntu/Debian: sudo apt-get install -y ffmpeg\n' + ' macOS: brew install ffmpeg\n' + '(当前 ffmpeg: ' + (c.ffmpeg.version || '未找到') + ')' ); } return c; } /** * 执行 ffmpeg(或任意命令)。 * @param {string[]} args * @param {{bin?:string, timeout?:number, label?:string, cwd?:string}} opts */ export function run(args, opts = {}) { const { bin = FFMPEG, timeout = 30 * 60_000, label = 'ffmpeg', cwd } = opts; return new Promise((resolvePromise, reject) => { const p = spawn(bin, args, { cwd, env: safeEnv() }); let stderr = ''; let stdout = ''; const timer = setTimeout(() => { p.kill('SIGKILL'); reject(new Error(`${label} 超时(${Math.round(timeout / 1000)}s)`)); }, timeout); p.stdout.on('data', (d) => (stdout += d)); p.stderr.on('data', (d) => { stderr += d; if (stderr.length > 200_000) stderr = stderr.slice(-100_000); }); p.on('error', (e) => { clearTimeout(timer); reject(new Error(`${label} 启动失败:${e.message}(未安装 ${bin}?)`)); }); p.on('close', (code) => { clearTimeout(timer); if (code === 0) return resolvePromise({ stdout, stderr }); const tail = stderr.split('\n').filter(Boolean).slice(-8).join('\n '); reject(new Error(`${label} 退出码 ${code}:\n ${tail}`)); }); }); } /** ffprobe 读取媒体信息 */ export async function probe(file) { const { stdout } = await run( ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file], { bin: FFPROBE, label: 'ffprobe', timeout: 60_000 } ); const j = JSON.parse(stdout); const v = (j.streams || []).find((s) => s.codec_type === 'video'); const a = (j.streams || []).find((s) => s.codec_type === 'audio'); const num = (x) => (x == null || x === 'N/A' ? null : Number(x)); return { raw: j, duration: num(j.format?.duration) ?? num(v?.duration) ?? 0, size: num(j.format?.size) ?? (existsSync(file) ? statSync(file).size : 0), hasVideo: !!v, hasAudio: !!a, width: num(v?.width), height: num(v?.height), fps: parseFps(v?.r_frame_rate), vcodec: v?.codec_name || null, acodec: a?.codec_name || null, sampleRate: num(a?.sample_rate), channels: num(a?.channels), }; } function parseFps(r) { if (!r || r === 'N/A') return null; const [a, b] = String(r).split('/').map(Number); if (!b) return a || null; return +(a / b).toFixed(3); } export function ensureDir(dir) { mkdirSync(dir, { recursive: true }); return dir; } export function fileSize(p) { try { return statSync(p).size; } catch { return 0; } } export function humanSize(bytes) { if (!bytes) return '0B'; const u = ['B', 'KB', 'MB', 'GB']; let i = 0; let n = bytes; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)}${u[i]}`; } // ==================== 音视频常用操作 ==================== /** 从视频中提取音频 */ export async function extractAudio(input, output, { format = 'wav', sampleRate = 44100, channels = 2 } = {}) { ensureDir(dirname(output)); await run([ '-y', '-i', input, '-vn', '-acodec', format === 'wav' ? 'pcm_s16le' : format === 'mp3' ? 'libmp3lame' : 'aac', '-ar', String(sampleRate), '-ac', String(channels), output, ], { label: 'extractAudio' }); return output; } /** 把音频合到视频上(替换音轨),支持淡出与时长裁剪 */ export async function muxAudio(video, audio, output, { duration, fadeOut = 0, audioBitrate = '192k', volume = 1 } = {}) { ensureDir(dirname(output)); const filters = []; if (volume !== 1) filters.push(`volume=${volume}`); if (fadeOut > 0 && duration) { filters.push(`afade=t=out:st=${Math.max(0, duration - fadeOut).toFixed(3)}:d=${fadeOut}`); } const af = filters.length ? ['-af', filters.join(',')] : []; const args = [ '-y', '-i', video, '-i', audio, '-map', '0:v:0', '-map', '1:a:0', '-c:v', 'copy', '-c:a', 'aac', '-b:a', audioBitrate, ...af, ...(duration ? ['-t', String(duration)] : []), '-shortest', output, ]; await run(args, { label: 'muxAudio' }); return output; } /** 拼接多段视频(统一重编码,避免参数不一致导致 concat 失败) */ export async function concat(clips, output, { width = 1280, height = 720, fps = 30, transition = 0, withAudio = false } = {}) { ensureDir(dirname(output)); if (!clips.length) throw new Error('concat 需要至少一个片段'); if (clips.length === 1) { await run(['-y', '-i', clips[0], '-c', 'copy', output], { label: 'concat(single)' }); return output; } const args = ['-y']; for (const c of clips) args.push('-i', c); const scale = `scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2:color=black,setsar=1,fps=${fps}`; if (transition > 0) { // xfade 交叉溶解 let filter = ''; let prev = '[0:v]'; for (let i = 1; i < clips.length; i++) { const off = i * (transition > 0 ? 0.5 : 0); const out = i === clips.length - 1 ? '[vout]' : `[v${i}]`; filter += `${prev}[${i}:v]xfade=transition=fade:duration=${transition}:offset=${off}${out};`; prev = out; } const mapArgs = ['-map', '[vout]', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '18']; await run([...args, '-filter_complex', filter.replace(/;$/, ''), ...mapArgs, output], { label: 'concat(xfade)' }); return output; } const fc = clips.map((_, i) => `[${i}:v]${scale}[v${i}]`).join(';') + ';' + clips.map((_, i) => `[v${i}]`).join('') + `concat=n=${clips.length}:v=1:a=0[vout]`; await run( [...args, '-filter_complex', fc, '-map', '[vout]', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '18', output], { label: 'concat' } ); return output; } /** 给视频烧硬字幕(SRT / ASS) */ export async function burnSubtitles(video, subtitleFile, output, { fontSize = 20, marginV = 28, fontName = 'Noto Sans CJK SC' } = {}) { ensureDir(dirname(output)); const isAss = subtitleFile.toLowerCase().endsWith('.ass'); const style = `FontName=${fontName},FontSize=${fontSize},PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BorderStyle=1,Outline=2,Shadow=0,Alignment=2,MarginV=${marginV}`; const esc = subtitleFile.replace(/\\/g, '/').replace(/:/g, '\\:').replace(/'/g, "\\'"); const vf = isAss ? `ass='${esc}'` : `subtitles='${esc}':force_style='${style}'`; await run(['-y', '-i', video, '-vf', vf, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '18', '-c:a', 'copy', output], { label: 'burnSubtitles', }); return output; } /** 静帧 + Ken Burns 运镜(视频通道未开通时的兜底动态镜头) */ export async function kenBurns(image, output, { duration = 5, width = 1280, height = 720, fps = 30, zoom = 0.12, direction = 'in' } = {}) { ensureDir(dirname(output)); const frames = Math.max(1, Math.round(duration * fps)); const zEnd = direction === 'out' ? 1 + zoom : 1 + zoom; const zStart = direction === 'out' ? 1 + zoom : 1; // zoompan 在 4K 画布上运算以获得平滑慢推 const pre = `scale=${width * 2}:${height * 2}:force_original_aspect_ratio=increase,crop=${width * 2}:${height * 2}`; const zp = `zoompan=z='${zStart}+(${zEnd}-${zStart})*on/${frames}':` + `x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=${frames}:s=${width}x${height}:fps=${fps}`; await run( ['-y', '-loop', '1', '-i', image, '-t', String(duration), '-vf', `${pre},${zp},format=yuv420p`, '-r', String(fps), '-c:v', 'libx264', '-crf', '19', output], { label: 'kenBurns' } ); return output; } /** 纯色背景 + 音频(无画面素材时的占位成片) */ export async function colorWithAudio(audio, output, { duration, width = 1280, height = 720, color = 'black', fps = 30 } = {}) { ensureDir(dirname(output)); await run( [ '-y', '-f', 'lavfi', '-i', `color=c=${color}:s=${width}x${height}:r=${fps}`, '-i', audio, '-map', '0:v', '-map', '1:a', ...(duration ? ['-t', String(duration)] : []), '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', output, ], { label: 'colorWithAudio' } ); return output; } /** 生成静音音轨 */ export async function silence(output, duration, { sampleRate = 44100, channels = 2 } = {}) { ensureDir(dirname(output)); await run( ['-y', '-f', 'lavfi', '-i', `anullsrc=r=${sampleRate}:cl=${channels === 1 ? 'mono' : 'stereo'}`, '-t', String(duration), '-c:a', 'pcm_s16le', output], { label: 'silence' } ); return output; } /** 响度归一化(成片听感统一) */ export async function normalizeLoudness(input, output, { target = -14 } = {}) { ensureDir(dirname(output)); const pass1 = await run(['-y', '-i', input, '-af', `loudnorm=I=${target}:print_format=json`, '-f', 'null', '-'], { label: 'loudnorm(pass1)', }); let measured = {}; try { const m = pass1.stderr.match(/\{[\s\S]*?"target_offset"[\s\S]*?\}/); if (m) measured = JSON.parse(m[0]); } catch { /* 忽略解析失败,退回单遍 */ } const af = measured.input_i ? `loudnorm=I=${target}:TP=-1.5:LRA=11:measured_I=${measured.input_i}:measured_TP=${measured.input_tp}:measured_LRA=${measured.input_lra}:measured_thresh=${measured.input_thresh}:offset=${measured.target_offset}:linear=true` : `loudnorm=I=${target}:TP=-1.5:LRA=11`; await run(['-y', '-i', input, '-af', af, '-c:a', 'aac', '-b:a', '192k', output], { label: 'loudnorm(pass2)' }); return output; } /** 生成 MP3 伴奏/人声的简易分离(纯 ffmpeg 兜底,见 scripts/separate.mjs 说明) */ export async function separateByChannelPhase(input, { outDir, mode = 'instrumental' } = {}) { ensureDir(outDir); const out = join(outDir, `${mode}.wav`); // 中置声道消除:人声通常在中央,L-R 相减可压低人声 → 近似伴奏 // 中置提取: (L+R)/2 - (L-R)/2 的近似用 pan 实现 const af = mode === 'instrumental' ? 'pan=stereo|c0=c0|c1=-c1,volume=1.4' // 侧信号(近似伴奏) : 'pan=mono|c0=0.5*c0+0.5*c1'; // 中置(近似人声/主唱) await run(['-y', '-i', input, '-af', af, '-ar', '44100', out], { label: 'separate' }); return out; } /** 用 demucs(若安装)做高质量分离 */ export function checkDemucs() { for (const cmd of ['demucs', 'python3']) { const r = spawnSync(cmd, cmd === 'python3' ? ['-c', 'import demucs;print(demucs.__version__)'] : ['--help'], { encoding: 'utf8', timeout: 30_000, }); if (r.status === 0) { if (cmd === 'demucs') return { available: true, cmd: 'demucs', version: (r.stdout || '').split('\n')[0] }; return { available: true, cmd: 'python3 -m demucs', version: (r.stdout || '').trim() }; } } return { available: false }; } /** 用 demucs 分离(可选增强路径) */ export async function separateWithDemucs(input, outDir, { model = 'htdemucs' } = {}) { ensureDir(outDir); const d = checkDemucs(); if (!d.available) throw new Error('未检测到 demucs(可选依赖,pip install demucs)'); const args = d.cmd === 'demucs' ? ['-n', model, '-o', outDir, input] : ['-m', 'demucs', '-n', model, '-o', outDir, input]; const [bin, ...rest] = d.cmd.split(' '); await run([...rest, ...args], { bin, label: 'demucs', timeout: 30 * 60_000 }); const base = basename(input).replace(/\.[^.]+$/, ''); return { vocals: join(outDir, model, `${base}.vocals.wav`), instrumental: join(outDir, model, `${base}.no_vocals.wav`), drums: join(outDir, model, `${base}.drums.wav`), bass: join(outDir, model, `${base}.bass.wav`), other: join(outDir, model, `${base}.other.wav`), }; } // ==================== 卡点:能量包络节拍检测(纯 Node,零依赖)==================== /** * 解码音频为 PCM 并做能量包络节拍检测,返回节拍时间点(秒)。 * 不依赖 aubio 等外部库,够用且稳定。 * @returns {Promise<{bpm:number|null, beats:number[], duration:number}>} */ export async function detectBeats(audioFile, { sampleRate = 11025, hop = 1024, sensitivity = 1.4 } = {}) { const tmp = join('/tmp', `fmode-mv-beat-${process.pid}-${Date.now()}.raw`); await run( ['-y', '-i', audioFile, '-ac', '1', '-ar', String(sampleRate), '-f', 's16le', '-acodec', 'pcm_s16le', tmp], { label: 'decode(for beat detection)' } ); const { readFileSync, unlinkSync } = await import('node:fs'); const buf = readFileSync(tmp); try { unlinkSync(tmp); } catch { /* ignore */ } const samples = new Int16Array(buf.buffer, buf.byteOffset, Math.floor(buf.length / 2)); const frames = Math.floor(samples.length / hop); const energy = new Float64Array(frames); for (let f = 0; f < frames; f++) { let sum = 0; const off = f * hop; for (let i = 0; i < hop; i++) { const v = samples[off + i] / 32768; sum += v * v; } energy[f] = Math.sqrt(sum / hop); } // 局部均值 + 阈值攻陷 → onset 峰值 const win = 16; const beats = []; for (let f = 1; f < frames - 1; f++) { let sum = 0; let n = 0; for (let k = Math.max(0, f - win); k <= Math.min(frames - 1, f + win); k++) { sum += energy[k]; n++; } const local = sum / n; const th = local * sensitivity; if (energy[f] > th && energy[f] >= energy[f - 1] && energy[f] > energy[f + 1]) { const t = (f * hop) / sampleRate; if (!beats.length || t - beats[beats.length - 1] > 0.18) beats.push(+t.toFixed(3)); } } // 由中位间隔估计 BPM let bpm = null; if (beats.length > 4) { const gaps = []; for (let i = 1; i < beats.length; i++) gaps.push(beats[i] - beats[i - 1]); gaps.sort((a, b) => a - b); const med = gaps[Math.floor(gaps.length / 2)]; if (med > 0.2) { let b = 60 / med; while (b < 70) b *= 2; while (b > 180) b /= 2; bpm = +b.toFixed(1); } } const duration = samples.length / sampleRate; return { bpm, beats, duration: +duration.toFixed(3) }; } /** * 把分镜按节拍对齐:给定每镜时长,吸附到最近的节拍点。 * @param {{beats:number[], duration:number}} beatInfo * @param {Array<{duration:number}>} shots */ export function alignToBeats(beatInfo, shots, { offset = 0 } = {}) { if (!beatInfo?.beats?.length) return shots; const out = []; let t = offset; for (const s of shots) { let target = t + s.duration; // 找最近的节拍点 let best = target; let bestD = Infinity; for (const b of beatInfo.beats) { const d = Math.abs(b - target); if (d < bestD) { bestD = d; best = b; } } const snapped = bestD <= 0.6 ? best : target; const dur = Math.max(0.8, +(snapped - t).toFixed(3)); out.push({ ...s, start: +t.toFixed(3), duration: dur }); t = +(t + dur).toFixed(3); } return out; } /** 列出目录下的媒体文件 */ export function listMedia(dir, exts = ['.mp4', '.mov', '.mkv', '.webm']) { if (!existsSync(dir)) return []; return readdirSync(dir) .filter((f) => exts.includes(f.slice(f.lastIndexOf('.')).toLowerCase())) .sort() .map((f) => join(dir, f)); }