mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user