fix(engine,cli): resolve drawElement to a Chrome build that actually has it

canvas.drawElementImage is an unlaunched Dev/Canary-only Blink feature
(~151+). The CLI's pinned CHROME_VERSION fallback was still 131.0.6778.85 —
a puppeteer 24→25.2.1 bump that pinned it to Chrome Dev 151.0.7912.0 was
written on 2026-06-29 but never merged (orphaned local commit, no PR). Any
render on that pin, or on the shared puppeteer-cache binary, or on system
Chrome (Stable, no drawElementImage at all) got a canvas.getContext("2d")
missing the method and crashed mid-capture with "ctx.drawElementImage is
not a function" instead of falling back (HF#2060).

Three changes:
- Bump puppeteer/puppeteer-core to ^25.2.1 across every package that
  depends on it, and CHROME_VERSION to 152.0.7928.2 (today's Dev channel;
  confirmed via direct probe to implement drawElementImage, unlike 131).
- `ensureBrowser({ preferManagedChrome: true })`, always used by `render`:
  resolve straight to our pinned/cached build, skipping both the shared
  puppeteer-cache preference and system Chrome. Rendering shouldn't depend
  on whatever arbitrary Chrome a machine happens to have — that's exactly
  how this regressed (any Mac with Chrome.app installed bypassed the CLI's
  pin entirely).
- A runtime capability probe in the engine, right before any other
  drawElement work: if `drawElementImage` isn't a function on the injected
  canvas, route to the existing screenshot-fallback gate instead of
  crashing. This is the real backstop — it protects every resolution path
  (env override, stale cache entry, a future Chrome regression), not just
  the ones `preferManagedChrome` reaches.

Verified end-to-end: rendering against chrome-headless-shell 131 (confirmed
to lack drawElementImage) now falls back cleanly and produces a valid MP4
instead of crashing; rendering against a capable build still engages
drawElement normally. 922 engine tests + 1373 CLI tests pass.

Fixes #2060.
This commit is contained in:
Vance Ingalls
2026-07-08 14:14:28 -07:00
parent 38b27c4d82
commit 8854bad8f9
11 changed files with 187 additions and 74 deletions
+1 -1
View File
@@ -53,7 +53,7 @@
"@sparticuz/chromium": "148.0.0",
"ffmpeg-static": "^5.2.0",
"ffprobe-static": "^3.1.0",
"puppeteer-core": "^24.39.1",
"puppeteer-core": "^25.2.1",
"tar": "^7.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -41,7 +41,7 @@
"open": "^10.0.0",
"postcss": "^8.5.8",
"prettier": "^3.8.1",
"puppeteer-core": "^24.39.1",
"puppeteer-core": "^25.2.1",
"sharp": "^0.34.5"
},
"devDependencies": {
+61 -37
View File
@@ -20,7 +20,7 @@ async function loadPuppeteerBrowsers(): Promise<PuppeteerBrowsers> {
}
}
const CHROME_VERSION = "131.0.6778.85";
const CHROME_VERSION = "152.0.7928.2";
const CACHE_ROOT_DIR = join(homedir(), ".cache", "hyperframes");
const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome");
// Puppeteer's managed cache — where `@puppeteer/browsers install
@@ -133,6 +133,17 @@ export interface EnsureBrowserOptions {
// Purge any cached HF-managed download before resolving, so a stale or
// partially-extracted install can't make the retry look like a no-op.
force?: boolean;
// Always resolve to OUR pinned `CHROME_VERSION` build (cached, or freshly
// downloaded) — skip both the shared puppeteer-cache preference (some other
// tool's install, arbitrary version) and system Chrome (tracks Stable,
// arbitrary version, doesn't get updated in lockstep with this codebase).
// Rendering behavior should not vary with whatever Chrome happens to be
// sitting on the machine: it's the version we've actually tested against,
// and the one that implements `canvas.drawElementImage` (Dev/Canary-only —
// Stable doesn't have it, so system Chrome used to crash drawElement-
// eligible renders outright; HF#2060). `HYPERFRAMES_BROWSER_PATH` still
// wins over this — an explicit override is still an explicit override.
preferManagedChrome?: boolean;
}
interface CacheLookupResult {
@@ -195,6 +206,38 @@ function findFromEnv(): BrowserResult | undefined {
return undefined;
}
/**
* Hyperframes-managed cache only (populated by `ensureBrowser` as a
* download-of-last-resort, pinned to `CHROME_VERSION`).
*/
async function findFromHyperframesCache(): Promise<CacheLookupResult> {
if (!existsSync(CACHE_DIR)) return {};
const { Browser, getInstalledBrowsers } = await loadPuppeteerBrowsers();
// A corrupt cache (stub file where a browser dir is expected, malformed
// metadata) makes getInstalledBrowsers throw. Treat that as "no cached
// browser" so resolution falls through to system/download instead of
// crashing every caller.
let installed: Awaited<ReturnType<typeof getInstalledBrowsers>>;
try {
installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
const suffix = code ? ` (${code})` : "";
console.warn(
`[hyperframes] Browser cache read failed${suffix}: ${normalizeErrorMessage(err)}. Falling back to system Chrome or a fresh download.`,
);
installed = [];
}
const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
if (match && existsSync(match.executablePath)) {
return { result: { executablePath: match.executablePath, source: "cache" } };
}
if (match) {
return { staleHyperframesCachePath: match.executablePath, staleInstallPath: match.path };
}
return {};
}
async function findFromCache(): Promise<CacheLookupResult> {
// 1) Puppeteer's managed cache — where `npx @puppeteer/browsers install
// chrome-headless-shell` lands, and where `puppeteer install` from a project
@@ -213,36 +256,9 @@ async function findFromCache(): Promise<CacheLookupResult> {
return { result: fromPuppeteer };
}
// 2) Hyperframes-managed cache (populated by `ensureBrowser` below as a
// download-of-last-resort). This is the fallback path: only reached when
// no puppeteer-cache binary exists.
if (existsSync(CACHE_DIR)) {
const { Browser, getInstalledBrowsers } = await loadPuppeteerBrowsers();
// A corrupt cache (stub file where a browser dir is expected, malformed
// metadata) makes getInstalledBrowsers throw. Treat that as "no cached
// browser" so resolution falls through to system/download instead of
// crashing every caller.
let installed: Awaited<ReturnType<typeof getInstalledBrowsers>>;
try {
installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
} catch (err) {
const code = (err as NodeJS.ErrnoException | undefined)?.code;
const suffix = code ? ` (${code})` : "";
console.warn(
`[hyperframes] Browser cache read failed${suffix}: ${normalizeErrorMessage(err)}. Falling back to system Chrome or a fresh download.`,
);
installed = [];
}
const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
if (match && existsSync(match.executablePath)) {
return { result: { executablePath: match.executablePath, source: "cache" } };
}
if (match) {
return { staleHyperframesCachePath: match.executablePath, staleInstallPath: match.path };
}
}
return {};
// 2) Hyperframes-managed cache. This is the fallback path: only reached
// when no puppeteer-cache binary exists.
return findFromHyperframesCache();
}
/**
@@ -466,13 +482,17 @@ async function ensureLinuxArmBrowser(options?: EnsureBrowserOptions): Promise<Br
/**
* Find or download a browser.
* Resolution: env var -> cached download -> system Chrome -> auto-download.
* With `preferManagedChrome`: env var -> OUR pinned cache -> auto-download
* (puppeteer-cache preference and system Chrome are both skipped).
*/
export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
const fromEnv = findFromEnv();
if (fromEnv) return fromEnv;
if (!options?.force) {
const fromCache = await findFromCache();
const fromCache = await (options?.preferManagedChrome
? findFromHyperframesCache()
: findFromCache());
if (fromCache.result) return fromCache.result;
if (fromCache.staleHyperframesCachePath) {
console.warn(
@@ -484,10 +504,12 @@ export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<Bro
});
}
const fromSystem = findFromSystem();
if (fromSystem) {
warnSystemFallbackOnce(fromSystem.executablePath);
return fromSystem;
if (!options?.preferManagedChrome) {
const fromSystem = findFromSystem();
if (fromSystem) {
warnSystemFallbackOnce(fromSystem.executablePath);
return fromSystem;
}
}
}
@@ -505,7 +527,9 @@ export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<Bro
// result instead of downloading and extracting a second time. Skipped
// under --force, which already purged and always wants a fresh download.
if (!options?.force) {
const afterLock = await findFromCache();
const afterLock = await (options?.preferManagedChrome
? findFromHyperframesCache()
: findFromCache());
if (afterLock.result) return afterLock.result;
if (afterLock.staleInstallPath) purgeStaleInstall(afterLock.staleInstallPath);
}
+6 -1
View File
@@ -757,6 +757,10 @@ export default defineCommand({
}
// ── Ensure browser for local renders ────────────────────────────────
// Always resolve to our own pinned/managed Chrome, never a
// separately-installed puppeteer-cache binary or system Chrome — render
// behavior (drawElement support included, HF#2060) shouldn't depend on
// whatever arbitrary Chrome version happens to be on the machine.
let browserPath: string | undefined;
if (!useDocker) {
const { ensureBrowser } = await import("../browser/manager.js");
@@ -769,13 +773,14 @@ export default defineCommand({
| undefined;
try {
if (effectiveQuiet) {
const info = await ensureBrowser();
const info = await ensureBrowser({ preferManagedChrome: true });
browserPath = info.executablePath;
} else {
const clack = await import("@clack/prompts");
browserSpinner = clack.spinner();
browserSpinner.start("Checking browser...");
const info = await ensureBrowser({
preferManagedChrome: true,
onProgress: (downloaded, total) => {
if (total <= 0) return;
const pct = Math.floor((downloaded / total) * 100);
+2 -2
View File
@@ -49,8 +49,8 @@
"@hyperframes/core": "workspace:^",
"hono": "^4.6.0",
"linkedom": "^0.18.12",
"puppeteer": "^24.0.0",
"puppeteer-core": "^24.39.1"
"puppeteer": "^25.2.1",
"puppeteer-core": "^25.2.1"
},
"devDependencies": {
"@types/node": "^25.0.10",
@@ -525,6 +525,36 @@ async function initDrawElementOrTransparentBackground(
await armStaticDedup(session, page, logInitPhase);
}
}
// Capability gate: `canvas.drawElementImage` is an unlaunched Blink feature
// that only exists on recent Dev/Canary Chrome builds (~151+); it is absent
// from Stable and from most pinned/system Chrome installs. The
// `--enable-features=CanvasDrawElement` flag no-ops silently on a build that
// doesn't implement it, so without this probe the first drawElementImage()
// call throws `TypeError: ... is not a function` deep inside the capture
// loop and takes the whole render down instead of falling back (HF#2060).
// Cheap (no paint-wait) and must run before any other drawElement work.
// Not gated by forceDE (HF_FORCE_DRAWELEMENT, an R&D knob that bypasses the
// quality gates below to measure raw damage) — there's no "forced but
// degraded" mode for a method that doesn't exist, only a crash, so this
// always routes to the fallback instead.
const supportsDrawElement = await page.evaluate(() => {
const c = document.createElement("canvas");
const ctx = c.getContext("2d");
return (
typeof (ctx as unknown as { drawElementImage?: unknown })?.drawElementImage === "function"
);
});
if (!supportsDrawElement) {
session.deGateReason = "unsupported_chrome";
console.log(
`[engine] fast capture: falling back to ${session.launchCaptureMode} capture — ` +
"this Chrome build does not implement canvas.drawElementImage (Dev/Canary-only " +
"feature, ~151+); run `hyperframes doctor` or set HYPERFRAMES_BROWSER_PATH to a " +
"build that supports it.",
);
await routeToFallback();
return;
}
// SwiftShader gate: drawElement's only advantage is skipping the GPU→CPU
// screenshot-readback IPC. On a software rasterizer (Docker/CI, no GPU) both
// paths block on identical software raster, so drawElement is parity-or-slower
+1 -1
View File
@@ -46,7 +46,7 @@
"@hono/node-server": "^1.13.0",
"@hyperframes/producer": "workspace:^",
"hono": "^4.6.0",
"puppeteer-core": "^24.39.1",
"puppeteer-core": "^25.2.1",
"tar": "^7.4.3"
},
"devDependencies": {
+1 -1
View File
@@ -39,7 +39,7 @@
"devDependencies": {
"@types/bun": "^1.1.0",
"gsap": "^3.12.5",
"puppeteer-core": "^24.39.1",
"puppeteer-core": "^25.2.1",
"tsup": "^8.0.0",
"typescript": "^5.0.0",
"vitest": "^3.2.4"
+2 -2
View File
@@ -76,8 +76,8 @@
"hono": "^4.6.0",
"linkedom": "^0.18.12",
"postcss": "^8.4.0",
"puppeteer": "^24.0.0",
"puppeteer-core": "^24.39.1",
"puppeteer": "^25.2.1",
"puppeteer-core": "^25.2.1",
"wawoff2": "^2.0.1"
},
"devDependencies": {
+1 -1
View File
@@ -73,7 +73,7 @@
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"puppeteer-core": "^24.40.0",
"puppeteer-core": "^25.2.1",
"tailwindcss": "^3.4.0",
"tsup": "^8.0.0",
"typescript": "^5.0.0",