From 7980479083f3dcc061fc8b8306e2e4870d292eac Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 16 Jul 2026 19:10:04 -0400 Subject: [PATCH] fix: preserve shared FFmpeg resolver behavior --- packages/parsers/src/ffBinaries.test.ts | 32 +++++++++++++++++++ packages/parsers/src/ffBinaries.ts | 16 +++++++--- .../studio-server/src/helpers/waveform.ts | 11 ++----- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/packages/parsers/src/ffBinaries.test.ts b/packages/parsers/src/ffBinaries.test.ts index 858750bac..1c1bd54eb 100644 --- a/packages/parsers/src/ffBinaries.test.ts +++ b/packages/parsers/src/ffBinaries.test.ts @@ -108,6 +108,38 @@ describe("findFfBinary", () => { expect(findFfBinary("ffmpeg")).toBe(resolve("/opt/homebrew/bin/ffmpeg")); }); + it("falls back to the project-local .hyperframes bin", async () => { + delete process.env.HYPERFRAMES_FFMPEG_PATH; + process.env.PATH = ""; + const projectBinary = resolve( + ".hyperframes", + "bin", + process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg", + ); + vi.resetModules(); + vi.doMock("node:child_process", () => { + const mocked = { + execFileSync: () => { + throw new Error("not found"); + }, + }; + return { ...mocked, default: mocked }; + }); + vi.doMock("node:fs", () => { + const mocked = { + existsSync: (candidate: unknown) => candidate === projectBinary, + accessSync: () => { + throw new Error("not executable"); + }, + constants: { X_OK: 1 }, + }; + return { ...mocked, default: mocked }; + }); + const { findFfBinary } = await importFresh(); + + expect(findFfBinary("ffmpeg")).toBe(projectBinary); + }); + it("returns undefined when the binary is nowhere, and caches the miss until cleared", async () => { delete process.env.HYPERFRAMES_FFMPEG_PATH; Object.defineProperty(process, "platform", { value: "linux", configurable: true }); diff --git a/packages/parsers/src/ffBinaries.ts b/packages/parsers/src/ffBinaries.ts index 1f2966630..c0623ef32 100644 --- a/packages/parsers/src/ffBinaries.ts +++ b/packages/parsers/src/ffBinaries.ts @@ -4,7 +4,7 @@ import { delimiter, join, resolve } from "node:path"; /** * Shared FFmpeg/FFprobe binary resolution for every package that shells out - * to them (engine, cli, lint). Node-only: import via the + * to them (engine, cli, lint, studio-server). Node-only: import via the * `@hyperframes/parsers/ff-binaries` subpath, never from a browser bundle. */ @@ -92,6 +92,12 @@ function findInCommonDirs(name: FfBinaryName): string | undefined { return undefined; } +function findInProjectLocalBin(name: FfBinaryName): string | undefined { + const extension = process.platform === "win32" ? ".exe" : ""; + const candidate = resolve(".hyperframes", "bin", `${name}${extension}`); + return existsSync(candidate) ? candidate : undefined; +} + function lookupOnSystem(name: FfBinaryName): string | undefined { if (pathLookupCache.has(name)) return pathLookupCache.get(name); let found: string | undefined; @@ -106,6 +112,7 @@ function lookupOnSystem(name: FfBinaryName): string | undefined { } catch { found = scanPath(name); } + found ??= findInProjectLocalBin(name); found ??= findInCommonDirs(name); const resolved = found ? resolve(found) : undefined; pathLookupCache.set(name, resolved); @@ -125,9 +132,10 @@ export interface FindFfBinaryOptions { /** * Resolve an FFmpeg-family binary: env override first, then `which`/`where`, - * then a manual PATH scan (covers Windows PATHEXT), then well-known Unix - * install dirs. System lookups are cached per binary for the process - * lifetime; the env override is re-read on every call. + * then a manual PATH scan (covers Windows PATHEXT), a project-local + * `.hyperframes/bin`, then well-known Unix install dirs. System lookups are + * cached per binary for the process lifetime; the env override is re-read on + * every call. */ export function findFfBinary( name: FfBinaryName, diff --git a/packages/studio-server/src/helpers/waveform.ts b/packages/studio-server/src/helpers/waveform.ts index 03f91a244..e2f972db1 100644 --- a/packages/studio-server/src/helpers/waveform.ts +++ b/packages/studio-server/src/helpers/waveform.ts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import { existsSync, writeFileSync, mkdirSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { join } from "node:path"; +import { findFfBinary } from "@hyperframes/parsers/ff-binaries"; const SAMPLE_RATE = 4000; const PEAK_COUNT = 4000; @@ -28,16 +29,10 @@ function computePeaks(floats: Float32Array, count: number): number[] { return peaks.map((p) => p / maxPeak); } -function ffmpegBinary(): string { - const configured = process.env.HYPERFRAMES_FFMPEG_PATH?.trim(); - if (configured) return resolve(configured); - return "ffmpeg"; -} - export function decodeAudioPeaks(audioPath: string): Promise { return new Promise((resolvePromise, reject) => { const proc = spawn( - ffmpegBinary(), + findFfBinary("ffmpeg") ?? "ffmpeg", [ "-i", audioPath,