| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435 |
- // 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));
- }
|