Skip to content
Merged
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
77 changes: 70 additions & 7 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ import { maybePromptRenderFeedback } from "../telemetry/feedback.js";
import { bytesToMb } from "../telemetry/system.js";
import { VERSION } from "../version.js";
import { isDevMode } from "../utils/env.js";
import { buildDockerRunArgs } from "../utils/dockerRunArgs.js";
import { buildDockerRunArgs, resolveDockerPlatform } from "../utils/dockerRunArgs.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
import { findFFmpeg, getFFmpegInstallHint } from "../browser/ffmpeg.js";
import type { RenderJob } from "@hyperframes/producer";
Expand Down Expand Up @@ -632,15 +632,23 @@ function dockerImageExists(tag: string): boolean {
}
}

function ensureDockerImage(version: string, quiet: boolean): string {
const tag = dockerImageTag(version);
function dockerImageTagForPlatform(version: string, platform: string): string {
// Suffix the tag with the arch so amd64 and arm64 images of the same
// hyperframes version coexist in the local cache (a developer who flips
// between hosts shouldn't have to rebuild).
const archSuffix = platform === "linux/arm64" ? "-arm64" : "";
return `${dockerImageTag(version)}${archSuffix}`;
}

function ensureDockerImage(version: string, platform: string, quiet: boolean): string {
const tag = dockerImageTagForPlatform(version, platform);

if (dockerImageExists(tag)) {
if (!quiet) console.log(c.dim(` Docker image: ${tag} (cached)`));
return tag;
}

if (!quiet) console.log(c.dim(` Building Docker image: ${tag}...`));
if (!quiet) console.log(c.dim(` Building Docker image: ${tag} (${platform})...`));

const dockerfilePath = resolveDockerfilePath();

Expand All @@ -649,16 +657,27 @@ function ensureDockerImage(version: string, quiet: boolean): string {
mkdirSync(tmpDir, { recursive: true });
writeFileSync(join(tmpDir, "Dockerfile"), readFileSync(dockerfilePath));

// linux/amd64 forced — chrome-headless-shell doesn't ship ARM Linux binaries
// Platform is now derived from the host arch (see resolveDockerPlatform).
// Apple Silicon and other arm64 hosts get a native linux/arm64 build; the
// Dockerfile skips chrome-headless-shell on arm64 and falls back to system
// chromium because chrome-headless-shell ships linux64 only.
//
// TARGETARCH is passed explicitly rather than relying on BuildKit's
// automatic platform args because the legacy builder (and some BuildKit
// configurations like colima 0.6.x) leaves it unset, which would defeat
// the arch conditional in the Dockerfile.
const targetArch = platform === "linux/arm64" ? "arm64" : "amd64";
try {
execFileSync(
"docker",
[
"build",
"--platform",
"linux/amd64",
platform,
"--build-arg",
`HYPERFRAMES_VERSION=${version}`,
"--build-arg",
`TARGETARCH=${targetArch}`,
"-t",
tag,
tmpDir,
Expand All @@ -676,6 +695,47 @@ function ensureDockerImage(version: string, quiet: boolean): string {
return tag;
}

/**
* Resolves the Docker `--platform` for this host and enforces the constraints
* that come with it — keeping that policy out of `renderDocker` so the
* orchestrator stays focused on build/run wiring. May terminate the process
* via errorBox on unrecoverable mismatches (e.g. --gpu on arm64).
*/
function resolveDockerHostPlatform(options: RenderOptions): string {
const platform = resolveDockerPlatform();

// Docker Desktop on Apple Silicon (and colima with VZ) doesn't implement
// the `--gpus` host-passthrough flag, so requesting `--gpu` on a linux/arm64
// container fails at `docker run` with an opaque device-driver error. Catch
// it early with actionable guidance.
if (options.gpu && platform === "linux/arm64") {
errorBox(
"--gpu is not supported with --docker on arm64 hosts",
"Docker Desktop/colima on Apple Silicon doesn't expose --gpus host passthrough to linux/arm64 containers.",
"Drop --gpu, or run a native (non-Docker) render on this host, or set HYPERFRAMES_DOCKER_PLATFORM=linux/amd64 if you need GPU encoding (slow under qemu but works).",
);
process.exit(1);
}

if (!options.quiet && platform === "linux/arm64") {
// chrome-headless-shell doesn't publish a linux-arm64 build, so the arm64
// image falls back to system chromium. That loses byte-for-byte parity
// with amd64 renders — fine for end-user output, not fine if you're
// comparing against an amd64 golden baseline. Set
// HYPERFRAMES_DOCKER_PLATFORM=linux/amd64 to keep parity (qemu-emulated,
// slower).
console.log(
c.dim(
" Host is arm64 — using linux/arm64 image with system chromium " +
"(output won't be byte-identical to amd64 renders; " +
"set HYPERFRAMES_DOCKER_PLATFORM=linux/amd64 to force parity).",
),
);
}

return platform;
}

async function renderDocker(
projectDir: string,
outputPath: string,
Expand All @@ -689,9 +749,11 @@ async function renderDocker(
console.log(c.dim(" Dev mode: using hyperframes@latest in Docker image"));
}

const platform = resolveDockerHostPlatform(options);

let imageTag: string;
try {
imageTag = ensureDockerImage(dockerVersion, options.quiet);
imageTag = ensureDockerImage(dockerVersion, platform, options.quiet);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
const isDockerMissing = /connect|not found|ENOENT/i.test(message);
Expand All @@ -712,6 +774,7 @@ async function renderDocker(
projectDir: resolve(projectDir),
outputDir: resolve(outputDir),
outputFilename,
platform,
options: {
fps: options.fps,
quality: options.quality,
Expand Down
40 changes: 33 additions & 7 deletions packages/cli/src/docker/Dockerfile.render
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
FROM node:22-bookworm-slim

ARG HYPERFRAMES_VERSION=latest
# Set automatically by `docker build --platform` (BuildKit); we use it to
# decide whether to install chrome-headless-shell, which only ships for
# linux64 (see https://googlechromelabs.github.io/chrome-for-testing/).
ARG TARGETARCH=amd64

RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates curl unzip ffmpeg chromium \
Expand All @@ -16,16 +20,38 @@ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
ENV CONTAINER=true

RUN npx --yes @puppeteer/browsers install chrome-headless-shell@stable \
--path /root/.cache/puppeteer
# chrome-headless-shell unlocks BeginFrame-based deterministic capture but the
# project only publishes a linux64 binary. On arm64 we skip the install and
# let the engine fall back to system chromium (set via
# PUPPETEER_EXECUTABLE_PATH above). The wrapper script below only sets
# PRODUCER_HEADLESS_SHELL_PATH when the binary is actually present.
RUN if [ "$TARGETARCH" = "amd64" ]; then \
npx --yes @puppeteer/browsers install chrome-headless-shell@stable \
--path /root/.cache/puppeteer; \
else \
echo "Skipping chrome-headless-shell install on ${TARGETARCH} (linux64-only); using system chromium."; \
fi

RUN npm install -g hyperframes@${HYPERFRAMES_VERSION}

# Wrapper script: resolves chrome-headless-shell path at build time,
# sets PRODUCER_HEADLESS_SHELL_PATH at runtime so the engine uses
# BeginFrame rendering instead of falling back to system Chromium.
RUN SHELL_PATH=$(find /root/.cache/puppeteer/chrome-headless-shell -name "chrome-headless-shell" -type f | head -1) \
&& printf '#!/bin/sh\nexport PRODUCER_HEADLESS_SHELL_PATH=%s\nexec hyperframes render "$@"\n' "$SHELL_PATH" > /usr/local/bin/hf-render \
# Wrapper script: resolves chrome-headless-shell path at build time when
# available so the engine uses BeginFrame rendering. On arm64 (no
# chrome-headless-shell) it leaves PRODUCER_HEADLESS_SHELL_PATH unset, so the
# engine falls back to PUPPETEER_EXECUTABLE_PATH (system chromium).
#
# If TARGETARCH=amd64 and the binary is missing, fail the build loudly — the
# previous (pre-PR) wrapper used an `&&` chain that crashed `docker build` in
# this case, and silently downgrading to system chromium on amd64 would mask
# golden-baseline regressions.
RUN SHELL_PATH=$(find /root/.cache/puppeteer/chrome-headless-shell -name "chrome-headless-shell" -type f 2>/dev/null | head -1); \
if [ -n "$SHELL_PATH" ]; then \
printf '#!/bin/sh\nexport PRODUCER_HEADLESS_SHELL_PATH=%s\nexec hyperframes render "$@"\n' "$SHELL_PATH" > /usr/local/bin/hf-render; \
elif [ "$TARGETARCH" = "amd64" ]; then \
echo "ERROR: chrome-headless-shell binary not found on amd64 — @puppeteer/browsers install must have failed or moved its cache layout." >&2; \
exit 1; \
else \
printf '#!/bin/sh\nexec hyperframes render "$@"\n' > /usr/local/bin/hf-render; \
fi \
&& chmod +x /usr/local/bin/hf-render

WORKDIR /project
Expand Down
86 changes: 85 additions & 1 deletion packages/cli/src/utils/dockerRunArgs.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { describe, expect, it } from "vitest";
import { buildDockerRunArgs, type DockerRenderOptions } from "./dockerRunArgs.js";
import {
buildDockerRunArgs,
resolveDockerPlatform,
type DockerRenderOptions,
} from "./dockerRunArgs.js";

const BASE: DockerRenderOptions = {
fps: { num: 30, den: 1 },
Expand All @@ -18,6 +22,10 @@ const FIXED_INPUT = {
projectDir: "/abs/proj",
outputDir: "/abs/out",
outputFilename: "out.mp4",
// Pin platform in tests so snapshots are arch-independent (otherwise they
// flip between linux/amd64 and linux/arm64 depending on the host running
// the test).
platform: "linux/amd64",
};

describe("buildDockerRunArgs", () => {
Expand Down Expand Up @@ -290,4 +298,80 @@ describe("buildDockerRunArgs", () => {
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
expect(args).not.toContain("--no-page-side-compositing");
});

// Regression for #1193: an arm64 host (Apple Silicon) was being pinned to
// linux/amd64, which forced qemu emulation of chrome-headless-shell and
// produced either navigation timeouts or chrome SEGVs. Each host arch must
// land in its native --platform value.
it("emits linux/arm64 when host platform is arm64", () => {
const args = buildDockerRunArgs({
imageTag: "hyperframes-renderer:0.0.0-test",
projectDir: "/abs/proj",
outputDir: "/abs/out",
outputFilename: "out.mp4",
platform: "linux/arm64",
options: BASE,
});
const idx = args.indexOf("--platform");
expect(idx).toBeGreaterThanOrEqual(0);
expect(args[idx + 1]).toBe("linux/arm64");
});

it("emits linux/amd64 when platform is explicitly amd64", () => {
const args = buildDockerRunArgs({ ...FIXED_INPUT, options: BASE });
const idx = args.indexOf("--platform");
expect(idx).toBeGreaterThanOrEqual(0);
expect(args[idx + 1]).toBe("linux/amd64");
});
});

describe("resolveDockerPlatform", () => {
it("maps arm64 hosts to linux/arm64", () => {
expect(resolveDockerPlatform("arm64", {})).toBe("linux/arm64");
});

it("maps x64 hosts to linux/amd64", () => {
expect(resolveDockerPlatform("x64", {})).toBe("linux/amd64");
});

it("treats unknown architectures as linux/amd64 (safe default)", () => {
expect(resolveDockerPlatform("riscv64", {})).toBe("linux/amd64");
});

// Regression guard: the production call site is `resolveDockerPlatform()`
// with no args. If a refactor drops either default parameter, every other
// arch-mapping test would still pass — this one fails loudly.
it("uses process.arch and process.env when called with no arguments", () => {
const result = resolveDockerPlatform();
// Must equal the explicit-arg form (env override notwithstanding, which
// wouldn't be set in the test runner unless deliberately stubbed).
const expected = process.env.HYPERFRAMES_DOCKER_PLATFORM
? process.env.HYPERFRAMES_DOCKER_PLATFORM
: resolveDockerPlatform(process.arch, {});
expect(result).toBe(expected);
});

it("honors HYPERFRAMES_DOCKER_PLATFORM override on an arm64 host (Rosetta-Node / parity-regen escape hatch)", () => {
expect(resolveDockerPlatform("arm64", { HYPERFRAMES_DOCKER_PLATFORM: "linux/amd64" })).toBe(
"linux/amd64",
);
});

it("honors HYPERFRAMES_DOCKER_PLATFORM override on an amd64 host", () => {
expect(resolveDockerPlatform("x64", { HYPERFRAMES_DOCKER_PLATFORM: "linux/arm64" })).toBe(
"linux/arm64",
);
});

it("trims whitespace from HYPERFRAMES_DOCKER_PLATFORM and ignores empty override", () => {
expect(resolveDockerPlatform("arm64", { HYPERFRAMES_DOCKER_PLATFORM: " linux/amd64 " })).toBe(
"linux/amd64",
);
// Empty/whitespace-only override falls back to arch detection — important
// for shells where `export FOO=""` would otherwise pin platform to "".
expect(resolveDockerPlatform("arm64", { HYPERFRAMES_DOCKER_PLATFORM: "" })).toBe("linux/arm64");
expect(resolveDockerPlatform("arm64", { HYPERFRAMES_DOCKER_PLATFORM: " " })).toBe(
"linux/arm64",
);
});
});
38 changes: 37 additions & 1 deletion packages/cli/src/utils/dockerRunArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ export interface DockerRunArgsInput {
outputDir: string;
/** Filename within `outputDir` (joined to /output inside the container). */
outputFilename: string;
/**
* Docker `--platform` value (`linux/amd64` or `linux/arm64`). When omitted,
* resolves to the host architecture via `resolveDockerPlatform()`. Pinning
* to `linux/amd64` on an arm64 host (the legacy default) forces qemu
* emulation of chrome-headless-shell, which segfaults or stalls on Apple
* Silicon — see issue #1193. Native `linux/arm64` falls back to the
* system chromium baked into the image at the cost of byte-for-byte
* parity with amd64 renders.
*/
platform?: string;
options: DockerRenderOptions;
}

Expand Down Expand Up @@ -44,13 +54,39 @@ export interface DockerRenderOptions {
pageSideCompositing?: boolean;
}

/**
* Maps Node's `process.arch` to a Docker `--platform` string. We only emit
* the two architectures the renderer actively supports — arm64 hosts (Apple
* Silicon, Graviton, Ampere) and everything else (treated as amd64).
*
* Honors `HYPERFRAMES_DOCKER_PLATFORM` as an escape hatch (typed loosely so
* the override can target future platforms without a CLI release):
*
* - Apple Silicon users running an x64 Node binary under Rosetta (where
* `process.arch === "x64"` despite the host being arm64) can set it to
* `linux/arm64` to avoid re-triggering issue #1193.
* - Maintainers regenerating amd64 golden baselines on an arm64 host can set
* it to `linux/amd64` to keep the byte-for-byte guarantee.
* - Users on remote daemons (`DOCKER_HOST=ssh://amd64-server`) can force the
* actual daemon arch instead of relying on local `process.arch`.
*/
export function resolveDockerPlatform(
arch: string = process.arch,
env: NodeJS.ProcessEnv = process.env,
): string {
const override = env.HYPERFRAMES_DOCKER_PLATFORM;
if (override && override.trim() !== "") return override.trim();
return arch === "arm64" ? "linux/arm64" : "linux/amd64";
}

export function buildDockerRunArgs(input: DockerRunArgsInput): string[] {
const { imageTag, projectDir, outputDir, outputFilename, options } = input;
const platform = input.platform ?? resolveDockerPlatform();
return [
"run",
"--rm",
"--platform",
"linux/amd64",
platform,
"--shm-size=2g",
// GPU encoding requires host GPU passthrough.
...(options.gpu ? ["--gpus", "all"] : []),
Expand Down
Loading