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/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 4f1ff681..c1587564 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; @@ -246,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; @@ -287,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; @@ -302,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, 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; }