Skip to content

Commit 1cd1363

Browse files
committed
Tags for 128 and asm programs via bot
1 parent dfc024d commit 1cd1363

6 files changed

Lines changed: 181 additions & 22 deletions

File tree

apps/gif-service/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import express from 'express';
22
import tapeRoutes from './routes/tape.js';
33
import basicRoutes from './routes/basic.js';
44
import projectRoutes from './routes/project.js';
5+
import sourceRoutes from './routes/source.js';
56

67
const app = express();
78
const PORT = process.env.PORT || 5001;
@@ -11,6 +12,7 @@ app.use(express.text({ type: 'text/plain', limit: '256kb' }));
1112
app.use('/api', tapeRoutes);
1213
app.use('/api', basicRoutes);
1314
app.use('/api', projectRoutes);
15+
app.use('/api', sourceRoutes);
1416

1517
app.get('/health', (req, res) => {
1618
res.json({ status: 'ok' });
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { Router, Request, Response } from 'express';
2+
import { GIFGenerator } from '../gif-generator.js';
3+
import { compileProject } from '../compile.js';
4+
import { CompileError } from '../errors.js';
5+
6+
const router = Router();
7+
8+
type Format = 'gif' | 'mp4';
9+
10+
function readSource(req: Request): { code: string; lang: string } | null {
11+
if (typeof req.body === 'string') {
12+
return req.body.trim() ? { code: req.body, lang: 'basic' } : null;
13+
}
14+
const body = req.body ?? {};
15+
const code: unknown = body.code;
16+
const lang: unknown = body.lang;
17+
if (typeof code === 'string' && code.trim()) {
18+
return { code, lang: typeof lang === 'string' && lang ? lang : 'basic' };
19+
}
20+
return null;
21+
}
22+
23+
/**
24+
* Compile inline source for a given language and render it. Generalises the
25+
* BASIC route via compileProject, so the bot can render pasted assembly
26+
* (lang=asm, pasmo) as well as BASIC. A compile failure responds 400 with the
27+
* compiler messages.
28+
*/
29+
async function handle(format: Format, req: Request, res: Response): Promise<void> {
30+
try {
31+
const source = readSource(req);
32+
if (!source) {
33+
res.status(400).json({ error: 'No source provided' });
34+
return;
35+
}
36+
37+
const maxSeconds = parseInt(req.query.maxSeconds as string) || 30;
38+
const staleThreshold = parseInt(req.query.staleThreshold as string) || 150;
39+
const machineType = parseInt(req.query.machineType as string) || 48;
40+
const scale = parseInt(req.query.scale as string) || (format === 'mp4' ? 4 : 2);
41+
42+
let tap: Buffer;
43+
try {
44+
tap = await compileProject(source.lang, source.code);
45+
} catch (err) {
46+
if (err instanceof CompileError) {
47+
res.status(400).json({ error: 'Compilation failed', detail: err.message });
48+
return;
49+
}
50+
throw err;
51+
}
52+
53+
const generator = new GIFGenerator({
54+
maxDurationMs: maxSeconds * 1000,
55+
staleFrameThreshold: staleThreshold,
56+
scale,
57+
});
58+
await generator.initialize();
59+
60+
console.log(
61+
`Generating ${format.toUpperCase()} from ${source.code.length} bytes of ${source.lang}...`,
62+
);
63+
if (format === 'mp4') {
64+
const mp4 = await generator.generateMp4FromTAP(tap, machineType);
65+
res.setHeader('Content-Type', 'video/mp4');
66+
res.send(mp4);
67+
} else {
68+
const gif = await generator.generateFromTAP(tap, machineType);
69+
res.setHeader('Content-Type', 'image/gif');
70+
res.send(gif);
71+
}
72+
} catch (error: any) {
73+
console.error(`Error generating ${format} from source:`, error);
74+
res.status(500).json({ error: error.message });
75+
}
76+
}
77+
78+
router.post('/source-to-gif', (req, res) => handle('gif', req, res));
79+
router.post('/source-to-mp4', (req, res) => handle('mp4', req, res));
80+
81+
export default router;
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Hashtag directives a user can add to a toot to steer rendering.
2+
const MACHINE_TAGS: Record<string, number> = { '128': 128, '48': 48 };
3+
const LANG_TAGS: Record<string, string> = { asm: 'asm' };
4+
5+
export interface Directives {
6+
machineType?: number; // from #128 / #48
7+
lang?: string; // from #asm
8+
code: string; // source with recognised directive tags removed
9+
}
10+
11+
/**
12+
* Pull rendering directives out of plain-text toot content (the output of
13+
* htmlToBasic) and return the remaining source.
14+
*
15+
* Only the exact recognised tags (#128, #48, #asm) are stripped; every other
16+
* `#token` is left in place — Sinclair BASIC uses # for stream numbers
17+
* (PRINT #2), so blanket stripping would corrupt valid programs.
18+
*/
19+
export function parseDirectives(text: string): Directives {
20+
let machineType: number | undefined;
21+
let lang: string | undefined;
22+
23+
const code = text
24+
.replace(/(^|\s)#([A-Za-z0-9]+)\b/g, (whole, lead: string, tag: string) => {
25+
const key = tag.toLowerCase();
26+
if (key in MACHINE_TAGS) {
27+
machineType = MACHINE_TAGS[key];
28+
return lead;
29+
}
30+
if (key in LANG_TAGS) {
31+
lang = LANG_TAGS[key];
32+
return lead;
33+
}
34+
return whole; // unrecognised hashtag: leave untouched
35+
})
36+
.trim();
37+
38+
return { machineType, lang, code };
39+
}

apps/mastodon-bot/src/index.ts

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { config, assertRuntimeConfig } from './config.js';
22
import { htmlToBasic } from './basic.js';
33
import { extractProjectRef } from './project.js';
4-
import { basicToMedia, projectToMedia } from './media.js';
4+
import { parseDirectives } from './directives.js';
5+
import { sourceToMedia, projectToMedia } from './media.js';
56
import { loadState, saveState } from './state.js';
67
import {
78
MastodonAccount,
@@ -31,29 +32,42 @@ async function handleMention(self: MastodonAccount, n: MastodonNotification): Pr
3132
if (!status) return;
3233
if (status.account.id === self.id) return; // never answer ourselves
3334

34-
// A project link takes precedence over inline BASIC: the source already
35+
// A project link takes precedence over inline source: the program already
3536
// lives on the site, so render it directly rather than parsing the toot.
37+
// Directives (#128/#48 machine, #asm language) are read from the toot text
38+
// in both cases; for a project link the URL's ?m= hint wins over a tag.
3639
const projectRef = extractProjectRef(status.content, config.projectHost);
37-
const code = projectRef ? null : htmlToBasic(status.content);
40+
const directives = parseDirectives(htmlToBasic(status.content));
41+
const code = projectRef ? null : directives.code;
3842
if (!projectRef && !code) {
39-
console.log(`Mention ${n.id} from @${status.account.acct}: no project link or BASIC found, skipping`);
43+
console.log(`Mention ${n.id} from @${status.account.acct}: no project link or source found, skipping`);
4044
return;
4145
}
46+
47+
const machineType = projectRef ? (projectRef.machineType ?? directives.machineType) : directives.machineType;
48+
const lang = directives.lang ?? 'basic';
4249
const projectPath = projectRef ? `${projectRef.userSlug}/${projectRef.projectSlug}` : '';
50+
const machineNote = machineType ? ` @${machineType}K` : '';
4351
console.log(
4452
projectRef
45-
? `Mention ${n.id} from @${status.account.acct}: project ${projectPath}`
46-
: `Mention ${n.id} from @${status.account.acct}: ${code!.split('\n').length} line(s)`,
53+
? `Mention ${n.id} from @${status.account.acct}: project ${projectPath}${machineNote}`
54+
: `Mention ${n.id} from @${status.account.acct}: ${code!.split('\n').length} line(s) of ${lang}${machineNote}`,
4755
);
4856

4957
const visibility = replyVisibility(status.visibility);
5058

5159
if (config.dryRun) {
52-
console.log(projectRef ? `[dry-run] would render project ${projectPath}` : `[dry-run] would run:\n${code}`);
60+
console.log(
61+
projectRef
62+
? `[dry-run] would render project ${projectPath}${machineNote}`
63+
: `[dry-run] would run ${lang}${machineNote}:\n${code}`,
64+
);
5365
return;
5466
}
5567

56-
const result = projectRef ? await projectToMedia(projectRef) : await basicToMedia(code!);
68+
const result = projectRef
69+
? await projectToMedia(projectRef, machineType)
70+
: await sourceToMedia(code!, lang, machineType);
5771

5872
if (!result.ok) {
5973
await postReply({

apps/mastodon-bot/src/media.ts

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@ export type MediaResult =
66
| { ok: false; error: string };
77

88
/** POST a render request to gif-service and shape the response into a MediaResult. */
9-
async function requestMedia(path: string, body: unknown, altText: string): Promise<MediaResult> {
10-
const url = `${config.gifServiceUrl}${path}?maxSeconds=${config.maxSeconds}`;
11-
const res = await fetch(url, {
9+
async function requestMedia(
10+
path: string,
11+
body: unknown,
12+
altText: string,
13+
params: Record<string, string> = {},
14+
): Promise<MediaResult> {
15+
const query = new URLSearchParams({ maxSeconds: String(config.maxSeconds), ...params });
16+
const res = await fetch(`${config.gifServiceUrl}${path}?${query}`, {
1217
method: 'POST',
1318
headers: { 'Content-Type': 'application/json' },
1419
body: JSON.stringify(body),
@@ -34,16 +39,30 @@ async function requestMedia(path: string, body: unknown, altText: string): Promi
3439
return { ok: false, error: detail || `gif-service returned ${res.status}` };
3540
}
3641

37-
/** Compile and run BASIC via gif-service, returning an MP4 or a user-facing error. */
42+
function machineParam(machineType?: number): Record<string, string> {
43+
return machineType ? { machineType: String(machineType) } : {};
44+
}
45+
46+
/** Compile and run inline source (BASIC or asm) via gif-service. */
47+
export function sourceToMedia(
48+
code: string,
49+
lang: string,
50+
machineType?: number,
51+
): Promise<MediaResult> {
52+
return requestMedia('/api/source-to-mp4', { code, lang }, code, machineParam(machineType));
53+
}
54+
55+
/** Compile and run Sinclair BASIC via gif-service. */
3856
export function basicToMedia(code: string): Promise<MediaResult> {
39-
return requestMedia('/api/basic-to-mp4', { code }, code);
57+
return sourceToMedia(code, 'basic');
4058
}
4159

42-
/** Render a public code.zxplay.org project via gif-service, returning an MP4 or an error. */
43-
export function projectToMedia(ref: ProjectRef): Promise<MediaResult> {
60+
/** Render a public code.zxplay.org project via gif-service. */
61+
export function projectToMedia(ref: ProjectRef, machineType?: number): Promise<MediaResult> {
4462
return requestMedia(
4563
'/api/project-to-mp4',
46-
ref,
64+
{ userSlug: ref.userSlug, projectSlug: ref.projectSlug },
4765
`${config.projectHost}/u/${ref.userSlug}/${ref.projectSlug}`,
66+
machineParam(machineType),
4867
);
4968
}

apps/mastodon-bot/src/project.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,25 @@
11
export interface ProjectRef {
22
userSlug: string;
33
projectSlug: string;
4+
machineType?: number; // from a ?m=128 / ?m=48 hint on the URL
45
}
56

67
const SLUG = '[a-z0-9-]+';
78

89
/**
910
* Find a code.zxplay.org project link in a status's HTML content and return its
1011
* user/project slugs. Matches the canonical URL, https://<host>/u/<user>/<project>,
11-
* whether it appears as an <a href> or as plain text. Returns null when there
12-
* is no such link.
12+
* whether it appears as an <a href> or as plain text, and reads an optional
13+
* ?m=128 / ?m=48 machine hint. Returns null when there is no such link.
1314
*/
1415
export function extractProjectRef(content: string, host: string): ProjectRef | null {
1516
const escapedHost = host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
16-
const re = new RegExp(`https?://${escapedHost}/u/(${SLUG})/(${SLUG})`, 'i');
17+
const re = new RegExp(`https?://${escapedHost}/u/(${SLUG})/(${SLUG})(?:\\?m=(48|128))?`, 'i');
1718
const match = content.match(re);
18-
return match
19-
? { userSlug: match[1].toLowerCase(), projectSlug: match[2].toLowerCase() }
20-
: null;
19+
if (!match) return null;
20+
return {
21+
userSlug: match[1].toLowerCase(),
22+
projectSlug: match[2].toLowerCase(),
23+
machineType: match[3] ? parseInt(match[3], 10) : undefined,
24+
};
2125
}

0 commit comments

Comments
 (0)