fix(engine): detect onUpdate callbacks and enable verification events (#3794)

The static-frame dedup predictor walked tween intervals from
window.__timelines but was blind to onUpdate callbacks — motion driven
from a timeline's onUpdate in a tween-free window was predicted static.
The verifier compounded the gap by seeking with suppressEvents: true,
so the onUpdate never fired and the frozen frame passed verification.

Two changes:
1. Predictor: when a timeline carries vars.onUpdate, mark its full span
   as animated so those frames are never predicted static.
2. Verifier: seek with suppressEvents: false so the verification page
   behaves identically to the capture page. The verification page is
   already isolated (separate Page instance), so out-of-order event
   side effects cannot corrupt sequential capture.

Fixes #3793
This commit is contained in:
miga-heygen
2026-09-09 01:57:36 +00:00
committed by GitHub
parent 662f96b3f4
commit 1aaa35d073
2 changed files with 13 additions and 5 deletions
@@ -70,7 +70,7 @@ describe("verifyStaticFramesSafe catches drift the old fixed-point density would
expect(result.badFrame).toBe(changeAt);
});
it("uses silent verification seeks and restores the playhead to frame zero", async () => {
it("uses event-enabled verification seeks and restores the playhead to frame zero", async () => {
const seekCalls: Array<{ t: number; options?: { suppressEvents?: boolean } }> = [];
const page = {
evaluate: vi.fn(async (fn: (tt: number) => void, t: number) => {
@@ -103,7 +103,7 @@ describe("verifyStaticFramesSafe catches drift the old fixed-point density would
expect(result.outcome).toBe("verified");
expect(seekCalls.map((call) => Math.round(call.t * fps))).toEqual([0, 3, 0]);
expect(seekCalls.every((call) => call.options?.suppressEvents === true)).toBe(true);
expect(seekCalls.every((call) => call.options?.suppressEvents === false)).toBe(true);
});
it("disarms within a wall-clock budget instead of spending minutes verifying a long static run", async () => {
+11 -3
View File
@@ -2736,6 +2736,13 @@ export async function computeStaticFrameSet(
let hasTimelineCall = false;
function walk(tl: AnyTween, offset: number): void {
if (typeof tl.getChildren !== "function") return;
// A timeline with an onUpdate callback can drive motion without any
// visible tween (e.g. setting x/y from Math.random inside onUpdate).
// Mark its entire span as animated so those frames are never deduped.
if (typeof tl.vars?.onUpdate === "function") {
const total = typeof tl.totalDuration === "function" ? tl.totalDuration() : 0;
if (total > 0) intervals.push({ start: offset, end: offset + total });
}
for (const child of tl.getChildren(false, true, true)) {
const start = offset + (typeof child.startTime === "function" ? child.startTime() : 0);
const single = typeof child.duration === "function" ? child.duration() : 0;
@@ -3054,8 +3061,9 @@ interface StaticVerificationDependencies {
/**
* 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.
* intentionally visit frames out of capture order on a SEPARATE page that is
* never used for the real capture — out-of-order event-callback side effects
* are contained to this disposable page and cannot corrupt sequential capture.
*/
export async function createStaticVerificationPage(session: CaptureSession): Promise<Page> {
const page = await session.browser.newPage();
@@ -3167,7 +3175,7 @@ export async function verifyStaticFramesSafe(
__hf?: { seek?: (t: number, options?: { suppressEvents?: boolean }) => void };
}
).__hf;
if (hf && typeof hf.seek === "function") hf.seek(tt, { suppressEvents: true });
if (hf && typeof hf.seek === "function") hf.seek(tt, { suppressEvents: false });
}, t);
};
const hardCap = Math.max(