fix(cli): Apple Silicon Macs without ffmpeg get working arm64 ffprobe and ffmpeg

This commit is contained in:
Miguel Angel Simon Sierra
2026-10-29 18:44:58 -04:00
parent 3e462f4277
commit dbcdaeb3c6
4 changed files with 131 additions and 12 deletions
+40 -4
View File
@@ -69,7 +69,6 @@
"hyperframes-localize-fonts": "./bin/hyperframes-localize-fonts.mjs",
},
"dependencies": {
"undici": "7.25.0",
"@hono/node-server": "^2.0.5",
"@puppeteer/browsers": "^3.2.2",
"acorn": "^8.17.0",
@@ -86,6 +85,7 @@
"open": "^10.0.0",
"postcss": "^8.5.8",
"puppeteer-core": "^25.10.0",
"undici": "7.25.0",
"yauzl": "^3.4.0",
},
"devDependencies": {
@@ -114,6 +114,8 @@
"vitest": "^4.1.11",
},
"optionalDependencies": {
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"@ffprobe-installer/ffprobe": "^2.1.2",
"sharp": "^0.35.0",
},
},
@@ -166,7 +168,6 @@
"name": "@hyperframes/engine",
"version": "0.8.210",
"dependencies": {
"undici": "7.25.0",
"@hono/node-server": "^2.0.5",
"@hyperframes/core": "workspace:^",
"@hyperframes/parsers": "workspace:^",
@@ -174,6 +175,7 @@
"linkedom": "^0.18.12",
"puppeteer": "~25.11.0",
"puppeteer-core": "^25.11.0",
"undici": "7.25.0",
},
"devDependencies": {
"@types/node": "^25.0.10",
@@ -699,6 +701,42 @@
"@fallow-cli/win32-x64-msvc": ["@fallow-cli/win32-x64-msvc@3.34.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rdjchbPiFK5ExxyZnj50FN6+n+KYTtbLrtcEegVg+iL97LvLml9sVvF1YcDP69fWDJVAhAwlE2mQZhW/P2HPyw=="],
"@ffmpeg-installer/darwin-arm64": ["@ffmpeg-installer/darwin-arm64@4.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA=="],
"@ffmpeg-installer/darwin-x64": ["@ffmpeg-installer/darwin-x64@4.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z4EyG3cIFjdhlY8wI9aLUXuH8nVt7E9SlMVZtWvSPnm2sm37/yC2CwjUzyCQbJbySnef1tQwGG2Sx+uWhd9IAw=="],
"@ffmpeg-installer/ffmpeg": ["@ffmpeg-installer/ffmpeg@1.1.0", "", { "optionalDependencies": { "@ffmpeg-installer/darwin-arm64": "4.1.5", "@ffmpeg-installer/darwin-x64": "4.1.0", "@ffmpeg-installer/linux-arm": "4.1.3", "@ffmpeg-installer/linux-arm64": "4.1.4", "@ffmpeg-installer/linux-ia32": "4.1.0", "@ffmpeg-installer/linux-x64": "4.1.0", "@ffmpeg-installer/win32-ia32": "4.1.0", "@ffmpeg-installer/win32-x64": "4.1.0" } }, "sha512-Uq4rmwkdGxIa9A6Bd/VqqYbT7zqh1GrT5/rFwCwKM70b42W5gIjWeVETq6SdcL0zXqDtY081Ws/iJWhr1+xvQg=="],
"@ffmpeg-installer/linux-arm": ["@ffmpeg-installer/linux-arm@4.1.3", "", { "os": "linux", "cpu": "arm" }, "sha512-NDf5V6l8AfzZ8WzUGZ5mV8O/xMzRag2ETR6+TlGIsMHp81agx51cqpPItXPib/nAZYmo55Bl2L6/WOMI3A5YRg=="],
"@ffmpeg-installer/linux-arm64": ["@ffmpeg-installer/linux-arm64@4.1.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dljEqAOD0oIM6O6DxBW9US/FkvqvQwgJ2lGHOwHDDwu/pX8+V0YsDL1xqHbj1DMX/+nP9rxw7G7gcUvGspSoKg=="],
"@ffmpeg-installer/linux-ia32": ["@ffmpeg-installer/linux-ia32@4.1.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-0LWyFQnPf+Ij9GQGD034hS6A90URNu9HCtQ5cTqo5MxOEc7Rd8gLXrJvn++UmxhU0J5RyRE9KRYstdCVUjkNOQ=="],
"@ffmpeg-installer/linux-x64": ["@ffmpeg-installer/linux-x64@4.1.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Y5BWhGLU/WpQjOArNIgXD3z5mxxdV8c41C+U15nsE5yF8tVcdCGet5zPs5Zy3Ta6bU7haGpIzryutqCGQA/W8A=="],
"@ffmpeg-installer/win32-ia32": ["@ffmpeg-installer/win32-ia32@4.1.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV2D7RlaZv/lrtdhaQ4oETwoFUsUjlUiasiZLDxhEUPdNDWcH1OU9K1xTvqz+OXLdsmYelUDuBS/zkMOTtlUAw=="],
"@ffmpeg-installer/win32-x64": ["@ffmpeg-installer/win32-x64@4.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Drt5u2vzDnIONf4ZEkKtFlbvwj6rI3kxw1Ck9fpudmtgaZIHD4ucsWB2lCZBXRxJgXR+2IMSti+4rtM4C4rXgg=="],
"@ffprobe-installer/darwin-arm64": ["@ffprobe-installer/darwin-arm64@5.0.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-vwNCNjokH8hfkbl6m95zICHwkSzhEvDC3GVBcUp5HX8+4wsX10SP3B+bGur7XUzTIZ4cQpgJmEIAx6TUwRepMg=="],
"@ffprobe-installer/darwin-x64": ["@ffprobe-installer/darwin-x64@5.1.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-J+YGscZMpQclFg31O4cfVRGmDpkVsQ2fZujoUdMAAYcP0NtqpC49Hs3SWJpBdsGB4VeqOt5TTm1vSZQzs1NkhA=="],
"@ffprobe-installer/ffprobe": ["@ffprobe-installer/ffprobe@2.1.2", "", { "optionalDependencies": { "@ffprobe-installer/darwin-arm64": "5.0.1", "@ffprobe-installer/darwin-x64": "5.1.0", "@ffprobe-installer/linux-arm": "5.2.0", "@ffprobe-installer/linux-arm64": "5.2.0", "@ffprobe-installer/linux-ia32": "5.2.0", "@ffprobe-installer/linux-x64": "5.2.0", "@ffprobe-installer/win32-ia32": "5.1.0", "@ffprobe-installer/win32-x64": "5.1.0" } }, "sha512-ZNvwk4f2magF42Zji2Ese16SMj9BS7Fui4kRjg6gTYTxY3gWZNpg85n4MIfQyI9nimHg4x/gT6FVkp/bBDuBwg=="],
"@ffprobe-installer/linux-arm": ["@ffprobe-installer/linux-arm@5.2.0", "", { "os": "linux", "cpu": "arm" }, "sha512-PF5HqEhCY7WTWHtLDYbA/+rLS+rhslWvyBlAG1Fk8VzVlnRdl93o6hy7DE2kJgxWQbFaR3ZktPQGEzfkrmQHvQ=="],
"@ffprobe-installer/linux-arm64": ["@ffprobe-installer/linux-arm64@5.2.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-X1VvWtlLs6ScP73biVLuHD5ohKJKsMTa0vafCESOen4mOoNeLAYbxOVxDWAdFz9cpZgRiloFj5QD6nDj8E28yQ=="],
"@ffprobe-installer/linux-ia32": ["@ffprobe-installer/linux-ia32@5.2.0", "", { "os": "linux", "cpu": "ia32" }, "sha512-TFVK5sasXyXhbIG7LtPRDmtkrkOsInwKcL43iEvEw+D9vCS2rc//mn9/0Q+BR0UoJEiMK4+ApYr/3LLVUBPOCQ=="],
"@ffprobe-installer/linux-x64": ["@ffprobe-installer/linux-x64@5.2.0", "", { "os": "linux", "cpu": "x64" }, "sha512-D3UeqTLYPNs7pBWPLUYGehPdRVqU8eACox4OZy3pZUZatxye2YKlvBwEfaLdL1v2Z4FOAlLUhms0kY8m8kqSRA=="],
"@ffprobe-installer/win32-ia32": ["@ffprobe-installer/win32-ia32@5.1.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-5O3vOoNRxmut0/Nu9vSazTdSHasrr+zPT2B3Hm7kjmO3QVFcIfVImS6ReQnZeSy8JPJOqXts5kX5x/3KOX54XQ=="],
"@ffprobe-installer/win32-x64": ["@ffprobe-installer/win32-x64@5.1.0", "", { "os": "win32", "cpu": "x64" }, "sha512-jMGYeAgkrdn4e2vvYt/qakgHRE3CPju4bn5TmdPfoAm1BlX1mY9cyMd8gf5vSzI8gH8Zq5WQAyAkmekX/8TSTg=="],
"@floating-ui/core": ["@floating-ui/core@1.8.0", "", { "dependencies": { "@floating-ui/utils": "^0.2.12" } }, "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ=="],
"@floating-ui/dom": ["@floating-ui/dom@1.8.0", "", { "dependencies": { "@floating-ui/core": "^1.8.0", "@floating-ui/utils": "^0.2.12" } }, "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg=="],
@@ -1477,8 +1515,6 @@
"data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="],
"date-fns": ["date-fns@2.30.0", "", { "dependencies": { "@babel/runtime": "^7.21.0" } }, "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="],
+2
View File
@@ -99,6 +99,8 @@
"vitest": "^4.1.11"
},
"optionalDependencies": {
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"@ffprobe-installer/ffprobe": "^2.1.2",
"sharp": "^0.35.0"
},
"engines": {
+47 -2
View File
@@ -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);
});
});
+42 -6
View File
@@ -1,7 +1,8 @@
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";
import { fileURLToPath } from "node:url";
/**
* Shared FFmpeg/FFprobe binary resolution for every package that shells out
@@ -41,11 +42,39 @@ 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;
}
return !isX86OnlyMachO(candidate);
}
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 +124,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);
}
@@ -117,10 +146,17 @@ function tryResolve(fromFile: string, request: string): string | 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).
// An @ffmpeg-installer / @ffprobe-installer package reachable from the current folder or from
// this package's own install (the CLI's optional dependencies), 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");
return (
findInInstallerPackageFrom(name, resolve("package.json")) ??
findInInstallerPackageFrom(name, fileURLToPath(import.meta.url))
);
}
function findInInstallerPackageFrom(name: FfBinaryName, project: string): string | undefined {
const platformManifest = `@${name}-installer/${process.platform}-${process.arch}/package.json`;
const installer = tryResolve(project, `@${name}-installer/${name}/package.json`);
const manifest =