Skip to content

Commit 74984e7

Browse files
committed
Try MP4 instead of GIF
1 parent d58193c commit 74984e7

9 files changed

Lines changed: 167 additions & 81 deletions

File tree

apps/gif-service/Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
# ROMs from apps/web/public (see emulator.ts paths relative to apps/gif-service).
33
FROM node:22-slim
44

5+
# ffmpeg is required for MP4 output.
6+
RUN apt-get update \
7+
&& apt-get install -y --no-install-recommends ffmpeg \
8+
&& rm -rf /var/lib/apt/lists/*
9+
510
WORKDIR /app/apps/gif-service
611

712
# Install this service's own dependencies (not workspace-hoisted).

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

Lines changed: 75 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import GIFEncoder from 'gif-encoder-2';
2+
import { spawn } from 'child_process';
3+
import { tmpdir } from 'os';
24
import { Emulator } from './emulator.js';
35
import { FrameDecoder } from './frame-decoder.js';
4-
import { readFile } from 'fs/promises';
6+
import { readFile, unlink } from 'fs/promises';
57
import { join, dirname } from 'path';
68
import { 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

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,51 @@
1-
import { Router } from 'express';
1+
import { Router, Request, Response } from 'express';
22
import zmakebas, { ZmakebasMessage } from 'zmakebas';
33
import { GIFGenerator } from '../gif-generator.js';
44

55
const router = Router();
66

7+
type Format = 'gif' | 'mp4';
8+
9+
function readCode(req: Request): string | null {
10+
const code: unknown = typeof req.body === 'string' ? req.body : req.body?.code;
11+
return typeof code === 'string' && code.trim() ? code : null;
12+
}
13+
14+
async function compile(code: string): Promise<{ tap: Uint8Array | null; detail: string }> {
15+
try {
16+
return { tap: await zmakebas(code), detail: '' };
17+
} catch (messages) {
18+
const detail = Array.isArray(messages)
19+
? (messages as ZmakebasMessage[])
20+
.filter((m) => m.type === 'err')
21+
.map((m) => m.text)
22+
.join('\n')
23+
: String(messages);
24+
return { tap: null, detail: detail || 'unknown error' };
25+
}
26+
}
27+
728
/**
8-
* POST /api/basic-to-gif
9-
*
10-
* Body: JSON `{ "code": "10 PRINT ..." }` or raw `text/plain` BASIC source.
11-
* Compiles the source to a .tap with zmakebas, runs it, and returns an
12-
* animated GIF. On a compile error responds 400 with the compiler messages so
13-
* the caller can show the user what to fix.
29+
* Compile BASIC and render it. GIF defaults to 2x (size-constrained), MP4 to 4x
30+
* (H.264 stays small at high resolution). On a compile error responds 400 with
31+
* the compiler messages so the caller can show the user what to fix.
1432
*/
15-
router.post('/basic-to-gif', async (req, res) => {
33+
async function handle(format: Format, req: Request, res: Response): Promise<void> {
1634
try {
17-
const code: unknown = typeof req.body === 'string' ? req.body : req.body?.code;
18-
if (typeof code !== 'string' || !code.trim()) {
35+
const code = readCode(req);
36+
if (!code) {
1937
res.status(400).json({ error: 'No BASIC source provided' });
2038
return;
2139
}
2240

2341
const maxSeconds = parseInt(req.query.maxSeconds as string) || 30;
2442
const staleThreshold = parseInt(req.query.staleThreshold as string) || 150;
2543
const machineType = parseInt(req.query.machineType as string) || 48;
26-
const scale = parseInt(req.query.scale as string) || 2;
27-
28-
let tap: Uint8Array;
29-
try {
30-
tap = await zmakebas(code);
31-
} catch (messages) {
32-
const detail = Array.isArray(messages)
33-
? (messages as ZmakebasMessage[])
34-
.filter((m) => m.type === 'err')
35-
.map((m) => m.text)
36-
.join('\n')
37-
: String(messages);
38-
res.status(400).json({ error: 'BASIC compilation failed', detail: detail || 'unknown error' });
44+
const scale = parseInt(req.query.scale as string) || (format === 'mp4' ? 4 : 2);
45+
46+
const { tap: compiledTap, detail } = await compile(code);
47+
if (!compiledTap) {
48+
res.status(400).json({ error: 'BASIC compilation failed', detail });
3949
return;
4050
}
4151

@@ -46,16 +56,24 @@ router.post('/basic-to-gif', async (req, res) => {
4656
});
4757
await generator.initialize();
4858

49-
console.log(`Generating GIF from ${code.length} bytes of BASIC...`);
50-
const gifBuffer = await generator.generateFromTAP(Buffer.from(tap), machineType);
51-
console.log(`GIF generated: ${gifBuffer.length} bytes`);
52-
53-
res.setHeader('Content-Type', 'image/gif');
54-
res.send(gifBuffer);
59+
console.log(`Generating ${format.toUpperCase()} from ${code.length} bytes of BASIC...`);
60+
const tap = Buffer.from(compiledTap);
61+
if (format === 'mp4') {
62+
const mp4 = await generator.generateMp4FromTAP(tap, machineType);
63+
res.setHeader('Content-Type', 'video/mp4');
64+
res.send(mp4);
65+
} else {
66+
const gif = await generator.generateFromTAP(tap, machineType);
67+
res.setHeader('Content-Type', 'image/gif');
68+
res.send(gif);
69+
}
5570
} catch (error: any) {
56-
console.error('Error generating GIF from BASIC:', error);
71+
console.error(`Error generating ${format} from BASIC:`, error);
5772
res.status(500).json({ error: error.message });
5873
}
59-
});
74+
}
75+
76+
router.post('/basic-to-gif', (req, res) => handle('gif', req, res));
77+
router.post('/basic-to-mp4', (req, res) => handle('mp4', req, res));
6078

6179
export default router;

apps/mastodon-bot/.env-dist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ STATE_FILE=/data/state.json
1010
POLL_INTERVAL_MS=15000
1111
# Capture and size limits.
1212
MAX_SECONDS=30
13-
MAX_GIF_BYTES=8000000
14-
# Optional text posted alongside the GIF (leave empty for image-only replies).
13+
MAX_MEDIA_BYTES=8000000
14+
# Optional text posted alongside the video (leave empty for media-only replies).
1515
REPLY_CAPTION=
1616
# Set true to log what would run without posting anything.
1717
DRY_RUN=false

apps/mastodon-bot/src/cli.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { readFile, writeFile } from 'fs/promises';
2-
import { basicToGif } from './gif.js';
2+
import { basicToMedia } from './media.js';
33

4-
// Local end-to-end test of the BASIC -> GIF chain without any Mastodon account.
4+
// Local end-to-end test of the BASIC -> MP4 chain without any Mastodon account.
55
// Usage:
66
// GIF_SERVICE_URL=http://localhost:5001 npm run cli -- program.bas
77
// echo '10 PRINT "HI"' | GIF_SERVICE_URL=http://localhost:5001 npm run cli
@@ -22,15 +22,15 @@ async function main(): Promise<void> {
2222
process.exit(1);
2323
}
2424

25-
const result = await basicToGif(code);
25+
const result = await basicToMedia(code);
2626
if (!result.ok) {
2727
console.error('Error:', result.error);
2828
process.exit(1);
2929
}
3030

31-
const out = process.env.OUT ?? 'program.gif';
32-
await writeFile(out, result.gif);
33-
console.log(`Wrote ${out} (${result.gif.length} bytes)`);
31+
const out = process.env.OUT ?? result.filename;
32+
await writeFile(out, result.data);
33+
console.log(`Wrote ${out} (${result.data.length} bytes)`);
3434
}
3535

3636
main().catch((err) => {

apps/mastodon-bot/src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const config = {
99
stateFile: process.env.STATE_FILE ?? './state.json',
1010
pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS ?? '15000', 10),
1111
maxSeconds: parseInt(process.env.MAX_SECONDS ?? '30', 10),
12-
maxGifBytes: parseInt(process.env.MAX_GIF_BYTES ?? '8000000', 10),
12+
maxMediaBytes: parseInt(process.env.MAX_MEDIA_BYTES ?? '8000000', 10),
1313
replyCaption: process.env.REPLY_CAPTION ?? '',
1414
dryRun: process.env.DRY_RUN === 'true',
1515
};

apps/mastodon-bot/src/index.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { config, assertRuntimeConfig } from './config.js';
22
import { htmlToBasic } from './basic.js';
3-
import { basicToGif } from './gif.js';
3+
import { basicToMedia } from './media.js';
44
import { loadState, saveState } from './state.js';
55
import {
66
MastodonAccount,
@@ -10,7 +10,7 @@ import {
1010
fetchMentions,
1111
postReply,
1212
sleep,
13-
uploadGif,
13+
uploadMedia,
1414
verifyCredentials,
1515
} from './mastodon.js';
1616

@@ -43,7 +43,7 @@ async function handleMention(self: MastodonAccount, n: MastodonNotification): Pr
4343
return;
4444
}
4545

46-
const result = await basicToGif(code);
46+
const result = await basicToMedia(code);
4747

4848
if (!result.ok) {
4949
await postReply({
@@ -54,23 +54,23 @@ async function handleMention(self: MastodonAccount, n: MastodonNotification): Pr
5454
return;
5555
}
5656

57-
if (result.gif.length > config.maxGifBytes) {
57+
if (result.data.length > config.maxMediaBytes) {
5858
await postReply({
5959
inReplyToId: status.id,
60-
statusText: 'That program produced a GIF too large to post here.',
60+
statusText: 'That program produced a file too large to post here.',
6161
visibility,
6262
});
6363
return;
6464
}
6565

66-
const mediaId = await uploadGif(result.gif, code);
66+
const mediaId = await uploadMedia(result.data, result.contentType, result.filename, code);
6767
await postReply({
6868
inReplyToId: status.id,
6969
statusText: config.replyCaption,
7070
mediaIds: [mediaId],
7171
visibility,
7272
});
73-
console.log(`Replied to ${n.id} with ${result.gif.length} byte GIF`);
73+
console.log(`Replied to ${n.id} with ${result.data.length} byte ${result.contentType}`);
7474
}
7575

7676
async function pollOnce(self: MastodonAccount): Promise<void> {

apps/mastodon-bot/src/mastodon.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,15 @@ export async function fetchMentions(sinceId: string | null): Promise<MastodonNot
6464
return notifications.reverse();
6565
}
6666

67-
/** Upload a GIF, waiting for server-side processing, and return its media id. */
68-
export async function uploadGif(gif: Buffer, description: string): Promise<string> {
67+
/** Upload media, waiting for server-side processing, and return its media id. */
68+
export async function uploadMedia(
69+
data: Buffer,
70+
contentType: string,
71+
filename: string,
72+
description: string,
73+
): Promise<string> {
6974
const form = new FormData();
70-
form.append('file', new Blob([gif], { type: 'image/gif' }), 'program.gif');
75+
form.append('file', new Blob([data], { type: contentType }), filename);
7176
if (description) {
7277
form.append('description', description.slice(0, 1400));
7378
}
@@ -76,9 +81,10 @@ export async function uploadGif(gif: Buffer, description: string): Promise<strin
7681
headers: authHeaders(),
7782
body: form,
7883
});
79-
// v2 media may return with url=null while the server processes it.
84+
// v2 media may return with url=null while the server processes it. Video
85+
// transcoding can take longer than images, so allow up to ~60s.
8086
let media = created;
81-
for (let i = 0; i < 30 && !media.url; i++) {
87+
for (let i = 0; i < 60 && !media.url; i++) {
8288
await sleep(1000);
8389
media = await api<MediaAttachment>(`/api/v1/media/${created.id}`, { headers: authHeaders() });
8490
}

0 commit comments

Comments
 (0)