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:
@@ -176,6 +176,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
||||
// Distance-based priority: nearest to viewport center get resources first.
|
||||
|
||||
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_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);
|
||||
const shouldLoadImage = new Set(
|
||||
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;
|
||||
for (const { item, sprite } of imageItems) {
|
||||
if (shouldLoadImage.has(item.id) && !sprite.loaded && imgLoadsThisTick < 3) {
|
||||
@@ -228,6 +237,14 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
||||
loadedImages.add(item.id);
|
||||
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)
|
||||
|
||||
@@ -27,6 +27,18 @@ import type {
|
||||
SceneObject,
|
||||
} 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -276,7 +288,7 @@ export class SceneManager {
|
||||
switch (data.type) {
|
||||
case 'image': {
|
||||
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);
|
||||
} else {
|
||||
displayObject = new ImageSprite(imgData.asset, imgData.w, imgData.h, this.textures);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Texture, Assets } from "pixi.js";
|
||||
import type { GifSource } from "pixi.js/gif";
|
||||
|
||||
interface TextureEntry {
|
||||
texture: Texture;
|
||||
@@ -6,6 +7,12 @@ interface TextureEntry {
|
||||
refCount: number;
|
||||
}
|
||||
|
||||
interface GifEntry {
|
||||
source: GifSource;
|
||||
url: string;
|
||||
refCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* TextureManager — GPU texture cache with reference counting.
|
||||
*
|
||||
@@ -19,6 +26,7 @@ interface TextureEntry {
|
||||
*/
|
||||
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 {
|
||||
@@ -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 {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Container, Graphics, Assets } from "pixi.js";
|
||||
import { GifSprite, GifSource } from "pixi.js/gif";
|
||||
import { Container, Graphics } from "pixi.js";
|
||||
import { GifSprite } from "pixi.js/gif";
|
||||
import { TextureManager } from "../TextureManager";
|
||||
|
||||
/**
|
||||
@@ -8,7 +8,8 @@ import { TextureManager } from "../TextureManager";
|
||||
* Follows the same pattern as ImageSprite:
|
||||
* - Shadow + placeholder shown immediately
|
||||
* - 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).
|
||||
*/
|
||||
@@ -26,6 +27,7 @@ export class AnimatedGifSprite extends Container {
|
||||
private _shadow: Graphics;
|
||||
private _naturalWidth: number;
|
||||
private _naturalHeight: number;
|
||||
private _playing = false;
|
||||
|
||||
constructor(
|
||||
assetKey: string,
|
||||
@@ -71,20 +73,29 @@ export class AnimatedGifSprite extends Container {
|
||||
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> {
|
||||
if (this.loaded || this.loading) return;
|
||||
|
||||
this.loading = true;
|
||||
try {
|
||||
const url = this.textures.urlForAsset(this.assetKey);
|
||||
const source: GifSource = await Assets.load(url);
|
||||
if (this.destroyed) return;
|
||||
const source = await this.textures.loadGif(this.assetKey);
|
||||
if (this.destroyed) {
|
||||
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.height = this._naturalHeight;
|
||||
this._gif = gif;
|
||||
this._playing = false;
|
||||
this.addChild(gif);
|
||||
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 {
|
||||
if (!this.loaded) return;
|
||||
|
||||
if (this._gif) {
|
||||
this._gif.stop();
|
||||
this._playing = false;
|
||||
this.removeChild(this._gif);
|
||||
this._gif.destroy();
|
||||
this._gif = null;
|
||||
}
|
||||
|
||||
// Unload the GifSource from Assets cache
|
||||
const url = this.textures.urlForAsset(this.assetKey);
|
||||
try { Assets.unload(url); } catch { /* ignore */ }
|
||||
// Release through ref-counted manager (safe for duplicates)
|
||||
this.textures.releaseGif(this.assetKey);
|
||||
|
||||
this.loaded = false;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user