Skip to content

Commit f63dc7c

Browse files
committed
Screenshots on project cards
1 parent 9e5f41a commit f63dc7c

11 files changed

Lines changed: 239 additions & 78 deletions

File tree

apps/gif-service/Dockerfile

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ COPY apps/gif-service/ ./
2121
COPY apps/web/public/dist/jsspeccy-core.wasm /app/apps/web/public/dist/jsspeccy-core.wasm
2222
COPY apps/web/public/roms/ /app/apps/web/public/roms/
2323

24-
ENV PORT=5001
24+
# Screenshot cache mount point, pre-owned by `node` so the named volume
25+
# initialises writable for the non-root user under a read-only root FS.
26+
RUN mkdir -p /cache && chown node:node /cache
27+
28+
ENV PORT=5001 \
29+
SCREENSHOT_CACHE_DIR=/cache
2530
EXPOSE 5001
2631
CMD ["npx", "tsx", "src/index.ts"]

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

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,54 @@ export class GIFGenerator {
289289
return buffer;
290290
}
291291

292+
/** Render a representative still frame to PNG, with a ZX rainbow ribbon baked into a corner. */
293+
async generatePngFromTAP(tapData: Buffer, machineType: number = 48): Promise<Buffer> {
294+
const { frames } = await this.captureFrames(tapData, machineType, false);
295+
const frame = frames[frames.length - 1] ?? frames[0];
296+
if (!frame) throw new Error('No frame captured');
297+
return this.encodePng(frame);
298+
}
299+
300+
/** ffmpeg-encode one decoded frame to PNG, drawing a 4-bar Spectrum ribbon bottom-right. */
301+
private async encodePng(frame: Uint8Array): Promise<Buffer> {
302+
const width = this.decoder.getWidth();
303+
const height = this.decoder.getHeight();
304+
const rgba = this.decoder.decode(frame);
305+
const outPath = join(tmpdir(), `zxshot-${process.pid}-${Date.now()}.png`);
306+
307+
// Four bars (red/yellow/green/cyan) tiled into the bottom-right corner.
308+
const colors = ['red', 'yellow', 'lime', 'cyan'];
309+
const ribbon = colors
310+
.map((c, i) => {
311+
const left = (0.24 - i * 0.06).toFixed(2);
312+
return `drawbox=x=iw-iw*${left}:y=ih-ih*0.13:w=iw*0.06:h=ih*0.13:color=${c}:t=fill`;
313+
})
314+
.join(',');
315+
316+
const args = [
317+
'-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${width}x${height}`, '-i', 'pipe:0',
318+
'-vf', ribbon,
319+
'-frames:v', '1',
320+
'-y', outPath,
321+
];
322+
const ff = spawn('ffmpeg', args, { stdio: ['pipe', 'ignore', 'pipe'] });
323+
let stderr = '';
324+
ff.stderr.on('data', (d) => { stderr += d.toString(); });
325+
const finished = new Promise<void>((resolve, reject) => {
326+
ff.on('error', reject);
327+
ff.on('close', (code) =>
328+
code === 0 ? resolve() : reject(new Error(`ffmpeg exited ${code}: ${stderr.slice(-500)}`)),
329+
);
330+
});
331+
ff.stdin.write(rgba);
332+
ff.stdin.end();
333+
await finished;
334+
335+
const buffer = await readFile(outPath);
336+
await unlink(outPath).catch(() => undefined);
337+
return buffer;
338+
}
339+
292340
private async parseSZXSnapshot(data: Buffer): Promise<any> {
293341
const file = new DataView(data.buffer, data.byteOffset, data.byteLength);
294342

apps/gif-service/src/hasura.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,31 @@ export interface ProjectRecord {
5151
title: string;
5252
}
5353

54+
export interface ProjectById {
55+
lang: string;
56+
code: string;
57+
updated_at: string;
58+
}
59+
60+
/**
61+
* Look up a public project by id (for screenshots). Returns null when missing or
62+
* private (the `public` role filters to is_public). `updated_at` is the cache key
63+
* component so a screenshot self-invalidates when the project is edited.
64+
*/
65+
export async function fetchProjectById(projectId: string): Promise<ProjectById | null> {
66+
const query = `
67+
query ($id: uuid!) {
68+
project(where: { project_id: { _eq: $id } }, limit: 1) {
69+
lang
70+
code
71+
updated_at
72+
}
73+
}
74+
`;
75+
const data = await gql<{ project: ProjectById[] }>(query, { id: projectId });
76+
return data.project[0] ?? null;
77+
}
78+
5479
/**
5580
* Look up a public project from its canonical /u/<userSlug>/<projectSlug> URL.
5681
*

apps/gif-service/src/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import tapeRoutes from './routes/tape.js';
33
import basicRoutes from './routes/basic.js';
44
import projectRoutes from './routes/project.js';
55
import sourceRoutes from './routes/source.js';
6+
import screenshotRoutes from './routes/screenshot.js';
67

78
const app = express();
89
const PORT = process.env.PORT || 5001;
@@ -13,6 +14,8 @@ app.use('/api', tapeRoutes);
1314
app.use('/api', basicRoutes);
1415
app.use('/api', projectRoutes);
1516
app.use('/api', sourceRoutes);
17+
// Public, read-only screenshots (only this path is exposed via the proxy).
18+
app.use('/screenshots', screenshotRoutes);
1619

1720
app.get('/health', (req, res) => {
1821
res.json({ status: 'ok' });
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { Router, Request, Response } from 'express';
2+
import { mkdir, readFile, writeFile, access } from 'fs/promises';
3+
import { join } from 'path';
4+
import { GIFGenerator } from '../gif-generator.js';
5+
import { fetchProjectById } from '../hasura.js';
6+
import { compileProjectIsolated } from '../compile-isolated.js';
7+
import { CompileError } from '../errors.js';
8+
9+
const router = Router();
10+
11+
const CACHE_DIR = process.env.SCREENSHOT_CACHE_DIR ?? '/cache';
12+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
13+
14+
// Single-flight: collapse concurrent requests for the same cache key onto one render.
15+
const inFlight = new Map<string, Promise<Buffer>>();
16+
17+
async function fileExists(path: string): Promise<boolean> {
18+
try {
19+
await access(path);
20+
return true;
21+
} catch {
22+
return false;
23+
}
24+
}
25+
26+
/**
27+
* Serve a PNG screenshot of a public project, with the ZX ribbon baked into a
28+
* corner. Cached per project + updated_at, so it self-invalidates on edit and
29+
* the cache can't be exploded with arbitrary keys. 404 (→ web shows the
30+
* cartridge fallback) when the project is missing or not public.
31+
*/
32+
router.get('/:id', async (req: Request, res: Response) => {
33+
const id = (req.params.id || '').replace(/\.png$/i, '').toLowerCase();
34+
if (!UUID.test(id)) {
35+
res.status(400).end();
36+
return;
37+
}
38+
39+
try {
40+
const project = await fetchProjectById(id);
41+
if (!project) {
42+
res.status(404).end();
43+
return;
44+
}
45+
46+
const key = `${id}-${Date.parse(project.updated_at) || 0}`;
47+
const file = join(CACHE_DIR, `${key}.png`);
48+
49+
let png: Buffer;
50+
if (await fileExists(file)) {
51+
png = await readFile(file);
52+
} else {
53+
let pending = inFlight.get(key);
54+
if (!pending) {
55+
pending = (async () => {
56+
const tap = await compileProjectIsolated(project.lang, project.code);
57+
const generator = new GIFGenerator({ maxDurationMs: 4000, scale: 2 });
58+
await generator.initialize();
59+
const out = await generator.generatePngFromTAP(tap, 48);
60+
await mkdir(CACHE_DIR, { recursive: true }).catch(() => undefined);
61+
await writeFile(file, out).catch(() => undefined);
62+
return out;
63+
})();
64+
inFlight.set(key, pending);
65+
pending.finally(() => inFlight.delete(key));
66+
}
67+
png = await pending;
68+
}
69+
70+
res.setHeader('Content-Type', 'image/png');
71+
res.setHeader('Cache-Control', 'public, max-age=86400');
72+
res.send(png);
73+
} catch (err) {
74+
if (err instanceof CompileError) {
75+
res.status(422).end(); // unrenderable source → web falls back to cartridge
76+
return;
77+
}
78+
console.error('Screenshot error:', err);
79+
res.status(500).end();
80+
}
81+
});
82+
83+
export default router;

apps/proxy/Caddyfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@
3333
reverse_proxy localhost:4000
3434
}
3535

36+
# Read-only project screenshots (PNG) from gif-service. Local dev must expose
37+
# gif-service on :5001 (publish the port) for this to resolve.
38+
handle /screenshots/* {
39+
reverse_proxy localhost:5001
40+
}
41+
3642
handle {
3743
reverse_proxy localhost:8000
3844
}

apps/proxy/prod.Caddyfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@
2929
reverse_proxy hasura:8080
3030
}
3131

32+
# Read-only project screenshots (PNG). Only this gif-service path is exposed;
33+
# the compile /api routes stay internal. Served at /screenshots/<id>.png.
34+
handle /screenshots/* {
35+
reverse_proxy gif-service:5001
36+
}
37+
3238
handle {
3339
# Strict Content Security Policy for production
3440
header {

apps/web/src/components/ActivityFeed.jsx

Lines changed: 5 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Paginator } from "primereact/paginator";
1010
import { formatDistanceToNow } from "date-fns";
1111
import { fetchActivityFeed } from "../redux/social/actions";
1212
import { getLanguageLabel } from "../lib/lang";
13+
import ProjectThumbnail from "./ProjectThumbnail";
1314
import { sep } from "../constants";
1415

1516
function getLanguageColor(lang) {
@@ -108,32 +109,10 @@ export default function ActivityFeed() {
108109
}}
109110
>
110111
<div className="flex flex-column h-full relative">
111-
{/* Background icon in corner */}
112-
<div
113-
className="absolute"
114-
style={{
115-
top: "-5px",
116-
right: "-5px",
117-
width: "120px",
118-
height: "120px",
119-
background: "#000",
120-
borderRadius: "20px",
121-
transform: "rotate(12deg)",
122-
overflow: "hidden",
123-
opacity: 0.7,
124-
}}
125-
>
126-
<img
127-
src="/assets/images/zx-square.png"
128-
alt=""
129-
style={{
130-
width: "94%",
131-
height: "94%",
132-
objectFit: "cover",
133-
margin: "3%",
134-
}}
135-
/>
136-
</div>
112+
<ProjectThumbnail
113+
projectId={project.project_id}
114+
updatedAt={project.updated_at}
115+
/>
137116

138117
<h3 className="mb-2 text-white relative z-1">
139118
{project.title}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import React, { useState } from "react";
2+
3+
// Corner thumbnail for a project card. Shows a rendered screenshot of the
4+
// project (with the ZX rainbow ribbon baked into a corner by gif-service);
5+
// falls back to the static cartridge graphic when there's no screenshot yet,
6+
// the project isn't public, or the render failed.
7+
export default function ProjectThumbnail({ projectId, updatedAt }) {
8+
const [failed, setFailed] = useState(false);
9+
const version = updatedAt ? Date.parse(updatedAt) || 0 : 0;
10+
const showShot = Boolean(projectId) && !failed;
11+
12+
return (
13+
<div
14+
className="absolute"
15+
style={{
16+
top: "-5px",
17+
right: "-5px",
18+
width: "120px",
19+
height: "120px",
20+
background: "#000",
21+
borderRadius: "20px",
22+
overflow: "hidden",
23+
// Screenshots read cleaner upright and opaque; the cartridge fallback
24+
// keeps its original tilted, translucent look.
25+
transform: showShot ? "none" : "rotate(12deg)",
26+
opacity: showShot ? 1 : 0.7,
27+
}}
28+
>
29+
{showShot ? (
30+
<img
31+
src={`/screenshots/${projectId}.png?v=${version}`}
32+
alt=""
33+
onError={() => setFailed(true)}
34+
style={{ width: "100%", height: "100%", objectFit: "cover" }}
35+
/>
36+
) : (
37+
<img
38+
src="/assets/images/zx-square.png"
39+
alt=""
40+
style={{ width: "94%", height: "94%", objectFit: "cover", margin: "3%" }}
41+
/>
42+
)}
43+
</div>
44+
);
45+
}

apps/web/src/components/PublicUserProfile.jsx

Lines changed: 9 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
import { useSortable } from "@dnd-kit/sortable";
3737
import { CSS } from "@dnd-kit/utilities";
3838
import { getLanguageLabel } from "../lib/lang";
39+
import ProjectThumbnail from "./ProjectThumbnail";
3940
import { generateRetroAvatar } from "../lib/avatar";
4041
import { generateRetroSpriteAvatar } from "../lib/retroSpriteAvatar";
4142
import AvatarSelector from "./AvatarSelector";
@@ -213,31 +214,10 @@ function SortableProjectCard({ project, projectUrl, isDragging }) {
213214
/>
214215
</div>
215216
<div className="flex flex-column h-full relative">
216-
<div
217-
className="absolute"
218-
style={{
219-
top: "-5px",
220-
right: "-5px",
221-
width: "120px",
222-
height: "120px",
223-
background: "#000",
224-
borderRadius: "20px",
225-
transform: "rotate(12deg)",
226-
overflow: "hidden",
227-
opacity: 0.7,
228-
}}
229-
>
230-
<img
231-
src="/assets/images/zx-square.png"
232-
alt=""
233-
style={{
234-
width: "94%",
235-
height: "94%",
236-
objectFit: "cover",
237-
margin: "3%",
238-
}}
239-
/>
240-
</div>
217+
<ProjectThumbnail
218+
projectId={project.project_id}
219+
updatedAt={project.updated_at}
220+
/>
241221

242222
<h3 className="mb-2 text-white relative z-1">{project.title}</h3>
243223

@@ -752,32 +732,10 @@ export default function PublicUserProfile() {
752732
}}
753733
>
754734
<div className="flex flex-column h-full relative">
755-
{/* Background icon in corner */}
756-
<div
757-
className="absolute"
758-
style={{
759-
top: "-5px",
760-
right: "-5px",
761-
width: "120px",
762-
height: "120px",
763-
background: "#000",
764-
borderRadius: "20px",
765-
transform: "rotate(12deg)",
766-
overflow: "hidden",
767-
opacity: 0.7,
768-
}}
769-
>
770-
<img
771-
src="/assets/images/zx-square.png"
772-
alt=""
773-
style={{
774-
width: "94%",
775-
height: "94%",
776-
objectFit: "cover",
777-
margin: "3%",
778-
}}
779-
/>
780-
</div>
735+
<ProjectThumbnail
736+
projectId={project.project_id}
737+
updatedAt={project.updated_at}
738+
/>
781739

782740
<h3 className="mb-2 text-white relative z-1">
783741
{project.title}

0 commit comments

Comments
 (0)