@@ -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+ // One first-pass stride: a held frame is never more than 233 ms behind 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,8 @@ 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 ;
163173 private liveStillInFlight : boolean = false ;
164174 private stillRequest : number | null = null ;
165175 // The frame the running bake draws next. Null between bakes.
@@ -1092,8 +1102,9 @@ export class Html5Player extends Player {
10921102 // One still renders at a time. Positions asked for meanwhile replace each other, so only the newest renders next.
10931103 private showStillAt ( seconds : number ) : void {
10941104 const frame = Math . floor ( seconds * this . captureFps ) ;
1095- if ( frame === this . liveStillFrame ) return ;
1105+ if ( frame === this . liveStillFrame && ( this . liveStillExact || this . edit . isPlaying ) ) return ;
10961106 this . liveStillFrame = frame ;
1107+ this . liveStillExact = true ;
10971108 this . stillRequest = seconds ;
10981109 if ( ! this . liveStillInFlight ) this . renderLiveStills ( ) . catch ( err => console . warn ( "[Html5Player] still failed:" , err ) ) ;
10991110 }
@@ -1111,7 +1122,10 @@ export class Html5Player extends Player {
11111122 if ( ! this . disposed ) console . warn ( "[Html5Player] still failed:" , err ) ;
11121123 return null ;
11131124 } ) ;
1114- const baked = this . bakingFrames ?. [ frame ] ;
1125+ // Playing, a clip holds a recent baked frame rather than spend bake time on a still.
1126+ 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 ;
11151129 let texture = baked ? await this . bakingTexture ( baked ) : await attempt ( ) ;
11161130 // A page can load too slowly once, so a failed still gets one more try while nothing newer is waiting.
11171131 if ( ! texture && ! baked && this . liveStillFrame === frame && this . stillRequest === null ) texture = await attempt ( ) ;
@@ -1143,6 +1157,7 @@ export class Html5Player extends Player {
11431157 private removeLiveStill ( ) : void {
11441158 this . stillRequest = null ;
11451159 this . liveStillFrame = - 1 ;
1160+ this . liveStillExact = true ;
11461161 if ( ! this . liveStill ) return ;
11471162 this . contentContainer . removeChild ( this . liveStill ) ;
11481163 this . liveStill . texture . destroy ( true ) ;
0 commit comments