Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 107 additions & 1 deletion packages/parsers/src/ffBinaries.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import {
type PathLike,
chmodSync,
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -173,6 +182,103 @@ describe("findFfBinary", () => {
expect(findFfBinary("ffmpeg")).toBe(projectBinary);
});

function installerProject(
name: "ffmpeg" | "ffprobe",
{ viaInstaller = false, mode = 0o755 } = {},
) {
const project = realpathSync(mkdtempSync(join(tmpdir(), "hf-ff-installer-")));
const platformPackage = `@${name}-installer/${process.platform}-${process.arch}`;
const packageDir = viaInstaller
? join(project, "store", "node_modules", platformPackage)
: join(project, "node_modules", platformPackage);
mkdirSync(packageDir, { recursive: true });
writeFileSync(join(packageDir, "package.json"), "{}");
if (viaInstaller) {
const installerDir = join(project, "store", "node_modules", `@${name}-installer`, name);
mkdirSync(installerDir, { recursive: true });
writeFileSync(join(installerDir, "package.json"), "{}");
mkdirSync(join(project, "node_modules", `@${name}-installer`), { recursive: true });
symlinkSync(
installerDir,
join(project, "node_modules", `@${name}-installer`, name),
"junction",
);
}
const binary = join(packageDir, process.platform === "win32" ? `${name}.exe` : name);
writeFileSync(binary, "");
chmodSync(binary, mode);
return { project, binary };
}

async function findInProjectDir(
project: string,
name: "ffmpeg" | "ffprobe",
systemBinaries: string[] = [],
) {
vi.stubEnv("HYPERFRAMES_FFMPEG_PATH", "");
vi.stubEnv("HYPERFRAMES_FFPROBE_PATH", "");
process.env.PATH = "";
vi.spyOn(process, "cwd").mockReturnValue(project);
vi.resetModules();
vi.doMock("node:fs", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs")>();
const existsSync = (candidate: PathLike) =>
systemBinaries.includes(String(candidate)) ||
(String(candidate).startsWith(project) && actual.existsSync(candidate));
return { ...actual, existsSync, default: { ...actual, existsSync } };
});
vi.doMock("node:child_process", () => {
const mocked = {
execFileSync: () => {
throw new Error("not found");
},
};
return { ...mocked, default: mocked };
});
try {
const { findFfBinary } = await importFresh();
return findFfBinary(name);
} finally {
vi.unstubAllEnvs();
rmSync(project, { recursive: true, force: true });
}
}

it.each(["ffmpeg", "ffprobe"] as const)(
"falls back to the project's @%s-installer package",
async (name) => {
const { project, binary } = installerProject(name);

expect(await findInProjectDir(project, name)).toBe(binary);
},
);

it("finds the platform package next to the installer package (pnpm layout)", async () => {
const { project, binary } = installerProject("ffprobe", { viaInstaller: true });

expect(await findInProjectDir(project, "ffprobe")).toBe(binary);
});

it.skipIf(process.platform === "win32")(
"prefers a system install in a common dir over the installer package",
async () => {
const { project } = installerProject("ffprobe");

expect(await findInProjectDir(project, "ffprobe", ["/usr/bin/ffprobe"])).toBe(
"/usr/bin/ffprobe",
);
},
);

it.skipIf(process.platform === "win32")(
"skips an installer binary that is not executable",
async () => {
const { project } = installerProject("ffprobe", { mode: 0o644 });

expect(await findInProjectDir(project, "ffprobe")).toBeUndefined();
},
);

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 });
Expand Down
41 changes: 33 additions & 8 deletions packages/parsers/src/ffBinaries.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { execFileSync } from "node:child_process";
import { accessSync, constants, existsSync } from "node:fs";
import { delimiter, join, resolve } from "node:path";
import { createRequire } from "node:module";
import { delimiter, dirname, join, resolve } from "node:path";

/**
* Shared FFmpeg/FFprobe binary resolution for every package that shells out
Expand Down Expand Up @@ -87,20 +88,44 @@ const COMMON_BIN_DIRS =
? []
: ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/snap/bin"];

function findInCommonDirs(name: FfBinaryName): string | undefined {
// The npm installer package comes last: its binaries are old builds, so any system install wins.
function findInCommonDirsOrInstallerPackage(name: FfBinaryName): string | undefined {
for (const dir of COMMON_BIN_DIRS) {
const candidate = `${dir}/${name}`;
if (existsSync(candidate)) return candidate;
}
return undefined;
return findInInstallerPackage(name);
}

function binaryFileName(name: FfBinaryName): string {
return process.platform === "win32" ? `${name}.exe` : name;
}

function findInProjectLocalBin(name: FfBinaryName): string | undefined {
const extension = process.platform === "win32" ? ".exe" : "";
const candidate = resolve(".hyperframes", "bin", `${name}${extension}`);
const candidate = resolve(".hyperframes", "bin", binaryFileName(name));
return existsSync(candidate) ? candidate : undefined;
}

function tryResolve(fromFile: string, request: string): string | undefined {
try {
return createRequire(fromFile).resolve(request);
} catch {
return undefined;
}
}

// An @ffmpeg-installer / @ffprobe-installer package reachable from the current folder, located
// but never loaded. The platform package is resolved via the installer package first (pnpm layout).
function findInInstallerPackage(name: FfBinaryName): string | undefined {
const project = resolve("package.json");
const platformManifest = `@${name}-installer/${process.platform}-${process.arch}/package.json`;
const installer = tryResolve(project, `@${name}-installer/${name}/package.json`);
const manifest =
(installer && tryResolve(installer, platformManifest)) ?? tryResolve(project, platformManifest);
const candidate = manifest && join(dirname(manifest), binaryFileName(name));
return candidate && isExecutablePathCandidate(candidate) ? candidate : undefined;
}

function lookupOnSystem(name: FfBinaryName): string | undefined {
if (pathLookupCache.has(name)) return pathLookupCache.get(name);
let found: string | undefined;
Expand All @@ -124,7 +149,7 @@ function lookupOnSystem(name: FfBinaryName): string | undefined {
}
}
found ??= findInProjectLocalBin(name);
found ??= findInCommonDirs(name);
found ??= findInCommonDirsOrInstallerPackage(name);
const resolved = found ? resolve(found) : undefined;
pathLookupCache.set(name, resolved);
return resolved;
Expand All @@ -144,8 +169,8 @@ export interface FindFfBinaryOptions {
/**
* Resolve an FFmpeg-family binary: env override first, then a native
* current-directory/PATH scan on Windows or `which` plus PATH scan on Unix,
* then a project-local
* `.hyperframes/bin`, then well-known Unix install dirs. System lookups are
* then a project-local `.hyperframes/bin`, then well-known Unix install dirs, then an
* `@ffmpeg-installer`/`@ffprobe-installer` package reachable from the current folder. System lookups are
* cached per binary for the process lifetime; the env override is re-read on
* every call.
*/
Expand Down
Loading