@@ -25,7 +25,7 @@ import { createCaptureLoadingGraphic, createPlaceholderGraphic } from "./placeho
2525const IFRAME_OFFSCREEN_X = - 10000 ;
2626const IFRAME_LOAD_TIMEOUT_MS = 10_000 ;
2727const DECODED_FRAME_LIMIT = 30 ;
28- // One first-pass stride: a held frame is never more than 233 ms behind at 30 fps.
28+ // How stale a held frame may be when it's chosen: 233 ms at 30 fps.
2929const HELD_FRAME_LIMIT = 7 ;
3030
3131type HarnessWindow = Window & {
@@ -170,6 +170,8 @@ export class Html5Player extends Player {
170170 private liveStillFrame : number = - 1 ;
171171 // False while the live still is a held frame from before the playhead, so pausing on it renders the exact one.
172172 private liveStillExact : boolean = true ;
173+ // The frame the live still shows, which a held frame trails.
174+ private liveStillShown : number = - 1 ;
173175 private liveStillInFlight : boolean = false ;
174176 private stillRequest : number | null = null ;
175177 // The frame the running bake draws next. Null between bakes.
@@ -1117,38 +1119,51 @@ export class Html5Player extends Player {
11171119 const frame = Math . floor ( seconds * this . captureFps ) ;
11181120 const hash = this . contentHash ;
11191121 this . stillRequest = null ;
1120- const attempt = ( ) =>
1121- this . renderStill ( seconds ) . catch ( err => {
1122- if ( ! this . disposed ) console . warn ( "[Html5Player] still failed:" , err ) ;
1123- return null ;
1124- } ) ;
1125- // Playing, a clip holds a recent baked frame rather than spend bake time on a still.
1122+ // A still takes main-thread time from the bake, so a playing clip holds a recent baked frame.
11261123 const held = this . bakingFrames ? heldFrame ( this . bakingFrames , frame , this . edit . isPlaying ? HELD_FRAME_LIMIT : 0 ) : null ;
1127- const baked = held === null ? undefined : this . bakingFrames ?. [ held ] ;
1128- if ( baked && held !== frame ) this . liveStillExact = false ;
1129- let texture = baked ? await this . bakingTexture ( baked ) : await attempt ( ) ;
1130- // A page can load too slowly once, so a failed still gets one more try while nothing newer is waiting.
1131- if ( ! texture && ! baked && this . liveStillFrame === frame && this . stillRequest === null ) texture = await attempt ( ) ;
1132- const unbaked = this . mode === "editing" || this . mode === "stale" || this . mode === "capturing" ;
1133- if ( ! texture || this . disposed || ! unbaked || this . contentHash !== hash ) {
1134- texture ?. destroy ( true ) ;
1135- } else if ( this . liveStill ) {
1136- this . liveStill . texture . destroy ( true ) ;
1137- this . liveStill . texture = texture ;
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 ;
11381128 } else {
1139- this . removeLoadingGraphic ( ) ;
1140- this . liveStill = new pixi . Sprite ( texture ) ;
1141- // A capture in progress shows its own copy of the clip.
1142- this . liveStill . visible = ! this . staticSprite ;
1143- this . contentContainer . addChild ( this . liveStill ) ;
1144- if ( this . clipConfiguration . width && this . clipConfiguration . height ) this . applyFixedDimensions ( ) ;
1129+ await this . renderLiveStill ( seconds , frame , held , hash ) ;
11451130 }
11461131 }
11471132 } finally {
11481133 this . liveStillInFlight = false ;
11491134 }
11501135 }
11511136
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+
11521167 // The resolution makes a scaled frame's texture report the clip's full size.
11531168 private async bakingTexture ( blob : Blob ) : Promise < pixi . Texture > {
11541169 return pixi . Texture . from ( { resource : await createImageBitmap ( blob ) , resolution : this . bakingScale } ) ;
@@ -1157,7 +1172,7 @@ export class Html5Player extends Player {
11571172 private removeLiveStill ( ) : void {
11581173 this . stillRequest = null ;
11591174 this . liveStillFrame = - 1 ;
1160- this . liveStillExact = true ;
1175+ this . liveStillShown = - 1 ;
11611176 if ( ! this . liveStill ) return ;
11621177 this . contentContainer . removeChild ( this . liveStill ) ;
11631178 this . liveStill . texture . destroy ( true ) ;
0 commit comments