From 699e7efb0dfce44c3147989432fb1305ad554c8e Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 6 Apr 2026 16:40:36 -0700 Subject: [PATCH 1/7] feat(cli): implement Docker rendering for deterministic output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The --docker flag was accepted but never actually launched a container. renderDocker called the same executeRenderJob as renderLocal. Now renderDocker: - Generates a Dockerfile that installs hyperframes@ - Builds a linux/amd64 image (hyperframes-renderer:) with Chrome, FFmpeg, fonts, and chrome-headless-shell - Caches the image per version — subsequent renders skip the build - Uses a shell wrapper entrypoint that sets PRODUCER_HEADLESS_SHELL_PATH so the engine uses BeginFrame rendering - Mounts the project read-only and output directory read-write - Streams container output to the terminal Forces linux/amd64 platform because chrome-headless-shell doesn't ship ARM Linux binaries. Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile.render | 68 ++++++++++ packages/cli/src/commands/render.ts | 186 ++++++++++++++++++++++++++-- 2 files changed, 241 insertions(+), 13 deletions(-) create mode 100644 Dockerfile.render diff --git a/Dockerfile.render b/Dockerfile.render new file mode 100644 index 000000000..af45b5e17 --- /dev/null +++ b/Dockerfile.render @@ -0,0 +1,68 @@ +# HyperFrames — Deterministic Render Image +# +# Provides Chrome, FFmpeg, and fonts for byte-identical renders. +# The CLI generates this Dockerfile dynamically (with the correct version) +# when you run `hyperframes render --docker`. +# +# Manual build: +# docker build -f Dockerfile.render -t hyperframes-renderer:dev \ +# --build-arg HYPERFRAMES_VERSION=0.2.3-alpha.1 . +# +# Manual run: +# docker run --rm --security-opt seccomp=unconfined --shm-size=2g \ +# -v /path/to/project:/project:ro \ +# -v /path/to/output:/output \ +# hyperframes-renderer:dev \ +# /project --output /output/render.mp4 --fps 30 --quality standard + +FROM node:22-bookworm-slim + +ARG HYPERFRAMES_VERSION=latest + +# ── System dependencies ───────────────────────────────────────────────────── +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + unzip \ + ffmpeg \ + chromium \ + libgbm1 \ + libnss3 \ + libatk-bridge2.0-0 \ + libdrm2 \ + libxcomposite1 \ + libxdamage1 \ + libxrandr2 \ + libcups2 \ + libasound2 \ + libpangocairo-1.0-0 \ + libxshmfence1 \ + libgtk-3-0 \ + fonts-liberation \ + fonts-noto-color-emoji \ + fonts-noto-cjk \ + fonts-noto-core \ + fonts-noto-extra \ + fonts-noto-ui-core \ + fonts-freefont-ttf \ + fonts-dejavu-core \ + fontconfig \ + && rm -rf /var/lib/apt/lists/* \ + && apt-get clean \ + && fc-cache -fv + +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium +ENV CONTAINER=true + +# chrome-headless-shell for deterministic BeginFrame rendering +RUN npx --yes @puppeteer/browsers install chrome-headless-shell@stable \ + --path /root/.cache/puppeteer \ + && echo "chrome-headless-shell installed" + +# Install hyperframes CLI (bundles producer, engine, core) +RUN npm install -g hyperframes@${HYPERFRAMES_VERSION} + +WORKDIR /project + +ENTRYPOINT ["hyperframes", "render"] diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index c607e7ba2..4e3184fbc 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1,6 +1,6 @@ import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; -import { existsSync, mkdirSync, statSync } from "node:fs"; +import { existsSync, mkdirSync, statSync, writeFileSync, rmSync } from "node:fs"; export const examples: Example[] = [ ["Render to MP4", "hyperframes render --output output.mp4"], @@ -9,8 +9,9 @@ export const examples: Example[] = [ ["Deterministic render via Docker", "hyperframes render --docker --output deterministic.mp4"], ["Parallel rendering with 6 workers", "hyperframes render --workers 6 --output fast.mp4"], ]; -import { cpus, freemem } from "node:os"; -import { resolve, dirname, join } from "node:path"; +import { cpus, freemem, tmpdir } from "node:os"; +import { resolve, dirname, join, basename } from "node:path"; +import { execSync, spawn } from "node:child_process"; import { resolveProject } from "../utils/project.js"; import { lintProject, shouldBlockRender } from "../utils/lintProject.js"; import { formatLintFindings } from "../utils/lintFormat.js"; @@ -20,6 +21,7 @@ import { formatBytes, formatDuration, errorBox } from "../ui/format.js"; import { renderProgress } from "../ui/progress.js"; import { trackRenderComplete, trackRenderError } from "../telemetry/events.js"; import { bytesToMb } from "../telemetry/system.js"; +import { VERSION } from "../version.js"; import type { RenderJob } from "@hyperframes/producer"; const VALID_FPS = new Set([24, 30, 60]); @@ -269,30 +271,188 @@ interface RenderOptions { browserPath?: string; } +// ── Docker image name + Dockerfile template ──────────────────────────────── + +const DOCKER_IMAGE_PREFIX = "hyperframes-renderer"; + +function dockerImageTag(version: string): string { + return `${DOCKER_IMAGE_PREFIX}:${version}`; +} + +/** + * Generate the Dockerfile content for the render image. + * Installs the same hyperframes version as the running CLI. + */ +function generateDockerfile(version: string): string { + return `FROM node:22-bookworm-slim +RUN apt-get update && apt-get install -y --no-install-recommends \\ + ca-certificates curl unzip ffmpeg chromium \\ + libgbm1 libnss3 libatk-bridge2.0-0 libdrm2 libxcomposite1 \\ + libxdamage1 libxrandr2 libcups2 libasound2 libpangocairo-1.0-0 \\ + libxshmfence1 libgtk-3-0 \\ + fonts-liberation fonts-noto-color-emoji fonts-noto-cjk fonts-noto-core \\ + fonts-noto-extra fonts-noto-ui-core fonts-freefont-ttf fonts-dejavu-core \\ + fontconfig \\ + && rm -rf /var/lib/apt/lists/* && apt-get clean && fc-cache -fv +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 +RUN npm install -g 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 \\ + && chmod +x /usr/local/bin/hf-render +WORKDIR /project +ENTRYPOINT ["hf-render"] +`; +} + +/** + * Check if a Docker image exists locally. + */ +function dockerImageExists(tag: string): boolean { + try { + execSync(`docker image inspect ${tag}`, { stdio: "pipe", timeout: 10_000 }); + return true; + } catch { + return false; + } +} + +/** + * Build the Docker render image if it doesn't already exist. + * Returns the image tag. + */ +function ensureDockerImage(version: string, quiet: boolean): string { + const tag = dockerImageTag(version); + + 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}...`)); + + // Write Dockerfile to a temp directory + const tmpDir = join(tmpdir(), `hyperframes-docker-${Date.now()}`); + mkdirSync(tmpDir, { recursive: true }); + const dockerfilePath = join(tmpDir, "Dockerfile"); + writeFileSync(dockerfilePath, generateDockerfile(version)); + + // Build for linux/amd64 — chrome-headless-shell doesn't ship ARM Linux + // binaries, so we use x86 emulation via Docker Desktop's Rosetta/QEMU. + try { + execSync(`docker build --platform linux/amd64 -t ${tag} -f ${dockerfilePath} ${tmpDir}`, { + stdio: quiet ? "pipe" : "inherit", + timeout: 600_000, // 10 minutes + }); + } catch (error: unknown) { + // Clean up temp dir before throwing + rmSync(tmpDir, { recursive: true, force: true }); + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Failed to build Docker image: ${message}`); + } + + rmSync(tmpDir, { recursive: true, force: true }); + + if (!quiet) console.log(c.dim(` Docker image: ${tag} (built)`)); + return tag; +} + async function renderDocker( projectDir: string, outputPath: string, options: RenderOptions, ): Promise { - const producer = await loadProducer(); const startTime = Date.now(); - let job: RenderJob; + // ── Verify Docker is available ─────────────────────────────────────────── + try { + execSync("docker info", { stdio: "pipe", timeout: 10_000 }); + } catch { + errorBox( + "Docker not available", + "Docker is not running or not installed.", + "Start Docker Desktop or install from https://docs.docker.com/get-docker/", + ); + process.exit(1); + } + + // ── Build or reuse the render image ────────────────────────────────────── + const imageTag = ensureDockerImage(VERSION, options.quiet); + + // ── Prepare output directory ───────────────────────────────────────────── + const outputDir = dirname(outputPath); + const outputFilename = basename(outputPath); + mkdirSync(outputDir, { recursive: true }); + + // ── Run the render inside Docker ───────────────────────────────────────── + const dockerArgs = [ + "run", + "--rm", + "--platform", + "linux/amd64", + "--security-opt", + "seccomp=unconfined", + "--shm-size=2g", + // Mount project read-only, output directory read-write + "-v", + `${resolve(projectDir)}:/project:ro`, + "-v", + `${resolve(outputDir)}:/output`, + imageTag, + // Arguments to `hyperframes render` inside the container + "/project", + "--output", + `/output/${outputFilename}`, + "--fps", + String(options.fps), + "--quality", + options.quality, + "--format", + options.format, + "--workers", + String(options.workers), + ]; + if (options.gpu) dockerArgs.push("--gpu"); + + if (!options.quiet) { + console.log(c.dim(" Running render in Docker container...")); + console.log(""); + } + try { - job = producer.createRenderJob({ - fps: options.fps, - quality: options.quality, - format: options.format, - workers: options.workers, - useGpu: options.gpu, + await new Promise((resolvePromise, reject) => { + const child = spawn("docker", dockerArgs, { + stdio: options.quiet ? "pipe" : "inherit", + }); + child.on("close", (code) => { + if (code === 0) resolvePromise(); + else reject(new Error(`Docker render exited with code ${code}`)); + }); + child.on("error", (err) => reject(err)); }); - await producer.executeRenderJob(job, projectDir, outputPath); } catch (error: unknown) { handleRenderError(error, options, startTime, true, "Check Docker is running: docker info"); } const elapsed = Date.now() - startTime; - trackRenderMetrics(job, elapsed, options, true); + + // Track metrics (no job object available from Docker — use a minimal stub) + trackRenderComplete({ + durationMs: elapsed, + fps: options.fps, + quality: options.quality, + workers: options.workers, + docker: true, + gpu: options.gpu, + ...getMemorySnapshot(), + }); + printRenderComplete(outputPath, elapsed, options.quiet); } From 623a8ee067208dd4e921e567aa9fbc24372bf9dc Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 6 Apr 2026 16:44:15 -0700 Subject: [PATCH 2/7] =?UTF-8?q?fix(cli):=20harden=20Docker=20rendering=20?= =?UTF-8?q?=E2=80=94=20shell=20injection,=20cleanup,=20Dockerfile=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace execSync string interpolation with execFileSync array form to eliminate shell injection surface on docker build/inspect commands - Use try/finally for temp directory cleanup instead of duplicated rmSync - Remove redundant docker info pre-check — dockerImageExists already fails with a clear error if Docker isn't running - Remove duplicate mkdirSync (output dir already created by caller) - Fix Dockerfile.render drift — add hf-render wrapper script so manual builds use chrome-headless-shell for deterministic BeginFrame rendering - Fix printRenderComplete TOCTOU — statSync directly with catch instead of existsSync guard - Remove unused imports (existsSync, execSync) Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile.render | 9 +++- packages/cli/src/commands/render.ts | 70 +++++++++++------------------ 2 files changed, 35 insertions(+), 44 deletions(-) diff --git a/Dockerfile.render b/Dockerfile.render index af45b5e17..2de8c8309 100644 --- a/Dockerfile.render +++ b/Dockerfile.render @@ -63,6 +63,13 @@ RUN npx --yes @puppeteer/browsers install chrome-headless-shell@stable \ # Install hyperframes CLI (bundles producer, engine, core) 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 \ + && chmod +x /usr/local/bin/hf-render + WORKDIR /project -ENTRYPOINT ["hyperframes", "render"] +ENTRYPOINT ["hf-render"] diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 4e3184fbc..6db29d552 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1,6 +1,6 @@ import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; -import { existsSync, mkdirSync, statSync, writeFileSync, rmSync } from "node:fs"; +import { mkdirSync, statSync, writeFileSync, rmSync } from "node:fs"; export const examples: Example[] = [ ["Render to MP4", "hyperframes render --output output.mp4"], @@ -11,7 +11,7 @@ export const examples: Example[] = [ ]; import { cpus, freemem, tmpdir } from "node:os"; import { resolve, dirname, join, basename } from "node:path"; -import { execSync, spawn } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { resolveProject } from "../utils/project.js"; import { lintProject, shouldBlockRender } from "../utils/lintProject.js"; import { formatLintFindings } from "../utils/lintFormat.js"; @@ -271,18 +271,12 @@ interface RenderOptions { browserPath?: string; } -// ── Docker image name + Dockerfile template ──────────────────────────────── - const DOCKER_IMAGE_PREFIX = "hyperframes-renderer"; function dockerImageTag(version: string): string { return `${DOCKER_IMAGE_PREFIX}:${version}`; } -/** - * Generate the Dockerfile content for the render image. - * Installs the same hyperframes version as the running CLI. - */ function generateDockerfile(version: string): string { return `FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \\ @@ -311,22 +305,15 @@ ENTRYPOINT ["hf-render"] `; } -/** - * Check if a Docker image exists locally. - */ function dockerImageExists(tag: string): boolean { try { - execSync(`docker image inspect ${tag}`, { stdio: "pipe", timeout: 10_000 }); + execFileSync("docker", ["image", "inspect", tag], { stdio: "pipe", timeout: 10_000 }); return true; } catch { return false; } } -/** - * Build the Docker render image if it doesn't already exist. - * Returns the image tag. - */ function ensureDockerImage(version: string, quiet: boolean): string { const tag = dockerImageTag(version); @@ -337,28 +324,25 @@ function ensureDockerImage(version: string, quiet: boolean): string { if (!quiet) console.log(c.dim(` Building Docker image: ${tag}...`)); - // Write Dockerfile to a temp directory const tmpDir = join(tmpdir(), `hyperframes-docker-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); const dockerfilePath = join(tmpDir, "Dockerfile"); writeFileSync(dockerfilePath, generateDockerfile(version)); - // Build for linux/amd64 — chrome-headless-shell doesn't ship ARM Linux - // binaries, so we use x86 emulation via Docker Desktop's Rosetta/QEMU. + // linux/amd64 forced — chrome-headless-shell doesn't ship ARM Linux binaries try { - execSync(`docker build --platform linux/amd64 -t ${tag} -f ${dockerfilePath} ${tmpDir}`, { - stdio: quiet ? "pipe" : "inherit", - timeout: 600_000, // 10 minutes - }); + execFileSync( + "docker", + ["build", "--platform", "linux/amd64", "-t", tag, "-f", dockerfilePath, tmpDir], + { stdio: quiet ? "pipe" : "inherit", timeout: 600_000 }, + ); } catch (error: unknown) { - // Clean up temp dir before throwing - rmSync(tmpDir, { recursive: true, force: true }); const message = error instanceof Error ? error.message : String(error); throw new Error(`Failed to build Docker image: ${message}`); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); } - rmSync(tmpDir, { recursive: true, force: true }); - if (!quiet) console.log(c.dim(` Docker image: ${tag} (built)`)); return tag; } @@ -370,27 +354,26 @@ async function renderDocker( ): Promise { const startTime = Date.now(); - // ── Verify Docker is available ─────────────────────────────────────────── + // ensureDockerImage calls `docker image inspect` which fails with a clear + // error if Docker isn't running — no need for a separate `docker info` check. + let imageTag: string; try { - execSync("docker info", { stdio: "pipe", timeout: 10_000 }); - } catch { + imageTag = ensureDockerImage(VERSION, options.quiet); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + const isDockerMissing = /connect|not found|ENOENT/i.test(message); errorBox( - "Docker not available", - "Docker is not running or not installed.", - "Start Docker Desktop or install from https://docs.docker.com/get-docker/", + isDockerMissing ? "Docker not available" : "Docker image build failed", + message, + isDockerMissing + ? "Start Docker Desktop or install from https://docs.docker.com/get-docker/" + : "Check Docker is running: docker info", ); process.exit(1); } - // ── Build or reuse the render image ────────────────────────────────────── - const imageTag = ensureDockerImage(VERSION, options.quiet); - - // ── Prepare output directory ───────────────────────────────────────────── const outputDir = dirname(outputPath); const outputFilename = basename(outputPath); - mkdirSync(outputDir, { recursive: true }); - - // ── Run the render inside Docker ───────────────────────────────────────── const dockerArgs = [ "run", "--rm", @@ -567,9 +550,10 @@ function printRenderComplete(outputPath: string, elapsedMs: number, quiet: boole if (quiet) return; let fileSize = "unknown"; - if (existsSync(outputPath)) { - const stat = statSync(outputPath); - fileSize = formatBytes(stat.size); + try { + fileSize = formatBytes(statSync(outputPath).size); + } catch { + // file doesn't exist or is inaccessible } const duration = formatDuration(elapsedMs); From 9837962fa83d26894b791ece1a5b4265d2ddaed2 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 6 Apr 2026 16:47:55 -0700 Subject: [PATCH 3/7] fix(cli): forward --quiet/--gpu flags into Docker container - Forward --quiet so the container suppresses its own output, avoiding duplicate render progress from both host and container - When quiet, still inherit stderr so container errors surface - Pass --gpus all to docker run when --gpu is set, so the container actually has GPU access for FFmpeg encoding Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/render.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 6db29d552..6cb98890b 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -401,7 +401,12 @@ async function renderDocker( "--workers", String(options.workers), ]; - if (options.gpu) dockerArgs.push("--gpu"); + if (options.quiet) dockerArgs.push("--quiet"); + // GPU encoding requires host GPU access via --gpus + if (options.gpu) { + dockerArgs.splice(dockerArgs.indexOf("--rm") + 1, 0, "--gpus", "all"); + dockerArgs.push("--gpu"); + } if (!options.quiet) { console.log(c.dim(" Running render in Docker container...")); @@ -411,7 +416,8 @@ async function renderDocker( try { await new Promise((resolvePromise, reject) => { const child = spawn("docker", dockerArgs, { - stdio: options.quiet ? "pipe" : "inherit", + // When quiet, still show stderr so container errors surface + stdio: options.quiet ? ["pipe", "pipe", "inherit"] : "inherit", }); child.on("close", (code) => { if (code === 0) resolvePromise(); From 3b6771a269c77196ecac554de39021ec273150ce Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 6 Apr 2026 16:52:06 -0700 Subject: [PATCH 4/7] docs: remove alpha tag from Dockerfile.render example Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile.render | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.render b/Dockerfile.render index 2de8c8309..cabdb007c 100644 --- a/Dockerfile.render +++ b/Dockerfile.render @@ -6,7 +6,7 @@ # # Manual build: # docker build -f Dockerfile.render -t hyperframes-renderer:dev \ -# --build-arg HYPERFRAMES_VERSION=0.2.3-alpha.1 . +# --build-arg HYPERFRAMES_VERSION=0.2.3 . # # Manual run: # docker run --rm --security-opt seccomp=unconfined --shm-size=2g \ From 87a714e88212d91e469300257103d77c544cd24f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 6 Apr 2026 17:50:46 -0700 Subject: [PATCH 5/7] fix(cli): address code review feedback on Docker rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Error early when VERSION is "0.0.0-dev" (running from source) since the Docker image installs from npm and needs a real published version 2. Build GPU args (--gpus all) conditionally in the array instead of splicing after construction — splice with indexOf is fragile if the target element moves 3. Remove Dockerfile.render — the CLI generates the Dockerfile dynamically via generateDockerfile(), a second copy at the repo root will drift Not addressed (pushback): - seccomp=unconfined: consistent with docker:test scripts in producer/package.json and the CI regression workflow. Required for Chromium's syscall requirements in Docker alongside --no-sandbox. - Version bumps to other packages: no package.json version changes exist in this PR. The reviewer may be seeing the local-only "chore: release v0.2.3-alpha.1" commit on main. Co-Authored-By: Claude Opus 4.6 (1M context) --- Dockerfile.render | 75 ----------------------------- packages/cli/src/commands/render.ts | 26 ++++++---- 2 files changed, 16 insertions(+), 85 deletions(-) delete mode 100644 Dockerfile.render diff --git a/Dockerfile.render b/Dockerfile.render deleted file mode 100644 index cabdb007c..000000000 --- a/Dockerfile.render +++ /dev/null @@ -1,75 +0,0 @@ -# HyperFrames — Deterministic Render Image -# -# Provides Chrome, FFmpeg, and fonts for byte-identical renders. -# The CLI generates this Dockerfile dynamically (with the correct version) -# when you run `hyperframes render --docker`. -# -# Manual build: -# docker build -f Dockerfile.render -t hyperframes-renderer:dev \ -# --build-arg HYPERFRAMES_VERSION=0.2.3 . -# -# Manual run: -# docker run --rm --security-opt seccomp=unconfined --shm-size=2g \ -# -v /path/to/project:/project:ro \ -# -v /path/to/output:/output \ -# hyperframes-renderer:dev \ -# /project --output /output/render.mp4 --fps 30 --quality standard - -FROM node:22-bookworm-slim - -ARG HYPERFRAMES_VERSION=latest - -# ── System dependencies ───────────────────────────────────────────────────── -RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates \ - curl \ - unzip \ - ffmpeg \ - chromium \ - libgbm1 \ - libnss3 \ - libatk-bridge2.0-0 \ - libdrm2 \ - libxcomposite1 \ - libxdamage1 \ - libxrandr2 \ - libcups2 \ - libasound2 \ - libpangocairo-1.0-0 \ - libxshmfence1 \ - libgtk-3-0 \ - fonts-liberation \ - fonts-noto-color-emoji \ - fonts-noto-cjk \ - fonts-noto-core \ - fonts-noto-extra \ - fonts-noto-ui-core \ - fonts-freefont-ttf \ - fonts-dejavu-core \ - fontconfig \ - && rm -rf /var/lib/apt/lists/* \ - && apt-get clean \ - && fc-cache -fv - -ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true -ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium -ENV CONTAINER=true - -# chrome-headless-shell for deterministic BeginFrame rendering -RUN npx --yes @puppeteer/browsers install chrome-headless-shell@stable \ - --path /root/.cache/puppeteer \ - && echo "chrome-headless-shell installed" - -# Install hyperframes CLI (bundles producer, engine, core) -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 \ - && chmod +x /usr/local/bin/hf-render - -WORKDIR /project - -ENTRYPOINT ["hf-render"] diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 6cb98890b..d5ab4364a 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -354,8 +354,18 @@ async function renderDocker( ): Promise { const startTime = Date.now(); - // ensureDockerImage calls `docker image inspect` which fails with a clear - // error if Docker isn't running — no need for a separate `docker info` check. + // VERSION is "0.0.0-dev" when running from source (tsx/ts-node) because + // __CLI_VERSION__ is only defined by tsup at build time. The Docker image + // installs from npm, so it needs a real published version. + if (VERSION === "0.0.0-dev") { + errorBox( + "Docker rendering requires a published version", + 'Running from source sets VERSION to "0.0.0-dev" which does not exist on npm.', + "Build the CLI first (bun run build) or use a published release (npx hyperframes render --docker)", + ); + process.exit(1); + } + let imageTag: string; try { imageTag = ensureDockerImage(VERSION, options.quiet); @@ -382,13 +392,13 @@ async function renderDocker( "--security-opt", "seccomp=unconfined", "--shm-size=2g", - // Mount project read-only, output directory read-write + // GPU encoding requires host GPU passthrough + ...(options.gpu ? ["--gpus", "all"] : []), "-v", `${resolve(projectDir)}:/project:ro`, "-v", `${resolve(outputDir)}:/output`, imageTag, - // Arguments to `hyperframes render` inside the container "/project", "--output", `/output/${outputFilename}`, @@ -400,13 +410,9 @@ async function renderDocker( options.format, "--workers", String(options.workers), + ...(options.quiet ? ["--quiet"] : []), + ...(options.gpu ? ["--gpu"] : []), ]; - if (options.quiet) dockerArgs.push("--quiet"); - // GPU encoding requires host GPU access via --gpus - if (options.gpu) { - dockerArgs.splice(dockerArgs.indexOf("--rm") + 1, 0, "--gpus", "all"); - dockerArgs.push("--gpu"); - } if (!options.quiet) { console.log(c.dim(" Running render in Docker container...")); From f6c6509fff5151a03ce031763e4904675355ab3f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 6 Apr 2026 17:58:32 -0700 Subject: [PATCH 6/7] fix(cli): drop seccomp=unconfined from Docker render Chrome runs with --no-sandbox inside the container, which is sufficient. Tested: renders complete successfully without seccomp=unconfined. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/src/commands/render.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index d5ab4364a..33ac3d41d 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -389,8 +389,6 @@ async function renderDocker( "--rm", "--platform", "linux/amd64", - "--security-opt", - "seccomp=unconfined", "--shm-size=2g", // GPU encoding requires host GPU passthrough ...(options.gpu ? ["--gpus", "all"] : []), From 2bed6a0cf3b0ade38ebd49e8715c51be5e16d6e2 Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 6 Apr 2026 18:15:01 -0700 Subject: [PATCH 7/7] refactor(cli): package Dockerfile as static asset, use isDevMode() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review feedback from jrusso1020: 1. Package Dockerfile.render as a static asset in src/docker/ instead of generating it as a template string — easier to read, lint, diff. Copied to dist/docker/ during build:copy step. 2. Use existing isDevMode() from utils/env.ts instead of checking VERSION === "0.0.0-dev". Dev mode falls back to "latest" so --docker works from source without a published version. 3. Fix Docker hint to be generic ("Install Docker") instead of Mac-specific ("Start Docker Desktop"). Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/cli/package.json | 2 +- packages/cli/src/commands/render.ts | 78 +++++++++++------------ packages/cli/src/docker/Dockerfile.render | 33 ++++++++++ 3 files changed, 70 insertions(+), 43 deletions(-) create mode 100644 packages/cli/src/docker/Dockerfile.render diff --git a/packages/cli/package.json b/packages/cli/package.json index 4f2146f63..185290277 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -21,7 +21,7 @@ "build:fonts": "cd ../producer && tsx scripts/generate-font-data.ts", "build:studio": "cd ../studio && bun run build", "build:runtime": "tsx scripts/build-runtime.ts", - "build:copy": "mkdir -p dist/studio dist/docs dist/templates dist/skills && cp -r ../studio/dist/* dist/studio/ && cp -r src/templates/blank src/templates/_shared dist/templates/ && cp -r ../../skills/hyperframes ../../skills/hyperframes-cli ../../skills/gsap dist/skills/ && (cp src/docs/*.md dist/docs/ 2>/dev/null || true)", + "build:copy": "mkdir -p dist/studio dist/docs dist/templates dist/skills dist/docker && cp -r ../studio/dist/* dist/studio/ && cp -r src/templates/blank src/templates/_shared dist/templates/ && cp -r ../../skills/hyperframes ../../skills/hyperframes-cli ../../skills/gsap dist/skills/ && cp src/docker/Dockerfile.render dist/docker/ && (cp src/docs/*.md dist/docs/ 2>/dev/null || true)", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 33ac3d41d..a4b495c15 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -1,6 +1,6 @@ import { defineCommand } from "citty"; import type { Example } from "./_examples.js"; -import { mkdirSync, statSync, writeFileSync, rmSync } from "node:fs"; +import { mkdirSync, readFileSync, statSync, writeFileSync, rmSync } from "node:fs"; export const examples: Example[] = [ ["Render to MP4", "hyperframes render --output output.mp4"], @@ -22,6 +22,7 @@ import { renderProgress } from "../ui/progress.js"; import { trackRenderComplete, trackRenderError } from "../telemetry/events.js"; import { bytesToMb } from "../telemetry/system.js"; import { VERSION } from "../version.js"; +import { isDevMode } from "../utils/env.js"; import type { RenderJob } from "@hyperframes/producer"; const VALID_FPS = new Set([24, 30, 60]); @@ -277,32 +278,20 @@ function dockerImageTag(version: string): string { return `${DOCKER_IMAGE_PREFIX}:${version}`; } -function generateDockerfile(version: string): string { - return `FROM node:22-bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends \\ - ca-certificates curl unzip ffmpeg chromium \\ - libgbm1 libnss3 libatk-bridge2.0-0 libdrm2 libxcomposite1 \\ - libxdamage1 libxrandr2 libcups2 libasound2 libpangocairo-1.0-0 \\ - libxshmfence1 libgtk-3-0 \\ - fonts-liberation fonts-noto-color-emoji fonts-noto-cjk fonts-noto-core \\ - fonts-noto-extra fonts-noto-ui-core fonts-freefont-ttf fonts-dejavu-core \\ - fontconfig \\ - && rm -rf /var/lib/apt/lists/* && apt-get clean && fc-cache -fv -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 -RUN npm install -g 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 \\ - && chmod +x /usr/local/bin/hf-render -WORKDIR /project -ENTRYPOINT ["hf-render"] -`; +function resolveDockerfilePath(): string { + // Built CLI: dist/docker/Dockerfile.render + const builtPath = resolve(__dirname, "docker", "Dockerfile.render"); + // Dev mode: src/docker/Dockerfile.render + const devPath = resolve(__dirname, "..", "src", "docker", "Dockerfile.render"); + for (const p of [builtPath, devPath]) { + try { + statSync(p); + return p; + } catch { + continue; + } + } + throw new Error("Dockerfile.render not found — CLI package may be corrupted"); } function dockerImageExists(tag: string): boolean { @@ -324,16 +313,27 @@ function ensureDockerImage(version: string, quiet: boolean): string { if (!quiet) console.log(c.dim(` Building Docker image: ${tag}...`)); + const dockerfilePath = resolveDockerfilePath(); + + // Copy Dockerfile to a temp build context so docker build has a clean context const tmpDir = join(tmpdir(), `hyperframes-docker-${Date.now()}`); mkdirSync(tmpDir, { recursive: true }); - const dockerfilePath = join(tmpDir, "Dockerfile"); - writeFileSync(dockerfilePath, generateDockerfile(version)); + writeFileSync(join(tmpDir, "Dockerfile"), readFileSync(dockerfilePath)); // linux/amd64 forced — chrome-headless-shell doesn't ship ARM Linux binaries try { execFileSync( "docker", - ["build", "--platform", "linux/amd64", "-t", tag, "-f", dockerfilePath, tmpDir], + [ + "build", + "--platform", + "linux/amd64", + "--build-arg", + `HYPERFRAMES_VERSION=${version}`, + "-t", + tag, + tmpDir, + ], { stdio: quiet ? "pipe" : "inherit", timeout: 600_000 }, ); } catch (error: unknown) { @@ -354,21 +354,15 @@ async function renderDocker( ): Promise { const startTime = Date.now(); - // VERSION is "0.0.0-dev" when running from source (tsx/ts-node) because - // __CLI_VERSION__ is only defined by tsup at build time. The Docker image - // installs from npm, so it needs a real published version. - if (VERSION === "0.0.0-dev") { - errorBox( - "Docker rendering requires a published version", - 'Running from source sets VERSION to "0.0.0-dev" which does not exist on npm.', - "Build the CLI first (bun run build) or use a published release (npx hyperframes render --docker)", - ); - process.exit(1); + // Dev mode (tsx/ts-node) uses "latest" since the local version isn't on npm + const dockerVersion = isDevMode() ? "latest" : VERSION; + if (!options.quiet && isDevMode()) { + console.log(c.dim(" Dev mode: using hyperframes@latest in Docker image")); } let imageTag: string; try { - imageTag = ensureDockerImage(VERSION, options.quiet); + imageTag = ensureDockerImage(dockerVersion, options.quiet); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); const isDockerMissing = /connect|not found|ENOENT/i.test(message); @@ -376,7 +370,7 @@ async function renderDocker( isDockerMissing ? "Docker not available" : "Docker image build failed", message, isDockerMissing - ? "Start Docker Desktop or install from https://docs.docker.com/get-docker/" + ? "Install Docker: https://docs.docker.com/get-docker/" : "Check Docker is running: docker info", ); process.exit(1); diff --git a/packages/cli/src/docker/Dockerfile.render b/packages/cli/src/docker/Dockerfile.render new file mode 100644 index 000000000..ba3cb12c5 --- /dev/null +++ b/packages/cli/src/docker/Dockerfile.render @@ -0,0 +1,33 @@ +FROM node:22-bookworm-slim + +ARG HYPERFRAMES_VERSION=latest + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl unzip ffmpeg chromium \ + libgbm1 libnss3 libatk-bridge2.0-0 libdrm2 libxcomposite1 \ + libxdamage1 libxrandr2 libcups2 libasound2 libpangocairo-1.0-0 \ + libxshmfence1 libgtk-3-0 \ + fonts-liberation fonts-noto-color-emoji fonts-noto-cjk fonts-noto-core \ + fonts-noto-extra fonts-noto-ui-core fonts-freefont-ttf fonts-dejavu-core \ + fontconfig \ + && rm -rf /var/lib/apt/lists/* && apt-get clean && fc-cache -fv + +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 + +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 \ + && chmod +x /usr/local/bin/hf-render + +WORKDIR /project + +ENTRYPOINT ["hf-render"]