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)
This commit is contained in:
Hiren Kangad
2026-03-10 19:41:50 +05:30
parent 5a64708bd4
commit e45fce3e67
4 changed files with 122 additions and 15 deletions
+18 -1
View File
@@ -176,6 +176,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
// Distance-based priority: nearest to viewport center get resources first. // Distance-based priority: nearest to viewport center get resources first.
const MAX_LOADED_TEXTURES = 60; // max image textures in GPU const MAX_LOADED_TEXTURES = 60; // max image textures in GPU
const MAX_PLAYING_GIFS = 8; // max GIFs actively animating
const MAX_INITIALIZED_VIDEOS = 6; // max <video> elements (tier 1+) const MAX_INITIALIZED_VIDEOS = 6; // max <video> elements (tier 1+)
const MAX_POSTER_VIDEOS = 4; // max poster textures in GPU (tier 2) const MAX_POSTER_VIDEOS = 4; // max poster textures in GPU (tier 2)
@@ -215,12 +216,20 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
} }
} }
// ---- Image budget management ---- // ---- Image + GIF texture budget management ----
// Both static images and GIFs share the texture load budget.
// GIFs additionally have a separate play budget (only nearest N animate).
imageItems.sort((a, b) => a.dist - b.dist); imageItems.sort((a, b) => a.dist - b.dist);
const shouldLoadImage = new Set( const shouldLoadImage = new Set(
imageItems.slice(0, MAX_LOADED_TEXTURES).map(i => i.item.id) imageItems.slice(0, MAX_LOADED_TEXTURES).map(i => i.item.id)
); );
// Build GIF play budget: only nearest loaded GIFs should animate
const gifItems = imageItems.filter(i => i.sprite instanceof AnimatedGifSprite);
const shouldPlayGif = new Set(
gifItems.slice(0, MAX_PLAYING_GIFS).map(i => i.item.id)
);
let imgLoadsThisTick = 0; let imgLoadsThisTick = 0;
for (const { item, sprite } of imageItems) { for (const { item, sprite } of imageItems) {
if (shouldLoadImage.has(item.id) && !sprite.loaded && imgLoadsThisTick < 3) { if (shouldLoadImage.has(item.id) && !sprite.loaded && imgLoadsThisTick < 3) {
@@ -228,6 +237,14 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
loadedImages.add(item.id); loadedImages.add(item.id);
imgLoadsThisTick++; imgLoadsThisTick++;
} }
// Manage GIF play/stop based on play budget
if (sprite instanceof AnimatedGifSprite && sprite.loaded) {
if (shouldPlayGif.has(item.id)) {
sprite.play();
} else {
sprite.stop();
}
}
} }
// Unload images that left the viewport (check tracked set, not all items) // Unload images that left the viewport (check tracked set, not all items)
+13 -1
View File
@@ -27,6 +27,18 @@ import type {
SceneObject, SceneObject,
} from './scene-format'; } from './scene-format';
// ---------------------------------------------------------------------------
// GIF detection
// ---------------------------------------------------------------------------
/** Check if an asset key points to a GIF file.
* Strips query strings and fragments before checking extension. */
function isGifAsset(asset: string): boolean {
// Strip query string and fragment
const clean = asset.split('?')[0].split('#')[0];
return clean.toLowerCase().endsWith('.gif');
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SceneItem // SceneItem
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -276,7 +288,7 @@ export class SceneManager {
switch (data.type) { switch (data.type) {
case 'image': { case 'image': {
const imgData = data as ImageObject; const imgData = data as ImageObject;
if (imgData.asset.toLowerCase().endsWith('.gif')) { if (isGifAsset(imgData.asset)) {
displayObject = new AnimatedGifSprite(imgData.asset, imgData.w, imgData.h, this.textures); displayObject = new AnimatedGifSprite(imgData.asset, imgData.w, imgData.h, this.textures);
} else { } else {
displayObject = new ImageSprite(imgData.asset, imgData.w, imgData.h, this.textures); displayObject = new ImageSprite(imgData.asset, imgData.w, imgData.h, this.textures);
+52 -1
View File
@@ -1,4 +1,5 @@
import { Texture, Assets } from "pixi.js"; import { Texture, Assets } from "pixi.js";
import type { GifSource } from "pixi.js/gif";
interface TextureEntry { interface TextureEntry {
texture: Texture; texture: Texture;
@@ -6,6 +7,12 @@ interface TextureEntry {
refCount: number; refCount: number;
} }
interface GifEntry {
source: GifSource;
url: string;
refCount: number;
}
/** /**
* TextureManager — GPU texture cache with reference counting. * TextureManager — GPU texture cache with reference counting.
* *
@@ -19,6 +26,7 @@ interface TextureEntry {
*/ */
export class TextureManager { export class TextureManager {
private cache = new Map<string, TextureEntry>(); private cache = new Map<string, TextureEntry>();
private gifCache = new Map<string, GifEntry>();
/** Build the URL for a given asset. */ /** Build the URL for a given asset. */
urlForAsset(assetKey: string): string { urlForAsset(assetKey: string): string {
@@ -84,11 +92,54 @@ export class TextureManager {
} }
} }
/** Unload all cached textures. */ // -- 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 { clear(): void {
for (const [, entry] of this.cache) { for (const [, entry] of this.cache) {
try { Assets.unload(entry.url); } catch { /* ignore */ } try { Assets.unload(entry.url); } catch { /* ignore */ }
} }
this.cache.clear(); this.cache.clear();
for (const [, entry] of this.gifCache) {
try { Assets.unload(entry.url); } catch { /* ignore */ }
}
this.gifCache.clear();
} }
} }
@@ -1,5 +1,5 @@
import { Container, Graphics, Assets } from "pixi.js"; import { Container, Graphics } from "pixi.js";
import { GifSprite, GifSource } from "pixi.js/gif"; import { GifSprite } from "pixi.js/gif";
import { TextureManager } from "../TextureManager"; import { TextureManager } from "../TextureManager";
/** /**
@@ -8,7 +8,8 @@ import { TextureManager } from "../TextureManager";
* Follows the same pattern as ImageSprite: * Follows the same pattern as ImageSprite:
* - Shadow + placeholder shown immediately * - Shadow + placeholder shown immediately
* - Texture loaded lazily by viewport culling system * - Texture loaded lazily by viewport culling system
* - GIF plays automatically when loaded, pauses when unloaded * - GIF loaded/unloaded via TextureManager's ref-counted gifCache
* (safe for duplicated GIFs on the same board)
* *
* Uses PixiJS v8's built-in GifSprite (backed by gifuct-js). * Uses PixiJS v8's built-in GifSprite (backed by gifuct-js).
*/ */
@@ -26,6 +27,7 @@ export class AnimatedGifSprite extends Container {
private _shadow: Graphics; private _shadow: Graphics;
private _naturalWidth: number; private _naturalWidth: number;
private _naturalHeight: number; private _naturalHeight: number;
private _playing = false;
constructor( constructor(
assetKey: string, assetKey: string,
@@ -71,20 +73,29 @@ export class AnimatedGifSprite extends Container {
return this._gif?.texture ?? null; return this._gif?.texture ?? null;
} }
/** Load the GIF. Called by viewport culling when near viewport. */ /** Whether the GIF is currently animating. */
get playing(): boolean {
return this._playing;
}
/** Load the GIF source (ref-counted). Called by viewport culling. */
async loadTexture(): Promise<void> { async loadTexture(): Promise<void> {
if (this.loaded || this.loading) return; if (this.loaded || this.loading) return;
this.loading = true; this.loading = true;
try { try {
const url = this.textures.urlForAsset(this.assetKey); const source = await this.textures.loadGif(this.assetKey);
const source: GifSource = await Assets.load(url); if (this.destroyed) {
if (this.destroyed) return; this.textures.releaseGif(this.assetKey);
return;
}
const gif = new GifSprite({ source, loop: true, autoPlay: true }); // Create GifSprite paused — culling system decides whether to play
const gif = new GifSprite({ source, loop: true, autoPlay: false });
gif.width = this._naturalWidth; gif.width = this._naturalWidth;
gif.height = this._naturalHeight; gif.height = this._naturalHeight;
this._gif = gif; this._gif = gif;
this._playing = false;
this.addChild(gif); this.addChild(gif);
this.loaded = true; this.loaded = true;
@@ -101,20 +112,36 @@ export class AnimatedGifSprite extends Container {
} }
} }
/** Unload GIF to free memory. Called by viewport culling when far from viewport. */ /** Start GIF animation. Called by culling when within play budget. */
play(): void {
if (this._gif && !this._playing) {
this._gif.play();
this._playing = true;
}
}
/** Stop GIF animation (stays on current frame). Called by culling when outside budget. */
stop(): void {
if (this._gif && this._playing) {
this._gif.stop();
this._playing = false;
}
}
/** Unload GIF to free memory (ref-counted). Called by viewport culling. */
unloadTexture(): void { unloadTexture(): void {
if (!this.loaded) return; if (!this.loaded) return;
if (this._gif) { if (this._gif) {
this._gif.stop(); this._gif.stop();
this._playing = false;
this.removeChild(this._gif); this.removeChild(this._gif);
this._gif.destroy(); this._gif.destroy();
this._gif = null; this._gif = null;
} }
// Unload the GifSource from Assets cache // Release through ref-counted manager (safe for duplicates)
const url = this.textures.urlForAsset(this.assetKey); this.textures.releaseGif(this.assetKey);
try { Assets.unload(url); } catch { /* ignore */ }
this.loaded = false; this.loaded = false;