mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-01 19:42:03 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user