diff --git a/frontend/src/canvas/PixiCanvas.tsx b/frontend/src/canvas/PixiCanvas.tsx index 2392012..670df45 100644 --- a/frontend/src/canvas/PixiCanvas.tsx +++ b/frontend/src/canvas/PixiCanvas.tsx @@ -14,16 +14,21 @@ import { useCallback, useState, } from 'react'; -import { Application } from 'pixi.js'; +import { Application, extensions } from 'pixi.js'; import { Viewport } from 'pixi-viewport'; +import { GifAsset } from 'pixi.js/gif'; import { SceneManager, getItemWorldBounds, type SceneItem } from './SceneManager'; import { TextureManager } from './TextureManager'; import { ImageSprite } from './sprites/ImageSprite'; import { VideoSprite } from './sprites/VideoSprite'; +import { AnimatedGifSprite } from './sprites/AnimatedGifSprite'; import { SpringManager } from './spring'; import { convertFabricToV2 } from './scene-format'; import type { SceneData } from './scene-format'; +// Register PixiJS GIF asset loader (once, at module level) +extensions.add(GifAsset); + // --------------------------------------------------------------------------- // Public interfaces // --------------------------------------------------------------------------- @@ -188,7 +193,7 @@ const PixiCanvas = forwardRef( const vcx = bounds.x + bounds.width / 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 }[] = []; // Spatial query: only check items within the extended viewport region @@ -203,7 +208,7 @@ const PixiCanvas = forwardRef( const cy = item.data.y + (item.data.h * Math.abs(item.data.sy)) / 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 }); } else if (item.type === 'video' && item.displayObject instanceof VideoSprite) { videoItems.push({ item, sprite: item.displayObject, dist, near: true }); @@ -231,7 +236,7 @@ const PixiCanvas = forwardRef( const item = scene.items.get(id); if (!item) { loadedImages.delete(id); continue; } const sprite = item.displayObject; - if (sprite instanceof ImageSprite && sprite.loaded) { + if ((sprite instanceof ImageSprite || sprite instanceof AnimatedGifSprite) && sprite.loaded) { sprite.unloadTexture(); } loadedImages.delete(id); diff --git a/frontend/src/canvas/SceneManager.ts b/frontend/src/canvas/SceneManager.ts index 08c79f4..9f23ede 100644 --- a/frontend/src/canvas/SceneManager.ts +++ b/frontend/src/canvas/SceneManager.ts @@ -9,6 +9,7 @@ import { Container, Graphics, Text, TextStyle } from 'pixi.js'; import type { Viewport } from 'pixi-viewport'; import { TextureManager } from './TextureManager'; import { ImageSprite } from './sprites/ImageSprite'; +import { AnimatedGifSprite } from './sprites/AnimatedGifSprite'; import { VideoSprite } from './sprites/VideoSprite'; import { DrawingSprite } from './sprites/DrawingSprite'; import { FrameSprite } from './sprites/FrameSprite'; @@ -275,7 +276,11 @@ export class SceneManager { switch (data.type) { case 'image': { 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; } diff --git a/frontend/src/canvas/sprites/AnimatedGifSprite.ts b/frontend/src/canvas/sprites/AnimatedGifSprite.ts new file mode 100644 index 0000000..af4608d --- /dev/null +++ b/frontend/src/canvas/sprites/AnimatedGifSprite.ts @@ -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 { + 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); + } + } +}