|
|
@@ -0,0 +1,435 @@
|
|
|
+// 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));
|
|
|
+}
|
|
|
+
|