Skip to content

Commit 7ed5d33

Browse files
committed
Program leader skip on generating animation
1 parent cb3a328 commit 7ed5d33

3 files changed

Lines changed: 75 additions & 29 deletions

File tree

apps/gif-service/src/emulator.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ export class Emulator {
5252
private audioSamplesPerFrame: number = 0;
5353
private lastAudioLeft: Float32Array | null = null;
5454
private lastAudioRight: Float32Array | null = null;
55+
// Number of tape-load traps that fired during the most recent runFrame.
56+
// Non-zero only while a LOAD is pulling blocks in, so it marks the loader
57+
// phase distinctly from the program running afterwards.
58+
private tapeTrapsLastFrame: number = 0;
5559

5660
// ZX Spectrum keyboard matrix (character -> [row, bitmask]).
5761
// Rows/masks match the core's keyDown(row, mask) convention used by the web
@@ -143,6 +147,12 @@ export class Emulator {
143147
return this.tapeIsPlaying;
144148
}
145149

150+
// How many tape-load traps fired during the most recent runFrame. Used to
151+
// tell the loader phase apart from the program running afterwards.
152+
getTapeTrapsLastFrame(): number {
153+
return this.tapeTrapsLastFrame;
154+
}
155+
146156
// Ask the core to render `samplesPerFrame` stereo audio samples each frame
147157
// (0 disables audio generation entirely, the default).
148158
enableAudio(samplesPerFrame: number): void {
@@ -259,6 +269,7 @@ export class Emulator {
259269
}
260270
status = this.core.resumeFrame();
261271
}
272+
this.tapeTrapsLastFrame = trapCount;
262273

263274
if (this.audioSamplesPerFrame > 0) {
264275
// Copy out of WASM memory: the views alias the heap, which the next

apps/gif-service/src/gif-generator.ts

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -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 {

apps/gif-service/test-clip.ts

Lines changed: 0 additions & 24 deletions
This file was deleted.

0 commit comments

Comments
 (0)