@@ -25,6 +25,8 @@ import { createCaptureLoadingGraphic, createPlaceholderGraphic } from "./placeho
2525const IFRAME_OFFSCREEN_X = - 10000 ;
2626const IFRAME_LOAD_TIMEOUT_MS = 10_000 ;
2727const DECODED_FRAME_LIMIT = 30 ;
28+ // How stale a held frame may be when it's chosen: 233 ms at 30 fps.
29+ const HELD_FRAME_LIMIT = 7 ;
2830
2931type HarnessWindow = Window & {
3032 [ "__shotstackSeek" ] ?: ( ms : number ) => void ;
@@ -99,6 +101,12 @@ export function captureFramesInFlight(logicalCores: number | undefined, frameByt
99101 return Math . max ( 1 , Math . min ( byCores , Math . floor ( CAPTURE_MEMORY_BUDGET_BYTES / frameBytes ) ) ) ;
100102}
101103
104+ /** The latest baked frame at most `maxBehind` frames before `idx`, or null. */
105+ export function heldFrame ( frames : ArrayLike < Blob | undefined > , idx : number , maxBehind : number ) : number | null {
106+ for ( let i = idx ; i >= Math . max ( 0 , idx - maxBehind ) ; i -= 1 ) if ( frames [ i ] ) return i ;
107+ return null ;
108+ }
109+
102110export function bakeScaleFor ( onScreenScale : number ) : number {
103111 return BAKE_SCALES . find ( scale => scale >= onScreenScale ) ?? 1 ;
104112}
@@ -160,6 +168,10 @@ export class Html5Player extends Player {
160168 private stillTexture : pixi . Texture | null = null ;
161169 private liveStill : pixi . Sprite | null = null ;
162170 private liveStillFrame : number = - 1 ;
171+ // False while the live still is a held frame from before the playhead, so pausing on it renders the exact one.
172+ private liveStillExact : boolean = true ;
173+ // The frame the live still shows, which a held frame trails.
174+ private liveStillShown : number = - 1 ;
163175 private liveStillInFlight : boolean = false ;
164176 private stillRequest : number | null = null ;
165177 // The frame the running bake draws next. Null between bakes.
@@ -1092,8 +1104,9 @@ export class Html5Player extends Player {
10921104 // One still renders at a time. Positions asked for meanwhile replace each other, so only the newest renders next.
10931105 private showStillAt ( seconds : number ) : void {
10941106 const frame = Math . floor ( seconds * this . captureFps ) ;
1095- if ( frame === this . liveStillFrame ) return ;
1107+ if ( frame === this . liveStillFrame && ( this . liveStillExact || this . edit . isPlaying ) ) return ;
10961108 this . liveStillFrame = frame ;
1109+ this . liveStillExact = true ;
10971110 this . stillRequest = seconds ;
10981111 if ( ! this . liveStillInFlight ) this . renderLiveStills ( ) . catch ( err => console . warn ( "[Html5Player] still failed:" , err ) ) ;
10991112 }
@@ -1106,35 +1119,51 @@ export class Html5Player extends Player {
11061119 const frame = Math . floor ( seconds * this . captureFps ) ;
11071120 const hash = this . contentHash ;
11081121 this . stillRequest = null ;
1109- const attempt = ( ) =>
1110- this . renderStill ( seconds ) . catch ( err => {
1111- if ( ! this . disposed ) console . warn ( "[Html5Player] still failed:" , err ) ;
1112- return null ;
1113- } ) ;
1114- const baked = this . bakingFrames ?. [ frame ] ;
1115- let texture = baked ? await this . bakingTexture ( baked ) : await attempt ( ) ;
1116- // A page can load too slowly once, so a failed still gets one more try while nothing newer is waiting.
1117- if ( ! texture && ! baked && this . liveStillFrame === frame && this . stillRequest === null ) texture = await attempt ( ) ;
1118- const unbaked = this . mode === "editing" || this . mode === "stale" || this . mode === "capturing" ;
1119- if ( ! texture || this . disposed || ! unbaked || this . contentHash !== hash ) {
1120- texture ?. destroy ( true ) ;
1121- } else if ( this . liveStill ) {
1122- this . liveStill . texture . destroy ( true ) ;
1123- this . liveStill . texture = texture ;
1122+ // A still takes main-thread time from the bake, so a playing clip holds a recent baked frame.
1123+ const held = this . bakingFrames ? heldFrame ( this . bakingFrames , frame , this . edit . isPlaying ? HELD_FRAME_LIMIT : 0 ) : null ;
1124+ const shown = this . liveStillShown ;
1125+ // Playback never steps back to an older frame, or decodes the frame it already shows.
1126+ if ( this . edit . isPlaying && held !== null && this . liveStill && held <= shown && shown <= frame ) {
1127+ this . liveStillExact = shown === frame ;
11241128 } else {
1125- this . removeLoadingGraphic ( ) ;
1126- this . liveStill = new pixi . Sprite ( texture ) ;
1127- // A capture in progress shows its own copy of the clip.
1128- this . liveStill . visible = ! this . staticSprite ;
1129- this . contentContainer . addChild ( this . liveStill ) ;
1130- if ( this . clipConfiguration . width && this . clipConfiguration . height ) this . applyFixedDimensions ( ) ;
1129+ await this . renderLiveStill ( seconds , frame , held , hash ) ;
11311130 }
11321131 }
11331132 } finally {
11341133 this . liveStillInFlight = false ;
11351134 }
11361135 }
11371136
1137+ private async renderLiveStill ( seconds : number , frame : number , held : number | null , hash : string | null ) : Promise < void > {
1138+ const attempt = ( ) =>
1139+ this . renderStill ( seconds ) . catch ( err => {
1140+ if ( ! this . disposed ) console . warn ( "[Html5Player] still failed:" , err ) ;
1141+ return null ;
1142+ } ) ;
1143+ const baked = held === null ? undefined : this . bakingFrames ?. [ held ] ;
1144+ if ( baked && held !== frame ) this . liveStillExact = false ;
1145+ let texture = baked ? await this . bakingTexture ( baked ) : await attempt ( ) ;
1146+ // A page can load too slowly once, so a failed still gets one more try while nothing newer is waiting.
1147+ if ( ! texture && ! baked && this . liveStillFrame === frame && this . stillRequest === null ) texture = await attempt ( ) ;
1148+ const unbaked = this . mode === "editing" || this . mode === "stale" || this . mode === "capturing" ;
1149+ if ( ! texture || this . disposed || ! unbaked || this . contentHash !== hash ) {
1150+ texture ?. destroy ( true ) ;
1151+ return ;
1152+ }
1153+ this . liveStillShown = baked && held !== null ? held : frame ;
1154+ if ( this . liveStill ) {
1155+ this . liveStill . texture . destroy ( true ) ;
1156+ this . liveStill . texture = texture ;
1157+ return ;
1158+ }
1159+ this . removeLoadingGraphic ( ) ;
1160+ this . liveStill = new pixi . Sprite ( texture ) ;
1161+ // A capture in progress shows its own copy of the clip.
1162+ this . liveStill . visible = ! this . staticSprite ;
1163+ this . contentContainer . addChild ( this . liveStill ) ;
1164+ if ( this . clipConfiguration . width && this . clipConfiguration . height ) this . applyFixedDimensions ( ) ;
1165+ }
1166+
11381167 // The resolution makes a scaled frame's texture report the clip's full size.
11391168 private async bakingTexture ( blob : Blob ) : Promise < pixi . Texture > {
11401169 return pixi . Texture . from ( { resource : await createImageBitmap ( blob ) , resolution : this . bakingScale } ) ;
@@ -1143,6 +1172,7 @@ export class Html5Player extends Player {
11431172 private removeLiveStill ( ) : void {
11441173 this . stillRequest = null ;
11451174 this . liveStillFrame = - 1 ;
1175+ this . liveStillShown = - 1 ;
11461176 if ( ! this . liveStill ) return ;
11471177 this . contentContainer . removeChild ( this . liveStill ) ;
11481178 this . liveStill . texture . destroy ( true ) ;
0 commit comments