@@ -17,6 +17,7 @@ import {
1717} from "@shotstack/shotstack-canvas" ;
1818import * as pixi from "pixi.js" ;
1919
20+ import { passSegments } from "./bake-order" ;
2021import { BakeQueue } from "./bake-queue" ;
2122import { computeHtml5CacheKey , html5CacheGet , html5CachePut } from "./html5-cache" ;
2223import { createCaptureLoadingGraphic , createPlaceholderGraphic } from "./placeholder-graphic" ;
@@ -117,12 +118,17 @@ export class Html5Player extends Player {
117118 private static bakes = new BakeQueue ( ) ;
118119 // Pages still mounting. A bake joins the queue once they have, so the queue orders every clip that loaded together.
119120 private static readonly mounts = new Set < Promise < void > > ( ) ;
121+ // Frames every bake has in flight. Bakes take turns, so they share one window and one memory budget.
122+ private static readonly inFlight = { frames : 0 , bytes : 0 } ;
120123
121124 private iframe : HTMLIFrameElement | null = null ;
122125 private renderedWidth : number = 0 ;
123126 private renderedHeight : number = 0 ;
124127 private capturedFrames : Blob [ ] | null = null ;
125128 private capturedScale : number = 1 ;
129+ // A first bake's frames by index as they land, for the stills they replace. Export never reads them.
130+ private bakingFrames : Array < Blob | undefined > | null = null ;
131+ private bakingScale : number = 1 ;
126132 private readonly globalTransform = new pixi . Matrix ( ) ;
127133 // Stays set after a failed re-bake, so a failure isn't retried every frame.
128134 private rebakeStarted : boolean = false ;
@@ -156,7 +162,9 @@ export class Html5Player extends Player {
156162 private liveStillFrame : number = - 1 ;
157163 private liveStillInFlight : boolean = false ;
158164 private stillRequest : number | null = null ;
159- private readonly bakeSlot = { start : ( ) => this . getStart ( ) , end : ( ) => this . getEnd ( ) , playhead : ( ) => this . edit . playbackTime } ;
165+ // The frame the running bake draws next. Null between bakes.
166+ private bakeNextIndex : number | null = null ;
167+ private readonly bakeSlot = { nextFrameEnd : ( ) => this . nextFrameEnd ( ) , playhead : ( ) => this . edit . playbackTime } ;
160168
161169 constructor ( edit : Edit , clipConfiguration : ResolvedClip ) {
162170 super ( edit , clipConfiguration , PlayerType . Html5 ) ;
@@ -376,6 +384,7 @@ export class Html5Player extends Player {
376384 private transitionToFailed ( ) : void {
377385 if ( this . disposed ) return ;
378386 this . mode = "failed" ;
387+ this . bakingFrames = null ;
379388 this . removeLoadingGraphic ( ) ;
380389 this . removeLiveStill ( ) ;
381390 this . createFallbackGraphic ( ) ;
@@ -402,13 +411,30 @@ export class Html5Player extends Player {
402411
403412 private async queueBake ( queuedFor : string | null , minScale : number ) : Promise < Blob [ ] | null > {
404413 while ( Html5Player . mounts . size > 0 ) await Promise . allSettled ( [ ...Html5Player . mounts ] ) ;
405- return Html5Player . bakes . add ( this . bakeSlot , async ( ) => {
414+ return Html5Player . bakes . add ( this . bakeSlot , async turn => {
406415 // A content change while this waited in the queue would bake the new content at this capture's floor.
407416 if ( this . disposed || this . contentHash !== queuedFor ) return null ;
408- return this . runCapture ( minScale ) ;
417+ try {
418+ return await this . runCapture ( minScale , turn ) ;
419+ } finally {
420+ this . bakeNextIndex = null ;
421+ }
409422 } ) ;
410423 }
411424
425+ // A bake that hasn't started begins at the playhead's frame, or at the clip's first once the playhead has passed it.
426+ private nextFrameEnd ( ) : number {
427+ const start = this . getStart ( ) ;
428+ const { playbackTime } = this . edit ;
429+ const idx = this . bakeNextIndex ?? ( playbackTime >= this . getEnd ( ) ? 0 : Math . max ( 0 , Math . floor ( ( playbackTime - start ) * this . captureFps ) ) ) ;
430+ return start + ( idx + 1 ) / this . captureFps ;
431+ }
432+
433+ // The frame on screen at the playhead, or past the last frame once the playhead has left the clip.
434+ private bakeAnchor ( frameCount : number ) : number {
435+ return Math . min ( Math . max ( 0 , Math . floor ( ( this . edit . playbackTime - this . getStart ( ) ) * this . captureFps ) ) , frameCount ) ;
436+ }
437+
412438 // After a tick, an active clip's global transform is the size the canvas shows it at. An inactive clip's own
413439 // transform isn't applied, so its bake reads only the viewport's zoom and may re-bake once the clip is active.
414440 private async bakeScale ( minScale : number ) : Promise < number > {
@@ -432,7 +458,7 @@ export class Html5Player extends Player {
432458 }
433459
434460 // A re-bake passes the scale it was started for, so a later, smaller sample can't bake softer frames.
435- private async runCapture ( minScale : number = 0 ) : Promise < Blob [ ] | null > {
461+ private async runCapture ( minScale : number = 0 , turn : ( ) => Promise < void > = async ( ) => { } ) : Promise < Blob [ ] | null > {
436462 if ( ! this . iframe || ! this . iframe . contentDocument || ! this . iframe . contentWindow ) return null ;
437463
438464 const { contentHash } = this ;
@@ -465,24 +491,65 @@ export class Html5Player extends Player {
465491 this . captureFramesTotal = frameCount ;
466492 this . captureFramesDone = 0 ;
467493
468- const framesInFlight = captureFramesInFlight ( navigator . hardwareConcurrency , Math . trunc ( W * scale ) * Math . trunc ( H * scale ) * 4 ) ;
469- const blobs : Blob [ ] = [ ] ;
470- const pending : Promise < Blob > [ ] = [ ] ;
494+ const frameBytes = Math . trunc ( W * scale ) * Math . trunc ( H * scale ) * 4 ;
495+ const framesInFlight = captureFramesInFlight ( navigator . hardwareConcurrency , frameBytes ) ;
496+ const { inFlight } = Html5Player ;
497+ // The playhead's frame goes first, so the clip under it plays soonest.
498+ const segments = passSegments (
499+ Array . from ( { length : frameCount } , ( _ , i ) => i ) ,
500+ this . bakeAnchor ( frameCount )
501+ ) ;
502+ const order = segments . flat ( ) ;
503+ // Where each segment after the first starts in the issue order.
504+ const freshPageAt = new Set ( segments . slice ( 0 , - 1 ) . map ( ( _ , i ) => segments . slice ( 0 , i + 1 ) . flat ( ) . length ) ) ;
505+ const frames : Array < Blob | undefined > = new Array ( frameCount ) ;
506+ // A re-bake leaves complete frames on screen until it finishes, so only a first bake shows frames as they land.
507+ if ( this . mode === "capturing" ) {
508+ this . bakingFrames = frames ;
509+ this . bakingScale = scale ;
510+ }
511+ const pending : { idx : number ; drawn : Promise < Blob > ; settled ?: Blob } [ ] = [ ] ;
512+ const hasRoom = ( ) : boolean =>
513+ pending . length < framesInFlight &&
514+ ( inFlight . frames === 0 || ( inFlight . frames < framesInFlight && inFlight . bytes + frameBytes <= CAPTURE_MEMORY_BUDGET_BYTES ) ) ;
471515 // The SVG is a frame's whole input, so a frame that repeats a recent one draws identical bytes and shares its Blob.
472516 const recent = new Map < string , Promise < Blob > > ( ) ;
473517 let recentStyles = "" ;
474518 let issued = 0 ;
519+ let published = 0 ;
475520 let progressStep = 0 ;
476521 let sliceStart = - Infinity ;
477- // Frames publish in order, so the oldest in flight gates the window.
478- while ( blobs . length < frameCount ) {
479- if ( issued < frameCount && pending . length < framesInFlight ) {
522+ const publish = ( idx : number , frame : Blob ) : void => {
523+ frames [ idx ] = frame ;
524+ published += 1 ;
525+ this . captureFramesDone += 1 ;
526+ // Steps, not frames: enough for a ring to fill smoothly, and bounded whatever the frame count.
527+ const step = Math . floor ( ( this . captureFramesDone / frameCount ) * CAPTURE_PROGRESS_STEPS ) ;
528+ if ( step > progressStep ) {
529+ progressStep = step ;
530+ this . emitCaptureProgress ( ) ;
531+ }
532+ } ;
533+ // Frames publish in the order they were issued, so the oldest in flight gates the window.
534+ while ( published < frameCount ) {
535+ // Draws finish while the bake yields, so finished frames publish before the next one is drawn.
536+ for ( let head = pending [ 0 ] ?. settled ; head ; head = pending [ 0 ] ?. settled ) publish ( pending . shift ( ) ! . idx , head ) ;
537+ if ( issued < frameCount && hasRoom ( ) ) {
538+ const idx = order [ issued ] ;
539+ this . bakeNextIndex = idx ;
480540 if ( performance . now ( ) - sliceStart >= CAPTURE_SLICE_MS ) {
481541 await nextFrame ( ) ;
482542 if ( stale ( ) ) return null ;
543+ await turn ( ) ;
544+ if ( stale ( ) ) return null ;
483545 sliceStart = performance . now ( ) ;
484546 }
485- this . seekHarness ( issued / fps ) ;
547+ }
548+ // Other bakes draw during their turns, so the window is read again once this one has its turn back.
549+ if ( issued < frameCount && hasRoom ( ) ) {
550+ const idx = order [ issued ] ;
551+ if ( freshPageAt . has ( issued ) && ! ( await this . loadFreshPage ( this . iframe , stale , scale ) ) ) return null ;
552+ this . seekHarness ( idx / fps ) ;
486553 forceLayout ( this . iframe . contentDocument . body ) ;
487554 const { styles, body } = this . serialiseIframe ( W , H ) ;
488555 if ( styles !== recentStyles ) {
@@ -491,31 +558,43 @@ export class Html5Player extends Player {
491558 }
492559 let drawn = recent . get ( body ) ;
493560 if ( ! drawn ) {
494- drawn = foreignObjectSvgToWebp ( foreignObjectSvg ( W , H , styles , body ) , W , H , scale ) . then (
495- bytes => new Blob ( [ bytes as BlobPart ] , { type : "image/webp" } )
496- ) ;
561+ inFlight . frames += 1 ;
562+ inFlight . bytes += frameBytes ;
563+ drawn = foreignObjectSvgToWebp ( foreignObjectSvg ( W , H , styles , body ) , W , H , scale )
564+ . then ( bytes => new Blob ( [ bytes as BlobPart ] , { type : "image/webp" } ) )
565+ . finally ( ( ) => {
566+ inFlight . frames -= 1 ;
567+ inFlight . bytes -= frameBytes ;
568+ } ) ;
497569 // Marks a queued frame's failure handled; awaiting it rethrows.
498570 drawn . catch ( ( ) => undefined ) ;
499571 // A Map iterates in insertion order, so its first key is the oldest frame.
500572 if ( recent . size >= REPEAT_WINDOW_FRAMES ) recent . delete ( recent . keys ( ) . next ( ) . value ! ) ;
501573 recent . set ( body , drawn ) ;
502574 }
503- pending . push ( drawn ) ;
575+ const entry : ( typeof pending ) [ number ] = { idx, drawn } ;
576+ drawn . then (
577+ blob => {
578+ entry . settled = blob ;
579+ } ,
580+ ( ) => undefined
581+ ) ;
582+ pending . push ( entry ) ;
504583 issued += 1 ;
505- } else {
506- const frame = await pending . shift ( ) ! ;
584+ } else if ( pending . length > 0 ) {
585+ const { idx, drawn } = pending . shift ( ) ! ;
586+ const frame = await drawn ;
587+ if ( stale ( ) ) return null ;
588+ publish ( idx , frame ) ;
589+ } else if ( issued < frameCount ) {
590+ // Other bakes' frames fill the window, and they finish without their bakes' turns.
591+ await nextFrame ( ) ;
507592 if ( stale ( ) ) return null ;
508- blobs . push ( frame ) ;
509- this . captureFramesDone += 1 ;
510- // Steps, not frames: enough for a ring to fill smoothly, and bounded whatever the frame count.
511- const step = Math . floor ( ( this . captureFramesDone / frameCount ) * CAPTURE_PROGRESS_STEPS ) ;
512- if ( step > progressStep ) {
513- progressStep = step ;
514- this . emitCaptureProgress ( ) ;
515- }
516593 }
517594 }
518595
596+ // Every index has published by now.
597+ const blobs = frames as Blob [ ] ;
519598 if ( ! ( await this . adoptBake ( blobs , cacheKey , scale , stale ) ) ) return null ;
520599 this . emitCaptureCompleted ( frameCount ) ;
521600
@@ -846,6 +925,7 @@ export class Html5Player extends Player {
846925 this . decodedFrames . clear ( ) ;
847926 this . capturedFrames = null ;
848927 this . capturedHash = null ;
928+ this . bakingFrames = null ;
849929 this . lastFrameIdx = - 1 ;
850930 this . decodeInFlight = null ;
851931 this . rebakeStarted = false ;
@@ -1031,9 +1111,10 @@ export class Html5Player extends Player {
10311111 if ( ! this . disposed ) console . warn ( "[Html5Player] still failed:" , err ) ;
10321112 return null ;
10331113 } ) ;
1034- let texture = await attempt ( ) ;
1114+ const baked = this . bakingFrames ?. [ frame ] ;
1115+ let texture = baked ? await this . bakingTexture ( baked ) : await attempt ( ) ;
10351116 // A page can load too slowly once, so a failed still gets one more try while nothing newer is waiting.
1036- if ( ! texture && this . liveStillFrame === frame && this . stillRequest === null ) texture = await attempt ( ) ;
1117+ if ( ! texture && ! baked && this . liveStillFrame === frame && this . stillRequest === null ) texture = await attempt ( ) ;
10371118 const unbaked = this . mode === "editing" || this . mode === "stale" || this . mode === "capturing" ;
10381119 if ( ! texture || this . disposed || ! unbaked || this . contentHash !== hash ) {
10391120 texture ?. destroy ( true ) ;
@@ -1054,6 +1135,11 @@ export class Html5Player extends Player {
10541135 }
10551136 }
10561137
1138+ // The resolution makes a scaled frame's texture report the clip's full size.
1139+ private async bakingTexture ( blob : Blob ) : Promise < pixi . Texture > {
1140+ return pixi . Texture . from ( { resource : await createImageBitmap ( blob ) , resolution : this . bakingScale } ) ;
1141+ }
1142+
10571143 private removeLiveStill ( ) : void {
10581144 this . stillRequest = null ;
10591145 this . liveStillFrame = - 1 ;
0 commit comments