mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
co-authored by
miguel.sierra
parent
e5d89f770f
commit
e02722098e
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user