@@ -59,6 +59,26 @@ 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 inkBytes ( 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+
6282 // ZX Spectrum keyboard matrix cells.
6383 private static readonly KEY = {
6484 ENTER : [ 6 , 0x01 ] as [ number , number ] ,
@@ -93,6 +113,14 @@ export class GIFGenerator {
93113
94114 console . log ( `Booting ${ machineType } K machine with tape traps enabled` ) ;
95115
116+ // Boot, reach the LOAD prompt, then press the key that kicks off the
117+ // instant trap-load. The trigger key is deliberately left held here: the
118+ // capture loop below runs (and records) the frames during which the ROM
119+ // registers the press and the program starts drawing, and releases it a
120+ // few frames in. Releasing it here (as a pressKeys "gap") would swallow
121+ // the program's opening frames, since a trap-loaded program can begin
122+ // drawing within a frame or two of the keypress.
123+ let triggerKey : [ number , number ] ;
96124 if ( machineType === 48 ) {
97125 // 48K boots straight to the (C) screen with a K cursor and no loader
98126 // window. Enter LOAD "" using single-key tokens: J = LOAD,
@@ -101,20 +129,22 @@ export class GIFGenerator {
101129 this . pressKeys ( [ GIFGenerator . KEY . J ] ) ;
102130 this . pressKeys ( [ GIFGenerator . KEY . SYMBOL_SHIFT , GIFGenerator . KEY . P ] ) ;
103131 this . pressKeys ( [ GIFGenerator . KEY . SYMBOL_SHIFT , GIFGenerator . KEY . P ] ) ;
104- this . pressKeys ( [ GIFGenerator . KEY . ENTER ] ) ;
132+ triggerKey = GIFGenerator . KEY . ENTER ;
105133 } else {
106134 // 128K boots to a menu with "Tape Loader" pre-selected; ENTER runs it.
107135 // Note: leaves the loader's bottom-window UI on screen.
108136 for ( let i = 0 ; i < 150 ; i ++ ) this . emulator . runFrame ( ) ; // ~3s to menu
109- this . pressKeys ( [ GIFGenerator . KEY . ENTER ] ) ;
137+ triggerKey = GIFGenerator . KEY . ENTER ;
110138 }
139+ this . emulator . rawKeyDown ( triggerKey [ 0 ] , triggerKey [ 1 ] ) ;
140+ const triggerReleaseFrame = 4 ; // hold the trigger ~4 frames so the ROM registers it
111141
112- // Start capturing immediately so a program's one-shot startup audio
113- // ( e.g. a beep on launch) isn't lost in a pre-roll. The trap loader has
114- // already injected the blocks during the LOAD keypresses above; the
142+ // Capture from the first frame after the trigger keypress so a program's
143+ // opening draws (and one-shot startup audio, e.g. a launch beep) aren't
144+ // lost in a pre-roll. With tape traps the blocks load instantly, so the
115145 // first captured frames cover the loader handing off to the program.
116- // Capture the running program. Stop once the screen has been static for
117- // `staleFrameThreshold` consecutive frames, then trim trailing static.
146+ // Stop once the screen has been static for `staleFrameThreshold`
147+ // consecutive frames, then trim trailing static.
118148 const maxFrames = Math . floor ( this . options . maxDurationMs / 20 ) ;
119149 const staleStop = this . options . staleFrameThreshold ;
120150 const tailFrames = 25 ; // ~0.5s of static tail kept for readability
@@ -129,14 +159,21 @@ export class GIFGenerator {
129159 let previousFrame : Uint8Array | null = null ;
130160 let staleCount = 0 ;
131161 let lastChangeIndex = - 1 ;
162+ let lastLoadFrame = - 1 ; // last frame in which a tape-load trap fired
132163
133164 for ( let f = 0 ; f < maxFrames ; f ++ ) {
134165 if ( Date . now ( ) > renderDeadline ) {
135166 console . warn ( `Render wall-clock budget exceeded after ${ f } frames; stopping` ) ;
136167 break ;
137168 }
169+ // Release the load-trigger key once the ROM has had a few frames to
170+ // register the press; the frames it was held for are captured above.
171+ if ( f === triggerReleaseFrame ) {
172+ this . emulator . rawKeyUp ( triggerKey [ 0 ] , triggerKey [ 1 ] ) ;
173+ }
138174 const frameBuffer = new Uint8Array ( this . emulator . runFrame ( ) ) ;
139175 frames . push ( frameBuffer ) ;
176+ if ( this . emulator . getTapeTrapsLastFrame ( ) > 0 ) lastLoadFrame = f ;
140177
141178 // Audio activity counts as a change: a tune over a static screen must
142179 // not be cut short, nor its tail trimmed. Kept aligned 1:1 with frames.
@@ -167,11 +204,54 @@ export class GIFGenerator {
167204
168205 if ( lastChangeIndex < 0 ) {
169206 // Nothing ever changed; keep a short clip so the output is not empty.
170- lastChangeIndex = Math . min ( frames . length , tailFrames ) - 1 ;
207+ // There is no drawing to open on, so start at the first frame.
208+ const keepStatic = Math . min ( frames . length , tailFrames ) ;
209+ console . log ( `Captured ${ frames . length } frames, no changes; keeping ${ keepStatic } ` ) ;
210+ return { frames : frames . slice ( 0 , keepStatic ) , audio : audio . slice ( 0 , keepStatic ) } ;
211+ }
212+
213+ // Open on the program's first real content, not the ROM editor/loader
214+ // pre-roll or the bare cleared screen. Capturing begins at the load
215+ // keypress (so no opening draw is ever missed), which leaves the blank
216+ // boot screen and the loader's "Program:" header at the head. Tape-load
217+ // traps fire only while LOAD pulls blocks in, so the program takes
218+ // control on the frame after the last trap. From there a program
219+ // typically clears the loader screen (CLS) and then draws: open on the
220+ // first frame that carries real drawn pixels after that clear, so the
221+ // first frame (and thus the social preview, which is frame 0) shows
222+ // content rather than a blank screen.
223+ const INK_CONTENT = 16 ; // set bitmap bytes that count as real drawn content
224+ const INK_CLEARED = 8 ; // at/below this the screen is effectively blank
225+ let start = lastLoadFrame >= 0 ? Math . min ( lastLoadFrame + 1 , lastChangeIndex ) : 0 ;
226+ if ( lastLoadFrame >= 0 ) {
227+ // Find where the program clears the loader's screen (ink falls to
228+ // ~blank) after taking control.
229+ let clearedAt = - 1 ;
230+ for ( let i = lastLoadFrame + 1 ; i <= lastChangeIndex ; i ++ ) {
231+ if ( this . inkBytes ( frames [ i ] ) <= INK_CLEARED ) {
232+ clearedAt = i ;
233+ break ;
234+ }
235+ }
236+ // Then open on the first frame drawing real content. Searching after
237+ // the clear skips the loader header; if the program never clears,
238+ // search from the load handoff so the clip still opens on content.
239+ const from = clearedAt >= 0 ? clearedAt + 1 : lastLoadFrame + 1 ;
240+ for ( let i = from ; i <= lastChangeIndex ; i ++ ) {
241+ if ( this . inkBytes ( frames [ i ] ) >= INK_CONTENT ) {
242+ start = i ;
243+ break ;
244+ }
245+ }
246+ // No content found after the clear (e.g. an audio-only program with a
247+ // blank screen): fall back to the cleared frame rather than the
248+ // loader header.
249+ if ( clearedAt >= 0 && start < clearedAt ) start = clearedAt ;
171250 }
251+
172252 const keep = Math . min ( frames . length , lastChangeIndex + 1 + tailFrames ) ;
173- console . log ( `Captured ${ frames . length } frames, keeping ${ keep } ` ) ;
174- return { frames : frames . slice ( 0 , keep ) , audio : audio . slice ( 0 , keep ) } ;
253+ console . log ( `Captured ${ frames . length } frames, keeping ${ start } .. ${ keep } ` ) ;
254+ return { frames : frames . slice ( start , keep ) , audio : audio . slice ( start , keep ) } ;
175255 }
176256
177257 private isAudioSilent ( left : Float32Array , right : Float32Array ) : boolean {
0 commit comments