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
102 changes: 102 additions & 0 deletions packages/engine/src/services/frameCapture-cssBackgroundReady.test.ts
Original file line number Diff line number Diff line change
@@ -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<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(() => {
delete (globalThis as { __hf_css_background_decoded?: Set<string> }).__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"]);
});
});
37 changes: 37 additions & 0 deletions packages/engine/src/services/frameCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,41 @@
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>;
};
const decoded = (root.__hf_css_background_decoded ??= new Set<string>());
const urls: string[] = [];
const urlPattern = /url\(\s*(?:"((?:\\.|[^"])*)"|'((?:\\.|[^'])*)'|([^)'"\s][^)]*?))\s*\)/g;

Check failure

Code scanning / CodeQL

Inefficient regular expression High

This part of the regular expression may cause exponential backtracking on strings starting with 'url("' and containing many repetitions of '\!'.

Check failure

Code scanning / CodeQL

Inefficient regular expression High

This part of the regular expression may cause exponential backtracking on strings starting with 'url('' and containing many repetitions of '\&'.

for (const element of document.querySelectorAll<HTMLElement>('[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;
Expand Down Expand Up @@ -1874,6 +1909,8 @@
.__hf_page_composite_pending;
}, quantizedTime);

await decodeDynamicCssBackgroundImages(page);

const seekMs = Date.now() - seekStart;

// Before-capture hook (e.g. video frame injection) — runs before
Expand Down
Loading