From 5b9b71df258f239a841542c92dd799126655eb05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Tue, 7 Jul 2026 21:06:59 -0400 Subject: [PATCH] fix(producer): suppress GSAP call side effects during render seeks (#2037) * fix(producer): suppress GSAP call side effects during render seeks * fix(core): preserve GSAP root render nudge safely --- .gitattributes | 1 + packages/core/src/runtime/adapters/gsap.ts | 5 +- packages/core/src/runtime/init.test.ts | 72 +++++++++++ packages/core/src/runtime/init.ts | 122 +++++++++++++++--- packages/core/src/runtime/player.test.ts | 14 ++ packages/core/src/runtime/player.ts | 19 +-- packages/core/src/runtime/types.ts | 8 +- packages/core/src/runtime/window.d.ts | 4 +- ...meCapture-staticDedupVerifyDensity.test.ts | 36 ++++++ packages/engine/src/services/frameCapture.ts | 53 +++++--- packages/parsers/src/types.ts | 2 +- .../producer/src/services/fileServer.test.ts | 43 ++++++ packages/producer/src/services/fileServer.ts | 4 +- .../tests/gsap-call-render-seek/meta.json | 13 ++ .../output/compiled.html | 3 + .../gsap-call-render-seek/output/output.mp4 | 3 + .../gsap-call-render-seek/src/index.html | 111 ++++++++++++++++ 17 files changed, 462 insertions(+), 51 deletions(-) create mode 100644 packages/producer/tests/gsap-call-render-seek/meta.json create mode 100644 packages/producer/tests/gsap-call-render-seek/output/compiled.html create mode 100644 packages/producer/tests/gsap-call-render-seek/output/output.mp4 create mode 100644 packages/producer/tests/gsap-call-render-seek/src/index.html diff --git a/.gitattributes b/.gitattributes index ab25dca7a..f5f9640cb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,6 +15,7 @@ packages/producer/tests/**/*.mp4 filter=lfs diff=lfs merge=lfs -text packages/producer/tests/**/*.mov filter=lfs diff=lfs merge=lfs -text packages/producer/tests/**/*.webm filter=lfs diff=lfs merge=lfs -text packages/producer/tests/**/*.png filter=lfs diff=lfs merge=lfs -text +packages/producer/tests/**/output/compiled.html filter=lfs diff=lfs merge=lfs -text # ONNX models must ALWAYS use LFS regardless of location: a 31 MB ppmattingv2 # model was once committed raw into skills/ then deleted — but a raw commit diff --git a/packages/core/src/runtime/adapters/gsap.ts b/packages/core/src/runtime/adapters/gsap.ts index 584e7304a..d76ea9946 100644 --- a/packages/core/src/runtime/adapters/gsap.ts +++ b/packages/core/src/runtime/adapters/gsap.ts @@ -13,13 +13,14 @@ export function createGsapAdapter(deps: GsapAdapterDeps): RuntimeDeterministicAd if (!timeline) return; timeline.pause(); const safeTime = Math.max(0, Number(ctx.time) || 0); + const suppressEvents = ctx.suppressEvents === true; if (typeof timeline.totalTime === "function") { // GSAP 3.x skips rendering when the new totalTime equals _tTime. // Nudge first to force a dirty state, then seek to the exact time. timeline.totalTime(safeTime + 0.001, true); - timeline.totalTime(safeTime, false); + timeline.totalTime(safeTime, suppressEvents); } else { - timeline.seek(safeTime, false); + timeline.seek(safeTime, suppressEvents); } }, pause: () => { diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index bbc8d2e7d..3f66788c1 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -1031,6 +1031,78 @@ describe("initSandboxRuntimeModular", () => { expect(hookHost.style.visibility).toBe("visible"); }); + it("keeps the root GSAP render nudge for normal frames but not silent probes", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "10"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const seekCalls: Array<{ time: number; suppressEvents?: boolean }> = []; + const rootTimeline = createMockTimeline(10); + const originalTotalTime = rootTimeline.totalTime; + rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => { + seekCalls.push({ time, suppressEvents }); + return originalTotalTime?.(time, suppressEvents); + }; + + window.__timelines = { main: rootTimeline }; + initSandboxRuntimeModular(); + seekCalls.length = 0; + + window.__player?.renderSeek(2); + + expect(seekCalls).toEqual([ + { time: 2, suppressEvents: false }, + { time: 2.001, suppressEvents: true }, + { time: 2, suppressEvents: true }, + ]); + + seekCalls.length = 0; + window.__player?.renderSeek(3, { suppressEvents: true }); + + expect(seekCalls).toEqual([{ time: 3, suppressEvents: true }]); + }); + + it("does not nudge root GSAP timelines that contain zero-duration callbacks", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "main"); + root.setAttribute("data-root", "true"); + root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "10"); + root.setAttribute("data-width", "1920"); + root.setAttribute("data-height", "1080"); + document.body.appendChild(root); + + const seekCalls: Array<{ time: number; suppressEvents?: boolean }> = []; + const rootTimeline = createMockTimeline(10); + const originalTotalTime = rootTimeline.totalTime; + rootTimeline.totalTime = (time: number, suppressEvents?: boolean) => { + seekCalls.push({ time, suppressEvents }); + return originalTotalTime?.(time, suppressEvents); + }; + Object.assign(rootTimeline, { + getChildren: () => [ + { + vars: { onComplete: () => {} }, + duration: () => 0, + totalDuration: () => 0, + }, + ], + }); + + window.__timelines = { main: rootTimeline }; + initSandboxRuntimeModular(); + seekCalls.length = 0; + + window.__player?.renderSeek(2); + + expect(seekCalls).toEqual([{ time: 2, suppressEvents: false }]); + }); + it("shows pip video at global start time even when host composition starts late", () => { // Regression: resolveStartForElement used to add the host composition's start on top of // the video's own data-start, causing double-offset. A pip video with data-start="45.40" diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 24bbfbde1..ae6b079b9 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -34,7 +34,12 @@ import { TransportClock } from "./clock"; import { WebAudioTransport } from "./webAudioTransport"; import { quantizeTimeToFrame } from "../inline-scripts/parityContract"; import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../studio-api/helpers/draftMarkers"; -import type { RuntimeDeterministicAdapter, RuntimeJson, RuntimeTimelineLike } from "./types"; +import type { + RuntimeDeterministicAdapter, + RuntimeJson, + RuntimeSeekOptions, + RuntimeTimelineLike, +} from "./types"; import type { PlayerAPI } from "../core.types"; import { swallow } from "./diagnostics"; @@ -205,7 +210,7 @@ export function initSandboxRuntimeModular(): void { getTime: () => number; getDuration: () => number; isPlaying: () => boolean; - renderSeek: (timeSeconds: number) => void; + renderSeek: (timeSeconds: number, options?: RuntimeSeekOptions) => void; }): PlayerAPI => { const defaultStageZoom: ReturnType = { scale: 1, @@ -1901,7 +1906,7 @@ export function initSandboxRuntimeModular(): void { } if (method === "discover") { try { - adapter.seek({ time: timeSeconds }); + adapter.seek({ time: timeSeconds, suppressEvents: true }); } catch (err) { // ignore seek bootstrap failures swallow("runtime.init.site9", err); @@ -2064,10 +2069,14 @@ export function initSandboxRuntimeModular(): void { syncMediaForCurrentState(); }, onStatePost: postState, - onDeterministicSeek: (timeSeconds) => { + onDeterministicSeek: (timeSeconds, options) => { for (const adapter of state.deterministicAdapters) { + if (adapter.name === "gsap" && state.capturedTimeline) continue; try { - adapter.seek({ time: Number(timeSeconds) || 0 }); + adapter.seek({ + time: Number(timeSeconds) || 0, + suppressEvents: options?.suppressEvents, + }); } catch (err) { // ignore adapter failure swallow("runtime.init.site11", err); @@ -2351,20 +2360,22 @@ export function initSandboxRuntimeModular(): void { timeline: RuntimeTimelineLike, timeSeconds: number, swallowLabel: string, + options?: RuntimeSeekOptions, ) => { try { + const suppressEvents = options?.suppressEvents === true; timeline.pause(); if (typeof timeline.totalTime === "function") { - timeline.totalTime(timeSeconds, false); + timeline.totalTime(timeSeconds, suppressEvents); } else { - timeline.seek(timeSeconds, false); + timeline.seek(timeSeconds, suppressEvents); } } catch (err) { swallow(swallowLabel, err); } }; - const seekStandaloneRegisteredTimelines = (timeSeconds: number) => { + const seekStandaloneRegisteredTimelines = (timeSeconds: number, options?: RuntimeSeekOptions) => { const timelines = (window.__timelines ?? {}) as Record; const rootCompositionId = resolveRootCompositionElement()?.getAttribute("data-composition-id") ?? null; @@ -2386,7 +2397,7 @@ export function initSandboxRuntimeModular(): void { ? Math.min(duration, timeSeconds - start) : timeSeconds - start, ); - seekRuntimeTimeline(timeline, localTime, "runtime.init.transport.childTimeline"); + seekRuntimeTimeline(timeline, localTime, "runtime.init.transport.childTimeline", options); } }; @@ -2410,8 +2421,76 @@ export function initSandboxRuntimeModular(): void { } }; - const seekTimelineAndAdapters = (t: number, opts?: { activateChildren?: boolean }) => { + const isObjectRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + + const gsapCallbackTweenCache = new WeakMap(); + const GSAP_CALLBACK_NAMES = [ + "onStart", + "onUpdate", + "onComplete", + "onReverseComplete", + "onRepeat", + ]; + + const readGsapDuration = (child: Record, property: string): number | null => { + const getter = child[property]; + if (typeof getter !== "function") return null; + try { + const value = Number(getter.call(child)); + return Number.isFinite(value) ? value : null; + } catch (err) { + swallow("runtime.init.gsapCallbackDuration", err); + return null; + } + }; + + const hasZeroDurationCallbackTween = (timeline: RuntimeTimelineLike): boolean => { + const cached = gsapCallbackTweenCache.get(timeline); + if (cached != null) return cached; + + if (!("getChildren" in timeline) || typeof timeline.getChildren !== "function") { + return false; + } + + let children: unknown; + try { + children = timeline.getChildren(true, true, true); + } catch (err) { + swallow("runtime.init.gsapCallbackChildren", err); + gsapCallbackTweenCache.set(timeline, false); + return false; + } + if (!Array.isArray(children)) { + gsapCallbackTweenCache.set(timeline, false); + return false; + } + + for (const child of children) { + if (!isObjectRecord(child) || !isObjectRecord(child.vars)) continue; + const hasCallback = GSAP_CALLBACK_NAMES.some( + (name) => typeof child.vars[name] === "function", + ); + if (!hasCallback) continue; + + const totalDuration = readGsapDuration(child, "totalDuration"); + const duration = totalDuration ?? readGsapDuration(child, "duration"); + if (duration != null && duration <= 0.000001) { + gsapCallbackTweenCache.set(timeline, true); + return true; + } + } + + gsapCallbackTweenCache.set(timeline, false); + return false; + }; + + const seekTimelineAndAdapters = ( + t: number, + opts?: { activateChildren?: boolean; suppressEvents?: boolean }, + ) => { const tl = state.capturedTimeline; + const suppressEvents = opts?.suppressEvents === true; if (tl) { // When rendering frame-by-frame (activateChildren=true), ensure all // sibling timelines are unpaused before seeking the root. GSAP @@ -2445,9 +2524,16 @@ export function initSandboxRuntimeModular(): void { } try { if (typeof tl.totalTime === "function") { - tl.totalTime(tlSeekTime, false); + tl.totalTime(tlSeekTime, suppressEvents); + if (!suppressEvents && !hasZeroDurationCallbackTween(tl)) { + // Preserve GSAP's forced-render nudge for root timelines without + // firing callbacks a second time. The first seek is the only + // eventful one; the follow-up nudges only refresh computed styles. + tl.totalTime(tlSeekTime + 0.001, true); + tl.totalTime(tlSeekTime, true); + } } else { - tl.seek(tlSeekTime, false); + tl.seek(tlSeekTime, suppressEvents); } } catch (err) { swallow("runtime.init.transport.seek", err); @@ -2460,11 +2546,12 @@ export function initSandboxRuntimeModular(): void { // Play/pause propagation for siblings happens in the player.play() // and player.pause() overrides via the adapter layer. } else { - seekStandaloneRegisteredTimelines(t); + seekStandaloneRegisteredTimelines(t, opts); } for (const adapter of state.deterministicAdapters) { + if (adapter.name === "gsap" && tl) continue; try { - adapter.seek({ time: t }); + adapter.seek({ time: t, suppressEvents }); } catch (err) { swallow("runtime.init.transport.adapter", err); } @@ -2791,7 +2878,7 @@ export function initSandboxRuntimeModular(): void { postState(true); }; - player.renderSeek = (timeSeconds: number) => { + player.renderSeek = (timeSeconds: number, options?: RuntimeSeekOptions) => { const quantized = quantizeTimeToFrame( Math.max(0, Number(timeSeconds) || 0), state.canonicalFps, @@ -2801,7 +2888,10 @@ export function initSandboxRuntimeModular(): void { state.currentTime = clock.now(); state.isPlaying = false; state.mediaForceSyncNextTick = true; - seekTimelineAndAdapters(state.currentTime, { activateChildren: true }); + seekTimelineAndAdapters(state.currentTime, { + activateChildren: true, + suppressEvents: options?.suppressEvents, + }); syncMediaForCurrentState(); colorGrading.redraw(); postState(true); diff --git a/packages/core/src/runtime/player.test.ts b/packages/core/src/runtime/player.test.ts index 13d1cadbe..78c3dfcf0 100644 --- a/packages/core/src/runtime/player.test.ts +++ b/packages/core/src/runtime/player.test.ts @@ -466,6 +466,20 @@ describe("createRuntimePlayer", () => { expect(deps.onRenderFrameSeek).toHaveBeenCalled(); }); + it("can suppress timeline events during administrative render seeks", () => { + const timeline = createMockTimeline({ duration: 10 }); + const deps = createMockDeps(timeline); + const player = createRuntimePlayer(deps); + const renderSeek = player.renderSeek as ( + time: number, + options?: { suppressEvents?: boolean }, + ) => void; + + renderSeek(5, { suppressEvents: true }); + + expect(timeline.totalTime).toHaveBeenCalledWith(5, true); + }); + it("renderSeek rearms paused siblings and keeps them active for export frames", () => { const { master, scene1, scene2, scene5 } = createNestedTimelineHarness(); const deps = createMockDeps(master); diff --git a/packages/core/src/runtime/player.ts b/packages/core/src/runtime/player.ts index d28f3592a..6ffd382e7 100644 --- a/packages/core/src/runtime/player.ts +++ b/packages/core/src/runtime/player.ts @@ -1,4 +1,4 @@ -import type { RuntimePlayer, RuntimeTimelineLike } from "./types"; +import type { RuntimePlayer, RuntimeSeekOptions, RuntimeTimelineLike } from "./types"; import { quantizeTimeToFrame } from "../inline-scripts/parityContract"; import { swallow } from "./diagnostics"; @@ -41,7 +41,7 @@ type PlayerDeps = { getCanonicalFps: () => number; onSyncMedia: (timeSeconds: number, playing: boolean) => void; onStatePost: (force: boolean) => void; - onDeterministicSeek: (timeSeconds: number) => void; + onDeterministicSeek: (timeSeconds: number, options?: RuntimeSeekOptions) => void; onDeterministicPause: () => void; onDeterministicPlay: () => void; onRenderFrameSeek: (timeSeconds: number) => void; @@ -79,13 +79,15 @@ function seekTimelineDeterministically( timeline: RuntimeTimelineLike, timeSeconds: number, canonicalFps: number, + options?: RuntimeSeekOptions, ): number { const quantized = quantizeTimeToFrame(timeSeconds, canonicalFps); + const suppressEvents = options?.suppressEvents === true; safeVoid(timeline, "pause"); if (typeof timeline.totalTime === "function") { - timeline.totalTime(quantized, false); + timeline.totalTime(quantized, suppressEvents); } else { - if (typeof timeline.seek === "function") timeline.seek(quantized, false); + if (typeof timeline.seek === "function") timeline.seek(quantized, suppressEvents); } return quantized; } @@ -95,6 +97,7 @@ function seekMasterAndSiblingTimelinesDeterministically( master: RuntimeTimelineLike, timeSeconds: number, canonicalFps: number, + options?: RuntimeSeekOptions, ): number { const rearmedSiblings: RuntimeTimelineLike[] = []; forEachSiblingTimeline(registry, master, (tl) => { @@ -102,7 +105,7 @@ function seekMasterAndSiblingTimelinesDeterministically( rearmedSiblings.push(tl); }); try { - return seekTimelineDeterministically(master, timeSeconds, canonicalFps); + return seekTimelineDeterministically(master, timeSeconds, canonicalFps, options); } finally { for (const tl of rearmedSiblings) { try { @@ -206,7 +209,7 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer { deps.onRenderFrameSeek(quantized); deps.onStatePost(true); }, - renderSeek: (timeSeconds: number) => { + renderSeek: (timeSeconds: number, options?: RuntimeSeekOptions) => { const timeline = deps.getTimeline(); const canonicalFps = deps.getCanonicalFps(); // When a composition has no GSAP timeline (pure CSS / WAAPI / Lottie / @@ -219,10 +222,10 @@ export function createRuntimePlayer(deps: PlayerDeps): RuntimePlayer { // If nested siblings stay paused, GSAP collapses the root back to the // authored master duration and later frames clamp incorrectly. activateSiblingTimelines(deps.getTimelineRegistry?.(), timeline); - return seekTimelineDeterministically(timeline, timeSeconds, canonicalFps); + return seekTimelineDeterministically(timeline, timeSeconds, canonicalFps, options); })() : quantizeTimeToFrame(Math.max(0, Number(timeSeconds) || 0), canonicalFps); - deps.onDeterministicSeek(quantized); + deps.onDeterministicSeek(quantized, options); deps.setIsPlaying(false); deps.onSyncMedia(quantized, false); deps.onRenderFrameSeek(quantized); diff --git a/packages/core/src/runtime/types.ts b/packages/core/src/runtime/types.ts index ade82a5c1..a50efae9c 100644 --- a/packages/core/src/runtime/types.ts +++ b/packages/core/src/runtime/types.ts @@ -212,7 +212,7 @@ export type RuntimePlayer = { play: () => void; pause: () => void; seek: (timeSeconds: number, options?: { keepPlaying?: boolean }) => void; - renderSeek: (timeSeconds: number) => void; + renderSeek: (timeSeconds: number, options?: RuntimeSeekOptions) => void; getTime: () => number; getDuration: () => number; isPlaying: () => boolean; @@ -220,6 +220,10 @@ export type RuntimePlayer = { getPlaybackRate: () => number; }; +export type RuntimeSeekOptions = { + suppressEvents?: boolean; +}; + export type RuntimeTimelineLike = { play: () => void; pause: () => void; @@ -236,7 +240,7 @@ export type RuntimeTimelineLike = { export type RuntimeDeterministicAdapter = { name: string; discover: () => void; - seek: (ctx: { time: number }) => void; + seek: (ctx: { time: number; suppressEvents?: boolean }) => void; pause: () => void; play?: () => void; revert?: () => void; diff --git a/packages/core/src/runtime/window.d.ts b/packages/core/src/runtime/window.d.ts index 789ed477e..5779df0c1 100644 --- a/packages/core/src/runtime/window.d.ts +++ b/packages/core/src/runtime/window.d.ts @@ -1,4 +1,4 @@ -import type { RuntimeTimelineMessage, RuntimeTimelineLike } from "./types"; +import type { RuntimeSeekOptions, RuntimeTimelineMessage, RuntimeTimelineLike } from "./types"; import type { RuntimeColorGradingApi } from "./colorGrading"; import type { HyperframePickerApi } from "../inline-scripts/pickerApi"; import type { PlayerAPI } from "../core.types"; @@ -35,6 +35,8 @@ declare global { __hf?: { colorGrading?: RuntimeColorGradingApi; onSwallowed?: (label: string, err: unknown) => void; + seek?: (timeSeconds: number, options?: RuntimeSeekOptions) => void; + duration?: number; }; __playerReady?: boolean; __renderReady?: boolean; diff --git a/packages/engine/src/services/frameCapture-staticDedupVerifyDensity.test.ts b/packages/engine/src/services/frameCapture-staticDedupVerifyDensity.test.ts index ea0c7c54c..43824272f 100644 --- a/packages/engine/src/services/frameCapture-staticDedupVerifyDensity.test.ts +++ b/packages/engine/src/services/frameCapture-staticDedupVerifyDensity.test.ts @@ -147,4 +147,40 @@ describe("verifyStaticFramesSafe catches drift the old fixed-point density would expect(result?.budgetExhausted).toBe(false); expect(result?.badFrame).toBe(changeAt); }); + + it("uses silent 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) => { + const globalWithWindow = globalThis as typeof globalThis & { window?: unknown }; + const previousWindow = globalWithWindow.window; + globalWithWindow.window = { + __hf: { + seek: (seekTime: number, options?: { suppressEvents?: boolean }) => { + seekCalls.push({ t: seekTime, options }); + }, + }, + }; + try { + fn(t); + } finally { + if (previousWindow === undefined) delete globalWithWindow.window; + else globalWithWindow.window = previousWindow; + } + }), + }; + vi.mocked(pageScreenshotCapture).mockImplementation(async () => Buffer.from("same")); + + const result = await verifyStaticFramesSafe( + { options: {} } as unknown as CaptureSession, + page as unknown as Parameters[1], + new Set([1, 2]), + fps, + 3, + ); + + expect(result).toBeNull(); + expect(seekCalls.map((call) => Math.round(call.t * fps))).toEqual([0, 1, 2, 0]); + expect(seekCalls.every((call) => call.options?.suppressEvents === true)).toBe(true); + }); }); diff --git a/packages/engine/src/services/frameCapture.ts b/packages/engine/src/services/frameCapture.ts index d017ea920..860a8fc49 100644 --- a/packages/engine/src/services/frameCapture.ts +++ b/packages/engine/src/services/frameCapture.ts @@ -2067,12 +2067,19 @@ export async function verifyStaticFramesSafe( if (last && f === last.b + 1) last.b = f; else runs.push({ a: f, b: f }); } - const seekCapture = async (frameIdx: number): Promise => { + const seekToFrame = async (frameIdx: number): Promise => { const t = quantizeTimeToFrame(frameIdx / fps, fps); await page.evaluate((tt: number) => { - const hf = (window as unknown as { __hf?: { seek?: (t: number) => void } }).__hf; - if (hf && typeof hf.seek === "function") hf.seek(tt); + const hf = ( + window as unknown as { + __hf?: { seek?: (t: number, options?: { suppressEvents?: boolean }) => void }; + } + ).__hf; + if (hf && typeof hf.seek === "function") hf.seek(tt, { suppressEvents: true }); }, t); + }; + const seekCapture = async (frameIdx: number): Promise => { + await seekToFrame(frameIdx); return pageScreenshotCapture(page, session.options); }; // Verify EVERY run in order (no longest-first truncation that would leave runs armed @@ -2093,23 +2100,27 @@ export async function verifyStaticFramesSafe( 400, Math.ceil(frames.length / STATIC_VERIFY_REFERENCE_STRIDE) * 3 + runs.length, ); - let spent = 0; - for (const { a, b } of runs) { - const anchor = a - 1; - if (anchor < 0) continue; - const anchorBuf = await seekCapture(anchor); - spent++; - for (const f of computeStaticVerificationPoints(a, b, sampleCount)) { - const cur = await seekCapture(f); + try { + let spent = 0; + for (const { a, b } of runs) { + const anchor = a - 1; + if (anchor < 0) continue; + const anchorBuf = await seekCapture(anchor); spent++; - if (!anchorBuf.equals(cur)) return { badFrame: f, budgetExhausted: false }; + for (const f of computeStaticVerificationPoints(a, b, sampleCount)) { + const cur = await seekCapture(f); + spent++; + if (!anchorBuf.equals(cur)) return { badFrame: f, budgetExhausted: false }; + } + // Budget exhausted → can't fully verify → disarm, distinct from real drift so a + // `verification_budget` spike in telemetry reads as "this composition has a lot + // of static material to verify," not "compositions are non-static." + if (spent > hardCap) return { badFrame: a, budgetExhausted: true }; } - // Budget exhausted → can't fully verify → disarm, distinct from real drift so a - // `verification_budget` spike in telemetry reads as "this composition has a lot - // of static material to verify," not "compositions are non-static." - if (spent > hardCap) return { badFrame: a, budgetExhausted: true }; + return null; + } finally { + await seekToFrame(0).catch(() => {}); } - return null; } /** @@ -2985,8 +2996,12 @@ async function captureDeVerificationFrames( const fractions = Array.from({ length: k }, (_, i) => (i + 1) / (k + 1)); const seekTo = async (t: number): Promise => { await page.evaluate((tt: number) => { - const hf = (window as unknown as { __hf?: { seek?: (x: number) => void } }).__hf; - if (hf && typeof hf.seek === "function") hf.seek(tt); + const hf = ( + window as unknown as { + __hf?: { seek?: (x: number, options?: { suppressEvents?: boolean }) => void }; + } + ).__hf; + if (hf && typeof hf.seek === "function") hf.seek(tt, { suppressEvents: true }); }, t); }; await seekTo(quantizeTimeToFrame(0, fps)); diff --git a/packages/parsers/src/types.ts b/packages/parsers/src/types.ts index 581a4dc8e..aa2efb427 100644 --- a/packages/parsers/src/types.ts +++ b/packages/parsers/src/types.ts @@ -363,7 +363,7 @@ export interface PlayerAPI { ensureTimeline(): void; enableRenderMode(): void; disableRenderMode(): void; - renderSeek(time: number): void; + renderSeek(time: number, options?: { suppressEvents?: boolean }): void; getElementVisibility(elementId: string): { visible: boolean; opacity?: number }; getVisibleElements(): Array<{ id: string; tagName: string; start: number; end: number }>; getRenderState(): { diff --git a/packages/producer/src/services/fileServer.test.ts b/packages/producer/src/services/fileServer.test.ts index dc5ff9e99..837a7eca1 100644 --- a/packages/producer/src/services/fileServer.test.ts +++ b/packages/producer/src/services/fileServer.test.ts @@ -643,6 +643,49 @@ describe("HF_EARLY_STUB + HF_BRIDGE_SCRIPT integration", () => { expect(sandbox.window.__hf?.duration).toBe(30); }); + it("forwards suppressEvents from __hf.seek to renderSeek", () => { + const renderSeekCalls: Array<[number, { suppressEvents?: boolean } | undefined]> = []; + const sandbox: { + window: Record & { + __hf?: { + seek?: (t: number, options?: { suppressEvents?: boolean }) => void; + duration?: number; + }; + __player?: { + renderSeek: (t: number, options?: { suppressEvents?: boolean }) => void; + getDuration: () => number; + }; + setInterval: typeof setInterval; + clearInterval: typeof clearInterval; + }; + document: { querySelector: () => null }; + } = { + window: { + setInterval: globalThis.setInterval, + clearInterval: globalThis.clearInterval, + }, + document: { querySelector: () => null }, + }; + sandbox.window.window = sandbox.window; + sandbox.window.document = sandbox.document; + sandbox.window.__renderReady = true; + sandbox.window.__player = { + renderSeek: (time, options) => { + renderSeekCalls.push([time, options]); + }, + getDuration: () => 10, + }; + + new Function("window", "document", `with (window) {\n${HF_BRIDGE_SCRIPT}\n}`)( + sandbox.window, + sandbox.document, + ); + + sandbox.window.__hf?.seek?.(5, { suppressEvents: true }); + + expect(renderSeekCalls).toEqual([[5, { suppressEvents: true }]]); + }); + it("keeps render-time timeline seeks synchronous during large renders", () => { const sandbox: { window: Record & { diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index dea33379d..3aff44d8e 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -636,8 +636,8 @@ const HF_BRIDGE_SCRIPT = `(function() { return d > 0 ? d : getDeclaredDuration(); }, }); - hf.seek = function(t) { - p.renderSeek(t); + hf.seek = function(t, options) { + p.renderSeek(t, options); var nextTimeMs = (Math.max(0, Number(t) || 0)) * 1000; if (window.__HF_VIRTUAL_TIME__ && typeof window.__HF_VIRTUAL_TIME__.seekToTime === "function") { window.__HF_VIRTUAL_TIME__.seekToTime(nextTimeMs); diff --git a/packages/producer/tests/gsap-call-render-seek/meta.json b/packages/producer/tests/gsap-call-render-seek/meta.json new file mode 100644 index 000000000..4d066193e --- /dev/null +++ b/packages/producer/tests/gsap-call-render-seek/meta.json @@ -0,0 +1,13 @@ +{ + "name": "gsap-call-render-seek", + "description": "Regression guard for GSAP .call() side effects during producer render seeks. Administrative/static-dedup seeks must not fire callbacks or leave the page playhead poisoned before real frame capture.", + "tags": ["regression", "render-compat", "gsap"], + "minPsnr": 30, + "maxFrameFailures": 0, + "minAudioCorrelation": 0, + "maxAudioLagWindows": 1, + "renderConfig": { + "fps": 30, + "workers": 1 + } +} diff --git a/packages/producer/tests/gsap-call-render-seek/output/compiled.html b/packages/producer/tests/gsap-call-render-seek/output/compiled.html new file mode 100644 index 000000000..29d77d16a --- /dev/null +++ b/packages/producer/tests/gsap-call-render-seek/output/compiled.html @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d350fb3d1c2e01ed5c21a811d79cee3bfb61a0f713326ba2fb729ab43115d92 +size 2563366 diff --git a/packages/producer/tests/gsap-call-render-seek/output/output.mp4 b/packages/producer/tests/gsap-call-render-seek/output/output.mp4 new file mode 100644 index 000000000..ce00087a3 --- /dev/null +++ b/packages/producer/tests/gsap-call-render-seek/output/output.mp4 @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5e8fd9d5f29443375a919d0229751f29c3b3e7da758d3bdec3e21f4546b25455 +size 123087 diff --git a/packages/producer/tests/gsap-call-render-seek/src/index.html b/packages/producer/tests/gsap-call-render-seek/src/index.html new file mode 100644 index 000000000..2bba11863 --- /dev/null +++ b/packages/producer/tests/gsap-call-render-seek/src/index.html @@ -0,0 +1,111 @@ + + + + + + + + + +
+
+
GSAP call render seek
+
0
+
+
+
+ + + +