Files
refboard-ayon/frontend/src/canvas/TextureManager.ts
T
Hiren Kangad e45fce3e67 fix: harden GIF support — ref-counted sources, defensive detection, play budget
1. Move GIF loading behind TextureManager.loadGif/releaseGif with
   ref-counting (fixes shared-source invalidation on duplicate GIFs)
2. Strip query strings/fragments before .gif extension check
3. GIFs now load paused — separate MAX_PLAYING_GIFS=8 budget in
   culling system, only nearest N animate (others show static frame)
2026-03-10 19:41:50 +05:30

146 lines
3.7 KiB
TypeScript

import { Texture, Assets } from "pixi.js";
import type { GifSource } from "pixi.js/gif";
interface TextureEntry {
texture: Texture;
url: string;
refCount: number;
}
interface GifEntry {
source: GifSource;
url: string;
refCount: number;
}
/**
* TextureManager — GPU texture cache with reference counting.
*
* Multiple ImageSprites can share the same assetKey (duplicated images).
* Assets.unload() destroys the shared texture source, so we must only
* call it when the LAST reference is released. Otherwise PixiJS v8
* crashes with null source errors (alphaMode, addressModeU).
*
* The viewport culling system (PixiCanvas) calls load/release to
* manage which textures consume GPU memory.
*/
export class TextureManager {
private cache = new Map<string, TextureEntry>();
private gifCache = new Map<string, GifEntry>();
/** Build the URL for a given asset. */
urlForAsset(assetKey: string): string {
if (assetKey.startsWith('http') || assetKey.startsWith('/api/')) {
return assetKey;
}
return `/api/images/${assetKey}`;
}
/**
* Load a texture for the given asset. Increments ref count.
* Returns cached texture if available, otherwise fetches and caches.
*/
async load(assetKey: string): Promise<Texture> {
const existing = this.cache.get(assetKey);
if (existing) {
existing.refCount++;
return existing.texture;
}
const url = this.urlForAsset(assetKey);
let texture: Texture;
try {
texture = await Assets.load(url);
} catch {
console.warn(`[TextureManager] Failed to load ${url}`);
return Texture.EMPTY;
}
this.cache.set(assetKey, { texture, url, refCount: 1 });
return texture;
}
/**
* Release a reference to a texture. Only actually unloads from GPU
* when the last reference is released.
*/
release(assetKey: string): void {
const entry = this.cache.get(assetKey);
if (!entry) return;
entry.refCount--;
if (entry.refCount > 0) return; // other sprites still using it
this.cache.delete(assetKey);
try {
Assets.unload(entry.url);
} catch {
// ignore — texture may already be gone
}
}
/** Force unload (used by clear). */
unload(assetKey: string): void {
const entry = this.cache.get(assetKey);
if (!entry) return;
this.cache.delete(assetKey);
try {
Assets.unload(entry.url);
} catch {
// ignore
}
}
// -- GIF source management (ref-counted, same pattern as textures) --
/**
* Load a GifSource for the given asset. Increments ref count.
* Returns cached source if available, otherwise fetches and caches.
*/
async loadGif(assetKey: string): Promise<GifSource> {
const existing = this.gifCache.get(assetKey);
if (existing) {
existing.refCount++;
return existing.source;
}
const url = this.urlForAsset(assetKey);
const source: GifSource = await Assets.load(url);
this.gifCache.set(assetKey, { source, url, refCount: 1 });
return source;
}
/**
* Release a reference to a GifSource. Only actually unloads
* when the last reference is released.
*/
releaseGif(assetKey: string): void {
const entry = this.gifCache.get(assetKey);
if (!entry) return;
entry.refCount--;
if (entry.refCount > 0) return;
this.gifCache.delete(assetKey);
try {
Assets.unload(entry.url);
} catch {
// ignore
}
}
/** Unload all cached textures and GIF sources. */
clear(): void {
for (const [, entry] of this.cache) {
try { Assets.unload(entry.url); } catch { /* ignore */ }
}
this.cache.clear();
for (const [, entry] of this.gifCache) {
try { Assets.unload(entry.url); } catch { /* ignore */ }
}
this.gifCache.clear();
}
}