diff --git a/packages/cli/src/browser/ffmpeg.test.ts b/packages/cli/src/browser/ffmpeg.test.ts new file mode 100644 index 0000000000..1088d22adf --- /dev/null +++ b/packages/cli/src/browser/ffmpeg.test.ts @@ -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(); + }); +}); diff --git a/packages/cli/src/browser/ffmpeg.ts b/packages/cli/src/browser/ffmpeg.ts index 30eaced3d5..6db32e3d87 100644 --- a/packages/cli/src/browser/ffmpeg.ts +++ b/packages/cli/src/browser/ffmpeg.ts @@ -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 { diff --git a/packages/player/src/runtime-message-handler.test.ts b/packages/player/src/runtime-message-handler.test.ts index 9539c6511f..76b71e3bec 100644 --- a/packages/player/src/runtime-message-handler.test.ts +++ b/packages/player/src/runtime-message-handler.test.ts @@ -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), diff --git a/packages/studio/src/hooks/useGsapTweenCache.ts b/packages/studio/src/hooks/useGsapTweenCache.ts index cb872b6cfc..246f53526c 100644 --- a/packages/studio/src/hooks/useGsapTweenCache.ts +++ b/packages/studio/src/hooks/useGsapTweenCache.ts @@ -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(); 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,