Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions packages/cli/src/browser/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,12 +107,23 @@ export async function findBrowser(): Promise<BrowserResult | undefined> {
}

/**
* 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<BrowserResult> {
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) {
Expand Down
36 changes: 33 additions & 3 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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,
Expand Down
10 changes: 8 additions & 2 deletions packages/engine/src/services/browserManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading