Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 15 additions & 13 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 13 additions & 0 deletions docs/guides/rendering.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,19 @@ npx hyperframes render --video-bitrate 10M --output controlled.mp4

**Tip**: The default `standard` preset (CRF 18) is visually lossless at 1080p — most people cannot distinguish it from the source. Use `--quality draft` for faster iteration, or `--quality high` / `--crf 10` when file size is no concern.

## Input Video Codecs

Video assets referenced by a composition (a `<video src="...">` clip) are decoded by FFmpeg, not by the browser: the pipeline pre-extracts every input video into frame images and injects them during capture, so the render never depends on what the capture browser can play. Any codec your FFmpeg build decodes works as an input, including:

- H.264 / AVC
- **HEVC / H.265, 8-bit and 10-bit** (`hvc1` and `hev1`): common for storage-optimized asset libraries; renders identically on macOS and Linux, no hardware decoder required
- VP8 / VP9 and ProRes 4444 (with alpha; see [Transparent Video](#transparent-video))
- HDR sources (HLG / PQ) are tone-mapped for SDR renders; see the [HDR guide](/guides/hdr)

The one caveat is **live preview**: `preview`, `play`, Studio, and published player pages play the file in a real browser, so playback there depends on that browser's codec support. Chrome (107+), Edge, and Safari hardware-decode HEVC on most modern machines; Firefox does not. If an HEVC asset shows a black frame in preview while rendering fine, generate an H.264 proxy for authoring (for example with `ffmpeg -i asset.mp4 -c:v libx264 -crf 18 proxy.mp4`, or via the media-use skill) and swap the original back in for the final render, or just keep the HEVC source, since the rendered output is unaffected.

`hyperframes lint` emits an info-level `hevc_preview_codec` note when a composition references an HEVC video, as a reminder of this preview-only limitation.

## Animated GIF

Use GIF when the output needs to autoplay inline in GitHub PRs, READMEs, issue reports, and docs pages:
Expand Down
12 changes: 12 additions & 0 deletions docs/guides/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ If your issue is about a specific coding mistake (animations not working, video

See [Rendering: Options](/guides/rendering#options) for all available flags.
</Accordion>

<Accordion title="Video asset plays black in preview but renders fine (HEVC/H.265)">
Rendering decodes video assets with FFmpeg, so HEVC (H.265) inputs render correctly on every platform. Live preview is different: `preview`, `play`, Studio, and published player pages play the file in your browser, and not every browser decodes HEVC (Chrome 107+, Edge, and Safari do on most modern hardware; Firefox does not).

If an HEVC asset shows a black or frozen frame in preview:

1. Confirm the render itself is fine: `npx hyperframes render` output will contain the video.
2. For authoring, generate an H.264 proxy (`ffmpeg -i asset.mp4 -c:v libx264 -crf 18 proxy.mp4`) and point the composition at it while you iterate.
3. Swap the HEVC original back before the final render, or keep the proxy; both render correctly.

`npx hyperframes lint` flags HEVC assets with an info-level `hevc_preview_codec` note. See [Rendering: Input Video Codecs](/guides/rendering#input-video-codecs).
</Accordion>
</AccordionGroup>

## System Diagnostics
Expand Down
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@hyperframes/engine": "workspace:*",
"@hyperframes/gcp-cloud-run": "workspace:*",
"@hyperframes/lint": "workspace:*",
"@hyperframes/parsers": "workspace:*",
"@hyperframes/producer": "workspace:*",
"@hyperframes/studio": "workspace:*",
"@hyperframes/studio-server": "workspace:*",
Expand Down
68 changes: 21 additions & 47 deletions packages/cli/src/browser/ffmpeg.test.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,29 @@
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() }));
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("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"));
import { join } from "node:path";
import { tmpdir } from "node:os";
import { afterEach, describe, expect, it } from "vitest";
import { findFFmpeg, findFFprobe } from "./ffmpeg.js";

// Lookup mechanics (PATH scan, common-dir fallback, Windows shim preference)
// are covered by @hyperframes/parsers ffBinaries.test.ts. These tests pin the
// CLI wrapper's contract: a configured-but-missing override means "not found"
// so callers surface the install hint instead of a spawn error.
describe("findFFmpeg / findFFprobe", () => {
afterEach(() => {
delete process.env.HYPERFRAMES_FFMPEG_PATH;
delete process.env.HYPERFRAMES_FFPROBE_PATH;
});

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");
it("returns undefined when the env override points at a missing file", () => {
process.env.HYPERFRAMES_FFMPEG_PATH = join(tmpdir(), "missing-ffmpeg");
process.env.HYPERFRAMES_FFPROBE_PATH = join(tmpdir(), "missing-ffprobe");

const { findFFmpeg } = await import("./ffmpeg.js");
expect(findFFmpeg()).toBe("/opt/homebrew/bin/ffmpeg");
expect(findFFmpeg()).toBeUndefined();
expect(findFFprobe()).toBeUndefined();
});

it("returns undefined when ffmpeg is on neither PATH nor a common dir", async () => {
mockExec.mockImplementation(() => {
throw new Error("not found");
});
mockExists.mockReturnValue(false);
it("returns the configured path when the env override exists", () => {
process.env.HYPERFRAMES_FFMPEG_PATH = process.execPath;

const { findFFmpeg } = await import("./ffmpeg.js");
expect(findFFmpeg()).toBeUndefined();
expect(findFFmpeg()).toBe(process.execPath);
});
});
76 changes: 7 additions & 69 deletions packages/cli/src/browser/ffmpeg.ts
Original file line number Diff line number Diff line change
@@ -1,79 +1,17 @@
// fallow-ignore-file code-duplication
import { execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
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}`;
const output = execSync(cmd, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
});
const candidate = chooseBestPathCandidate(name, output.split(/\r?\n/));
return candidate ? resolve(candidate) : undefined;
} catch {
return 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) ?? findInCommonDirs(binaryName);
}
export { FFMPEG_PATH_ENV, FFPROBE_PATH_ENV } from "@hyperframes/parsers/ff-binaries";

// `configuredMustExist`: the CLI surfaces an install hint when a binary is
// missing, so an env override pointing at a nonexistent file reports as
// not-found instead of being handed to spawn.
export function findFFmpeg(): string | undefined {
return findConfiguredBinary(FFMPEG_PATH_ENV, "ffmpeg");
return findFfBinary("ffmpeg", { configuredMustExist: true });
}

export function findFFprobe(): string | undefined {
return findConfiguredBinary(FFPROBE_PATH_ENV, "ffprobe");
return findFfBinary("ffprobe", { configuredMustExist: true });
}

export function getFFmpegInstallHint(): string {
Expand Down
1 change: 1 addition & 0 deletions packages/engine/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@
"dependencies": {
"@hono/node-server": "^1.13.0",
"@hyperframes/core": "workspace:^",
"@hyperframes/parsers": "workspace:^",
"hono": "^4.6.0",
"linkedom": "^0.18.12",
"puppeteer": "^25.2.1",
Expand Down
38 changes: 1 addition & 37 deletions packages/engine/src/utils/ffmpegBinaries.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
// fallow-ignore-file code-duplication
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
assertConfiguredFfmpegBinariesExist,
Expand Down Expand Up @@ -48,39 +45,6 @@ describe("ffmpeg binary env resolution", () => {
expect(() => assertConfiguredFfmpegBinariesExist()).not.toThrow();
});

it("prefers the real Windows exe when PATH lookup lists a cmd shim first", async () => {
vi.resetModules();
vi.doMock("child_process", () => ({
execFileSync: () => "C:\\tools\\ffmpeg.cmd\r\nC:\\tools\\ffmpeg.exe\r\n",
}));

const { getFfmpegBinary: getMockedFfmpegBinary } = await import("./ffmpegBinaries.js");

expect(getMockedFfmpegBinary()).toBe(resolve("C:\\tools\\ffmpeg.exe"));
});

it("falls back to scanning PATH when which/where fails", async () => {
const binDir = mkdtempSync(join(tmpdir(), "hyperframes-ffmpeg-path-"));
const ffmpegPath = join(binDir, process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg");
const execFileSync = vi.fn(() => {
throw new Error("lookup command failed");
});
writeFileSync(ffmpegPath, "#!/bin/sh\n");
chmodSync(ffmpegPath, 0o755);
process.env.PATH = binDir;
vi.resetModules();
vi.doMock("child_process", () => ({ execFileSync }));

try {
const { getFfmpegBinary: getMockedFfmpegBinary } = await import("./ffmpegBinaries.js");

expect(getMockedFfmpegBinary()).toBe(resolve(ffmpegPath));
expect(execFileSync).toHaveBeenCalledOnce();
} finally {
rmSync(binDir, { force: true, recursive: true });
}
});

it("calls out a mangled replacement character in configured binary paths", () => {
process.env.HYPERFRAMES_FFMPEG_PATH = "/missing/ffmpeg�.exe";

Expand Down
Loading
Loading