ffmpeg.mjs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435
  1. // lib/ffmpeg.mjs — ffmpeg / ffprobe 封装
  2. //
  3. // 只用系统 ffmpeg(/usr/bin/ffmpeg)。零 npm 依赖。
  4. // 所有对外部命令的调用都走 run(),统一超时、统一错误信息(带 stderr 尾部)。
  5. import { spawn, spawnSync } from 'node:child_process';
  6. import { mkdirSync, existsSync, writeFileSync, readdirSync, statSync } from 'node:fs';
  7. import { dirname, join, resolve, basename } from 'node:path';
  8. import { safeEnv } from './config.mjs';
  9. export const FFMPEG = process.env.FFMPEG_BIN || 'ffmpeg';
  10. export const FFPROBE = process.env.FFPROBE_BIN || 'ffprobe';
  11. /** 检查 ffmpeg / ffprobe 是否可用 */
  12. export function checkFfmpeg() {
  13. const out = {};
  14. for (const [name, bin] of [
  15. ['ffmpeg', FFMPEG],
  16. ['ffprobe', FFPROBE],
  17. ]) {
  18. const r = spawnSync(bin, ['-version'], { encoding: 'utf8', timeout: 15_000 });
  19. out[name] = r.status === 0
  20. ? { available: true, version: (r.stdout || '').split('\n')[0].trim() }
  21. : { available: false, version: null, error: (r.stderr || '').split('\n')[0] };
  22. }
  23. return out;
  24. }
  25. export function requireFfmpeg() {
  26. const c = checkFfmpeg();
  27. if (!c.ffmpeg.available || !c.ffprobe.available) {
  28. throw new Error(
  29. '缺少系统依赖 ffmpeg / ffprobe。\n' +
  30. ' Ubuntu/Debian: sudo apt-get install -y ffmpeg\n' +
  31. ' macOS: brew install ffmpeg\n' +
  32. '(当前 ffmpeg: ' + (c.ffmpeg.version || '未找到') + ')'
  33. );
  34. }
  35. return c;
  36. }
  37. /**
  38. * 执行 ffmpeg(或任意命令)。
  39. * @param {string[]} args
  40. * @param {{bin?:string, timeout?:number, label?:string, cwd?:string}} opts
  41. */
  42. export function run(args, opts = {}) {
  43. const { bin = FFMPEG, timeout = 30 * 60_000, label = 'ffmpeg', cwd } = opts;
  44. return new Promise((resolvePromise, reject) => {
  45. const p = spawn(bin, args, { cwd, env: safeEnv() });
  46. let stderr = '';
  47. let stdout = '';
  48. const timer = setTimeout(() => {
  49. p.kill('SIGKILL');
  50. reject(new Error(`${label} 超时(${Math.round(timeout / 1000)}s)`));
  51. }, timeout);
  52. p.stdout.on('data', (d) => (stdout += d));
  53. p.stderr.on('data', (d) => {
  54. stderr += d;
  55. if (stderr.length > 200_000) stderr = stderr.slice(-100_000);
  56. });
  57. p.on('error', (e) => {
  58. clearTimeout(timer);
  59. reject(new Error(`${label} 启动失败:${e.message}(未安装 ${bin}?)`));
  60. });
  61. p.on('close', (code) => {
  62. clearTimeout(timer);
  63. if (code === 0) return resolvePromise({ stdout, stderr });
  64. const tail = stderr.split('\n').filter(Boolean).slice(-8).join('\n ');
  65. reject(new Error(`${label} 退出码 ${code}:\n ${tail}`));
  66. });
  67. });
  68. }
  69. /** ffprobe 读取媒体信息 */
  70. export async function probe(file) {
  71. const { stdout } = await run(
  72. ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file],
  73. { bin: FFPROBE, label: 'ffprobe', timeout: 60_000 }
  74. );
  75. const j = JSON.parse(stdout);
  76. const v = (j.streams || []).find((s) => s.codec_type === 'video');
  77. const a = (j.streams || []).find((s) => s.codec_type === 'audio');
  78. const num = (x) => (x == null || x === 'N/A' ? null : Number(x));
  79. return {
  80. raw: j,
  81. duration: num(j.format?.duration) ?? num(v?.duration) ?? 0,
  82. size: num(j.format?.size) ?? (existsSync(file) ? statSync(file).size : 0),
  83. hasVideo: !!v,
  84. hasAudio: !!a,
  85. width: num(v?.width),
  86. height: num(v?.height),
  87. fps: parseFps(v?.r_frame_rate),
  88. vcodec: v?.codec_name || null,
  89. acodec: a?.codec_name || null,
  90. sampleRate: num(a?.sample_rate),
  91. channels: num(a?.channels),
  92. };
  93. }
  94. function parseFps(r) {
  95. if (!r || r === 'N/A') return null;
  96. const [a, b] = String(r).split('/').map(Number);
  97. if (!b) return a || null;
  98. return +(a / b).toFixed(3);
  99. }
  100. export function ensureDir(dir) {
  101. mkdirSync(dir, { recursive: true });
  102. return dir;
  103. }
  104. export function fileSize(p) {
  105. try {
  106. return statSync(p).size;
  107. } catch {
  108. return 0;
  109. }
  110. }
  111. export function humanSize(bytes) {
  112. if (!bytes) return '0B';
  113. const u = ['B', 'KB', 'MB', 'GB'];
  114. let i = 0;
  115. let n = bytes;
  116. while (n >= 1024 && i < u.length - 1) {
  117. n /= 1024;
  118. i++;
  119. }
  120. return `${n.toFixed(n < 10 && i > 0 ? 1 : 0)}${u[i]}`;
  121. }
  122. // ==================== 音视频常用操作 ====================
  123. /** 从视频中提取音频 */
  124. export async function extractAudio(input, output, { format = 'wav', sampleRate = 44100, channels = 2 } = {}) {
  125. ensureDir(dirname(output));
  126. await run([
  127. '-y', '-i', input,
  128. '-vn',
  129. '-acodec', format === 'wav' ? 'pcm_s16le' : format === 'mp3' ? 'libmp3lame' : 'aac',
  130. '-ar', String(sampleRate), '-ac', String(channels),
  131. output,
  132. ], { label: 'extractAudio' });
  133. return output;
  134. }
  135. /** 把音频合到视频上(替换音轨),支持淡出与时长裁剪 */
  136. export async function muxAudio(video, audio, output, { duration, fadeOut = 0, audioBitrate = '192k', volume = 1 } = {}) {
  137. ensureDir(dirname(output));
  138. const filters = [];
  139. if (volume !== 1) filters.push(`volume=${volume}`);
  140. if (fadeOut > 0 && duration) {
  141. filters.push(`afade=t=out:st=${Math.max(0, duration - fadeOut).toFixed(3)}:d=${fadeOut}`);
  142. }
  143. const af = filters.length ? ['-af', filters.join(',')] : [];
  144. const args = [
  145. '-y', '-i', video, '-i', audio,
  146. '-map', '0:v:0', '-map', '1:a:0',
  147. '-c:v', 'copy',
  148. '-c:a', 'aac', '-b:a', audioBitrate,
  149. ...af,
  150. ...(duration ? ['-t', String(duration)] : []),
  151. '-shortest',
  152. output,
  153. ];
  154. await run(args, { label: 'muxAudio' });
  155. return output;
  156. }
  157. /** 拼接多段视频(统一重编码,避免参数不一致导致 concat 失败) */
  158. export async function concat(clips, output, { width = 1280, height = 720, fps = 30, transition = 0, withAudio = false } = {}) {
  159. ensureDir(dirname(output));
  160. if (!clips.length) throw new Error('concat 需要至少一个片段');
  161. if (clips.length === 1) {
  162. await run(['-y', '-i', clips[0], '-c', 'copy', output], { label: 'concat(single)' });
  163. return output;
  164. }
  165. const args = ['-y'];
  166. for (const c of clips) args.push('-i', c);
  167. 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}`;
  168. if (transition > 0) {
  169. // xfade 交叉溶解
  170. let filter = '';
  171. let prev = '[0:v]';
  172. for (let i = 1; i < clips.length; i++) {
  173. const off = i * (transition > 0 ? 0.5 : 0);
  174. const out = i === clips.length - 1 ? '[vout]' : `[v${i}]`;
  175. filter += `${prev}[${i}:v]xfade=transition=fade:duration=${transition}:offset=${off}${out};`;
  176. prev = out;
  177. }
  178. const mapArgs = ['-map', '[vout]', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '18'];
  179. await run([...args, '-filter_complex', filter.replace(/;$/, ''), ...mapArgs, output], { label: 'concat(xfade)' });
  180. return output;
  181. }
  182. 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]`;
  183. await run(
  184. [...args, '-filter_complex', fc, '-map', '[vout]', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '18', output],
  185. { label: 'concat' }
  186. );
  187. return output;
  188. }
  189. /** 给视频烧硬字幕(SRT / ASS) */
  190. export async function burnSubtitles(video, subtitleFile, output, { fontSize = 20, marginV = 28, fontName = 'Noto Sans CJK SC' } = {}) {
  191. ensureDir(dirname(output));
  192. const isAss = subtitleFile.toLowerCase().endsWith('.ass');
  193. const style = `FontName=${fontName},FontSize=${fontSize},PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,BorderStyle=1,Outline=2,Shadow=0,Alignment=2,MarginV=${marginV}`;
  194. const esc = subtitleFile.replace(/\\/g, '/').replace(/:/g, '\\:').replace(/'/g, "\\'");
  195. const vf = isAss ? `ass='${esc}'` : `subtitles='${esc}':force_style='${style}'`;
  196. await run(['-y', '-i', video, '-vf', vf, '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '18', '-c:a', 'copy', output], {
  197. label: 'burnSubtitles',
  198. });
  199. return output;
  200. }
  201. /** 静帧 + Ken Burns 运镜(视频通道未开通时的兜底动态镜头) */
  202. export async function kenBurns(image, output, { duration = 5, width = 1280, height = 720, fps = 30, zoom = 0.12, direction = 'in' } = {}) {
  203. ensureDir(dirname(output));
  204. const frames = Math.max(1, Math.round(duration * fps));
  205. const zEnd = direction === 'out' ? 1 + zoom : 1 + zoom;
  206. const zStart = direction === 'out' ? 1 + zoom : 1;
  207. // zoompan 在 4K 画布上运算以获得平滑慢推
  208. const pre = `scale=${width * 2}:${height * 2}:force_original_aspect_ratio=increase,crop=${width * 2}:${height * 2}`;
  209. const zp =
  210. `zoompan=z='${zStart}+(${zEnd}-${zStart})*on/${frames}':` +
  211. `x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':d=${frames}:s=${width}x${height}:fps=${fps}`;
  212. await run(
  213. ['-y', '-loop', '1', '-i', image, '-t', String(duration), '-vf', `${pre},${zp},format=yuv420p`, '-r', String(fps), '-c:v', 'libx264', '-crf', '19', output],
  214. { label: 'kenBurns' }
  215. );
  216. return output;
  217. }
  218. /** 纯色背景 + 音频(无画面素材时的占位成片) */
  219. export async function colorWithAudio(audio, output, { duration, width = 1280, height = 720, color = 'black', fps = 30 } = {}) {
  220. ensureDir(dirname(output));
  221. await run(
  222. [
  223. '-y', '-f', 'lavfi', '-i', `color=c=${color}:s=${width}x${height}:r=${fps}`,
  224. '-i', audio,
  225. '-map', '0:v', '-map', '1:a',
  226. ...(duration ? ['-t', String(duration)] : []),
  227. '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-shortest', output,
  228. ],
  229. { label: 'colorWithAudio' }
  230. );
  231. return output;
  232. }
  233. /** 生成静音音轨 */
  234. export async function silence(output, duration, { sampleRate = 44100, channels = 2 } = {}) {
  235. ensureDir(dirname(output));
  236. await run(
  237. ['-y', '-f', 'lavfi', '-i', `anullsrc=r=${sampleRate}:cl=${channels === 1 ? 'mono' : 'stereo'}`, '-t', String(duration), '-c:a', 'pcm_s16le', output],
  238. { label: 'silence' }
  239. );
  240. return output;
  241. }
  242. /** 响度归一化(成片听感统一) */
  243. export async function normalizeLoudness(input, output, { target = -14 } = {}) {
  244. ensureDir(dirname(output));
  245. const pass1 = await run(['-y', '-i', input, '-af', `loudnorm=I=${target}:print_format=json`, '-f', 'null', '-'], {
  246. label: 'loudnorm(pass1)',
  247. });
  248. let measured = {};
  249. try {
  250. const m = pass1.stderr.match(/\{[\s\S]*?"target_offset"[\s\S]*?\}/);
  251. if (m) measured = JSON.parse(m[0]);
  252. } catch {
  253. /* 忽略解析失败,退回单遍 */
  254. }
  255. const af = measured.input_i
  256. ? `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`
  257. : `loudnorm=I=${target}:TP=-1.5:LRA=11`;
  258. await run(['-y', '-i', input, '-af', af, '-c:a', 'aac', '-b:a', '192k', output], { label: 'loudnorm(pass2)' });
  259. return output;
  260. }
  261. /** 生成 MP3 伴奏/人声的简易分离(纯 ffmpeg 兜底,见 scripts/separate.mjs 说明) */
  262. export async function separateByChannelPhase(input, { outDir, mode = 'instrumental' } = {}) {
  263. ensureDir(outDir);
  264. const out = join(outDir, `${mode}.wav`);
  265. // 中置声道消除:人声通常在中央,L-R 相减可压低人声 → 近似伴奏
  266. // 中置提取: (L+R)/2 - (L-R)/2 的近似用 pan 实现
  267. const af =
  268. mode === 'instrumental'
  269. ? 'pan=stereo|c0=c0|c1=-c1,volume=1.4' // 侧信号(近似伴奏)
  270. : 'pan=mono|c0=0.5*c0+0.5*c1'; // 中置(近似人声/主唱)
  271. await run(['-y', '-i', input, '-af', af, '-ar', '44100', out], { label: 'separate' });
  272. return out;
  273. }
  274. /** 用 demucs(若安装)做高质量分离 */
  275. export function checkDemucs() {
  276. for (const cmd of ['demucs', 'python3']) {
  277. const r = spawnSync(cmd, cmd === 'python3' ? ['-c', 'import demucs;print(demucs.__version__)'] : ['--help'], {
  278. encoding: 'utf8',
  279. timeout: 30_000,
  280. });
  281. if (r.status === 0) {
  282. if (cmd === 'demucs') return { available: true, cmd: 'demucs', version: (r.stdout || '').split('\n')[0] };
  283. return { available: true, cmd: 'python3 -m demucs', version: (r.stdout || '').trim() };
  284. }
  285. }
  286. return { available: false };
  287. }
  288. /** 用 demucs 分离(可选增强路径) */
  289. export async function separateWithDemucs(input, outDir, { model = 'htdemucs' } = {}) {
  290. ensureDir(outDir);
  291. const d = checkDemucs();
  292. if (!d.available) throw new Error('未检测到 demucs(可选依赖,pip install demucs)');
  293. const args = d.cmd === 'demucs' ? ['-n', model, '-o', outDir, input] : ['-m', 'demucs', '-n', model, '-o', outDir, input];
  294. const [bin, ...rest] = d.cmd.split(' ');
  295. await run([...rest, ...args], { bin, label: 'demucs', timeout: 30 * 60_000 });
  296. const base = basename(input).replace(/\.[^.]+$/, '');
  297. return {
  298. vocals: join(outDir, model, `${base}.vocals.wav`),
  299. instrumental: join(outDir, model, `${base}.no_vocals.wav`),
  300. drums: join(outDir, model, `${base}.drums.wav`),
  301. bass: join(outDir, model, `${base}.bass.wav`),
  302. other: join(outDir, model, `${base}.other.wav`),
  303. };
  304. }
  305. // ==================== 卡点:能量包络节拍检测(纯 Node,零依赖)====================
  306. /**
  307. * 解码音频为 PCM 并做能量包络节拍检测,返回节拍时间点(秒)。
  308. * 不依赖 aubio 等外部库,够用且稳定。
  309. * @returns {Promise<{bpm:number|null, beats:number[], duration:number}>}
  310. */
  311. export async function detectBeats(audioFile, { sampleRate = 11025, hop = 1024, sensitivity = 1.4 } = {}) {
  312. const tmp = join('/tmp', `fmode-mv-beat-${process.pid}-${Date.now()}.raw`);
  313. await run(
  314. ['-y', '-i', audioFile, '-ac', '1', '-ar', String(sampleRate), '-f', 's16le', '-acodec', 'pcm_s16le', tmp],
  315. { label: 'decode(for beat detection)' }
  316. );
  317. const { readFileSync, unlinkSync } = await import('node:fs');
  318. const buf = readFileSync(tmp);
  319. try { unlinkSync(tmp); } catch { /* ignore */ }
  320. const samples = new Int16Array(buf.buffer, buf.byteOffset, Math.floor(buf.length / 2));
  321. const frames = Math.floor(samples.length / hop);
  322. const energy = new Float64Array(frames);
  323. for (let f = 0; f < frames; f++) {
  324. let sum = 0;
  325. const off = f * hop;
  326. for (let i = 0; i < hop; i++) {
  327. const v = samples[off + i] / 32768;
  328. sum += v * v;
  329. }
  330. energy[f] = Math.sqrt(sum / hop);
  331. }
  332. // 局部均值 + 阈值攻陷 → onset 峰值
  333. const win = 16;
  334. const beats = [];
  335. for (let f = 1; f < frames - 1; f++) {
  336. let sum = 0;
  337. let n = 0;
  338. for (let k = Math.max(0, f - win); k <= Math.min(frames - 1, f + win); k++) {
  339. sum += energy[k];
  340. n++;
  341. }
  342. const local = sum / n;
  343. const th = local * sensitivity;
  344. if (energy[f] > th && energy[f] >= energy[f - 1] && energy[f] > energy[f + 1]) {
  345. const t = (f * hop) / sampleRate;
  346. if (!beats.length || t - beats[beats.length - 1] > 0.18) beats.push(+t.toFixed(3));
  347. }
  348. }
  349. // 由中位间隔估计 BPM
  350. let bpm = null;
  351. if (beats.length > 4) {
  352. const gaps = [];
  353. for (let i = 1; i < beats.length; i++) gaps.push(beats[i] - beats[i - 1]);
  354. gaps.sort((a, b) => a - b);
  355. const med = gaps[Math.floor(gaps.length / 2)];
  356. if (med > 0.2) {
  357. let b = 60 / med;
  358. while (b < 70) b *= 2;
  359. while (b > 180) b /= 2;
  360. bpm = +b.toFixed(1);
  361. }
  362. }
  363. const duration = samples.length / sampleRate;
  364. return { bpm, beats, duration: +duration.toFixed(3) };
  365. }
  366. /**
  367. * 把分镜按节拍对齐:给定每镜时长,吸附到最近的节拍点。
  368. * @param {{beats:number[], duration:number}} beatInfo
  369. * @param {Array<{duration:number}>} shots
  370. */
  371. export function alignToBeats(beatInfo, shots, { offset = 0 } = {}) {
  372. if (!beatInfo?.beats?.length) return shots;
  373. const out = [];
  374. let t = offset;
  375. for (const s of shots) {
  376. let target = t + s.duration;
  377. // 找最近的节拍点
  378. let best = target;
  379. let bestD = Infinity;
  380. for (const b of beatInfo.beats) {
  381. const d = Math.abs(b - target);
  382. if (d < bestD) {
  383. bestD = d;
  384. best = b;
  385. }
  386. }
  387. const snapped = bestD <= 0.6 ? best : target;
  388. const dur = Math.max(0.8, +(snapped - t).toFixed(3));
  389. out.push({ ...s, start: +t.toFixed(3), duration: dur });
  390. t = +(t + dur).toFixed(3);
  391. }
  392. return out;
  393. }
  394. /** 列出目录下的媒体文件 */
  395. export function listMedia(dir, exts = ['.mp4', '.mov', '.mkv', '.webm']) {
  396. if (!existsSync(dir)) return [];
  397. return readdirSync(dir)
  398. .filter((f) => exts.includes(f.slice(f.lastIndexOf('.')).toLowerCase()))
  399. .sort()
  400. .map((f) => join(dir, f));
  401. }