From d30cf68daea6279349ace9f1be325dc162c5b2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 29 Mar 2026 11:03:52 +0000 Subject: [PATCH 1/3] fix(cli): catch output directory creation errors in render mkdirSync for the render output path was not wrapped in try-catch, exposing a raw Node.js EACCES stack trace when the path was invalid or permissions were denied. Reproducer: npx hyperframes render --output /root/nope/output.mp4 # Was: raw "Error: EACCES: permission denied" with stack trace # Now: clean "Cannot create output directory" error box --- packages/cli/src/commands/render.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 4f1ff681..71f1d919 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -126,7 +126,17 @@ Examples: : join(rendersDir, `${project.name}_${datePart}_${timePart}${ext}`); // Ensure output directory exists - mkdirSync(dirname(outputPath), { recursive: true }); + try { + mkdirSync(dirname(outputPath), { recursive: true }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + errorBox( + "Cannot create output directory", + `${dirname(outputPath)}: ${message}`, + "Check that you have write permission or specify a different path with --output.", + ); + process.exit(1); + } const useDocker = args.docker ?? false; const useGpu = args.gpu ?? false; From aa2b4f57ea3711837ad67bbe6611bfbc0240af29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 29 Mar 2026 11:04:06 +0000 Subject: [PATCH 2/3] fix(cli): improve error message for zero-duration composition renders When a composition had zero duration (empty GSAP timeline, no data-duration), Docker render hung for 45s then showed the misleading hint "Check Docker is running" even though Docker was fine. Now detects the __hf-not-ready error and suggests adding data-duration or GSAP tweens instead. Reproducer: # Create composition with empty timeline and no data-duration npx hyperframes render --docker --output out.mp4 # Was: "Check Docker is running: docker info" # Now: "Composition may have zero duration. Add data-duration..." --- packages/cli/src/commands/render.ts | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 71f1d919..c1587564 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -256,7 +256,13 @@ async function renderDocker( }); await producer.executeRenderJob(job, projectDir, outputPath); } catch (error: unknown) { - handleRenderError(error, options, startTime, true, "Check Docker is running: docker info"); + handleRenderError( + error, + options, + startTime, + true, + renderHintForError(error, "Check Docker is running: docker info"), + ); } const elapsed = Date.now() - startTime; @@ -297,7 +303,13 @@ async function renderLocal( try { await producer.executeRenderJob(job, projectDir, outputPath, onProgress); } catch (error: unknown) { - handleRenderError(error, options, startTime, false, "Try --docker for containerized rendering"); + handleRenderError( + error, + options, + startTime, + false, + renderHintForError(error, "Try --docker for containerized rendering"), + ); } const elapsed = Date.now() - startTime; @@ -312,6 +324,14 @@ function getMemorySnapshot() { }; } +function renderHintForError(error: unknown, fallback: string): string { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("__hf not ready")) { + return "Composition may have zero duration. Add data-duration to the root element or ensure the GSAP timeline has tweens."; + } + return fallback; +} + function handleRenderError( error: unknown, options: RenderOptions, From 4c72ba4a36ec2bd6733f7b9cb2a9e63f9fb234b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sun, 29 Mar 2026 11:59:18 +0000 Subject: [PATCH 3/3] fix(engine): fall back to screenshot mode when system Chrome is not headless-shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine assumed any binary passed via PRODUCER_HEADLESS_SHELL_PATH supported the HeadlessExperimental.beginFrame CDP command. When the CLI resolved system Chrome (e.g. /usr/bin/google-chrome) instead of chrome-headless-shell, the render would silently hang for 120s then timeout — the #1 new-user friction point. Now checks the binary path for "chrome-headless-shell" before selecting beginframe capture mode. System Chrome falls back to screenshot mode which works universally. Reproducer: # On a machine with system Chrome but no chrome-headless-shell cached npx hyperframes init test --template blank --non-interactive cd test && npx hyperframes render --output out.mp4 # Was: 120s hang, then "Timed out after waiting 120000ms" # Now: renders successfully via screenshot mode --- packages/cli/src/browser/manager.ts | 19 +++++++++++++++---- .../engine/src/services/browserManager.ts | 10 ++++++++-- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/browser/manager.ts b/packages/cli/src/browser/manager.ts index 4ca5a4ab..eccbf162 100644 --- a/packages/cli/src/browser/manager.ts +++ b/packages/cli/src/browser/manager.ts @@ -107,12 +107,23 @@ export async function findBrowser(): Promise { } /** - * Find or download a browser. - * Resolution: env var -> cached download -> system Chrome -> auto-download. + * Find or download a browser suitable for rendering. + * Resolution: env var -> cached headless-shell -> auto-download. + * + * System Chrome is NOT used for rendering because it does not support + * the HeadlessExperimental.beginFrame CDP command required for + * deterministic frame capture, and older system Chrome versions are + * often incompatible with the version of puppeteer-core bundled in + * the engine (protocol mismatch → silent 120s timeout). */ export async function ensureBrowser(options?: EnsureBrowserOptions): Promise { - const existing = await findBrowser(); - if (existing) return existing; + // Env override and cached headless-shell are trusted. + // System Chrome is skipped — it causes silent render timeouts. + const fromEnv = findFromEnv(); + if (fromEnv) return fromEnv; + + const fromCache = await findFromCache(); + if (fromCache) return fromCache; const platform = detectBrowserPlatform(); if (!platform) { diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index 1262f1c7..03fd1bae 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -97,16 +97,22 @@ export async function acquireBrowser( const headlessShell = resolveHeadlessShellPath(config); // BeginFrame requires chrome-headless-shell AND Linux (crashes on macOS/Windows). + // System Chrome (google-chrome, chromium) does NOT support the + // HeadlessExperimental.beginFrame CDP command — only the dedicated + // chrome-headless-shell binary does. Passing system Chrome as the + // executable causes a 120-second silent hang followed by a timeout. const isLinux = process.platform === "linux"; const forceScreenshot = config?.forceScreenshot ?? DEFAULT_CONFIG.forceScreenshot; + const isHeadlessShellBinary = headlessShell ? /chrome-headless-shell/.test(headlessShell) : false; let captureMode: CaptureMode; let executablePath: string | undefined; - if (headlessShell && isLinux && !forceScreenshot) { + if (headlessShell && isLinux && isHeadlessShellBinary && !forceScreenshot) { captureMode = "beginframe"; executablePath = headlessShell; } else { - // Screenshot mode with renderSeek: works on all platforms. + // Screenshot mode with renderSeek: works on all platforms, and + // as a fallback when system Chrome is the only available binary. captureMode = "screenshot"; executablePath = headlessShell ?? undefined; }