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.
|
// 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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user