// ffmpeg / ffprobe helpers. import { spawn } from 'node:child_process'; import fs from 'node:fs/promises'; import type { Peaks } from '../shared/types.ts'; import { writeJsonAtomic } from './store.ts'; export function run(cmd: string, args: string[], opts: { onStdout?: (chunk: string) => void } = {}): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; p.stdout.setEncoding('utf8'); p.stderr.setEncoding('utf8'); p.stdout.on('data', (d: string) => { if (opts.onStdout) opts.onStdout(d); else stdout += d; }); p.stderr.on('data', (d: string) => { stderr += d; if (stderr.length > 200_000) stderr = stderr.slice(-100_000); }); p.on('error', reject); p.on('close', (code) => { if (code === 0) resolve({ stdout, stderr }); else reject(new Error(`${cmd} exited with code ${code}: ${stderr.trim().split('\n').slice(-8).join('\n')}`)); }); }); } export interface ProbeResult { duration: number; width?: number; height?: number; hasAudio: boolean; } export async function probe(file: string): Promise { const { stdout } = await run('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file]); const info = JSON.parse(stdout); const streams: { codec_type: string; width?: number; height?: number }[] = info.streams ?? []; const v = streams.find((s) => s.codec_type === 'video'); return { duration: parseFloat(info.format?.duration ?? '0') || 0, width: v?.width, height: v?.height, hasAudio: streams.some((s) => s.codec_type === 'audio'), }; } // Browser MediaRecorder output has no duration or seek index; a stream copy fixes both. export async function remux(src: string, dst: string) { await run('ffmpeg', ['-y', '-v', 'error', '-fflags', '+genpts', '-i', src, '-c', 'copy', dst]); } export async function extractFrame(file: string, t: number, dst: string, width = 640) { await run('ffmpeg', ['-y', '-v', 'error', '-ss', t.toFixed(3), '-i', file, '-frames:v', '1', '-vf', `scale=${width}:-2`, '-q:v', '3', dst]); } export async function extractAudio(file: string, dst: string) { await run('ffmpeg', ['-y', '-v', 'error', '-i', file, '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'libopus', '-b:a', '32k', dst]); } // Peak amplitude per 1/rate seconds, sqrt-scaled to 0..255 so quiet speech is still visible. export function computePeaks(file: string, dst: string, rate = 50): Promise { const sr = 8000; const win = sr / rate; return new Promise((resolve, reject) => { const p = spawn('ffmpeg', ['-v', 'error', '-i', file, '-vn', '-ac', '1', '-ar', String(sr), '-f', 's16le', '-'], { stdio: ['ignore', 'pipe', 'pipe'] }); const out: number[] = []; let cur = 0; let n = 0; let leftover: Buffer | null = null; p.stdout.on('data', (chunk: Buffer) => { const buf = leftover ? Buffer.concat([leftover, chunk]) : chunk; const usable = buf.length - (buf.length % 2); leftover = usable < buf.length ? buf.subarray(usable) : null; for (let i = 0; i < usable; i += 2) { const a = Math.abs(buf.readInt16LE(i)); if (a > cur) cur = a; if (++n >= win) { out.push(Math.round(255 * Math.sqrt(cur / 32768))); cur = 0; n = 0; } } }); let stderr = ''; p.stderr.on('data', (d) => (stderr += d)); p.on('error', reject); p.on('close', async (code) => { if (code !== 0) return reject(new Error(`ffmpeg peaks failed: ${stderr.slice(-500)}`)); if (n > 0) out.push(Math.round(255 * Math.sqrt(cur / 32768))); const peaks: Peaks = { rate, data: Buffer.from(Uint8Array.from(out)).toString('base64') }; try { await writeJsonAtomic(dst, peaks); resolve(); } catch (e) { reject(e); } }); }); } export async function fileSize(p: string) { return (await fs.stat(p)).size; }