Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 3 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@hyperframes/engine": "workspace:*",
"@hyperframes/gcp-cloud-run": "workspace:*",
"@hyperframes/lint": "workspace:*",
"@hyperframes/parsers": "workspace:*",
"@hyperframes/producer": "workspace:*",
"@hyperframes/studio": "workspace:*",
"@hyperframes/studio-server": "workspace:*",
Expand Down
73 changes: 20 additions & 53 deletions packages/cli/src/browser/ffmpeg.test.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,38 @@
import { execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { findFFmpeg, findFFprobe } from "./ffmpeg.js";

// Only child_process is mocked: the H264 encoder probe shells out, while the
// wrapper tests below resolve via env overrides and need the real `existsSync`.
vi.mock("node:child_process", () => ({ execFileSync: vi.fn(), execSync: vi.fn() }));
vi.mock("node:fs", () => ({ existsSync: vi.fn() }));

const mockExec = vi.mocked(execSync);
const mockExecFile = vi.mocked(execFileSync);
const mockExists = vi.mocked(existsSync);

// The common-dir fallback list is platform-gated (empty on win32), so pin the
// platform to a POSIX value to keep the test deterministic on Windows CI.
const originalPlatform = process.platform;
beforeEach(() => {
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
vi.resetModules();
});

afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true });
vi.clearAllMocks();
delete process.env.HYPERFRAMES_FFMPEG_PATH;
delete process.env.HYPERFRAMES_FFPROBE_PATH;
});

describe("findFFmpeg", () => {
it("prefers the real Windows exe when where lists a cmd shim first", async () => {
Object.defineProperty(process, "platform", { value: "win32", configurable: true });
mockExec.mockReturnValue("C:\\tools\\ffmpeg.cmd\r\nC:\\tools\\ffmpeg.exe\r\n");

const { findFFmpeg } = await import("./ffmpeg.js");
expect(findFFmpeg()).toBe(resolve("C:\\tools\\ffmpeg.exe"));
});

it("falls back to a common install dir when `which` fails (GUI-launched PATH)", async () => {
// Simulate a process whose PATH lacks /opt/homebrew/bin: `which ffmpeg` throws.
mockExec.mockImplementation(() => {
throw new Error("which: no ffmpeg in PATH");
});
mockExists.mockImplementation((p) => p === "/opt/homebrew/bin/ffmpeg");

const { findFFmpeg } = await import("./ffmpeg.js");
expect(findFFmpeg()).toBe("/opt/homebrew/bin/ffmpeg");
});

it("returns undefined when ffmpeg is on neither PATH nor a common dir", async () => {
mockExec.mockImplementation(() => {
throw new Error("not found");
});
mockExists.mockReturnValue(false);
// Lookup mechanics (PATH scan, common-dir fallback, Windows shim preference)
// are covered by @hyperframes/parsers ffBinaries.test.ts. These tests pin the
// CLI wrapper's contract: a configured-but-missing override means "not found"
// so callers surface the install hint instead of a spawn error.
describe("findFFmpeg / findFFprobe", () => {
it("returns undefined when the env override points at a missing file", () => {
process.env.HYPERFRAMES_FFMPEG_PATH = join(tmpdir(), "missing-ffmpeg");
process.env.HYPERFRAMES_FFPROBE_PATH = join(tmpdir(), "missing-ffprobe");

const { findFFmpeg } = await import("./ffmpeg.js");
expect(findFFmpeg()).toBeUndefined();
expect(findFFprobe()).toBeUndefined();
});

it("finds project-local FFmpeg binaries when they are not on PATH", async () => {
mockExec.mockImplementation(() => {
throw new Error("not found");
});
const localFFmpeg = resolve(".hyperframes", "bin", "ffmpeg");
const localFFprobe = resolve(".hyperframes", "bin", "ffprobe");
mockExists.mockImplementation((path) => path === localFFmpeg || path === localFFprobe);
it("returns the configured path when the env override exists", () => {
process.env.HYPERFRAMES_FFMPEG_PATH = process.execPath;

const { findFFmpeg, findFFprobe } = await import("./ffmpeg.js");
expect(findFFmpeg()).toBe(localFFmpeg);
expect(findFFprobe()).toBe(localFFprobe);
expect(findFFmpeg()).toBe(process.execPath);
});
});

Expand Down
85 changes: 8 additions & 77 deletions packages/cli/src/browser/ffmpeg.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
// fallow-ignore-file code-duplication
import { execFileSync, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { execFileSync } from "node:child_process";
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
import { detectLinuxDistro, ffmpegInstallCommand } from "./linuxDeps.js";

export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
export { FFMPEG_PATH_ENV, FFPROBE_PATH_ENV } from "@hyperframes/parsers/ff-binaries";

export type H264EncoderMode = "software" | "gpu";

Expand Down Expand Up @@ -35,81 +32,15 @@ export function detectH264EncoderMode(ffmpegPath: string, gpuRequested: boolean)
return resolveH264EncoderMode(encoders, gpuRequested);
}

function chooseBestPathCandidate(
name: "ffmpeg" | "ffprobe",
candidates: string[],
): string | undefined {
const normalized = candidates.map((s) => s.trim()).filter(Boolean);
if (normalized.length === 0) return undefined;
const lowerName = name.toLowerCase();
const preferredExe = normalized.find((candidate) =>
candidate.toLowerCase().endsWith(`${lowerName}.exe`),
);
if (preferredExe) return preferredExe;
const exact = normalized.find((candidate) => candidate.toLowerCase().endsWith(lowerName));
if (exact) return exact;
const nonShellShim = normalized.find((candidate) => {
const lower = candidate.toLowerCase();
return !lower.endsWith(".cmd") && !lower.endsWith(".bat");
});
return nonShellShim ?? normalized[0];
}

function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
try {
const cmd = process.platform === "win32" ? `where ${name}` : `which ${name}`;
const output = execSync(cmd, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
});
const candidate = chooseBestPathCandidate(name, output.split(/\r?\n/));
return candidate ? resolve(candidate) : undefined;
} catch {
return undefined;
}
}

// GUI/Dock/launchd-spawned processes on macOS don't inherit the shell PATH, so
// `which ffmpeg` fails even when ffmpeg is installed via Homebrew. Probe the
// well-known install dirs as a fallback. (No-op on Windows, where `where` and
// installer-added PATH entries cover it.)
const COMMON_BIN_DIRS =
process.platform === "win32"
? []
: ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/snap/bin"];

function findInCommonDirs(name: "ffmpeg" | "ffprobe"): string | undefined {
for (const dir of COMMON_BIN_DIRS) {
const candidate = `${dir}/${name}`;
if (existsSync(candidate)) return candidate;
}
return undefined;
}

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

function findConfiguredBinary(
envName: string,
binaryName: "ffmpeg" | "ffprobe",
): string | undefined {
const configured = process.env[envName]?.trim();
if (configured) return existsSync(configured) ? resolve(configured) : undefined;
return (
findOnPath(binaryName) ?? findInProjectLocalBin(binaryName) ?? findInCommonDirs(binaryName)
);
}

// `configuredMustExist`: the CLI surfaces an install hint when a binary is
// missing, so an env override pointing at a nonexistent file reports as
// not-found instead of being handed to spawn.
export function findFFmpeg(): string | undefined {
return findConfiguredBinary(FFMPEG_PATH_ENV, "ffmpeg");
return findFfBinary("ffmpeg", { configuredMustExist: true });
}

export function findFFprobe(): string | undefined {
return findConfiguredBinary(FFPROBE_PATH_ENV, "ffprobe");
return findFfBinary("ffprobe", { configuredMustExist: true });
}

export function getFFmpegInstallHint(): string {
Expand Down
1 change: 1 addition & 0 deletions packages/engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hyperframes/core": "workspace:^",
"@hyperframes/parsers": "workspace:^",
"hono": "^4.6.0",
"linkedom": "^0.18.12",
"puppeteer": "^25.2.1",
Expand Down
38 changes: 1 addition & 37 deletions packages/engine/src/utils/ffmpegBinaries.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
// fallow-ignore-file code-duplication
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
assertConfiguredFfmpegBinariesExist,
Expand Down Expand Up @@ -48,39 +45,6 @@ describe("ffmpeg binary env resolution", () => {
expect(() => assertConfiguredFfmpegBinariesExist()).not.toThrow();
});

it("prefers the real Windows exe when PATH lookup lists a cmd shim first", async () => {
vi.resetModules();
vi.doMock("child_process", () => ({
execFileSync: () => "C:\\tools\\ffmpeg.cmd\r\nC:\\tools\\ffmpeg.exe\r\n",
}));

const { getFfmpegBinary: getMockedFfmpegBinary } = await import("./ffmpegBinaries.js");

expect(getMockedFfmpegBinary()).toBe(resolve("C:\\tools\\ffmpeg.exe"));
});

it("falls back to scanning PATH when which/where fails", async () => {
const binDir = mkdtempSync(join(tmpdir(), "hyperframes-ffmpeg-path-"));
const ffmpegPath = join(binDir, process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg");
const execFileSync = vi.fn(() => {
throw new Error("lookup command failed");
});
writeFileSync(ffmpegPath, "#!/bin/sh\n");
chmodSync(ffmpegPath, 0o755);
process.env.PATH = binDir;
vi.resetModules();
vi.doMock("child_process", () => ({ execFileSync }));

try {
const { getFfmpegBinary: getMockedFfmpegBinary } = await import("./ffmpegBinaries.js");

expect(getMockedFfmpegBinary()).toBe(resolve(ffmpegPath));
expect(execFileSync).toHaveBeenCalledOnce();
} finally {
rmSync(binDir, { force: true, recursive: true });
}
});

it("calls out a mangled replacement character in configured binary paths", () => {
process.env.HYPERFRAMES_FFMPEG_PATH = "/missing/ffmpeg�.exe";

Expand Down
98 changes: 8 additions & 90 deletions packages/engine/src/utils/ffmpegBinaries.ts
Original file line number Diff line number Diff line change
@@ -1,99 +1,17 @@
// fallow-ignore-file code-duplication
import { execFileSync } from "child_process";
import { accessSync, constants, existsSync } from "fs";
import { delimiter, join, resolve } from "path";
import { existsSync } from "fs";
import { FFMPEG_PATH_ENV, FFPROBE_PATH_ENV, findFfBinary } from "@hyperframes/parsers/ff-binaries";

export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH";
export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";

const pathCache = new Map<string, string | undefined>();

function candidateFileName(candidate: string): string {
return candidate.split(/[\\/]/).at(-1)?.toLowerCase() ?? candidate.toLowerCase();
}

function chooseBestPathCandidate(
name: "ffmpeg" | "ffprobe",
candidates: readonly string[],
): string | undefined {
const normalized = candidates.map((candidate) => candidate.trim()).filter(Boolean);
return (
normalized.find((candidate) => candidateFileName(candidate) === `${name}.exe`) ??
normalized.find((candidate) => candidateFileName(candidate) === name) ??
normalized.find((candidate) => !candidateFileName(candidate).match(/\.(cmd|bat)$/i)) ??
normalized[0]
);
}

function scanPath(name: "ffmpeg" | "ffprobe"): string | undefined {
const pathValue = process.env.PATH;
if (!pathValue) return undefined;

const extensions =
process.platform === "win32"
? [
".exe",
...new Set(
(process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
.split(";")
.map((ext) => ext.trim().toLowerCase())
.filter(Boolean),
),
"",
]
: [""];
const candidates: string[] = [];
for (const dir of pathValue.split(delimiter)) {
if (!dir) continue;
for (const ext of extensions) {
const candidate = join(dir, `${name}${ext}`);
if (isExecutablePathCandidate(candidate)) candidates.push(candidate);
}
}
return chooseBestPathCandidate(name, candidates);
}

function isExecutablePathCandidate(candidate: string): boolean {
if (process.platform === "win32") return existsSync(candidate);
try {
accessSync(candidate, constants.X_OK);
return true;
} catch {
return false;
}
}

function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined {
if (pathCache.has(name)) return pathCache.get(name);
let found: string | undefined;
try {
const command = process.platform === "win32" ? "where" : "which";
const output = execFileSync(command, [name], {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
});
found = chooseBestPathCandidate(name, output.split(/\r?\n/));
} catch {
found = scanPath(name);
}
const resolved = found ? resolve(found) : undefined;
pathCache.set(name, resolved);
return resolved;
}

function getConfiguredBinary(envName: string, binaryName: "ffmpeg" | "ffprobe"): string {
const configured = process.env[envName]?.trim();
if (configured) return resolve(configured);
return findOnPath(binaryName) ?? binaryName;
}
export { FFMPEG_PATH_ENV, FFPROBE_PATH_ENV };

// The engine hands spawn a bare binary name as the last resort so the spawn
// error names what the user must install; a configured-but-missing override
// is surfaced separately by assertConfiguredFfmpegBinariesExist below.
export function getFfmpegBinary(): string {
return getConfiguredBinary(FFMPEG_PATH_ENV, "ffmpeg");
return findFfBinary("ffmpeg") ?? "ffmpeg";
}

export function getFfprobeBinary(): string {
return getConfiguredBinary(FFPROBE_PATH_ENV, "ffprobe");
return findFfBinary("ffprobe") ?? "ffprobe";
}

export function assertConfiguredFfmpegBinariesExist(): void {
Expand Down
Loading
Loading