Skip to content

Commit 9d0c184

Browse files
committed
Fix for screenshots
1 parent b8d2275 commit 9d0c184

3 files changed

Lines changed: 32 additions & 34 deletions

File tree

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

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -290,33 +290,29 @@ export class GIFGenerator {
290290
return buffer;
291291
}
292292

293-
/** Render a representative still frame to PNG, with a ZX rainbow ribbon baked into a corner. */
293+
/** Render a representative still frame to a square PNG (Spectrum border preserved). */
294294
async generatePngFromTAP(tapData: Buffer, machineType: number = 48): Promise<Buffer> {
295295
const { frames } = await this.captureFrames(tapData, machineType, false);
296296
const frame = frames[frames.length - 1] ?? frames[0];
297297
if (!frame) throw new Error('No frame captured');
298298
return this.encodePng(frame);
299299
}
300300

301-
/** ffmpeg-encode one decoded frame to PNG, drawing a 4-bar Spectrum ribbon bottom-right. */
301+
/** ffmpeg-encode one decoded frame to a square PNG, padding the (landscape) frame
302+
* with black so a rounded/tilted card crop never truncates the screen content. */
302303
private async encodePng(frame: Uint8Array): Promise<Buffer> {
303304
const width = this.decoder.getWidth();
304305
const height = this.decoder.getHeight();
305306
const rgba = this.decoder.decode(frame);
306307
const outPath = join(tmpdir(), `zxshot-${process.pid}-${Date.now()}.png`);
307308

308-
// Four bars (red/yellow/green/cyan) tiled into the bottom-right corner.
309-
const colors = ['red', 'yellow', 'lime', 'cyan'];
310-
const ribbon = colors
311-
.map((c, i) => {
312-
const left = (0.24 - i * 0.06).toFixed(2);
313-
return `drawbox=x=iw-iw*${left}:y=ih-ih*0.13:w=iw*0.06:h=ih*0.13:color=${c}:t=fill`;
314-
})
315-
.join(',');
309+
// Pad the landscape frame (border included) to a centred square so the
310+
// card's rounded corners clip black margin, not the Spectrum screen.
311+
const pad = 'pad=iw:iw:0:(iw-ih)/2:black';
316312

317313
const args = [
318314
'-f', 'rawvideo', '-pix_fmt', 'rgba', '-s', `${width}x${height}`, '-i', 'pipe:0',
319-
'-vf', ribbon,
315+
'-vf', pad,
320316
'-frames:v', '1',
321317
'-y', outPath,
322318
];

apps/gif-service/src/routes/screenshot.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import { CompileError } from '../errors.js';
1010
const router = Router();
1111

1212
const CACHE_DIR = process.env.SCREENSHOT_CACHE_DIR ?? '/cache';
13+
// Bump when the render output changes (size, padding, etc.) so cached PNGs
14+
// from older logic are superseded without manually clearing the volume.
15+
const RENDER_VERSION = 'v2';
1316
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
1417

1518
// Single-flight: collapse concurrent requests for the same cache key onto one render.
@@ -40,11 +43,14 @@ router.get('/:id', async (req: Request, res: Response) => {
4043
try {
4144
const project = await fetchProjectById(id);
4245
if (!project) {
46+
// Negative-cache so the browser doesn't re-request missing/private
47+
// projects on every page load (web shows the cartridge fallback).
48+
res.setHeader('Cache-Control', 'public, max-age=3600');
4349
res.status(404).end();
4450
return;
4551
}
4652

47-
const key = `${id}-${Date.parse(project.updated_at) || 0}`;
53+
const key = `${RENDER_VERSION}-${id}-${Date.parse(project.updated_at) || 0}`;
4854
const file = join(CACHE_DIR, `${key}.png`);
4955

5056
let png: Buffer;
@@ -77,7 +83,11 @@ router.get('/:id', async (req: Request, res: Response) => {
7783
res.send(png);
7884
} catch (err) {
7985
if (err instanceof CompileError) {
80-
res.status(422).end(); // unrenderable source → web falls back to cartridge
86+
// Unrenderable (e.g. zmac/sdcc not supported, or bad source) → web
87+
// falls back to the cartridge. Negative-cache to avoid re-rendering
88+
// it on every page load.
89+
res.setHeader('Cache-Control', 'public, max-age=3600');
90+
res.status(422).end();
8191
return;
8292
}
8393
console.error('Screenshot error:', err);
Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import React, { useState } from "react";
22

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.
3+
// Tilted corner thumbnail for a project card. Shows a rendered screenshot of
4+
// the project (a square, border-padded PNG from gif-service); falls back to the
5+
// static cartridge graphic when there's no screenshot yet, the project isn't
6+
// public, or it can't be rendered (e.g. zmac/sdcc).
77
export default function ProjectThumbnail({ projectId, updatedAt }) {
88
const [failed, setFailed] = useState(false);
99
const version = updatedAt ? Date.parse(updatedAt) || 0 : 0;
@@ -19,27 +19,19 @@ export default function ProjectThumbnail({ projectId, updatedAt }) {
1919
height: "120px",
2020
background: "#000",
2121
borderRadius: "20px",
22+
transform: "rotate(12deg)",
2223
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)",
24+
// Screenshots are the real content, so show them opaque; the cartridge
25+
// fallback keeps its original faded decoration look.
2626
opacity: showShot ? 1 : 0.7,
2727
}}
2828
>
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-
)}
29+
<img
30+
src={showShot ? `/screenshots/${projectId}.png?v=${version}` : "/assets/images/zx-square.png"}
31+
alt=""
32+
onError={showShot ? () => setFailed(true) : undefined}
33+
style={{ width: "94%", height: "94%", objectFit: "cover", margin: "3%" }}
34+
/>
4335
</div>
4436
);
4537
}

0 commit comments

Comments
 (0)