Skip to content

Commit d89e7c6

Browse files
authored
Merge pull request #44 from pixie-git/feature/PIX-39_image-export
Feature/pix 39 image export
2 parents 9e4bac2 + c902bed commit d89e7c6

5 files changed

Lines changed: 104 additions & 0 deletions

File tree

server/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"express": "^4.21.2",
1616
"jsonwebtoken": "^9.0.3",
1717
"mongoose": "^8.9.5",
18+
"pngjs": "^7.0.0",
1819
"socket.io": "^4.8.1",
1920
"swagger-ui-express": "^5.0.1",
2021
"yamljs": "^0.3.0"
@@ -24,6 +25,7 @@
2425
"@types/express": "^4.17.21",
2526
"@types/jsonwebtoken": "^9.0.7",
2627
"@types/node": "^20.10.6",
28+
"@types/pngjs": "^6.0.5",
2729
"@types/swagger-ui-express": "^4.1.8",
2830
"@types/yamljs": "^0.2.34",
2931
"nodemon": "^3.1.9",

server/pixie-api.yaml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,42 @@ paths:
175175
description: Forbidden (Not the owner)
176176
'404':
177177
description: Lobby not found
178+
/lobbies/{id}/image:
179+
get:
180+
summary: Export lobby canvas as PNG
181+
description: Export the current lobby canvas as a PNG image.
182+
tags:
183+
- Lobbies
184+
security:
185+
- bearerAuth: []
186+
parameters:
187+
- in: path
188+
name: id
189+
schema:
190+
type: string
191+
required: true
192+
description: Lobby ID
193+
- in: query
194+
name: scale
195+
schema:
196+
type: integer
197+
minimum: 1
198+
maximum: 10
199+
default: 1
200+
required: false
201+
description: Scale factor (1-10) for the exported image
202+
responses:
203+
'200':
204+
description: Canvas image
205+
content:
206+
image/png:
207+
schema:
208+
type: string
209+
format: binary
210+
'404':
211+
description: Lobby not found
212+
'500':
213+
description: Internal Server Error
178214
/lobbies/{id}/kick:
179215
post:
180216
summary: Kick a user from the lobby

server/src/controllers/lobby.controller.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Request, Response } from 'express';
22
import { LobbyService } from '../services/lobby.service.js';
3+
import { ImageService } from '../services/image.service.js';
34

45
export class LobbyController {
56

@@ -185,4 +186,26 @@ export class LobbyController {
185186
return res.status(500).json({ error: 'Internal Server Error' });
186187
}
187188
}
189+
// GET /api/lobbies/:id/image
190+
static async getLobbyImage(req: Request, res: Response) {
191+
try {
192+
const { id } = req.params;
193+
const lobby = await LobbyService.getById(id);
194+
195+
if (!lobby) {
196+
return res.status(404).json({ error: 'Lobby not found' });
197+
}
198+
199+
const scaleStr = req.query.scale as string;
200+
const scale = scaleStr ? parseInt(scaleStr, 10) : 1;
201+
202+
const png = await ImageService.generateLobbyPng(lobby.name, scale);
203+
204+
res.setHeader('Content-Type', 'image/png');
205+
png.pack().pipe(res);
206+
} catch (error) {
207+
console.error('[LobbyController] GetLobbyImage Error:', error);
208+
return res.status(500).json({ error: 'Internal Server Error' });
209+
}
210+
}
188211
}

server/src/routes/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ router.get("/lobbies/:id", authenticateToken, requireLobbyAccess, LobbyControlle
2222
router.get("/lobbies/:id/users", authenticateToken, requireLobbyAccess, LobbyController.getUsers)
2323
router.delete("/lobbies/:id", authenticateToken, requireLobbyOwner, LobbyController.delete)
2424
router.post("/lobbies/:id/kick", authenticateToken, requireLobbyOwner, LobbyController.kickUser)
25+
router.get("/lobbies/:id/image", authenticateToken, requireLobbyAccess, LobbyController.getLobbyImage)
2526
router.post("/lobbies/:id/ban", authenticateToken, requireLobbyOwner, LobbyController.banUser)
2627

2728
export default router
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { PNG } from 'pngjs';
2+
import { CanvasService } from './canvas.service.js';
3+
4+
export class ImageService {
5+
static async generateLobbyPng(lobbyName: string, scale: number = 1): Promise<PNG> {
6+
const { width, height, palette, data } = await CanvasService.getState(lobbyName);
7+
8+
// Validate scale to prevent memory issues
9+
const validScale = Math.max(1, Math.min(Math.floor(scale), 10)); // Clamp between 1 and 10
10+
const scaledWidth = width * validScale;
11+
const scaledHeight = height * validScale;
12+
13+
const png = new PNG({ width: scaledWidth, height: scaledHeight });
14+
15+
// Pre-calculate RGB values from hex palette
16+
const rgbPalette = palette.map(hex => {
17+
const r = parseInt(hex.slice(1, 3), 16);
18+
const g = parseInt(hex.slice(3, 5), 16);
19+
const b = parseInt(hex.slice(5, 7), 16);
20+
return [r, g, b];
21+
});
22+
23+
// Populate PNG buffer with Nearest-Neighbor Scaling
24+
for (let y = 0; y < scaledHeight; y++) {
25+
const srcY = Math.floor(y / validScale);
26+
for (let x = 0; x < scaledWidth; x++) {
27+
const srcX = Math.floor(x / validScale);
28+
29+
const colorIdx = data[srcY * width + srcX];
30+
const [r, g, b] = rgbPalette[colorIdx] || [0, 0, 0];
31+
32+
const pngIdx = (y * scaledWidth + x) << 2;
33+
png.data[pngIdx] = r;
34+
png.data[pngIdx + 1] = g;
35+
png.data[pngIdx + 2] = b;
36+
png.data[pngIdx + 3] = 255;
37+
}
38+
}
39+
40+
return png;
41+
}
42+
}

0 commit comments

Comments
 (0)