11import GIFEncoder from 'gif-encoder-2' ;
2+ import { spawn } from 'child_process' ;
3+ import { tmpdir } from 'os' ;
24import { Emulator } from './emulator.js' ;
35import { FrameDecoder } from './frame-decoder.js' ;
4- import { readFile } from 'fs/promises' ;
6+ import { readFile , unlink } from 'fs/promises' ;
57import { join , dirname } from 'path' ;
68import { fileURLToPath } from 'url' ;
79
@@ -65,19 +67,11 @@ export class GIFGenerator {
6567 for ( let i = 0 ; i < gapFrames ; i ++ ) this . emulator . runFrame ( ) ;
6668 }
6769
68- async generateFromTAP ( tapData : Buffer , machineType : number = 48 ) : Promise < Buffer > {
69- const width = this . decoder . getWidth ( ) ;
70- const height = this . decoder . getHeight ( ) ;
71-
72- // Encode every Nth emulated frame. The core runs at 50fps; encoding at
73- // 25fps halves GIF size and encode time with little visible loss.
74- const frameStep = 2 ;
75- const encoder = new GIFEncoder ( width , height , 'neuquant' ) ;
76- encoder . setDelay ( 20 * frameStep ) ; // match the decimated frame rate
77- encoder . setRepeat ( 0 ) ; // loop forever
78- encoder . setQuality ( 10 ) ;
79- encoder . start ( ) ;
80-
70+ /**
71+ * Boot the machine, load the tape, run the program, and return the kept raw
72+ * frame buffers (50fps, trailing static trimmed). Shared by both encoders.
73+ */
74+ private async captureFrames ( tapData : Buffer , machineType : number ) : Promise < Uint8Array [ ] > {
8175 this . emulator . setMachineType ( machineType ) ;
8276 this . emulator . setTapeTraps ( true ) ; // instant block loading; no pulse playback needed
8377 this . emulator . loadTAPFile ( tapData ) ;
@@ -104,9 +98,8 @@ export class GIFGenerator {
10498 // Let the trap loader inject every block and the program start.
10599 for ( let i = 0 ; i < 15 ; i ++ ) this . emulator . runFrame ( ) ;
106100
107- // Phase 4: capture the running program. Stop once the screen has been
108- // static for `staleFrameThreshold` consecutive frames (the program has
109- // finished or paused), then trim the trailing static frames.
101+ // Capture the running program. Stop once the screen has been static for
102+ // `staleFrameThreshold` consecutive frames, then trim trailing static.
110103 const maxFrames = Math . floor ( this . options . maxDurationMs / 20 ) ;
111104 const staleStop = this . options . staleFrameThreshold ;
112105 const tailFrames = 25 ; // ~0.5s of static tail kept for readability
@@ -134,21 +127,80 @@ export class GIFGenerator {
134127 }
135128
136129 if ( lastChangeIndex < 0 ) {
137- // Nothing ever changed; keep a short clip so the GIF is not empty.
130+ // Nothing ever changed; keep a short clip so the output is not empty.
138131 lastChangeIndex = Math . min ( frames . length , tailFrames ) - 1 ;
139132 }
140133 const keep = Math . min ( frames . length , lastChangeIndex + 1 + tailFrames ) ;
141- let encoded = 0 ;
142- for ( let i = 0 ; i < keep ; i += frameStep ) {
134+ console . log ( `Captured ${ frames . length } frames, keeping ${ keep } ` ) ;
135+ return frames . slice ( 0 , keep ) ;
136+ }
137+
138+ /** Render the program to an animated GIF (25fps to keep size sane). */
139+ async generateFromTAP ( tapData : Buffer , machineType : number = 48 ) : Promise < Buffer > {
140+ const frames = await this . captureFrames ( tapData , machineType ) ;
141+
142+ // The core runs at 50fps; encode every 2nd frame (25fps) to halve GIF
143+ // size and encode time with little visible loss.
144+ const frameStep = 2 ;
145+ const encoder = new GIFEncoder ( this . decoder . getWidth ( ) , this . decoder . getHeight ( ) , 'neuquant' ) ;
146+ encoder . setDelay ( 20 * frameStep ) ;
147+ encoder . setRepeat ( 0 ) ; // loop forever
148+ encoder . setQuality ( 10 ) ;
149+ encoder . start ( ) ;
150+
151+ for ( let i = 0 ; i < frames . length ; i += frameStep ) {
143152 encoder . addFrame ( this . decoder . decode ( frames [ i ] ) ) ;
144- encoded ++ ;
145153 }
146- console . log ( `Encoding ${ encoded } frames (${ frames . length } captured, ${ keep } kept)` ) ;
147-
148154 encoder . finish ( ) ;
149155 return Buffer . from ( encoder . out . getData ( ) ) ;
150156 }
151157
158+ /** Render the program to an H.264 MP4 at the full 50fps. */
159+ async generateMp4FromTAP ( tapData : Buffer , machineType : number = 48 ) : Promise < Buffer > {
160+ const frames = await this . captureFrames ( tapData , machineType ) ;
161+ return this . encodeMp4 ( frames , 50 ) ;
162+ }
163+
164+ /** Pipe decoded RGBA frames through ffmpeg to a temporary MP4 and return it. */
165+ private async encodeMp4 ( frames : Uint8Array [ ] , fps : number ) : Promise < Buffer > {
166+ const width = this . decoder . getWidth ( ) ;
167+ const height = this . decoder . getHeight ( ) ;
168+ const outPath = join ( tmpdir ( ) , `zxplay-${ process . pid } -${ Date . now ( ) } .mp4` ) ;
169+
170+ const args = [
171+ '-f' , 'rawvideo' , '-pix_fmt' , 'rgba' , '-s' , `${ width } x${ height } ` , '-r' , String ( fps ) ,
172+ '-i' , 'pipe:0' ,
173+ '-an' , // no audio
174+ '-c:v' , 'libx264' , '-pix_fmt' , 'yuv420p' , '-preset' , 'veryfast' , '-crf' , '20' ,
175+ '-movflags' , '+faststart' ,
176+ '-y' , outPath ,
177+ ] ;
178+ const ff = spawn ( 'ffmpeg' , args , { stdio : [ 'pipe' , 'ignore' , 'pipe' ] } ) ;
179+ let stderr = '' ;
180+ ff . stderr . on ( 'data' , ( d ) => { stderr += d . toString ( ) ; } ) ;
181+
182+ const finished = new Promise < void > ( ( resolve , reject ) => {
183+ ff . on ( 'error' , reject ) ;
184+ ff . on ( 'close' , ( code ) =>
185+ code === 0 ? resolve ( ) : reject ( new Error ( `ffmpeg exited ${ code } : ${ stderr . slice ( - 500 ) } ` ) ) ,
186+ ) ;
187+ } ) ;
188+
189+ for ( const raw of frames ) {
190+ const rgba = this . decoder . decode ( raw ) ;
191+ if ( ! ff . stdin . write ( rgba ) ) {
192+ await new Promise < void > ( ( resolve ) => ff . stdin . once ( 'drain' , ( ) => resolve ( ) ) ) ;
193+ }
194+ }
195+ ff . stdin . end ( ) ;
196+ await finished ;
197+
198+ const buffer = await readFile ( outPath ) ;
199+ await unlink ( outPath ) . catch ( ( ) => undefined ) ;
200+ console . log ( `Encoded MP4: ${ frames . length } frames, ${ buffer . length } bytes` ) ;
201+ return buffer ;
202+ }
203+
152204 private async parseSZXSnapshot ( data : Buffer ) : Promise < any > {
153205 const file = new DataView ( data . buffer , data . byteOffset , data . byteLength ) ;
154206
0 commit comments