From 687ef2e00fa87c77db1194407cae5c190d8955d2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 3 Mar 2026 01:13:39 +0000 Subject: [PATCH] refactor(media): add shared ffmpeg helpers --- src/media/ffmpeg-exec.test.ts | 24 +++++++++++++ src/media/ffmpeg-exec.ts | 63 +++++++++++++++++++++++++++++++++++ src/media/ffmpeg-limits.ts | 4 +++ src/media/temp-files.ts | 12 +++++++ 4 files changed, 103 insertions(+) create mode 100644 src/media/ffmpeg-exec.test.ts create mode 100644 src/media/ffmpeg-exec.ts create mode 100644 src/media/ffmpeg-limits.ts create mode 100644 src/media/temp-files.ts diff --git a/src/media/ffmpeg-exec.test.ts b/src/media/ffmpeg-exec.test.ts new file mode 100644 index 00000000000..9f516f011a9 --- /dev/null +++ b/src/media/ffmpeg-exec.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { parseFfprobeCodecAndSampleRate, parseFfprobeCsvFields } from "./ffmpeg-exec.js"; + +describe("parseFfprobeCsvFields", () => { + it("splits ffprobe csv output across commas and newlines", () => { + expect(parseFfprobeCsvFields("opus,\n48000\n", 2)).toEqual(["opus", "48000"]); + }); +}); + +describe("parseFfprobeCodecAndSampleRate", () => { + it("parses opus codec and numeric sample rate", () => { + expect(parseFfprobeCodecAndSampleRate("Opus,48000\n")).toEqual({ + codec: "opus", + sampleRateHz: 48_000, + }); + }); + + it("returns null sample rate for invalid numeric fields", () => { + expect(parseFfprobeCodecAndSampleRate("opus,not-a-number")).toEqual({ + codec: "opus", + sampleRateHz: null, + }); + }); +}); diff --git a/src/media/ffmpeg-exec.ts b/src/media/ffmpeg-exec.ts new file mode 100644 index 00000000000..1710a9dfbf5 --- /dev/null +++ b/src/media/ffmpeg-exec.ts @@ -0,0 +1,63 @@ +import { execFile, type ExecFileOptions } from "node:child_process"; +import { promisify } from "node:util"; +import { + MEDIA_FFMPEG_MAX_BUFFER_BYTES, + MEDIA_FFMPEG_TIMEOUT_MS, + MEDIA_FFPROBE_TIMEOUT_MS, +} from "./ffmpeg-limits.js"; + +const execFileAsync = promisify(execFile); + +export type MediaExecOptions = { + timeoutMs?: number; + maxBufferBytes?: number; +}; + +function resolveExecOptions( + defaultTimeoutMs: number, + options: MediaExecOptions | undefined, +): ExecFileOptions { + return { + timeout: options?.timeoutMs ?? defaultTimeoutMs, + maxBuffer: options?.maxBufferBytes ?? MEDIA_FFMPEG_MAX_BUFFER_BYTES, + }; +} + +export async function runFfprobe(args: string[], options?: MediaExecOptions): Promise { + const { stdout } = await execFileAsync( + "ffprobe", + args, + resolveExecOptions(MEDIA_FFPROBE_TIMEOUT_MS, options), + ); + return stdout.toString(); +} + +export async function runFfmpeg(args: string[], options?: MediaExecOptions): Promise { + const { stdout } = await execFileAsync( + "ffmpeg", + args, + resolveExecOptions(MEDIA_FFMPEG_TIMEOUT_MS, options), + ); + return stdout.toString(); +} + +export function parseFfprobeCsvFields(stdout: string, maxFields: number): string[] { + return stdout + .trim() + .toLowerCase() + .split(/[,\r\n]+/, maxFields) + .map((field) => field.trim()); +} + +export function parseFfprobeCodecAndSampleRate(stdout: string): { + codec: string | null; + sampleRateHz: number | null; +} { + const [codecRaw, sampleRateRaw] = parseFfprobeCsvFields(stdout, 2); + const codec = codecRaw ? codecRaw : null; + const sampleRate = sampleRateRaw ? Number.parseInt(sampleRateRaw, 10) : Number.NaN; + return { + codec, + sampleRateHz: Number.isFinite(sampleRate) ? sampleRate : null, + }; +} diff --git a/src/media/ffmpeg-limits.ts b/src/media/ffmpeg-limits.ts new file mode 100644 index 00000000000..937345fdd3c --- /dev/null +++ b/src/media/ffmpeg-limits.ts @@ -0,0 +1,4 @@ +export const MEDIA_FFMPEG_MAX_BUFFER_BYTES = 10 * 1024 * 1024; +export const MEDIA_FFPROBE_TIMEOUT_MS = 10_000; +export const MEDIA_FFMPEG_TIMEOUT_MS = 45_000; +export const MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS = 20 * 60; diff --git a/src/media/temp-files.ts b/src/media/temp-files.ts new file mode 100644 index 00000000000..d01bce135d1 --- /dev/null +++ b/src/media/temp-files.ts @@ -0,0 +1,12 @@ +import fs from "node:fs/promises"; + +export async function unlinkIfExists(filePath: string | null | undefined): Promise { + if (!filePath) { + return; + } + try { + await fs.unlink(filePath); + } catch { + // Best-effort cleanup for temp files. + } +}