@@ -97,16 +97,12 @@ async function captureSnapshots(
9797
9898 const numFrames = opts . frames ?? 5 ;
9999
100- // 1. Bundle. `bundleToSingleHtml` now inlines the runtime IIFE by default,
101- // so the previous post-bundle runtime substitution is no longer needed.
102100 const html = await bundleToSingleHtml ( projectDir ) ;
103-
104101 const server = await serveStaticProjectHtml ( projectDir , html ) ;
105102
106103 const savedPaths : string [ ] = [ ] ;
107104
108105 try {
109- // 3. Launch headless Chrome
110106 const browser = await ensureBrowser ( ) ;
111107 const puppeteer = await import ( "puppeteer-core" ) ;
112108 const chromeBrowser = await puppeteer . default . launch ( {
@@ -131,60 +127,33 @@ async function captureSnapshots(
131127 timeout : 10000 ,
132128 } ) ;
133129
134- // Wait for runtime to initialize and sub-compositions to load
130+ // __renderReady is set after the player is constructed AND the root
131+ // timeline is bound — waiting for it guarantees renderSeek will work.
135132 const timeoutMs = opts . timeout ?? 5000 ;
136- await page
137- . waitForFunction ( ( ) => ! ! ( window as any ) . __timelines || ! ! ( window as any ) . __playerReady , {
138- timeout : timeoutMs ,
139- } )
140- . catch ( ( ) => { } ) ;
133+ const runtimeReady = await page
134+ . waitForFunction ( ( ) => ! ! ( window as any ) . __renderReady , { timeout : timeoutMs } )
135+ . then ( ( ) => true )
136+ . catch ( ( ) => false ) ;
137+
138+ if ( ! runtimeReady ) {
139+ console . warn (
140+ `\n ${ c . warn ( "⚠" ) } Runtime did not become render-ready within ${ timeoutMs } ms — snapshots may be inaccurate` ,
141+ ) ;
142+ }
141143
142- // Wait for ALL sub-compositions to be mounted by the runtime.
143- // The old check resolved when the first sub-timeline registered, causing
144- // "last beat black" bugs: beat-5's sub-comp hadn't loaded yet when the
145- // snapshot seeked into its time range. Now we count data-composition-src
146- // host elements and wait until we have a matching number of sub-timelines.
147- await page
148- . waitForFunction (
149- ( ) => {
150- const tls = ( window as any ) . __timelines ;
151- if ( ! tls ) return false ;
152- const hosts = document . querySelectorAll ( "[data-composition-src]" ) . length ;
153- if ( hosts === 0 ) return Object . keys ( tls ) . length >= 1 ;
154- const subKeys = Object . keys ( tls ) . filter ( ( k ) => k !== "main" ) ;
155- return subKeys . length >= hosts ;
156- } ,
157- { timeout : timeoutMs } ,
158- )
159- . catch ( ( ) => { } ) ;
160-
161- // Wait for shader transition pre-rendering to complete (if active).
162- //
163- // Two failure modes existed with the previous overlay-only check:
164- // 1. Cold cache: HyperShader creates [data-hyper-shader-loading] but never
165- // removes it from the DOM — it only sets display:none. Checking for
166- // element *absence* never resolved, so the wait always timed out at 60s.
167- // 2. Warm cache: HyperShader loads frames from IndexedDB without showing
168- // the overlay at all. Checking for element absence resolved instantly
169- // (no element) while hydration was still running in the background.
170- //
171- // Fix: use window.__hf.shaderTransitions[].ready as the primary signal
172- // (set after both warm and cold cache paths complete), with the overlay
173- // display:none as a fallback for older builds that lack the ready state.
144+ // Wait for shader transition pre-rendering (HyperShader IndexedDB hydration).
145+ // Uses the ready state flag as primary signal, with the loading overlay
146+ // display:none as a fallback for older builds.
174147 await page
175148 . waitForFunction (
176149 ( ) => {
177150 const win = window as unknown as {
178151 __hf ?: { shaderTransitions ?: Record < string , { ready ?: boolean } > } ;
179152 } ;
180- // Primary: HyperShader ready state — authoritative for both cache paths
181153 const shaderTransitions = win . __hf ?. shaderTransitions ;
182154 if ( shaderTransitions !== undefined ) {
183155 return Object . values ( shaderTransitions ) . every ( ( s ) => s . ready === true ) ;
184156 }
185- // Fallback: overlay visibility (older builds without ready state).
186- // Check display:none rather than element absence — element stays in
187- // the DOM when hidden.
188157 const overlay = document . querySelector (
189158 "[data-hyper-shader-loading]" ,
190159 ) as HTMLElement | null ;
@@ -193,9 +162,14 @@ async function captureSnapshots(
193162 } ,
194163 { timeout : 90_000 } ,
195164 )
196- . catch ( ( ) => { } ) ;
165+ . catch ( ( ) => {
166+ console . warn ( ` ${ c . warn ( "⚠" ) } Shader transitions did not finish pre-rendering` ) ;
167+ } ) ;
197168
198- // Extra settle time for media, fonts, and animations to initialize
169+ // Wait for fonts to finish loading before capturing
170+ await page . evaluate ( ( ) => document . fonts . ready ) . catch ( ( ) => { } ) ;
171+
172+ // Extra settle time for media and animations to initialize
199173 await new Promise ( ( r ) => setTimeout ( r , 1500 ) ) ;
200174
201175 // Font verification — report which fonts loaded vs fell back
@@ -221,20 +195,14 @@ async function captureSnapshots(
221195 }
222196 }
223197
224- // Get composition duration
225198 const duration = await page . evaluate ( ( ) => {
226199 const win = window as any ;
227- const pd = win . __player ?. duration ;
228- if ( pd != null ) return typeof pd === "function" ? pd ( ) : pd ;
200+ if ( typeof win . __player ?. getDuration === "function" ) {
201+ const d = win . __player . getDuration ( ) ;
202+ if ( Number . isFinite ( d ) && d > 0 ) return d ;
203+ }
229204 const root = document . querySelector ( "[data-composition-id][data-duration]" ) ;
230205 if ( root ) return parseFloat ( root . getAttribute ( "data-duration" ) ?? "0" ) ;
231- const tls = win . __timelines ;
232- if ( tls ) {
233- for ( const key in tls ) {
234- const d = tls [ key ] ?. duration ;
235- if ( d != null ) return typeof d === "function" ? d ( ) : d ;
236- }
237- }
238206 return 0 ;
239207 } ) ;
240208
@@ -249,27 +217,21 @@ async function captureSnapshots(
249217 ? [ duration / 2 ]
250218 : Array . from ( { length : numFrames } , ( _ , i ) => ( i / ( numFrames - 1 ) ) * duration ) ;
251219
252- // Create output directory and clear previous frames so old captures
253- // don't mix with the current run in contact sheets.
254220 const snapshotDir = join ( projectDir , "snapshots" ) ;
255221 mkdirSync ( snapshotDir , { recursive : true } ) ;
256222 try {
257- const { readdirSync, rmSync } = await import ( "node:fs" ) ;
223+ const { readdirSync } = await import ( "node:fs" ) ;
258224 for ( const file of readdirSync ( snapshotDir ) ) {
259225 if ( / \. ( p n g | j p g | j p e g ) $ / i. test ( file ) ) {
260226 rmSync ( join ( snapshotDir , file ) , { force : true } ) ;
261227 }
262228 }
263229 } catch {
264- /* best-effort clear — proceed even if cleanup fails */
230+ /* best-effort — proceed even if cleanup fails */
265231 }
266232
267- // Lazily load the engine's <img>-overlay injector. Chrome-headless cannot
268- // reliably advance <video>.currentTime mid-seek (the setter is accepted but
269- // the decoder ignores it without user activation), so the render pipeline
270- // already extracts each frame via FFmpeg and injects it as an <img> sibling
271- // over the <video>. We reuse that same primitive here so `snapshot` and
272- // `render` behave identically for timed <video data-start> elements.
233+ // Chrome-headless ignores programmatic <video>.currentTime writes, so
234+ // we extract frames via FFmpeg and overlay them as <img> elements.
273235 type InjectFn = (
274236 page : unknown ,
275237 updates : Array < { videoId : string ; dataUri : string } > ,
@@ -306,57 +268,36 @@ async function captureSnapshots(
306268 return pending ;
307269 } ;
308270
309- // Seek and capture each frame
271+ const hasPlayer = await page . evaluate ( ( ) => ! ! ( window as any ) . __player ) ;
272+ if ( ! hasPlayer ) {
273+ console . warn ( ` ${ c . warn ( "⚠" ) } No player API — seeks will be skipped` ) ;
274+ }
275+
310276 for ( let i = 0 ; i < positions . length ; i ++ ) {
311277 const time = positions [ i ] ! ;
312278
313279 await page . evaluate ( ( t : number ) => {
314- const win = window as any ;
315- if ( win . __player ?. seek ) {
316- win . __player . seek ( t ) ;
317- } else {
318- const tls = win . __timelines ;
319- if ( tls ) {
320- for ( const key in tls ) {
321- if ( tls [ key ] ?. seek ) {
322- // Sub-composition timelines run in local time relative to
323- // their data-start. Seeking them to global time causes beats
324- // with exit animations to appear black (global t clamps past
325- // the exit). Compute local time: global_t - data_start.
326- const host = document . querySelector < HTMLElement > (
327- `[data-composition-id="${ key } "]` ,
328- ) ;
329- const dataStart = host
330- ? parseFloat ( host . getAttribute ( "data-start" ) ?? "0" ) || 0
331- : 0 ;
332- const localTime = Math . max ( 0 , t - dataStart ) ;
333- tls [ key ] . pause ( ) ;
334- tls [ key ] . seek ( localTime ) ;
335- }
336- }
337- }
280+ const player = ( window as any ) . __player ;
281+ if ( ! player ) return ;
282+ const safe = Math . max ( 0 , Number ( t ) || 0 ) ;
283+ if ( typeof player . renderSeek === "function" ) {
284+ player . renderSeek ( safe ) ;
285+ } else if ( typeof player . seek === "function" ) {
286+ player . seek ( safe ) ;
287+ }
288+ if ( ( window as any ) . gsap ?. ticker ?. tick ) {
289+ ( window as any ) . gsap . ticker . tick ( ) ;
338290 }
339291 } , time ) ;
340292
341- // Wait for rendering to settle after seek
342- await page . evaluate (
343- ( ) =>
344- new Promise < void > ( ( r ) => requestAnimationFrame ( ( ) => requestAnimationFrame ( ( ) => r ( ) ) ) ) ,
345- ) ;
346- await new Promise ( ( r ) => setTimeout ( r , 200 ) ) ;
293+ await page . evaluate ( `new Promise(function(r) {
294+ var settled = false;
295+ function finish () { if (settled) return; settled = true; r(); }
296+ window.setTimeout(finish, 100);
297+ requestAnimationFrame(function() { requestAnimationFrame(finish); } );
298+ })` ) ;
347299
348- // ─── Inject real video frames over any active <video data-start> ───
349- // Without this, Chrome-headless renders them blank/first-frame because
350- // it silently drops programmatic `currentTime` writes during capture.
351- // No-op when the composition has no timed videos (basecamp, linear, etc.)
352300 if ( injectVideoFramesBatch && syncVideoFrameVisibility ) {
353- // Mirror the runtime's media math in packages/core/src/runtime/media.ts
354- // so clips with non-1 `defaultPlaybackRate` get the right active
355- // window and the right `relTime`:
356- // playbackRate = clamp(defaultPlaybackRate, 0.1, 5) — default 1
357- // duration fallback = (sourceDuration - mediaStart) / playbackRate
358- // relTime = (t - start) * playbackRate + mediaStart
359- // active = t >= start && t < start+duration && relTime >= 0
360301 const active = await page . evaluate ( ( t : number ) => {
361302 return Array . from ( document . querySelectorAll ( "video[data-start]" ) )
362303 . map ( ( el ) => {
@@ -392,11 +333,6 @@ async function captureSnapshots(
392333
393334 const updates : Array < { videoId : string ; dataUri : string } > = [ ] ;
394335 for ( const v of active ) {
395- // The page-served URL (http://127.0.0.1:PORT/relative/path.mp4)
396- // maps 1:1 to <projectDir>/relative/path.mp4. decodeURIComponent
397- // the pathname — the file server decodes inbound requests, so a
398- // file with spaces in its path lives at the decoded name on disk
399- // while `new URL().pathname` preserves the %-encoding.
400336 let filePath : string | null = null ;
401337 try {
402338 const url = new URL ( v . src ) ;
@@ -422,12 +358,7 @@ async function captureSnapshots(
422358 } ) ;
423359 }
424360
425- // Always run the visibility sync — even when `active` is empty and
426- // no new updates were injected. Without this, stale __render_frame__
427- // <img> overlays left by a previous seek (where different clips were
428- // active) remain visible in later snapshots, because the runtime's
429- // visibility toggles act on the <video> element but not its injected
430- // <img> sibling.
361+ // Sync visibility even when empty — clears stale overlays from prior seeks
431362 try {
432363 if ( updates . length > 0 ) {
433364 await injectVideoFramesBatch ( page , updates ) ;
@@ -437,8 +368,7 @@ async function captureSnapshots(
437368 active . map ( ( a ) => a . id ) ,
438369 ) ;
439370 } catch {
440- // If either step fails, fall through to the plain screenshot —
441- // no worse than the pre-fix behaviour.
371+ /* fall through to plain screenshot */
442372 }
443373 }
444374
0 commit comments