From dbcdaeb3c69b571814204f9d733b2660d73bcaec Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Thu, 29 Oct 2026 18:44:58 -0400 Subject: [PATCH] fix(cli): Apple Silicon Macs without ffmpeg get working arm64 ffprobe and ffmpeg --- bun.lock | 44 ++++++++++++++++++++-- packages/cli/package.json | 2 + packages/parsers/src/ffBinaries.test.ts | 49 ++++++++++++++++++++++++- packages/parsers/src/ffBinaries.ts | 48 +++++++++++++++++++++--- 4 files changed, 131 insertions(+), 12 deletions(-) diff --git a/bun.lock b/bun.lock index 46a60d321e..a221e1412e 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/packages/cli/package.json b/packages/cli/package.json index e007ade5c5..a9c58cb971 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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": { 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..18e04b0fbb 100644 --- a/packages/parsers/src/ffBinaries.ts +++ b/packages/parsers/src/ffBinaries.ts @@ -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 =