diff --git a/packages/cli/package.json b/packages/cli/package.json index 4f2146f6..18529027 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 c607e7ba..a4b495c1 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 { mkdirSync, readFileSync, 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 { 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"; @@ -20,6 +21,8 @@ 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 { isDevMode } from "../utils/env.js"; import type { RenderJob } from "@hyperframes/producer"; const VALID_FPS = new Set([24, 30, 60]); @@ -269,30 +272,174 @@ interface RenderOptions { browserPath?: string; } +const DOCKER_IMAGE_PREFIX = "hyperframes-renderer"; + +function dockerImageTag(version: string): string { + return `${DOCKER_IMAGE_PREFIX}:${version}`; +} + +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 { + try { + execFileSync("docker", ["image", "inspect", tag], { stdio: "pipe", timeout: 10_000 }); + return true; + } catch { + return false; + } +} + +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}...`)); + + 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 }); + 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", + "--build-arg", + `HYPERFRAMES_VERSION=${version}`, + "-t", + tag, + tmpDir, + ], + { stdio: quiet ? "pipe" : "inherit", timeout: 600_000 }, + ); + } catch (error: unknown) { + 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 }); + } + + 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; + // 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 { - job = producer.createRenderJob({ - fps: options.fps, - quality: options.quality, - format: options.format, - workers: options.workers, - useGpu: options.gpu, + 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); + errorBox( + isDockerMissing ? "Docker not available" : "Docker image build failed", + message, + isDockerMissing + ? "Install Docker: https://docs.docker.com/get-docker/" + : "Check Docker is running: docker info", + ); + process.exit(1); + } + + const outputDir = dirname(outputPath); + const outputFilename = basename(outputPath); + const dockerArgs = [ + "run", + "--rm", + "--platform", + "linux/amd64", + "--shm-size=2g", + // GPU encoding requires host GPU passthrough + ...(options.gpu ? ["--gpus", "all"] : []), + "-v", + `${resolve(projectDir)}:/project:ro`, + "-v", + `${resolve(outputDir)}:/output`, + imageTag, + "/project", + "--output", + `/output/${outputFilename}`, + "--fps", + String(options.fps), + "--quality", + options.quality, + "--format", + options.format, + "--workers", + String(options.workers), + ...(options.quiet ? ["--quiet"] : []), + ...(options.gpu ? ["--gpu"] : []), + ]; + + if (!options.quiet) { + console.log(c.dim(" Running render in Docker container...")); + console.log(""); + } + + try { + await new Promise((resolvePromise, reject) => { + const child = spawn("docker", dockerArgs, { + // When quiet, still show stderr so container errors surface + stdio: options.quiet ? ["pipe", "pipe", "inherit"] : "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); } @@ -407,9 +554,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); diff --git a/packages/cli/src/docker/Dockerfile.render b/packages/cli/src/docker/Dockerfile.render new file mode 100644 index 00000000..ba3cb12c --- /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"]