fix(engine): consolidate capture readiness and retries (#2404)

* fix(engine): await dynamic CSS backgrounds before capture

* fix(render): retry transient network changes

* fix(engine): parse CSS URLs without backtracking

* fix(engine): decode CSS backgrounds in batch capture
This commit is contained in:
Miguel Ángel
2026-07-14 18:10:03 -04:00
committed by GitHub
parent 97cbe48f30
commit 9e2afbcce5
4 changed files with 251 additions and 0 deletions
@@ -941,6 +941,7 @@ export async function produceDrawElementFrameBatch(
__hf_accel_canvases?: HTMLCanvasElement[];
__hf3d?: { update: () => void };
__hf?: { seek?: (t: number) => void };
__hfDecodeDynamicCssBackgroundImages?: () => Promise<void>;
__hfDeInvalidate?: () => boolean;
__HF_ROOT_PROPS__?: boolean;
__HF_ROOT_BASE_OPACITY__?: number;
@@ -975,6 +976,7 @@ export async function produceDrawElementFrameBatch(
const { t, fid } = frame;
try {
if (aw.__hf && typeof aw.__hf.seek === "function") aw.__hf.seek(t);
await aw.__hfDecodeDynamicCssBackgroundImages?.();
aw.__hf3d?.update();
const accel = (aw.__hf_accel_canvases ?? []).filter((c) => root.contains(c));
for (const c of accel) {
@@ -0,0 +1,154 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import type { Page } from "puppeteer-core";
import { decodeDynamicCssBackgroundImages } from "./frameCapture.js";
function makeMockPage(
getBackgroundImage: () => string,
decoded: string[],
decodeImage?: (src: string) => Promise<void>,
): Page {
return {
evaluate: async (fn: () => unknown) => {
const previousDocument = globalThis.document;
const previousImage = globalThis.Image;
const previousWindow = globalThis.window;
const element = {
style: {
get backgroundImage() {
return getBackgroundImage();
},
},
};
class MockImage {
src = "";
async decode(): Promise<void> {
decoded.push(this.src);
await decodeImage?.(this.src);
}
}
Object.assign(globalThis, {
document: {
querySelectorAll: () => [element],
},
Image: MockImage,
window: previousWindow ?? {},
});
try {
return await fn();
} finally {
Object.assign(globalThis, {
document: previousDocument,
Image: previousImage,
window: previousWindow,
});
}
},
} as unknown as Page;
}
afterEach(() => {
const root = globalThis as {
__hf_css_background_decoded?: Set<string>;
__hfDecodeDynamicCssBackgroundImages?: () => Promise<void>;
};
delete root.__hf_css_background_decoded;
delete root.__hfDecodeDynamicCssBackgroundImages;
});
describe("decodeDynamicCssBackgroundImages", () => {
it("decodes each newly assigned inline background URL before capture", async () => {
let backgroundImage = 'url("/assets/row-0.jpg")';
const decoded: string[] = [];
const page = makeMockPage(() => backgroundImage, decoded);
await decodeDynamicCssBackgroundImages(page);
await decodeDynamicCssBackgroundImages(page);
backgroundImage = 'url("/assets/row-1.jpg")';
await decodeDynamicCssBackgroundImages(page);
expect(decoded).toEqual(["/assets/row-0.jpg", "/assets/row-1.jpg"]);
});
it("decodes every URL in a layered inline background", async () => {
const decoded: string[] = [];
const page = makeMockPage(
() => "linear-gradient(#000, #fff), url(\"/assets/plate.png\"), url('/assets/grain.webp')",
decoded,
);
await decodeDynamicCssBackgroundImages(page);
expect(decoded).toEqual(["/assets/plate.png", "/assets/grain.webp"]);
});
it("rejects malformed quoted URLs without exponential backtracking", async () => {
const decoded: string[] = [];
const malformed = `url("${"\\!".repeat(27)}`;
const page = makeMockPage(() => malformed, decoded);
const startedAt = performance.now();
await decodeDynamicCssBackgroundImages(page);
expect(performance.now() - startedAt).toBeLessThan(250);
expect(decoded).toEqual([]);
});
it("retries a URL after a transient decode failure", async () => {
const decoded: string[] = [];
let attempts = 0;
const page = makeMockPage(
() => 'url("/assets/late.jpg")',
decoded,
async () => {
attempts += 1;
if (attempts === 1) throw new Error("not ready");
},
);
await decodeDynamicCssBackgroundImages(page);
await decodeDynamicCssBackgroundImages(page);
expect(decoded).toEqual(["/assets/late.jpg", "/assets/late.jpg"]);
});
it("installs a page-local decoder that can run after an in-page seek", async () => {
let backgroundImage = 'url("/assets/row-0.jpg")';
const decoded: string[] = [];
const page = makeMockPage(() => backgroundImage, decoded);
await decodeDynamicCssBackgroundImages(page);
backgroundImage = 'url("/assets/row-1.jpg")';
await page.evaluate(async () => {
const decodeAfterSeek = (
globalThis as { __hfDecodeDynamicCssBackgroundImages?: () => Promise<void> }
).__hfDecodeDynamicCssBackgroundImages;
expect(decodeAfterSeek).toBeTypeOf("function");
await decodeAfterSeek?.();
});
expect(decoded).toEqual(["/assets/row-0.jpg", "/assets/row-1.jpg"]);
});
it("awaits the page-local decoder after every seek in drawElement batch capture", () => {
const drawElementSource = readFileSync(
fileURLToPath(new URL("./drawElementService.ts", import.meta.url)),
"utf8",
);
const batchSource = drawElementSource.slice(
drawElementSource.indexOf("export async function produceDrawElementFrameBatch"),
);
expect(batchSource).toMatch(
/aw\.__hf\.seek\(t\);\s*await aw\.__hfDecodeDynamicCssBackgroundImages\?\.\(\);/,
);
});
});
@@ -14,6 +14,7 @@ describe("isTransientBrowserError", () => {
"Cannot find context with specified id",
"Failed to launch the browser process! TROUBLESHOOTING: https://pptr.dev/troubleshooting",
"connect ECONNREFUSED 127.0.0.1:9222",
"net::ERR_NETWORK_CHANGED at http://127.0.0.1:4173/index.html",
"Navigation timeout of 60000 ms exceeded",
// pollHfReady timed out before window.__renderReady flipped true — the
// classic symptom of a slow/contended host (e.g. several renders running
@@ -222,6 +222,90 @@ export function isDrawElementVerificationError(err: unknown): boolean {
return false;
}
/** Wait for inline CSS background images introduced by the latest seek. */
export async function decodeDynamicCssBackgroundImages(page: Page): Promise<void> {
await page.evaluate(async () => {
const root = globalThis as typeof globalThis & {
__hf_css_background_decoded?: Set<string>;
__hfDecodeDynamicCssBackgroundImages?: () => Promise<void>;
};
const decode = (root.__hfDecodeDynamicCssBackgroundImages ??= async () => {
const decoded = (root.__hf_css_background_decoded ??= new Set<string>());
const urls: string[] = [];
const parseBackgroundUrls = (value: string): string[] => {
const found: string[] = [];
let cursor = 0;
while (cursor < value.length) {
const start = value.indexOf("url(", cursor);
if (start < 0) break;
let index = start + 4;
while (index < value.length && /\s/.test(value[index] ?? "")) index += 1;
const quote = value[index] === '"' || value[index] === "'" ? value[index] : null;
if (quote) index += 1;
const contentStart = index;
let contentEnd = -1;
while (index < value.length) {
const char = value[index];
if (char === "\\") {
index = Math.min(index + 2, value.length);
continue;
}
if ((quote && char === quote) || (!quote && char === ")")) {
contentEnd = index;
break;
}
index += 1;
}
if (contentEnd < 0) break;
if (quote) {
index += 1;
while (index < value.length && /\s/.test(value[index] ?? "")) index += 1;
if (value[index] !== ")") {
cursor = index;
continue;
}
}
const url = value.slice(contentStart, contentEnd).trim();
if (url) found.push(url);
cursor = index + 1;
}
return found;
};
for (const element of document.querySelectorAll<HTMLElement>('[style*="background"]')) {
const backgroundImage = element.style.backgroundImage;
if (!backgroundImage || backgroundImage === "none") continue;
for (const url of parseBackgroundUrls(backgroundImage)) {
if (!decoded.has(url)) urls.push(url);
}
}
await Promise.all(
[...new Set(urls)].map(async (url) => {
const image = new Image();
image.src = url;
try {
await image.decode();
decoded.add(url);
} catch {
// Keep existing capture behavior for missing assets; request diagnostics report them.
}
}),
);
});
await decode();
});
}
// Circular buffer for browser console messages dumped on render failure diagnostics.
// Complex compositions produce 100+ messages; 50 was too small to capture relevant errors.
const BROWSER_CONSOLE_BUFFER_SIZE = 200;
@@ -712,6 +796,9 @@ async function finalizeDrawElementInit(
opts: { transparent: boolean; forceDE: boolean },
): Promise<void> {
const { transparent, forceDE } = opts;
// Install the page-local decoder before batch drawElement capture begins;
// the batch loop re-runs it after every in-page seek.
await decodeDynamicCssBackgroundImages(page);
// Self-verification ground truth: must run pre-injection — after the canvas
// wraps the root, a page screenshot shows the canvas's last-drawn bitmap,
// not the live DOM (see the Lim 6 boundary-screenshot note).
@@ -2005,6 +2092,8 @@ async function prepareFrameForCapture(
.__hf_page_composite_pending;
}, quantizedTime);
await decodeDynamicCssBackgroundImages(page);
const seekMs = Date.now() - seekStart;
// Before-capture hook (e.g. video frame injection) — runs before
@@ -3406,6 +3495,11 @@ const TRANSIENT_BROWSER_ERROR_PATTERNS = [
/Failed to launch the browser process/i,
/Navigation timeout of \d+ ms exceeded/i,
/ECONNREFUSED/i,
// Chromium can briefly invalidate even a localhost connection when Windows
// reports an adapter/route change. A fresh capture session succeeds once the
// network stack settles, so treat this like the other bounded navigation
// retries instead of failing the render immediately.
/net::ERR_NETWORK_CHANGED/i,
// pollHfReady's own timeout — thrown when window.__renderReady never flips
// true within playerReadyTimeout. "Runtime ready: false" means init simply
// didn't finish in time (commonly a slow/contended host, e.g. several