fix(engine): isolate static dedup verification seeks (#3768)

Co-authored-by: heygengenesis[bot] <262951085+heygengenesis[bot]@users.noreply.github.com>
Co-authored-by: miguel.sierra <229591595+miguel-heygen@users.noreply.github.com>
This commit is contained in:
heygengenesis[bot]
2026-09-08 17:56:25 +00:00
committed by GitHub
co-authored by miguel.sierra
parent e5d89f770f
commit e02722098e
8 changed files with 197 additions and 1 deletions
@@ -0,0 +1,51 @@
import { describe, expect, it, vi } from "vitest";
import { createStaticVerificationPage, type CaptureSession } from "./frameCapture.js";
describe("createStaticVerificationPage", () => {
it("uses a fresh, ready page so verification seeks cannot affect capture", async () => {
const verificationPage = {
evaluateOnNewDocument: vi.fn().mockResolvedValue(undefined),
setViewport: vi.fn().mockResolvedValue(undefined),
goto: vi.fn().mockResolvedValue(undefined),
evaluate: vi.fn().mockResolvedValue(true),
close: vi.fn().mockResolvedValue(undefined),
};
const session = {
browser: { newPage: vi.fn().mockResolvedValue(verificationPage) },
page: {},
serverUrl: "http://127.0.0.1:3000",
options: {
width: 1920,
height: 1080,
deviceScaleFactor: 1,
format: "mp4",
skipReadinessVideoIds: [],
},
} as unknown as CaptureSession;
await expect(createStaticVerificationPage(session)).resolves.toBe(verificationPage);
expect(session.browser.newPage).toHaveBeenCalledOnce();
expect(verificationPage.goto).toHaveBeenCalledWith("http://127.0.0.1:3000/index.html", {
waitUntil: "domcontentloaded",
timeout: expect.any(Number),
});
expect(verificationPage.close).not.toHaveBeenCalled();
});
it("closes a failed verification page before static dedup fails closed", async () => {
const verificationPage = {
evaluateOnNewDocument: vi.fn().mockResolvedValue(undefined),
setViewport: vi.fn().mockRejectedValue(new Error("viewport failed")),
close: vi.fn().mockResolvedValue(undefined),
};
const session = {
browser: { newPage: vi.fn().mockResolvedValue(verificationPage) },
serverUrl: "http://127.0.0.1:3000",
options: { width: 1920, height: 1080, format: "mp4" },
} as unknown as CaptureSession;
await expect(createStaticVerificationPage(session)).rejects.toThrow("viewport failed");
expect(verificationPage.close).toHaveBeenCalledOnce();
});
});
+74 -1
View File
@@ -3045,6 +3045,62 @@ interface StaticVerificationDependencies {
capture?: typeof pageScreenshotCapture;
}
/**
* Prepare an isolated page for static-frame verification. Verification seeks
* intentionally visit frames out of capture order, so they must never mutate
* the page that will later be captured sequentially.
*/
export async function createStaticVerificationPage(session: CaptureSession): Promise<Page> {
const page = await session.browser.newPage();
const pageNavigationTimeout =
session.config?.pageNavigationTimeout ?? DEFAULT_CONFIG.pageNavigationTimeout;
const pageReadyTimeout = session.config?.playerReadyTimeout ?? DEFAULT_CONFIG.playerReadyTimeout;
try {
await page.evaluateOnNewDocument(() => {
const w = window as unknown as { __name?: <T>(fn: T, _name: string) => T };
if (typeof w.__name !== "function") {
w.__name = <T>(fn: T, _name: string): T => fn;
}
});
if (session.options.variables && Object.keys(session.options.variables).length > 0) {
const variablesJson = JSON.stringify(session.options.variables);
await page.evaluateOnNewDocument((json: string) => {
(window as Window & { __hfVariables?: Record<string, unknown> }).__hfVariables =
JSON.parse(json);
}, variablesJson);
}
await page.setViewport({
width: session.options.width,
height: session.options.height,
deviceScaleFactor: session.options.deviceScaleFactor || 1,
});
await page.goto(`${session.serverUrl}/index.html`, {
waitUntil: "domcontentloaded",
timeout: pageNavigationTimeout,
});
await page.evaluate(`window.__hfFlushSync?.()`);
await pollHfReady(page, pageReadyTimeout);
await pollSubCompositionTimelines(page, pageReadyTimeout);
await applyVideoMetadataHints(page, session.options.videoMetadataHints);
const skipVideoIds = session.options.skipReadinessVideoIds ?? [];
await Promise.all([
pollVideosReady(page, skipVideoIds, pageReadyTimeout),
pollImagesReady(page, pageReadyTimeout).then((ready) =>
ready ? decodeAllImages(page) : undefined,
),
page.evaluate(`document.fonts?.ready`),
waitForOptionalTailwindReady(page, pageReadyTimeout),
]);
if (session.options.format === "png") await initTransparentBackground(page);
return page;
} catch (error) {
await page.close().catch(() => {});
throw error;
}
}
/**
* Empirically verify the predicted-static set before trusting it. Group static frames
* into runs; each run [a..b] reuses anchor a-1. CRITICAL: compare against the ANCHOR,
@@ -3236,7 +3292,24 @@ async function armStaticDedup(
);
return;
}
const verdict = await verifyStaticFramesSafe(session, page, stats.staticFrameSet, fps, samples);
let verificationPage: Page | undefined;
let verdict: StaticVerificationResult;
try {
verificationPage = await createStaticVerificationPage(session);
verdict = await verifyStaticFramesSafe(
session,
verificationPage,
stats.staticFrameSet,
fps,
samples,
);
} catch {
session.staticDedupSkipReason = "verification_failed";
logInitPhase("static-frame dedup: disabled (verification infrastructure failure)");
return;
} finally {
await verificationPage?.close().catch(() => {});
}
session.staticDedupVerification = verdict;
if (verdict.outcome === "mismatch" || verdict.outcome === "infrastructure") {
session.staticDedupSkipReason = "verification_failed";
@@ -35,6 +35,7 @@
"raf-ball-render-compat": 22,
"render-symlinked-assets": 6,
"static-volume-future-set": 120,
"static-dedup-nested-visibility": 8,
"style-1-prod": 156,
"style-10-prod": 232,
"style-11-prod": 324,
@@ -0,0 +1,13 @@
{
"name": "Static dedup nested visibility",
"description": "Regression for #3767: static-frame verification must not leave a mounted sub-composition hidden after seeking backward across its own visibility kill.",
"tags": ["regression", "static-dedup", "sub-composition", "visibility"],
"minPsnr": 30,
"maxFrameFailures": 0,
"minAudioCorrelation": 0,
"maxAudioLagWindows": 1,
"renderConfig": {
"fps": 30,
"workers": 1
}
}
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ca4fdf520c4961eeaee542b9ea0f7eed4b2e3f3b8ab54b0860ede1c8b15dad5c
size 89000
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:a75f90109f9387a9850e5443db1c3562a4713fd57513a8dc86b97ad0b1fc83c5
size 21880
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<template>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
#sub-a-root { position: relative; width: 640px; height: 360px; overflow: hidden; }
#sub-a-square { position: absolute; top: 80px; left: 220px; width: 200px; height: 200px; background: #fff; opacity: 0; }
#sub-a-clip { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
</style>
<div id="sub-a-root" data-composition-id="sub-a" data-start="0" data-duration="3" data-width="640" data-height="360">
<div id="sub-a-clip" class="clip" data-start="0" data-duration="3"></div>
<div id="sub-a-square"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#sub-a-square", { autoAlpha: 1, duration: 0.6, ease: "none" }, 0.2);
tl.set("#sub-a-root", { visibility: "hidden" }, 2.98);
window.__timelines["sub-a"] = tl;
</script>
</template>
</head>
<body></body>
</html>
@@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=640, height=360" />
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<style>
* { box-sizing: border-box; }
html, body { width: 640px; height: 360px; margin: 0; overflow: hidden; background: #101010; }
#root { position: relative; width: 640px; height: 360px; overflow: hidden; background: #101010; }
#dot { position: absolute; top: 32px; left: 32px; width: 24px; height: 24px; border-radius: 50%; background: #ff4040; }
</style>
</head>
<body>
<div id="root" data-composition-id="main" data-start="0" data-duration="10" data-width="640" data-height="360">
<div id="dot"></div>
<div id="sub-a-host" data-composition-id="sub-a" data-composition-src="compositions/sub-a.html" data-start="5.5" data-duration="3" data-track-index="2" data-width="640" data-height="360"></div>
</div>
<script>
window.__timelines = window.__timelines || {};
const tl = gsap.timeline({ paused: true });
tl.to("#dot", { x: 100, duration: 1, ease: "none" }, 0);
window.__timelines.main = tl;
</script>
</body>
</html>