@@ -5,6 +5,11 @@ import { initSandboxRuntimeModular } from "./init";
55import { TYPEGPU_PRESENT_HEARTBEAT_MS } from "./adapters/typegpu" ;
66import { WebAudioTransport } from "./webAudioTransport" ;
77import type { RuntimeTimelineLike } from "./types" ;
8+ import {
9+ registerRuntimeDataHandler ,
10+ resetRuntimeDataForTests ,
11+ setRuntimeData ,
12+ } from "./runtimeData" ;
813
914it ( "schedules WebAudio element gain from author volume without bridge volume" , ( ) => {
1015 const source = readFileSync ( "src/runtime/init.ts" , "utf8" ) ;
@@ -107,6 +112,7 @@ describe("initSandboxRuntimeModular", () => {
107112 const originalCancelAnimationFrame = window . cancelAnimationFrame ;
108113
109114 beforeEach ( ( ) => {
115+ resetRuntimeDataForTests ( ) ;
110116 document . body . innerHTML = "" ;
111117 ( globalThis as typeof globalThis & { CSS ?: { escape ?: ( value : string ) => string } } ) . CSS ??= { } ;
112118 globalThis . CSS . escape ??= ( value : string ) => value ;
@@ -174,6 +180,7 @@ describe("initSandboxRuntimeModular", () => {
174180
175181 afterEach ( ( ) => {
176182 window . __hfRuntimeTeardown ?.( ) ;
183+ resetRuntimeDataForTests ( ) ;
177184 document . body . innerHTML = "" ;
178185 window . __timelines = { } as Record < string , RuntimeTimelineLike > ;
179186 delete window . __player ;
@@ -2725,6 +2732,117 @@ describe("initSandboxRuntimeModular", () => {
27252732 expect ( clipControl ?. style . visibility ) . toBe ( "visible" ) ;
27262733 } ) ;
27272734
2735+ it ( "rebinds the injected player before reporting runtime-data applied" , async ( ) => {
2736+ const root = document . createElement ( "div" ) ;
2737+ root . setAttribute ( "data-composition-id" , "main" ) ;
2738+ root . setAttribute ( "data-root" , "true" ) ;
2739+ root . setAttribute ( "data-duration" , "10" ) ;
2740+ root . setAttribute ( "data-width" , "1920" ) ;
2741+ root . setAttribute ( "data-height" , "1080" ) ;
2742+ document . body . appendChild ( root ) ;
2743+
2744+ const first = createMockTimeline ( 10 ) ;
2745+ const replacement = createMockTimeline ( 10 ) ;
2746+ window . __timelines = { main : first } ;
2747+ const applied : Array < Record < string , unknown > > = [ ] ;
2748+ const deliveryOrder : string [ ] = [ ] ;
2749+ vi . spyOn ( window . parent , "postMessage" ) . mockImplementation ( ( message : unknown ) => {
2750+ if ( typeof message !== "object" || message === null ) return ;
2751+ const payload = message as Record < string , unknown > ;
2752+ if ( payload . type === "timeline" || payload . type === "runtime-data-applied" ) {
2753+ deliveryOrder . push ( String ( payload . type ) ) ;
2754+ }
2755+ if ( payload . type === "runtime-data-applied" ) applied . push ( payload ) ;
2756+ } ) ;
2757+
2758+ initSandboxRuntimeModular ( ) ;
2759+ deliveryOrder . length = 0 ;
2760+ window . __player ?. seek ( 0.25 ) ;
2761+ registerRuntimeDataHandler ( "captions" , async ( ) => {
2762+ await Promise . resolve ( ) ;
2763+ window . __timelines = { main : replacement } ;
2764+ } ) ;
2765+
2766+ setRuntimeData ( "captions" , { style : "replacement" } , 7 ) ;
2767+ await vi . waitFor ( ( ) => expect ( applied ) . toHaveLength ( 1 ) ) ;
2768+
2769+ // Runtime seeks are canonicalized to the configured frame rate.
2770+ expect ( replacement . time ( ) ) . toBeCloseTo ( 7 / 30 , 5 ) ;
2771+ expect ( first . time ( ) ) . toBeCloseTo ( 7 / 30 , 5 ) ;
2772+
2773+ window . __player ?. seek ( 1.25 ) ;
2774+
2775+ expect ( first . time ( ) ) . toBeCloseTo ( 7 / 30 , 5 ) ;
2776+ expect ( replacement . time ( ) ) . toBeCloseTo ( 37 / 30 , 5 ) ;
2777+ expect ( applied [ 0 ] ) . toMatchObject ( { channel : "captions" , requestId : 7 } ) ;
2778+ expect ( deliveryOrder . slice ( 0 , 2 ) ) . toEqual ( [ "timeline" , "runtime-data-applied" ] ) ;
2779+ } ) ;
2780+
2781+ it ( "does not seek a removed timeline after runtime data is cleared" , async ( ) => {
2782+ const root = document . createElement ( "div" ) ;
2783+ root . setAttribute ( "data-composition-id" , "main" ) ;
2784+ root . setAttribute ( "data-root" , "true" ) ;
2785+ root . setAttribute ( "data-duration" , "10" ) ;
2786+ root . setAttribute ( "data-width" , "1920" ) ;
2787+ root . setAttribute ( "data-height" , "1080" ) ;
2788+ document . body . appendChild ( root ) ;
2789+
2790+ const first = createMockTimeline ( 10 ) ;
2791+ window . __timelines = { main : first } ;
2792+ const applied : Array < Record < string , unknown > > = [ ] ;
2793+ vi . spyOn ( window . parent , "postMessage" ) . mockImplementation ( ( message : unknown ) => {
2794+ if ( typeof message !== "object" || message === null ) return ;
2795+ const payload = message as Record < string , unknown > ;
2796+ if ( payload . type === "runtime-data-applied" ) applied . push ( payload ) ;
2797+ } ) ;
2798+
2799+ initSandboxRuntimeModular ( ) ;
2800+ window . __player ?. seek ( 0.25 ) ;
2801+ registerRuntimeDataHandler ( "captions" , ( ) => {
2802+ window . __timelines = { } ;
2803+ } ) ;
2804+
2805+ setRuntimeData ( "captions" , undefined , 8 ) ;
2806+ await vi . waitFor ( ( ) => expect ( applied ) . toHaveLength ( 1 ) ) ;
2807+ const timeAtClear = first . time ( ) ;
2808+
2809+ window . __player ?. seek ( 1.25 ) ;
2810+
2811+ expect ( first . time ( ) ) . toBe ( timeAtClear ) ;
2812+ } ) ;
2813+
2814+ it ( "does not report applied when a runtime-data handler rejects" , async ( ) => {
2815+ const root = document . createElement ( "div" ) ;
2816+ root . setAttribute ( "data-composition-id" , "main" ) ;
2817+ root . setAttribute ( "data-root" , "true" ) ;
2818+ root . setAttribute ( "data-duration" , "10" ) ;
2819+ root . setAttribute ( "data-width" , "1920" ) ;
2820+ root . setAttribute ( "data-height" , "1080" ) ;
2821+ document . body . appendChild ( root ) ;
2822+ window . __timelines = { main : createMockTimeline ( 10 ) } ;
2823+
2824+ const applied : Array < Record < string , unknown > > = [ ] ;
2825+ const errors : Array < Record < string , unknown > > = [ ] ;
2826+ vi . spyOn ( window . parent , "postMessage" ) . mockImplementation ( ( message : unknown ) => {
2827+ if ( typeof message !== "object" || message === null ) return ;
2828+ const payload = message as Record < string , unknown > ;
2829+ if ( payload . type === "runtime-data-applied" ) applied . push ( payload ) ;
2830+ if ( payload . type === "runtime-data-error" ) errors . push ( payload ) ;
2831+ } ) ;
2832+
2833+ initSandboxRuntimeModular ( ) ;
2834+ registerRuntimeDataHandler ( "captions" , async ( ) => {
2835+ await Promise . resolve ( ) ;
2836+ throw new Error ( "attach failed" ) ;
2837+ } ) ;
2838+
2839+ setRuntimeData ( "captions" , { style : "broken" } , 9 ) ;
2840+ await vi . waitFor ( ( ) => expect ( errors ) . toHaveLength ( 1 ) ) ;
2841+
2842+ expect ( applied ) . toHaveLength ( 0 ) ;
2843+ expect ( errors [ 0 ] ) . toMatchObject ( { channel : "captions" , requestId : 9 } ) ;
2844+ } ) ;
2845+
27282846 it ( "onSetMuted preserves authored muted attribute on video elements" , ( ) => {
27292847 const root = document . createElement ( "div" ) ;
27302848 root . setAttribute ( "data-composition-id" , "root" ) ;
@@ -2912,13 +3030,76 @@ describe("initSandboxRuntimeModular", () => {
29123030 expect ( seekTimes . length ) . toBeGreaterThan ( beforePlaying ) ;
29133031 player ?. pause ( ) ;
29143032
2915- // (3) Paused + marker cleared (drop/cancel) → the per-frame re- seek resumes .
3033+ // (3) Paused + marker cleared (drop/cancel) → one reconciliation seek runs .
29163034 document . getElementById ( "dragged" ) ?. removeAttribute ( "data-hf-studio-manual-edit-gesture" ) ;
29173035 const beforeResume = seekTimes . length ;
29183036 raf . step ( 16 ) ;
29193037 expect ( seekTimes . length ) . toBeGreaterThan ( beforeResume ) ;
29203038 } ) ;
29213039
3040+ it ( "does not re-seek an unchanged paused timeline on every animation frame" , ( ) => {
3041+ const raf = createManualRaf ( ) ;
3042+ vi . spyOn ( performance , "now" ) . mockImplementation ( ( ) => raf . now ( ) ) ;
3043+ window . requestAnimationFrame = raf . requestAnimationFrame as typeof window . requestAnimationFrame ;
3044+ window . cancelAnimationFrame = raf . cancelAnimationFrame as typeof window . cancelAnimationFrame ;
3045+
3046+ const seekTimes : number [ ] = [ ] ;
3047+ const tl = createMockTimeline ( 5 ) ;
3048+ const origTotalTime = tl . totalTime ;
3049+ tl . totalTime = ( ( time : number , ...rest : unknown [ ] ) => {
3050+ seekTimes . push ( time ) ;
3051+ ( origTotalTime as Function ) . call ( tl , time , ...rest ) ;
3052+ } ) as RuntimeTimelineLike [ "totalTime" ] ;
3053+
3054+ document . body . innerHTML = `
3055+ <div data-composition-id="root" data-duration="5" data-width="1920" data-height="1080"></div>
3056+ ` ;
3057+ window . __timelines = { root : tl } ;
3058+ initSandboxRuntimeModular ( ) ;
3059+
3060+ // The first transport frame reconciles the initial timeline at the paused playhead.
3061+ raf . step ( 16 ) ;
3062+ const afterInitialFrame = seekTimes . length ;
3063+ expect ( afterInitialFrame ) . toBeGreaterThan ( 0 ) ;
3064+
3065+ // No time or timeline change means there is no new frame to render.
3066+ raf . step ( 16 ) ;
3067+ raf . step ( 16 ) ;
3068+ raf . step ( 16 ) ;
3069+ expect ( seekTimes . length ) . toBe ( afterInitialFrame ) ;
3070+
3071+ // An explicit paused seek still renders immediately, then settles again after the transport
3072+ // records the new playhead on its next frame.
3073+ window . __player ?. seek ( 2 ) ;
3074+ expect ( seekTimes . some ( ( time ) => time === 2 ) ) . toBe ( true ) ;
3075+ raf . step ( 16 ) ;
3076+ const afterPausedSeek = seekTimes . length ;
3077+ raf . step ( 16 ) ;
3078+ expect ( seekTimes . length ) . toBe ( afterPausedSeek ) ;
3079+
3080+ // A runtime-data rebuild can replace the timeline without moving the paused playhead. The
3081+ // identity check must render that new object once instead of treating it as the old frame.
3082+ const replacementSeekTimes : number [ ] = [ ] ;
3083+ const replacement = createMockTimeline ( 5 ) ;
3084+ const replacementTotalTime = replacement . totalTime ;
3085+ replacement . totalTime = ( ( time : number , ...rest : unknown [ ] ) => {
3086+ replacementSeekTimes . push ( time ) ;
3087+ ( replacementTotalTime as Function ) . call ( replacement , time , ...rest ) ;
3088+ } ) as RuntimeTimelineLike [ "totalTime" ] ;
3089+ window . __timelines = { root : replacement } ;
3090+ window . __hfForceTimelineRebind ?.( ) ;
3091+ raf . step ( 16 ) ;
3092+ expect ( replacementSeekTimes . length ) . toBeGreaterThan ( 0 ) ;
3093+ const afterReplacementFrame = replacementSeekTimes . length ;
3094+ raf . step ( 16 ) ;
3095+ expect ( replacementSeekTimes . length ) . toBe ( afterReplacementFrame ) ;
3096+
3097+ // Playback still traverses the timeline every frame.
3098+ window . __player ?. play ( ) ;
3099+ raf . step ( 16 ) ;
3100+ expect ( replacementSeekTimes . length ) . toBeGreaterThan ( afterReplacementFrame ) ;
3101+ } ) ;
3102+
29223103 it ( "redraws animated grading from the transport clock only during playback" , ( ) => {
29233104 const raf = createManualRaf ( ) ;
29243105 vi . spyOn ( performance , "now" ) . mockImplementation ( ( ) => raf . now ( ) ) ;
0 commit comments