11import { describe , it , expect , vi , beforeEach , afterEach } from "vitest" ;
2+ import gsap from "gsap" ;
3+ import { keepLandedGsapCallbacksSpent } from "../../../core/src/runtime/adapters/gsapSpentCallbacks" ;
4+ import { resolveRenderSeekTime } from "../../../core/src/runtime/player" ;
5+ import type { HfSeekOptions } from "../types.js" ;
26import {
37 captureFrame ,
48 captureFrameToBuffer ,
@@ -24,12 +28,7 @@ vi.mock("./screenshotService.js", async (importOriginal) => ({
2428 * recording that a call happened. What lands in `seeks` is therefore what a browser's
2529 * `window.__hf.seek` would have received.
2630 */
27- interface RecordedSeek {
28- time : number ;
29- suppressEvents ?: boolean ;
30- subFrameDivisions ?: number ;
31- exact ?: boolean ;
32- }
31+ type RecordedSeek = HfSeekOptions & { time : number ; exact ?: boolean } ;
3332
3433function installPageGlobals ( seeks : RecordedSeek [ ] ) : void {
3534 const root = globalThis as Record < string , unknown > ;
@@ -125,12 +124,12 @@ describe("sub-frame accumulation reaches the page with distinct sample times", (
125124 expect ( seeks [ 0 ] ) . toBe ( eventful [ 0 ] ) ;
126125 } ) ;
127126
128- it ( "restores the playhead to the frame time so the next frame's callbacks are not skipped " , async ( ) => {
127+ it ( "pairs the frame's eventful seek with a silent return to the frame time " , async ( ) => {
129128 await captureFrameToBuffer ( makeSession ( ) , 10 , 10 / 30 ) ;
130129
130+ expect ( seeks [ 0 ] ) . toEqual ( { time : 10 / 30 , keepFiredCallbacksSpent : true } ) ;
131131 const last = seeks [ seeks . length - 1 ] ;
132- expect ( last ?. time ) . toBe ( 10 / 30 ) ;
133- expect ( last ?. suppressEvents ) . toBe ( true ) ;
132+ expect ( last ) . toEqual ( { time : 10 / 30 , suppressEvents : true , keepFiredCallbacksSpent : true } ) ;
134133 expect ( seeks ) . toHaveLength ( 18 ) ;
135134 } ) ;
136135
@@ -176,6 +175,65 @@ describe("sub-frame accumulation reaches the page with distinct sample times", (
176175 } ) ;
177176} ) ;
178177
178+ describe ( "a composition's callbacks fire once with motion blur on, as with it off" , ( ) => {
179+ function installRenderSeek ( timeline : ReturnType < typeof gsap . timeline > ) : void {
180+ ( globalThis as Record < string , unknown > ) . window = {
181+ __hf : {
182+ // Mirrors core renderSeek's snap, seek and keep-spent steps on a real GSAP root timeline.
183+ seek : ( time : number , options ?: Omit < RecordedSeek , "time" > ) => {
184+ timeline . totalTime (
185+ resolveRenderSeekTime ( time , 30 , options ) ,
186+ options ?. suppressEvents === true ,
187+ ) ;
188+ keepLandedGsapCallbacksSpent ( timeline , options ) ;
189+ } ,
190+ } ,
191+ } ;
192+ }
193+
194+ async function captureFrames ( count : number ) : Promise < void > {
195+ const session = makeSession ( ) ;
196+ for ( let frame = 1 ; frame <= count ; frame ++ )
197+ await captureFrameToBuffer ( session , frame , frame / 30 ) ;
198+ }
199+
200+ it ( "fires a call on a frame time once" , async ( ) => {
201+ const fired = vi . fn ( ) ;
202+ const timeline = gsap . timeline ( { paused : true } ) . to ( { x : 0 } , { x : 1 , duration : 1 } ) ;
203+ timeline . call ( fired , [ ] , 2 / 30 ) ;
204+ installRenderSeek ( timeline ) ;
205+
206+ await captureFrames ( 4 ) ;
207+
208+ expect ( fired ) . toHaveBeenCalledTimes ( 1 ) ;
209+ } ) ;
210+
211+ it ( "swaps a caption in a tween's onStart on a frame time" , async ( ) => {
212+ const caption = { text : "first line" } ;
213+ const started = vi . fn ( ( ) => ( caption . text = "second line" ) ) ;
214+ const timeline = gsap . timeline ( { paused : true } ) . to ( { x : 0 } , { x : 1 , duration : 1 } ) ;
215+ timeline . to ( { y : 0 } , { y : 1 , duration : 0.2 , onStart : started } , 3 / 30 ) ;
216+ installRenderSeek ( timeline ) ;
217+
218+ await captureFrames ( 6 ) ;
219+
220+ expect ( started ) . toHaveBeenCalledTimes ( 1 ) ;
221+ expect ( caption . text ) . toBe ( "second line" ) ;
222+ } ) ;
223+
224+ it ( "fires a call at a nested scene's start once" , async ( ) => {
225+ const fired = vi . fn ( ) ;
226+ const scene = gsap . timeline ( ) . call ( fired , [ ] , 0 ) . to ( { y : 0 } , { y : 1 , duration : 0.5 } , 0 ) ;
227+ const timeline = gsap . timeline ( { paused : true } ) . to ( { x : 0 } , { x : 1 , duration : 1 } ) ;
228+ timeline . add ( scene , 2 / 30 ) ;
229+ installRenderSeek ( timeline ) ;
230+
231+ await captureFrames ( 5 ) ;
232+
233+ expect ( fired ) . toHaveBeenCalledTimes ( 1 ) ;
234+ } ) ;
235+ } ) ;
236+
179237describe ( "frame export keeps its frame grid (issue #4430)" , ( ) => {
180238 // `exact` is the snapshot-only opt-out of the runtime's seek quantization. Export must
181239 // never send it, or frames would land between grid points instead of on them.
0 commit comments