From 98b539df728f221de677e7ede3e9e3eb4cad2bca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 5 Jul 2026 12:24:50 -0700 Subject: [PATCH] fix(cli): prefer real ffmpeg exe over cmd shim (#1958) --- packages/cli/src/browser/ffmpeg.test.ts | 9 +++++++++ packages/cli/src/browser/ffmpeg.ts | 27 ++++++++++++++++++++----- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/browser/ffmpeg.test.ts b/packages/cli/src/browser/ffmpeg.test.ts index 1088d22ad..f541e6d40 100644 --- a/packages/cli/src/browser/ffmpeg.test.ts +++ b/packages/cli/src/browser/ffmpeg.test.ts @@ -1,5 +1,6 @@ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; +import { resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("node:child_process", () => ({ execSync: vi.fn() })); @@ -23,6 +24,14 @@ afterEach(() => { }); 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(() => { diff --git a/packages/cli/src/browser/ffmpeg.ts b/packages/cli/src/browser/ffmpeg.ts index 9c0670ca1..e5f77cf62 100644 --- a/packages/cli/src/browser/ffmpeg.ts +++ b/packages/cli/src/browser/ffmpeg.ts @@ -7,6 +7,26 @@ import { detectLinuxDistro, ffmpegInstallCommand } from "./linuxDeps.js"; export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH"; export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH"; +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}`; @@ -15,11 +35,8 @@ function findOnPath(name: "ffmpeg" | "ffprobe"): string | undefined { stdio: ["pipe", "pipe", "pipe"], timeout: 5000, }); - const first = output - .split(/\r?\n/) - .map((s) => s.trim()) - .find(Boolean); - return first ? resolve(first) : undefined; + const candidate = chooseBestPathCandidate(name, output.split(/\r?\n/)); + return candidate ? resolve(candidate) : undefined; } catch { return undefined; }