@@ -59,6 +59,64 @@ export class GIFGenerator {
5959 return true ;
6060 }
6161
62+ // Count the set (non-zero) bitmap bytes on screen, i.e. how many pixel cells
63+ // carry drawn content. The 0x6600 buffer is a 24-row top border, then 192
64+ // rows of [16 left-border][32 (bitmap,attr) pairs][16 right-border], then a
65+ // 24-row bottom border (see FrameDecoder). Only the bitmap byte of each pair
66+ // is counted, so a blank/cleared screen reads ~0 regardless of paper colour,
67+ // while text or graphics read higher.
68+ private screenInk ( a : Uint8Array ) : number {
69+ let n = 0 ;
70+ let p = 24 * 160 ; // skip top border
71+ for ( let row = 0 ; row < 192 ; row ++ ) {
72+ p += 16 ; // left border
73+ for ( let i = 0 ; i < 32 ; i ++ ) {
74+ if ( a [ p ] !== 0 ) n ++ ; // bitmap byte
75+ p += 2 ; // step over this pair's attribute byte
76+ }
77+ p += 16 ; // right border
78+ }
79+ return n ;
80+ }
81+
82+ // Pick the frame the clip should open on: the program's first own frame,
83+ // with the ROM loader skipped entirely. Tape-load traps fire only while LOAD
84+ // pulls blocks in, so `lastLoadFrame` is where the loader hands control to
85+ // the program. From there the program typically clears the loader screen
86+ // (CLS), which is the one event that reliably wipes the ROM's "Program:" /
87+ // "Bytes:" text. Open on the first frame that draws content after that clear,
88+ // so no opening-animation frame is lost and no loader text is ever shown.
89+ private findProgramStart (
90+ frames : Uint8Array [ ] ,
91+ lastLoadFrame : number ,
92+ lastChangeIndex : number ,
93+ ) : number {
94+ if ( lastLoadFrame < 0 ) return 0 ; // no tape load seen; nothing to skip
95+ const CLEAR_INK = 2 ; // at/below this the screen is effectively blank
96+ const afterLoad = Math . min ( lastLoadFrame + 1 , lastChangeIndex ) ;
97+
98+ // Find the program's first screen clear after the loader handoff.
99+ let clearedAt = - 1 ;
100+ for ( let i = afterLoad ; i <= lastChangeIndex ; i ++ ) {
101+ if ( this . screenInk ( frames [ i ] ) <= CLEAR_INK ) {
102+ clearedAt = i ;
103+ break ;
104+ }
105+ }
106+ // No clear (e.g. a program that loads a screen and draws straight over
107+ // it): best effort is the loader handoff frame.
108+ if ( clearedAt < 0 ) return afterLoad ;
109+
110+ // Open on the first frame that draws anything after the clear. Catching
111+ // the very first drawn pixel (not a content threshold) means a slow
112+ // opening animation keeps all its frames. If the program never draws
113+ // (blank or audio-only), stay on the cleared frame.
114+ for ( let i = clearedAt + 1 ; i <= lastChangeIndex ; i ++ ) {
115+ if ( this . screenInk ( frames [ i ] ) > CLEAR_INK ) return i ;
116+ }
117+ return clearedAt ;
118+ }
119+
62120 // ZX Spectrum keyboard matrix cells.
63121 private static readonly KEY = {
64122 ENTER : [ 6 , 0x01 ] as [ number , number ] ,
@@ -139,6 +197,7 @@ export class GIFGenerator {
139197 let previousFrame : Uint8Array | null = null ;
140198 let staleCount = 0 ;
141199 let lastChangeIndex = - 1 ;
200+ let lastLoadFrame = - 1 ; // last frame in which a tape-load trap fired
142201
143202 for ( let f = 0 ; f < maxFrames ; f ++ ) {
144203 if ( Date . now ( ) > renderDeadline ) {
@@ -152,6 +211,7 @@ export class GIFGenerator {
152211 }
153212 const frameBuffer = new Uint8Array ( this . emulator . runFrame ( ) ) ;
154213 frames . push ( frameBuffer ) ;
214+ if ( this . emulator . getTapeTrapsLastFrame ( ) > 0 ) lastLoadFrame = f ;
155215
156216 // Audio activity counts as a change: a tune over a static screen must
157217 // not be cut short, nor its tail trimmed. Kept aligned 1:1 with frames.
@@ -188,12 +248,11 @@ export class GIFGenerator {
188248 return { frames : frames . slice ( 0 , keepStatic ) , audio : audio . slice ( 0 , keepStatic ) } ;
189249 }
190250
191- // Keep every captured frame from the very first one. The social preview
192- // is frame 0, so it may show the blank boot/loader screen before the
193- // program draws, but no opening frames are ever skipped.
251+ // Open on the program's first own frame, skipping the ROM loader.
252+ const start = this . findProgramStart ( frames , lastLoadFrame , lastChangeIndex ) ;
194253 const keep = Math . min ( frames . length , lastChangeIndex + 1 + tailFrames ) ;
195- console . log ( `Captured ${ frames . length } frames, keeping ${ keep } ` ) ;
196- return { frames : frames . slice ( 0 , keep ) , audio : audio . slice ( 0 , keep ) } ;
254+ console . log ( `Captured ${ frames . length } frames, keeping ${ start } .. ${ keep } ` ) ;
255+ return { frames : frames . slice ( start , keep ) , audio : audio . slice ( start , keep ) } ;
197256 }
198257
199258 private isAudioSilent ( left : Float32Array , right : Float32Array ) : boolean {
0 commit comments