11import { Player , PlayerType } from "@canvas/players/player" ;
22import type { Edit } from "@core/edit-session" ;
3- import { EditEvent } from "@core/events/edit-events" ;
3+ import { EditEvent , InternalEvent } from "@core/events/edit-events" ;
4+ import type { PlaybackReadiness } from "@core/pending-captures" ;
45import type { ResolvedTiming } from "@core/timing/types" ;
56import { type Size } from "@layouts/geometry" ;
67import type { ResolvedClip } from "@schemas" ;
@@ -31,6 +32,7 @@ const SEEK_KEY = "__shotstackSeek" as const;
3132const CAPTURE_SLICE_MS = 12 ;
3233// A frame in flight holds its canvas until toBlob takes its pixels.
3334const CAPTURE_MEMORY_BUDGET_BYTES = 256 * 1024 * 1024 ;
35+ const CAPTURE_PROGRESS_STEPS = 20 ;
3436const BAKE_SCALES = [ 0.25 , 0.5 , 0.75 , 1 ] ;
3537
3638function forceLayout ( el : HTMLElement ) : void {
@@ -227,6 +229,14 @@ export class Html5Player extends Player {
227229 }
228230 }
229231
232+ private emitCaptureProgress ( ) : void {
233+ try {
234+ this . edit . getInternalEvents ( ) . emit ( InternalEvent . CaptureProgressChanged , { clipId : this . clipId } ) ;
235+ } catch ( emitErr ) {
236+ console . warn ( "[Html5Player] failed to emit CaptureProgressChanged:" , emitErr ) ;
237+ }
238+ }
239+
230240 private emitCaptureCompleted ( frameCount : number ) : void {
231241 try {
232242 this . edit . getInternalEvents ( ) . emit ( EditEvent . ClipCaptureCompleted , {
@@ -341,6 +351,8 @@ export class Html5Player extends Player {
341351
342352 private transitionToCapturing ( ) : void {
343353 this . mode = "capturing" ;
354+ this . captureFramesDone = 0 ;
355+ this . captureFramesTotal = 0 ;
344356 if ( ! this . liveStill ) this . mountLoadingGraphic ( ) ;
345357 this . emitCaptureStarted ( ) ;
346358 }
@@ -360,21 +372,51 @@ export class Html5Player extends Player {
360372 return this . captureInFlight ;
361373 }
362374 const queuedFor = this . contentHash ;
363- const capture : Promise < Blob [ ] | null > = Html5Player . captureChain
364- . then ( ( ) => {
365- // A content change while this waited in the queue would bake the new content at this capture's floor.
366- if ( this . disposed || this . contentHash !== queuedFor ) return null ;
367- return this . runCapture ( minScale ) ;
368- } )
375+ const previous = Html5Player . captureChain ;
376+ // Cached frames cost no bake, so they never wait in the queue behind one.
377+ const capture : Promise < Blob [ ] | null > = this . bakeScale ( minScale )
378+ . then ( scale => this . adoptCachedBake ( scale ) )
379+ . then (
380+ cached =>
381+ cached ??
382+ previous . then ( ( ) => {
383+ // A content change while this waited in the queue would bake the new content at this capture's floor.
384+ if ( this . disposed || this . contentHash !== queuedFor ) return null ;
385+ return this . runCapture ( minScale ) ;
386+ } )
387+ )
369388 . finally ( ( ) => {
370389 // A content change frees the slot, and a newer capture may hold it by now.
371390 if ( this . captureInFlight === capture ) this . captureInFlight = null ;
372391 } ) ;
373392 this . captureInFlight = capture ;
374- Html5Player . captureChain = capture . catch ( ( ) => undefined ) ;
393+ // A cache hit settles early, so the next bake waits on the whole queue, not just this capture.
394+ Html5Player . captureChain = Promise . allSettled ( [ previous , capture ] ) . then ( ( ) => undefined ) ;
375395 return capture ;
376396 }
377397
398+ // After a tick, an active clip's global transform is the size the canvas shows it at. An inactive clip's own
399+ // transform isn't applied, so its bake reads only the viewport's zoom and may re-bake once the clip is active.
400+ private async bakeScale ( minScale : number ) : Promise < number > {
401+ await nextFrame ( ) ;
402+ return Math . max ( bakeScaleFor ( this . onScreenScale ( ) ) , minScale ) ;
403+ }
404+
405+ private async adoptCachedBake ( scale : number ) : Promise < Blob [ ] | null > {
406+ const { contentHash } = this ;
407+ if ( ! contentHash ) return null ;
408+ const stale = ( ) : boolean => this . disposed || this . contentHash !== contentHash ;
409+ const cacheKey = `${ contentHash } @${ scale } ` ;
410+ const cached = await html5CacheGet ( cacheKey ) ;
411+ if ( stale ( ) || ! cached ) return null ;
412+ this . captureFramesTotal = cached . frameCount ;
413+ this . captureFramesDone = cached . frameCount ;
414+ this . captureFps = cached . fps ;
415+ if ( ! ( await this . adoptBake ( cached . pngs , cacheKey , scale , stale ) ) ) return null ;
416+ this . emitCaptureCompleted ( cached . frameCount ) ;
417+ return cached . pngs ;
418+ }
419+
378420 // A re-bake passes the scale it was started for, so a later, smaller sample can't bake softer frames.
379421 private async runCapture ( minScale : number = 0 ) : Promise < Blob [ ] | null > {
380422 if ( ! this . iframe || ! this . iframe . contentDocument || ! this . iframe . contentWindow ) return null ;
@@ -383,23 +425,13 @@ export class Html5Player extends Player {
383425 if ( ! contentHash ) return null ;
384426 const stale = ( ) : boolean => this . disposed || this . contentHash !== contentHash ;
385427
386- // After a tick, an active clip's global transform is the size the canvas shows it at. An inactive clip's own
387- // transform isn't applied, so its bake reads only the viewport's zoom and may re-bake once the clip is active.
388- await nextFrame ( ) ;
428+ const scale = await this . bakeScale ( minScale ) ;
389429 if ( stale ( ) ) return null ;
390- const scale = Math . max ( bakeScaleFor ( this . onScreenScale ( ) ) , minScale ) ;
391- const cacheKey = `${ contentHash } @${ scale } ` ;
392-
393- const cached = await html5CacheGet ( cacheKey ) ;
430+ // The display scale can change while a clip waits in the queue, which can turn up a cached bake at the new scale.
431+ const cached = await this . adoptCachedBake ( scale ) ;
432+ if ( cached ) return cached ;
394433 if ( stale ( ) ) return null ;
395- if ( cached ) {
396- this . captureFramesTotal = cached . frameCount ;
397- this . captureFramesDone = cached . frameCount ;
398- this . captureFps = cached . fps ;
399- if ( ! ( await this . adoptBake ( cached . pngs , cacheKey , scale , stale ) ) ) return null ;
400- this . emitCaptureCompleted ( cached . frameCount ) ;
401- return cached . pngs ;
402- }
434+ const cacheKey = `${ contentHash } @${ scale } ` ;
403435
404436 // An earlier capture may have seeked this iframe, and a seeked animation can leave state
405437 // a fresh page lacks (GSAP writes start values inline once a tween has
@@ -425,6 +457,7 @@ export class Html5Player extends Player {
425457 let lastSvg = "" ;
426458 let lastFrame : Promise < Blob > | null = null ;
427459 let issued = 0 ;
460+ let progressStep = 0 ;
428461 let sliceStart = - Infinity ;
429462 // Frames publish in order, so the oldest in flight gates the window.
430463 while ( blobs . length < frameCount ) {
@@ -450,6 +483,12 @@ export class Html5Player extends Player {
450483 if ( stale ( ) ) return null ;
451484 blobs . push ( frame ) ;
452485 this . captureFramesDone += 1 ;
486+ // Steps, not frames: enough for a ring to fill smoothly, and bounded whatever the frame count.
487+ const step = Math . floor ( ( this . captureFramesDone / frameCount ) * CAPTURE_PROGRESS_STEPS ) ;
488+ if ( step > progressStep ) {
489+ progressStep = step ;
490+ this . emitCaptureProgress ( ) ;
491+ }
453492 }
454493 }
455494
@@ -761,8 +800,8 @@ export class Html5Player extends Player {
761800 } catch ( err ) {
762801 if ( this . disposed || this . contentHash !== newHash ) return ;
763802 console . warn ( "[Html5Player] reload iframe load failed:" , err ) ;
764- this . emitCaptureFailed ( err , "static-placeholder" ) ;
765803 this . transitionToFailed ( ) ;
804+ this . emitCaptureFailed ( err , "static-placeholder" ) ;
766805 }
767806 }
768807
@@ -795,6 +834,21 @@ export class Html5Player extends Player {
795834 this . fallbackGraphic = null ;
796835 }
797836
837+ public override getPlaybackReadiness ( ) : PlaybackReadiness {
838+ if ( this . disposed || this . fallbackGraphic ) return null ;
839+ // Frames count from adoption, when captureCompleted fires; playback takes over a decode later, with no event.
840+ const adopted = ! ! this . capturedFrames && this . capturedHash === `${ this . contentHash } @${ this . capturedScale } ` ;
841+ if ( adopted || this . mode === "playback" ) return "ready" ;
842+ if ( this . mode === "capturing" ) return "pending" ;
843+ // An edit that pre-renders on play bakes nothing until it plays.
844+ return this . edit . getHtml5Prerender ( ) === "on-load" || this . hasTriggeredCapture ? "pending" : null ;
845+ }
846+
847+ public override getCaptureProgress ( ) : number {
848+ if ( this . getPlaybackReadiness ( ) !== "pending" ) return 1 ;
849+ return this . captureFramesTotal > 0 ? this . captureFramesDone / this . captureFramesTotal : 0 ;
850+ }
851+
798852 public override update ( deltaTime : number , elapsed : number ) : void {
799853 super . update ( deltaTime , elapsed ) ;
800854 this . triggerCaptureIfNeeded ( ) ;
@@ -831,16 +885,19 @@ export class Html5Player extends Player {
831885 if ( this . disposed || this . contentHash !== hashAtStart ) return ;
832886 const fresh = ! ! frames && frames . length > 0 && this . capturedHash === `${ hashAtStart } @${ this . capturedScale } ` ;
833887 if ( ! fresh ) {
834- if ( this . mode === "capturing" ) this . transitionToFailed ( ) ;
888+ if ( this . mode !== "capturing" ) return ;
889+ this . transitionToFailed ( ) ;
890+ this . emitCaptureFailed ( new Error ( "The capture produced no frames" ) , "static-placeholder" ) ;
835891 return ;
836892 }
837893 await this . transitionToPlayback ( ) ;
838894 } )
839895 . catch ( err => {
840896 if ( this . disposed || this . contentHash !== hashAtStart ) return ;
841897 console . warn ( "[Html5Player] capture failed:" , err ) ;
842- this . emitCaptureFailed ( err , "static-placeholder" ) ;
898+ // Listeners redraw on the failure, so the clip shows it first.
843899 if ( this . mode === "capturing" ) this . transitionToFailed ( ) ;
900+ this . emitCaptureFailed ( err , "static-placeholder" ) ;
844901 } ) ;
845902 }
846903
0 commit comments