fix(refboard): cap texture dimensions and add server-side image resizing
Prevents WebGL OOM errors when loading 90+ items by: - Capping texture requests to 2048px via ?w= query param - Adding Sharp-based server-side resize in image proxy endpoint - Using Assets.unload() instead of texture.destroy() for proper cleanup - Reducing GPU memory budget from 512MB to 256MB
This commit is contained in:
+21
-5
@@ -18,19 +18,35 @@ app.get('/health', (_req, res) => {
|
|||||||
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Image proxy (MinIO → browser) ----
|
// ---- Image proxy (MinIO → browser, optional resize via ?w=) ----
|
||||||
app.get('/api/images/*', async (req, res) => {
|
app.get('/api/images/*', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const objectPath = req.params[0]; // everything after /api/images/
|
const objectPath = req.params[0]; // everything after /api/images/
|
||||||
if (!objectPath) return res.status(400).json({ error: 'Missing path' });
|
if (!objectPath) return res.status(400).json({ error: 'Missing path' });
|
||||||
const { minioClient, MINIO_BUCKET } = require('./minio');
|
const { minioClient, MINIO_BUCKET } = require('./minio');
|
||||||
const stat = await minioClient.statObject(MINIO_BUCKET, objectPath);
|
const stat = await minioClient.statObject(MINIO_BUCKET, objectPath);
|
||||||
if (stat.metaData?.['content-type']) {
|
const contentType = stat.metaData?.['content-type'] || '';
|
||||||
res.setHeader('Content-Type', stat.metaData['content-type']);
|
|
||||||
}
|
|
||||||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||||||
const stream = await minioClient.getObject(MINIO_BUCKET, objectPath);
|
const stream = await minioClient.getObject(MINIO_BUCKET, objectPath);
|
||||||
stream.pipe(res);
|
|
||||||
|
const maxW = parseInt(req.query.w, 10);
|
||||||
|
const isImage = contentType.startsWith('image/') && !contentType.includes('svg');
|
||||||
|
if (maxW > 0 && isImage) {
|
||||||
|
// Resize on the fly with Sharp — cap width, preserve aspect ratio
|
||||||
|
const sharp = require('sharp');
|
||||||
|
const transform = sharp()
|
||||||
|
.resize({ width: maxW, withoutEnlargement: true })
|
||||||
|
.on('error', () => {
|
||||||
|
// If sharp fails (e.g. unsupported format), just pipe original
|
||||||
|
res.setHeader('Content-Type', contentType);
|
||||||
|
});
|
||||||
|
// Sharp outputs same format by default
|
||||||
|
if (contentType) res.setHeader('Content-Type', contentType);
|
||||||
|
stream.pipe(transform).pipe(res);
|
||||||
|
} else {
|
||||||
|
if (contentType) res.setHeader('Content-Type', contentType);
|
||||||
|
stream.pipe(res);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === 'NoSuchKey' || err.code === 'NotFound') {
|
if (err.code === 'NoSuchKey' || err.code === 'NotFound') {
|
||||||
return res.status(404).json({ error: 'Image not found' });
|
return res.status(404).json({ error: 'Image not found' });
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
import { Texture, Assets } from "pixi.js";
|
import { Texture, Assets } from "pixi.js";
|
||||||
|
|
||||||
|
// Max texture dimension — avoids WebGL INVALID_VALUE on large images.
|
||||||
|
// Most GPUs support 4096 or 8192; we cap at 2048 for memory efficiency
|
||||||
|
// since display sizes are typically 300-600px anyway.
|
||||||
|
const MAX_TEXTURE_DIM = 2048;
|
||||||
|
|
||||||
interface TextureEntry {
|
interface TextureEntry {
|
||||||
texture: Texture;
|
texture: Texture;
|
||||||
|
url: string; // needed for Assets.unload()
|
||||||
lastUsed: number;
|
lastUsed: number;
|
||||||
memoryEstimate: number;
|
memoryEstimate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TextureManager — GPU texture cache with LRU eviction and memory budget.
|
* TextureManager — GPU texture cache with LRU eviction and memory budget.
|
||||||
* No LOD tiers — PixiJS GPU handles scaling natively.
|
* Caps texture dimensions at MAX_TEXTURE_DIM to prevent WebGL OOM.
|
||||||
* Just loads the full-res image and caches it.
|
* Uses Assets.unload() instead of texture.destroy() for proper cleanup.
|
||||||
*/
|
*/
|
||||||
export class TextureManager {
|
export class TextureManager {
|
||||||
private cache = new Map<string, TextureEntry>();
|
private cache = new Map<string, TextureEntry>();
|
||||||
private budget = 512 * 1024 * 1024; // 512 MB
|
private budget = 256 * 1024 * 1024; // 256 MB (conservative for 90+ items)
|
||||||
private currentUsage = 0;
|
private currentUsage = 0;
|
||||||
|
|
||||||
/** Build the URL for a given asset. */
|
/** Build the URL for a given asset. */
|
||||||
@@ -27,7 +33,7 @@ export class TextureManager {
|
|||||||
/**
|
/**
|
||||||
* Load a texture for the given asset.
|
* Load a texture for the given asset.
|
||||||
* Returns cached texture if available, otherwise fetches and caches.
|
* Returns cached texture if available, otherwise fetches and caches.
|
||||||
* GPU handles all scaling — no LOD tiers needed.
|
* Large images are downscaled server-side via width param to stay within GPU limits.
|
||||||
*/
|
*/
|
||||||
async load(assetKey: string): Promise<Texture> {
|
async load(assetKey: string): Promise<Texture> {
|
||||||
const existing = this.cache.get(assetKey);
|
const existing = this.cache.get(assetKey);
|
||||||
@@ -36,7 +42,12 @@ export class TextureManager {
|
|||||||
return existing.texture;
|
return existing.texture;
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = this.urlForAsset(assetKey);
|
// Request capped size from server to avoid loading huge textures into GPU
|
||||||
|
let url = this.urlForAsset(assetKey);
|
||||||
|
if (!url.includes('?')) {
|
||||||
|
url += `?w=${MAX_TEXTURE_DIM}`;
|
||||||
|
}
|
||||||
|
|
||||||
let texture: Texture;
|
let texture: Texture;
|
||||||
try {
|
try {
|
||||||
texture = await Assets.load(url);
|
texture = await Assets.load(url);
|
||||||
@@ -53,6 +64,7 @@ export class TextureManager {
|
|||||||
|
|
||||||
this.cache.set(assetKey, {
|
this.cache.set(assetKey, {
|
||||||
texture,
|
texture,
|
||||||
|
url,
|
||||||
lastUsed: performance.now(),
|
lastUsed: performance.now(),
|
||||||
memoryEstimate,
|
memoryEstimate,
|
||||||
});
|
});
|
||||||
@@ -64,13 +76,18 @@ export class TextureManager {
|
|||||||
return texture;
|
return texture;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a specific texture from the cache and destroy it. */
|
/** Remove a specific texture from the cache via Assets.unload(). */
|
||||||
unload(assetKey: string): void {
|
unload(assetKey: string): void {
|
||||||
const entry = this.cache.get(assetKey);
|
const entry = this.cache.get(assetKey);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
|
|
||||||
this.currentUsage -= entry.memoryEstimate;
|
this.currentUsage -= entry.memoryEstimate;
|
||||||
entry.texture.destroy(true);
|
try {
|
||||||
|
Assets.unload(entry.url);
|
||||||
|
} catch {
|
||||||
|
// Fallback if Assets doesn't know about it
|
||||||
|
entry.texture.destroy(true);
|
||||||
|
}
|
||||||
this.cache.delete(assetKey);
|
this.cache.delete(assetKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,17 +104,14 @@ export class TextureManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (oldestKey) {
|
if (oldestKey) {
|
||||||
const entry = this.cache.get(oldestKey)!;
|
this.unload(oldestKey);
|
||||||
this.currentUsage -= entry.memoryEstimate;
|
|
||||||
entry.texture.destroy(true);
|
|
||||||
this.cache.delete(oldestKey);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Destroy all cached textures and reset memory tracking. */
|
/** Unload all cached textures and reset memory tracking. */
|
||||||
clear(): void {
|
clear(): void {
|
||||||
for (const entry of this.cache.values()) {
|
for (const [key] of this.cache) {
|
||||||
entry.texture.destroy(true);
|
this.unload(key);
|
||||||
}
|
}
|
||||||
this.cache.clear();
|
this.cache.clear();
|
||||||
this.currentUsage = 0;
|
this.currentUsage = 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user