From 1aaa35d073da5ce518a814d45cabf0928dd4401a Mon Sep 17 00:00:00 2001 From: miga-heygen Date: Wed, 9 Sep 2026 01:57:36 +0000 Subject: [PATCH] fix(engine): detect onUpdate callbacks and enable verification events (#3794) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../frameCapture-staticDedupVerifyDensity.test.ts | 4 ++-- packages/engine/src/services/frameCapture.ts | 14 +++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/services/frameCapture-staticDedupVerifyDensity.test.ts b/packages/engine/src/services/frameCapture-staticDedupVerifyDensity.test.ts index 69df1fdfb0..125772694c 100644 --- a/packages/engine/src/services/frameCapture-staticDedupVerifyDensity.test.ts +++ b/packages/engine/src/services/frameCapture-staticDedupVerifyDensity.test.ts @@ -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 () => { diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index f4b038dcae..1fafae7111 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -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 { 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(