@@ -35,6 +35,13 @@ const CAPTURE_SLICE_MS = 12;
3535const CAPTURE_MEMORY_BUDGET_BYTES = 256 * 1024 * 1024 ;
3636const CAPTURE_PROGRESS_STEPS = 20 ;
3737const BAKE_SCALES = [ 0.25 , 0.5 , 0.75 , 1 ] ;
38+ // A remembered frame holds its body markup, so a window bounds the memory. 300 frames covers a 10 s loop at 30 fps.
39+ const REPEAT_WINDOW_FRAMES = 300 ;
40+ const ANIMATION_OVERRIDE = `<style>*,*::before,*::after{animation:none!important;transition:none!important}</style>` ;
41+
42+ function foreignObjectSvg ( width : number , height : number , styles : string , body : string ) : string {
43+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${ width } " height="${ height } "><foreignObject width="${ width } " height="${ height } ">${ styles } ${ ANIMATION_OVERRIDE } ${ body } </foreignObject></svg>` ;
44+ }
3845
3946function forceLayout ( el : HTMLElement ) : void {
4047 el . getBoundingClientRect ( ) ;
@@ -461,9 +468,9 @@ export class Html5Player extends Player {
461468 const framesInFlight = captureFramesInFlight ( navigator . hardwareConcurrency , Math . trunc ( W * scale ) * Math . trunc ( H * scale ) * 4 ) ;
462469 const blobs : Blob [ ] = [ ] ;
463470 const pending : Promise < Blob > [ ] = [ ] ;
464- // The SVG is a frame's whole input, so an identical one draws identical bytes and shares the previous frame's Blob.
465- let lastSvg = "" ;
466- let lastFrame : Promise < Blob > | null = null ;
471+ // The SVG is a frame's whole input, so a frame that repeats a recent one draws identical bytes and shares its Blob.
472+ const recent = new Map < string , Promise < Blob > > ( ) ;
473+ let recentStyles = "" ;
467474 let issued = 0 ;
468475 let progressStep = 0 ;
469476 let sliceStart = - Infinity ;
@@ -477,14 +484,23 @@ export class Html5Player extends Player {
477484 }
478485 this . seekHarness ( issued / fps ) ;
479486 forceLayout ( this . iframe . contentDocument . body ) ;
480- const svg = this . captureIframeAsForeignObjectSvg ( W , H ) ;
481- if ( ! lastFrame || svg !== lastSvg ) {
482- lastSvg = svg ;
483- lastFrame = foreignObjectSvgToWebp ( svg , W , H , scale ) . then ( bytes => new Blob ( [ bytes as BlobPart ] , { type : "image/webp" } ) ) ;
487+ const { styles, body } = this . serialiseIframe ( W , H ) ;
488+ if ( styles !== recentStyles ) {
489+ recent . clear ( ) ;
490+ recentStyles = styles ;
491+ }
492+ let drawn = recent . get ( body ) ;
493+ 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+ ) ;
484497 // Marks a queued frame's failure handled; awaiting it rethrows.
485- lastFrame . catch ( ( ) => undefined ) ;
498+ drawn . catch ( ( ) => undefined ) ;
499+ // A Map iterates in insertion order, so its first key is the oldest frame.
500+ if ( recent . size >= REPEAT_WINDOW_FRAMES ) recent . delete ( recent . keys ( ) . next ( ) . value ! ) ;
501+ recent . set ( body , drawn ) ;
486502 }
487- pending . push ( lastFrame ) ;
503+ pending . push ( drawn ) ;
488504 issued += 1 ;
489505 } else {
490506 const frame = await pending . shift ( ) ! ;
@@ -554,13 +570,19 @@ export class Html5Player extends Player {
554570 }
555571
556572 private captureIframeAsForeignObjectSvg ( width : number , height : number , iframe : HTMLIFrameElement | null = this . iframe ) : string {
573+ const { styles, body } = this . serialiseIframe ( width , height , iframe ) ;
574+ return foreignObjectSvg ( width , height , styles , body ) ;
575+ }
576+
577+ // The styles carry the page's inlined fonts and images, hundreds of KB that rarely change between frames, so a
578+ // bake compares them apart from the body.
579+ private serialiseIframe ( width : number , height : number , iframe : HTMLIFrameElement | null = this . iframe ) : { styles : string ; body : string } {
557580 if ( ! iframe ?. contentDocument ) throw new Error ( "iframe not ready" ) ;
558581 const doc = iframe . contentDocument ;
559582 const serializer = new XMLSerializer ( ) ;
560583 const styles = Array . from ( doc . querySelectorAll ( "style" ) )
561584 . map ( el => serializer . serializeToString ( el ) )
562585 . join ( "" ) ;
563- const animationOverride = `<style>*,*::before,*::after{animation:none!important;transition:none!important}</style>` ;
564586 const bodyClone = doc . body . cloneNode ( true ) as HTMLElement ;
565587 // Scripts never run in an SVG image, and their source can hold characters XML forbids, which fail the frame.
566588 bodyClone . querySelectorAll ( "script" ) . forEach ( script => script . remove ( ) ) ;
@@ -588,8 +610,7 @@ export class Html5Player extends Player {
588610 }
589611 const existingStyle = bodyClone . getAttribute ( "style" ) ?? "" ;
590612 bodyClone . setAttribute ( "style" , `width:${ width } px;height:${ height } px;margin:0;overflow:hidden;${ existingStyle } ` ) ;
591- const bodyXml = serializer . serializeToString ( bodyClone ) ;
592- return `<svg xmlns="http://www.w3.org/2000/svg" width="${ width } " height="${ height } "><foreignObject width="${ width } " height="${ height } ">${ styles } ${ animationOverride } ${ bodyXml } </foreignObject></svg>` ;
613+ return { styles, body : serializer . serializeToString ( bodyClone ) } ;
593614 }
594615
595616 private async getDecodedFrame ( idx : number ) : Promise < pixi . Texture | null > {
0 commit comments