From 854f4e2e187e00b7c5e6d79af3fbf68820fa89fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Mon, 13 Jul 2026 15:38:03 +0000 Subject: [PATCH] fix(engine): await dynamic CSS backgrounds before capture --- .../frameCapture-cssBackgroundReady.test.ts | 102 ++++++++++++++++++ packages/engine/src/services/frameCapture.ts | 37 +++++++ 2 files changed, 139 insertions(+) create mode 100644 packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts diff --git a/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts b/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts new file mode 100644 index 0000000000..33292b1d12 --- /dev/null +++ b/packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts @@ -0,0 +1,102 @@ +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, +): 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 { + 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(() => { + delete (globalThis as { __hf_css_background_decoded?: Set }).__hf_css_background_decoded; +}); + +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("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"]); + }); +}); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index 4786668261..11bef61849 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -219,6 +219,41 @@ 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 { + await page.evaluate(async () => { + const root = globalThis as typeof globalThis & { + __hf_css_background_decoded?: Set; + }; + const decoded = (root.__hf_css_background_decoded ??= new Set()); + const urls: string[] = []; + const urlPattern = /url\(\s*(?:"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'|([^)'"\s][^)]*?))\s*\)/g; + + for (const element of document.querySelectorAll('[style*="background"]')) { + const backgroundImage = element.style.backgroundImage; + if (!backgroundImage || backgroundImage === "none") continue; + + for (const match of backgroundImage.matchAll(urlPattern)) { + const url = match[1] ?? match[2] ?? match[3]?.trim(); + if (url && !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. + } + }), + ); + }); +} + // 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; @@ -1874,6 +1909,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