feat: add animated GIF support using PixiJS built-in GifSprite
Register GifAsset extension for .gif loading, create AnimatedGifSprite wrapper with lazy load/unload matching ImageSprite pattern, and detect .gif assets in SceneManager to use animated rendering. GIFs stored as type 'image' in scene format — no schema changes needed.
This commit is contained in:
@@ -14,16 +14,21 @@ import {
|
|||||||
useCallback,
|
useCallback,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Application } from 'pixi.js';
|
import { Application, extensions } from 'pixi.js';
|
||||||
import { Viewport } from 'pixi-viewport';
|
import { Viewport } from 'pixi-viewport';
|
||||||
|
import { GifAsset } from 'pixi.js/gif';
|
||||||
import { SceneManager, getItemWorldBounds, type SceneItem } from './SceneManager';
|
import { SceneManager, getItemWorldBounds, type SceneItem } from './SceneManager';
|
||||||
import { TextureManager } from './TextureManager';
|
import { TextureManager } from './TextureManager';
|
||||||
import { ImageSprite } from './sprites/ImageSprite';
|
import { ImageSprite } from './sprites/ImageSprite';
|
||||||
import { VideoSprite } from './sprites/VideoSprite';
|
import { VideoSprite } from './sprites/VideoSprite';
|
||||||
|
import { AnimatedGifSprite } from './sprites/AnimatedGifSprite';
|
||||||
import { SpringManager } from './spring';
|
import { SpringManager } from './spring';
|
||||||
import { convertFabricToV2 } from './scene-format';
|
import { convertFabricToV2 } from './scene-format';
|
||||||
import type { SceneData } from './scene-format';
|
import type { SceneData } from './scene-format';
|
||||||
|
|
||||||
|
// Register PixiJS GIF asset loader (once, at module level)
|
||||||
|
extensions.add(GifAsset);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Public interfaces
|
// Public interfaces
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -188,7 +193,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
|||||||
const vcx = bounds.x + bounds.width / 2;
|
const vcx = bounds.x + bounds.width / 2;
|
||||||
const vcy = bounds.y + bounds.height / 2;
|
const vcy = bounds.y + bounds.height / 2;
|
||||||
|
|
||||||
const imageItems: { item: SceneItem; sprite: ImageSprite; dist: number; near: boolean }[] = [];
|
const imageItems: { item: SceneItem; sprite: ImageSprite | AnimatedGifSprite; dist: number; near: boolean }[] = [];
|
||||||
const videoItems: { item: SceneItem; sprite: VideoSprite; dist: number; near: boolean }[] = [];
|
const videoItems: { item: SceneItem; sprite: VideoSprite; dist: number; near: boolean }[] = [];
|
||||||
|
|
||||||
// Spatial query: only check items within the extended viewport region
|
// Spatial query: only check items within the extended viewport region
|
||||||
@@ -203,7 +208,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
|||||||
const cy = item.data.y + (item.data.h * Math.abs(item.data.sy)) / 2;
|
const cy = item.data.y + (item.data.h * Math.abs(item.data.sy)) / 2;
|
||||||
const dist = (cx - vcx) ** 2 + (cy - vcy) ** 2;
|
const dist = (cx - vcx) ** 2 + (cy - vcy) ** 2;
|
||||||
|
|
||||||
if (item.type === 'image' && item.displayObject instanceof ImageSprite) {
|
if (item.type === 'image' && (item.displayObject instanceof ImageSprite || item.displayObject instanceof AnimatedGifSprite)) {
|
||||||
imageItems.push({ item, sprite: item.displayObject, dist, near: true });
|
imageItems.push({ item, sprite: item.displayObject, dist, near: true });
|
||||||
} else if (item.type === 'video' && item.displayObject instanceof VideoSprite) {
|
} else if (item.type === 'video' && item.displayObject instanceof VideoSprite) {
|
||||||
videoItems.push({ item, sprite: item.displayObject, dist, near: true });
|
videoItems.push({ item, sprite: item.displayObject, dist, near: true });
|
||||||
@@ -231,7 +236,7 @@ const PixiCanvas = forwardRef<PixiCanvasHandle, PixiCanvasProps>(
|
|||||||
const item = scene.items.get(id);
|
const item = scene.items.get(id);
|
||||||
if (!item) { loadedImages.delete(id); continue; }
|
if (!item) { loadedImages.delete(id); continue; }
|
||||||
const sprite = item.displayObject;
|
const sprite = item.displayObject;
|
||||||
if (sprite instanceof ImageSprite && sprite.loaded) {
|
if ((sprite instanceof ImageSprite || sprite instanceof AnimatedGifSprite) && sprite.loaded) {
|
||||||
sprite.unloadTexture();
|
sprite.unloadTexture();
|
||||||
}
|
}
|
||||||
loadedImages.delete(id);
|
loadedImages.delete(id);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Container, Graphics, Text, TextStyle } from 'pixi.js';
|
|||||||
import type { Viewport } from 'pixi-viewport';
|
import type { Viewport } from 'pixi-viewport';
|
||||||
import { TextureManager } from './TextureManager';
|
import { TextureManager } from './TextureManager';
|
||||||
import { ImageSprite } from './sprites/ImageSprite';
|
import { ImageSprite } from './sprites/ImageSprite';
|
||||||
|
import { AnimatedGifSprite } from './sprites/AnimatedGifSprite';
|
||||||
import { VideoSprite } from './sprites/VideoSprite';
|
import { VideoSprite } from './sprites/VideoSprite';
|
||||||
import { DrawingSprite } from './sprites/DrawingSprite';
|
import { DrawingSprite } from './sprites/DrawingSprite';
|
||||||
import { FrameSprite } from './sprites/FrameSprite';
|
import { FrameSprite } from './sprites/FrameSprite';
|
||||||
@@ -275,7 +276,11 @@ export class SceneManager {
|
|||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'image': {
|
case 'image': {
|
||||||
const imgData = data as ImageObject;
|
const imgData = data as ImageObject;
|
||||||
displayObject = new ImageSprite(imgData.asset, imgData.w, imgData.h, this.textures);
|
if (imgData.asset.toLowerCase().endsWith('.gif')) {
|
||||||
|
displayObject = new AnimatedGifSprite(imgData.asset, imgData.w, imgData.h, this.textures);
|
||||||
|
} else {
|
||||||
|
displayObject = new ImageSprite(imgData.asset, imgData.w, imgData.h, this.textures);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Container, Graphics, Assets } from "pixi.js";
|
||||||
|
import { GifSprite, GifSource } from "pixi.js/gif";
|
||||||
|
import { TextureManager } from "../TextureManager";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Container holding a shadow graphic + animated GIF sprite with lazy loading.
|
||||||
|
*
|
||||||
|
* 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
|
||||||
|
*
|
||||||
|
* Uses PixiJS v8's built-in GifSprite (backed by gifuct-js).
|
||||||
|
*/
|
||||||
|
const SHADOW_REST = { offsetX: 3, offsetY: 3, alpha: 0.2 };
|
||||||
|
const SHADOW_LIFT = { offsetX: 6, offsetY: 8, alpha: 0.3 };
|
||||||
|
|
||||||
|
export class AnimatedGifSprite extends Container {
|
||||||
|
readonly assetKey: string;
|
||||||
|
|
||||||
|
private textures: TextureManager;
|
||||||
|
loaded = false;
|
||||||
|
private loading = false;
|
||||||
|
private placeholder: Graphics | null = null;
|
||||||
|
private _gif: GifSprite | null = null;
|
||||||
|
private _shadow: Graphics;
|
||||||
|
private _naturalWidth: number;
|
||||||
|
private _naturalHeight: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
assetKey: string,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
textures: TextureManager,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.assetKey = assetKey;
|
||||||
|
this.textures = textures;
|
||||||
|
this._naturalWidth = w;
|
||||||
|
this._naturalHeight = h;
|
||||||
|
|
||||||
|
// Shadow
|
||||||
|
this._shadow = new Graphics();
|
||||||
|
this._drawShadow(SHADOW_REST);
|
||||||
|
this.addChild(this._shadow);
|
||||||
|
|
||||||
|
// Placeholder
|
||||||
|
const placeholder = new Graphics();
|
||||||
|
placeholder.rect(0, 0, w, h).fill(0x2a2a2a);
|
||||||
|
this.placeholder = placeholder;
|
||||||
|
this.addChild(placeholder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _drawShadow(cfg: { offsetX: number; offsetY: number; alpha: number }): void {
|
||||||
|
this._shadow.clear();
|
||||||
|
this._shadow.rect(cfg.offsetX, cfg.offsetY, this._naturalWidth, this._naturalHeight);
|
||||||
|
this._shadow.fill({ color: 0x000000, alpha: cfg.alpha });
|
||||||
|
}
|
||||||
|
|
||||||
|
liftShadow(): void {
|
||||||
|
this._drawShadow(SHADOW_LIFT);
|
||||||
|
}
|
||||||
|
|
||||||
|
dropShadow(): void {
|
||||||
|
this._drawShadow(SHADOW_REST);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The underlying GIF sprite's current texture (for clipboard/export). */
|
||||||
|
get texture() {
|
||||||
|
return this._gif?.texture ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load the GIF. Called by viewport culling when near viewport. */
|
||||||
|
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 gif = new GifSprite({ source, loop: true, autoPlay: true });
|
||||||
|
gif.width = this._naturalWidth;
|
||||||
|
gif.height = this._naturalHeight;
|
||||||
|
this._gif = gif;
|
||||||
|
this.addChild(gif);
|
||||||
|
this.loaded = true;
|
||||||
|
|
||||||
|
// Remove placeholder
|
||||||
|
if (this.placeholder) {
|
||||||
|
this.removeChild(this.placeholder);
|
||||||
|
this.placeholder.destroy();
|
||||||
|
this.placeholder = null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[AnimatedGifSprite] Failed to load "${this.assetKey}":`, err);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unload GIF to free memory. Called by viewport culling when far from viewport. */
|
||||||
|
unloadTexture(): void {
|
||||||
|
if (!this.loaded) return;
|
||||||
|
|
||||||
|
if (this._gif) {
|
||||||
|
this._gif.stop();
|
||||||
|
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 */ }
|
||||||
|
|
||||||
|
this.loaded = false;
|
||||||
|
|
||||||
|
// Restore placeholder
|
||||||
|
if (!this.placeholder) {
|
||||||
|
const placeholder = new Graphics();
|
||||||
|
placeholder.rect(0, 0, this._naturalWidth, this._naturalHeight).fill(0x2a2a2a);
|
||||||
|
this.placeholder = placeholder;
|
||||||
|
this.addChild(placeholder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user