f3253d0Takes: record, organize, and edit explainer videosJeremy Magland 1// ffmpeg / ffprobe helpers.
3import { spawn } from 'node:child_process';
4import fs from 'node:fs/promises';
5import type { Peaks } from '../shared/types.ts';
6import { writeJsonAtomic } from './store.ts';
8export function run(cmd: string, args: string[], opts: { onStdout?: (chunk: string) => void } = {}): Promise<{ stdout: string; stderr: string }> {
9 return new Promise((resolve, reject) => {
10 const p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] });
11 let stdout = '';
12 let stderr = '';
13 p.stdout.setEncoding('utf8');
14 p.stderr.setEncoding('utf8');
15 p.stdout.on('data', (d: string) => {
16 if (opts.onStdout) opts.onStdout(d);
17 else stdout += d;
18 });
19 p.stderr.on('data', (d: string) => {
20 stderr += d;
21 if (stderr.length > 200_000) stderr = stderr.slice(-100_000);
22 });
23 p.on('error', reject);
24 p.on('close', (code) => {
25 if (code === 0) resolve({ stdout, stderr });
26 else reject(new Error(`${cmd} exited with code ${code}: ${stderr.trim().split('\n').slice(-8).join('\n')}`));
27 });
28 });
29}
31export interface ProbeResult {
32 duration: number;
33 width?: number;
34 height?: number;
35 hasAudio: boolean;
36}
38export async function probe(file: string): Promise<ProbeResult> {
39 const { stdout } = await run('ffprobe', ['-v', 'error', '-print_format', 'json', '-show_format', '-show_streams', file]);
40 const info = JSON.parse(stdout);
41 const streams: { codec_type: string; width?: number; height?: number }[] = info.streams ?? [];
42 const v = streams.find((s) => s.codec_type === 'video');
43 return {
44 duration: parseFloat(info.format?.duration ?? '0') || 0,
45 width: v?.width,
46 height: v?.height,
47 hasAudio: streams.some((s) => s.codec_type === 'audio'),
48 };
49}
51// Browser MediaRecorder output has no duration or seek index; a stream copy fixes both.
52export async function remux(src: string, dst: string) {
53 await run('ffmpeg', ['-y', '-v', 'error', '-fflags', '+genpts', '-i', src, '-c', 'copy', dst]);
54}
56export async function extractFrame(file: string, t: number, dst: string, width = 640) {
57 await run('ffmpeg', ['-y', '-v', 'error', '-ss', t.toFixed(3), '-i', file, '-frames:v', '1', '-vf', `scale=${width}:-2`, '-q:v', '3', dst]);
58}
60export async function extractAudio(file: string, dst: string) {
61 await run('ffmpeg', ['-y', '-v', 'error', '-i', file, '-vn', '-ac', '1', '-ar', '16000', '-c:a', 'libopus', '-b:a', '32k', dst]);
62}
64// Peak amplitude per 1/rate seconds, sqrt-scaled to 0..255 so quiet speech is still visible.
65export function computePeaks(file: string, dst: string, rate = 50): Promise<void> {
66 const sr = 8000;
67 const win = sr / rate;
68 return new Promise((resolve, reject) => {
69 const p = spawn('ffmpeg', ['-v', 'error', '-i', file, '-vn', '-ac', '1', '-ar', String(sr), '-f', 's16le', '-'], { stdio: ['ignore', 'pipe', 'pipe'] });
70 const out: number[] = [];
71 let cur = 0;
72 let n = 0;
73 let leftover: Buffer | null = null;
74 p.stdout.on('data', (chunk: Buffer) => {
75 const buf = leftover ? Buffer.concat([leftover, chunk]) : chunk;
76 const usable = buf.length - (buf.length % 2);
77 leftover = usable < buf.length ? buf.subarray(usable) : null;
78 for (let i = 0; i < usable; i += 2) {
79 const a = Math.abs(buf.readInt16LE(i));
80 if (a > cur) cur = a;
81 if (++n >= win) {
82 out.push(Math.round(255 * Math.sqrt(cur / 32768)));
83 cur = 0;
84 n = 0;
85 }
86 }
87 });
88 let stderr = '';
89 p.stderr.on('data', (d) => (stderr += d));
90 p.on('error', reject);
91 p.on('close', async (code) => {
92 if (code !== 0) return reject(new Error(`ffmpeg peaks failed: ${stderr.slice(-500)}`));
93 if (n > 0) out.push(Math.round(255 * Math.sqrt(cur / 32768)));
94 const peaks: Peaks = { rate, data: Buffer.from(Uint8Array.from(out)).toString('base64') };
95 try {
96 await writeJsonAtomic(dst, peaks);
97 resolve();
98 } catch (e) {
99 reject(e);
100 }
101 });
102 });
103}
105export async function fileSize(p: string) {
106 return (await fs.stat(p)).size;
107}