Skip to content

feat(cli): implement Docker rendering for deterministic output#215

Merged
vanceingalls merged 7 commits intomainfrom
feat/docker-render
Apr 7, 2026
Merged

feat(cli): implement Docker rendering for deterministic output#215
vanceingalls merged 7 commits intomainfrom
feat/docker-render

Conversation

@vanceingalls
Copy link
Copy Markdown
Collaborator

Summary

  • The --docker flag was a no-op stubrenderDocker called the same local executeRenderJob as renderLocal, no container was ever launched
  • Now renderDocker generates a Dockerfile, builds a versioned hyperframes-renderer:<version> image with Chrome/FFmpeg/fonts/chrome-headless-shell, and runs the render inside a container
  • Image is cached per CLI version — first render builds (~2 min), subsequent renders reuse it
  • Forces linux/amd64 platform since chrome-headless-shell has no ARM Linux binary
  • Uses execFileSync (array form) throughout to prevent shell injection
  • Forwards --quiet, --gpu, and render config flags into the container
  • Dockerfile.render added as a reference for manual builds

Test plan

  • hyperframes render --docker builds image and produces valid MP4
  • Second run reuses cached image (no rebuild)
  • --quiet suppresses container output while keeping stderr for errors
  • Typecheck, lint, format all pass
  • Verify --gpu with --docker on a machine with GPU access

🤖 Generated with Claude Code

Copy link
Copy Markdown
Collaborator

@miguel-heygen miguel-heygen left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review

Good work — array-form exec, read-only mounts, try/finally cleanup, and no --docker recursion are well done.

Must fix

1. VERSION is "0.0.0-dev" when running from source (version.ts:2)

ensureDockerImage(VERSION, ...) generates npm install -g hyperframes@0.0.0-dev inside the Dockerfile — that version doesn't exist on npm. --docker is broken for anyone running from source. Options:

  • Early error: "Docker rendering requires a published hyperframes version"
  • Fallback to latest for dev versions
  • Add a --docker-image override flag

2. GPU flag insertion via splice is fragile (render.ts:407)

dockerArgs.splice(dockerArgs.indexOf("--rm") + 1, 0, "--gpus", "all");

If "--rm" is removed/moved, indexOf returns -1 and splice(0, 0, ...) silently corrupts the args. Build the list conditionally instead of patching after construction.

Should fix

3. seccomp=unconfined is overly broad (render.ts:383)

The engine already passes --no-sandbox to Chrome unconditionally (browserManager.ts:164), so full seccomp disable may not be necessary. Test with a minimal profile or document why it's required.

4. Dockerfile template duplication

generateDockerfile() in render.ts and Dockerfile.render at repo root contain the same Dockerfile with different formatting. These will drift. Either embed the file from package assets or remove Dockerfile.render.

5. Version bumps to core/engine/producer/studio are premature

All 5 packages bumped but only the CLI has code changes. Triggers unnecessary publishes.

@vanceingalls
Copy link
Copy Markdown
Collaborator Author

vanceingalls commented Apr 7, 2026

Addressed in 6104876:

1. VERSION "0.0.0-dev" — Fixed. Early error with clear message when running from source. The built CLI (tsup) defines __CLI_VERSION__ so published versions work.

2. GPU splice fragility — Fixed. Args built conditionally with spreads instead of post-construction splice.

3. seccomp=unconfined — Removed. Tested without it — renders complete successfully. --no-sandbox is sufficient.

4. Dockerfile duplication — Removed Dockerfile.render entirely. The CLI generates the Dockerfile dynamically via generateDockerfile() — a second copy at the repo root was drift waiting to happen.

5. Version bumps — Removed. The chore: release v0.2.3-alpha.1 commit existed on local main.

vanceingalls and others added 5 commits April 6, 2026 17:55
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@<current-version>
- Builds a linux/amd64 image (hyperframes-renderer:<version>) 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) <noreply@anthropic.com>
…ile drift

- 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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
Comment thread packages/cli/src/commands/render.ts Outdated
Comment on lines +280 to +305
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"]
`;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we just package a dockerfile in the CLI?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could go either way, dockerfile would be cleaner. ill go back to that

Comment thread packages/cli/src/commands/render.ts Outdated
// 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") {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we had a function for isDev or something?

Comment thread packages/cli/src/commands/render.ts Outdated
isDockerMissing ? "Docker not available" : "Docker image build failed",
message,
isDockerMissing
? "Start Docker Desktop or install from https://docs.docker.com/get-docker/"
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

docker desktop isn't the only way to run docker I"m not sure if we should specifiy this or just say download docker for your system to use this feature

Copy link
Copy Markdown
Collaborator

@jrusso1020 jrusso1020 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fwiw I'm not sure if we need docker rendering in the CLI.. but we can add as optional feature

@vanceingalls
Copy link
Copy Markdown
Collaborator Author

vanceingalls commented Apr 7, 2026

fwiw I'm not sure if we need docker rendering in the CLI.. but we can add as optional feature

Docker (or any Linux environment) is required for deterministic frame capture via HeadlessExperimental.beginFrame — Chrome's CDP API that runs an atomic layout → paint → composite → screenshot cycle in a single call. This is what guarantees frame-perfect output.

On macOS/Windows, the engine falls back to Page.captureScreenshot, which has a gap between "the browser finished painting" and "we took the screenshot" — the same non-determinism issue that Remotion has.

Platform Capture Mode Frame Accuracy
Linux (Docker, CI, prod server) BeginFrame — atomic compositor control Deterministic
macOS/Windows (local dev) Page.captureScreenshot Best-effort (same as Remotion)

Everything else — audio sync (FFmpeg-native), font embedding, video frame pre-extraction, GSAP direct seek, ProRes/H.265 codecs — works on all platforms. It's specifically the frame capture determinism that's Linux-only.

Practical model: author on your Mac, render for production on Linux. Docker is the simplest path for local deterministic renders on non-Linux machines.

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) <noreply@anthropic.com>
@vanceingalls vanceingalls merged commit 85a76c0 into main Apr 7, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants