Skip to content

Commit be0960c

Browse files
committed
Trying to get the best start point for the clip anim
1 parent 4bb6aba commit be0960c

3 files changed

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

apps/gif-service/test-clip.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import zmakebas from 'zmakebas';
2+
import { GIFGenerator } from './src/gif-generator.js';
3+
import { writeFile } from 'fs/promises';
4+
5+
// Draws one line every ~5 frames (PAUSE 5). The first line is the start of
6+
// intentional drawing. If the capture loses the program's opening frames, the
7+
// first captured frame will already show several lines.
8+
const CODE = [
9+
'10 BORDER 1: PAPER 7: INK 2: CLS',
10+
'20 FOR n=1 TO 20',
11+
'30 PRINT "DRAWING STEP ";n',
12+
'40 PAUSE 5',
13+
'50 NEXT n',
14+
'60 PAUSE 0',
15+
].join('\n');
16+
17+
const label = process.argv[2] ?? 'out';
18+
19+
const tap = Buffer.from(await zmakebas(CODE));
20+
const gen = new GIFGenerator({ maxDurationMs: 5000, scale: 2 });
21+
await gen.initialize();
22+
const mp4 = await gen.generateMp4FromTAP(tap, 48);
23+
await writeFile(`/tmp/${label}.mp4`, mp4);
24+
console.log(`wrote /tmp/${label}.mp4 (${mp4.length} bytes)`);

0 commit comments

Comments
 (0)