diff --git a/packages/cli/src/browser/ffmpeg.ts b/packages/cli/src/browser/ffmpeg.ts index c7fa224998..d2ad6384e4 100644 --- a/packages/cli/src/browser/ffmpeg.ts +++ b/packages/cli/src/browser/ffmpeg.ts @@ -1,5 +1,5 @@ import { getCachedGpuEncoder } from "@hyperframes/engine"; -import { findFfBinary } from "@hyperframes/parsers/ff-binaries"; +import { findFfBinary, getSkippedX86Binary } from "@hyperframes/parsers/ff-binaries"; import { detectLinuxDistro, ffmpegInstallCommand } from "./linuxDeps.js"; import { runCancellableProcess } from "../utils/cancellableProcess.js"; @@ -96,6 +96,12 @@ export function getFFmpegInstallCommand(): string | undefined { } export function getFFmpegInstallHint(): string { + const skipped = getSkippedX86Binary(); + const base = installHint(); + return skipped ? `${base} (ignored ${skipped}: x86_64 only, it cannot run on this Mac)` : base; +} + +function installHint(): string { const command = getFFmpegInstallCommand(); // Guarding on `command`, not the platform alone: the function above is the // sole owner of platform-to-command, so the day win32 stops returning one diff --git a/packages/parsers/src/ffBinaries.test.ts b/packages/parsers/src/ffBinaries.test.ts index 6ecec82644..a9da5deae2 100644 --- a/packages/parsers/src/ffBinaries.test.ts +++ b/packages/parsers/src/ffBinaries.test.ts @@ -184,7 +184,7 @@ describe("findFfBinary", () => { function installerProject( name: "ffmpeg" | "ffprobe", - { viaInstaller = false, mode = 0o755 } = {}, + { viaInstaller = false, mode = 0o755, content = "" as string | Buffer } = {}, ) { const project = realpathSync(mkdtempSync(join(tmpdir(), "hf-ff-installer-"))); const platformPackage = `@${name}-installer/${process.platform}-${process.arch}`; @@ -205,7 +205,7 @@ describe("findFfBinary", () => { ); } const binary = join(packageDir, process.platform === "win32" ? `${name}.exe` : name); - writeFileSync(binary, ""); + writeFileSync(binary, content); chmodSync(binary, mode); return { project, binary }; } @@ -279,6 +279,29 @@ describe("findFfBinary", () => { }, ); + describe.skipIf(process.platform === "win32")("on Apple Silicon without Rosetta", () => { + const machO = (cpu: number) => Buffer.from([0xcf, 0xfa, 0xed, 0xfe, cpu, 0x00, 0x00, 0x01]); + + function asAppleSilicon() { + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + vi.spyOn(process, "arch", "get").mockReturnValue("arm64"); + } + + it("skips an x86_64 installer binary", async () => { + asAppleSilicon(); + const { project } = installerProject("ffprobe", { content: machO(0x07) }); + + expect(await findInProjectDir(project, "ffprobe")).toBeUndefined(); + }); + + it("keeps an arm64 installer binary", async () => { + asAppleSilicon(); + const { project, binary } = installerProject("ffprobe", { content: machO(0x0c) }); + + expect(await findInProjectDir(project, "ffprobe")).toBe(binary); + }); + }); + 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 }); @@ -345,3 +368,25 @@ describe("findFfBinary", () => { expect(execFileSync).toHaveBeenCalledTimes(2); }); }); + +describe("isX86OnlyMachO", () => { + function fixture(bytes: number[]): string { + const file = join(realpathSync(mkdtempSync(join(tmpdir(), "hf-macho-"))), "ffprobe"); + writeFileSync(file, Buffer.from(bytes)); + onTestFinished(() => rmSync(file, { force: true })); + return file; + } + + it("flags a 64-bit x86_64 Mach-O (the ffprobe-static darwin/arm64 payload)", async () => { + const { isX86OnlyMachO } = await importFresh(); + expect(isX86OnlyMachO(fixture([0xcf, 0xfa, 0xed, 0xfe, 0x07, 0x00, 0x00, 0x01]))).toBe(true); + }); + + it("passes an arm64 Mach-O, a universal binary, a script and a missing file", async () => { + const { isX86OnlyMachO } = await importFresh(); + expect(isX86OnlyMachO(fixture([0xcf, 0xfa, 0xed, 0xfe, 0x0c, 0x00, 0x00, 0x01]))).toBe(false); + expect(isX86OnlyMachO(fixture([0xca, 0xfe, 0xba, 0xbe, 0x00, 0x00, 0x00, 0x02]))).toBe(false); + expect(isX86OnlyMachO(fixture([0x23, 0x21, 0x2f, 0x62, 0x69, 0x6e, 0x2f, 0x73]))).toBe(false); + expect(isX86OnlyMachO("/nonexistent/ffprobe")).toBe(false); + }); +}); diff --git a/packages/parsers/src/ffBinaries.ts b/packages/parsers/src/ffBinaries.ts index 3c91cec4de..78e1322a6d 100644 --- a/packages/parsers/src/ffBinaries.ts +++ b/packages/parsers/src/ffBinaries.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { accessSync, constants, existsSync } from "node:fs"; +import { accessSync, closeSync, constants, existsSync, openSync, readSync } from "node:fs"; import { createRequire } from "node:module"; import { delimiter, dirname, join, resolve } from "node:path"; @@ -41,11 +41,48 @@ function chooseBestPathCandidate( ); } +const MACHO_CPU_X86_64 = 0x01000007; +const ROSETTA_RUNTIME = "/Library/Apple/usr/libexec/oah/libRosettaRuntime"; + +// A Mach-O built only for x86_64 (some npm packages ship one under darwin/arm64). A universal +// binary or an unreadable file is not one. +export function isX86OnlyMachO(file: string): boolean { + const header = Buffer.alloc(8); + try { + const fd = openSync(file, "r"); + try { + readSync(fd, header, 0, 8, 0); + } finally { + closeSync(fd); + } + } catch { + return false; + } + return header.readUInt32LE(0) === 0xfeedfacf && header.readUInt32LE(4) === MACHO_CPU_X86_64; +} + +// Without Rosetta an Apple Silicon Mac cannot run an x86_64 binary, so it must not be chosen. +function runsOnThisMac(candidate: string): boolean { + if (process.platform !== "darwin" || process.arch !== "arm64" || existsSync(ROSETTA_RUNTIME)) { + return true; + } + if (!isX86OnlyMachO(candidate)) return true; + skippedX86Binary ??= candidate; + return false; +} + +let skippedX86Binary: string | undefined; + +/** The first x86_64-only binary skipped on this Mac, for naming in the "not found" hint. */ +export function getSkippedX86Binary(): string | undefined { + return skippedX86Binary; +} + function isExecutablePathCandidate(candidate: string): boolean { if (process.platform === "win32") return existsSync(candidate); try { accessSync(candidate, constants.X_OK); - return true; + return runsOnThisMac(candidate); } catch { return false; } @@ -95,7 +132,7 @@ const COMMON_BIN_DIRS = function findInCommonDirsOrInstallerPackage(name: FfBinaryName): string | undefined { for (const dir of COMMON_BIN_DIRS) { const candidate = `${dir}/${name}`; - if (existsSync(candidate)) return candidate; + if (existsSync(candidate) && runsOnThisMac(candidate)) return candidate; } return findInInstallerPackage(name); } @@ -205,4 +242,5 @@ export function findFfBinary( export function clearFfBinaryLookupCache(): void { pathLookupCache.clear(); lastMissAt.clear(); + skippedX86Binary = undefined; }