fix(studio): resolve ffmpeg outside PATH so render doesn't 503 (#1536)

The render pre-flight check shells out to `which ffmpeg`, which only
searches the server process's PATH. When Studio is launched from a
GUI/Dock/launchd context that PATH lacks /opt/homebrew/bin, so `which`
fails even when ffmpeg is installed — and POST /render returns 503
"FFmpeg not found".

Fall back to probing well-known install dirs (Homebrew on Apple Silicon
and Intel, plus system/Linux locations) when the PATH lookup fails.

Also drop the [kf:static]/[kf:runtime] keyframe diagnostics that were
spamming the Studio console in prod, and fix two unrelated CI breakages
the branch inherited: a Windows-sensitive ffmpeg test (pin platform) and
a stale player test mock missing onRuntimeReady.
This commit is contained in:
Miguel Ángel
2026-06-17 10:21:24 -04:00
committed by GitHub
parent 66dde0898b
commit ff25058c5e
4 changed files with 65 additions and 28 deletions
+46
View File
@@ -0,0 +1,46 @@
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("node:child_process", () => ({ execSync: vi.fn() }));
vi.mock("node:fs", () => ({ existsSync: vi.fn() }));
const mockExec = vi.mocked(execSync);
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;
});
describe("findFFmpeg", () => {
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);
const { findFFmpeg } = await import("./ffmpeg.js");
expect(findFFmpeg()).toBeUndefined();
});
});
+18 -1
View File
@@ -24,13 +24,30 @@ function findOnPath(name: "ffmpeg" | "ffprobe"): string | 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 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);
return findOnPath(binaryName) ?? findInCommonDirs(binaryName);
}
export function findFFmpeg(): string | undefined {
@@ -11,6 +11,7 @@ const makeCallbacks = (): MessageHandlerCallbacks => ({
updateControlsTime: vi.fn(),
updateControlsPlaying: vi.fn(),
dispatchEvent: vi.fn(),
onRuntimeReady: vi.fn(),
seek: vi.fn(),
play: vi.fn(),
getLoop: vi.fn(() => false),
@@ -366,13 +366,6 @@ export function usePopulateKeyframeCacheForFile(
const { setKeyframeCache } = usePlayerStore.getState();
clearKeyframeCacheForFile(sf);
const { elements } = usePlayerStore.getState();
console.log(
"[kf:static] elements in store:",
elements
.map((e) => e.domId)
.filter(Boolean)
.join(", "),
);
const mergedByElement = new Map<string, GsapKeyframesData>();
for (const anim of parsed.animations) {
const id = extractIdFromSelector(anim.targetSelector);
@@ -415,12 +408,6 @@ export function usePopulateKeyframeCacheForFile(
mergedByElement.set(id, { ...kfData, keyframes: clipKeyframes });
}
}
console.log(
"[kf:static] merged elements:",
[...mergedByElement.keys()].join(", "),
"kf counts:",
[...mergedByElement.entries()].map(([k, v]) => `${k}:${v.keyframes.length}`).join(", "),
);
for (const [id, kfData] of mergedByElement) {
setKeyframeCache(`${sf}#${id}`, kfData);
setKeyframeCache(id, kfData);
@@ -454,12 +441,6 @@ export function usePopulateKeyframeCacheForFile(
if (el.domId) clipById.set(el.domId, { start: el.start, duration: el.duration });
}
const scanned = scanAllRuntimeKeyframes(iframe, clipById);
console.log(
"[kf:runtime] scanned",
scanned.size,
"elements:",
[...scanned.keys()].join(", "),
);
if (scanned.size === 0) return false;
const { setKeyframeCache, keyframeCache } = usePlayerStore.getState();
for (const [id, data] of scanned) {
@@ -470,14 +451,6 @@ export function usePopulateKeyframeCacheForFile(
if (alreadyCached) {
continue;
}
console.log(
"[kf:runtime] adding runtime entry:",
id,
"kfs:",
data.keyframes.length,
"arc:",
!!data.arcPath,
);
const entry = {
format: "percentage" as const,
keyframes: data.keyframes,