diff --git a/package-lock.json b/package-lock.json index 442e77f..b3bd793 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "electron-updater": "^6.8.3", "fabric": "^7.2.0", "fontkit": "^2.0.4", + "gifuct-js": "^2.1.2", "svelte-i18n": "^4.0.1", "uuid": "^11.1.0" }, @@ -6483,6 +6484,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gifuct-js": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/gifuct-js/-/gifuct-js-2.1.2.tgz", + "integrity": "sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==", + "license": "MIT", + "dependencies": { + "js-binary-schema-parser": "^2.0.3" + } + }, "node_modules/github-from-package": { "version": "0.0.0", "resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz", @@ -7134,6 +7144,12 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-binary-schema-parser": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/js-binary-schema-parser/-/js-binary-schema-parser-2.0.3.tgz", + "integrity": "sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index 2fef980..a7bb911 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "electron-updater": "^6.8.3", "fabric": "^7.2.0", "fontkit": "^2.0.4", + "gifuct-js": "^2.1.2", "svelte-i18n": "^4.0.1", "uuid": "^11.1.0" }, @@ -51,6 +52,7 @@ "@types/better-sqlite3": "^7.6.13", "@types/node": "^22.19.13", "@types/uuid": "^10.0.0", + "@vitest/coverage-v8": "^4.1.4", "dotenv-cli": "^11.0.0", "electron": "^40.6.1", "electron-builder": "^26.8.1", @@ -68,7 +70,6 @@ "tailwindcss": "^4.2.1", "typescript": "^5.9.3", "vite": "^6.4.1", - "vitest": "^4.1.4", - "@vitest/coverage-v8": "^4.1.4" + "vitest": "^4.1.4" } } diff --git a/src/renderer/src/Presentation.svelte b/src/renderer/src/Presentation.svelte index e48477d..ef18ecd 100644 --- a/src/renderer/src/Presentation.svelte +++ b/src/renderer/src/Presentation.svelte @@ -36,6 +36,7 @@ import { normalizeFontBytes, type FontBytes } from './lib/fontUtils' import { shapeStyle } from './lib/shapeStyle' import { getTextboxWrappingOptions } from './lib/textboxUtils' + import { GifAnimationManager, isGifDataUrl } from './lib/gif' interface PresentationState { /** ID of the slide to display — presentation window fetches the full slide from DB */ @@ -127,6 +128,10 @@ let canvasEl: HTMLCanvasElement let presentationCanvas: Canvas | undefined + // Animates GIF images during presentation. Owned 1:1 with the canvas. + // Future video support should follow the same shape — one per-canvas manager, + // reset() per slide, dispose() on teardown — but as a separate manager. + let gifManager: GifAnimationManager | undefined let currentState = $state({ slideId: null, slideIndex: 0, @@ -177,6 +182,7 @@ interactive: false, backgroundColor: '#ffffff' }) + gifManager = new GifAnimationManager(presentationCanvas) scaleCanvas() @@ -189,10 +195,37 @@ const { slideId, filePath } = newState if (!slideId || !filePath) { + // Full teardown — invalidate every async pipeline (DB fetch, render, + // transition) and clear any visible canvas state so stale visuals, + // timers, and in-flight image/GIF work do not survive the wipe. + ++fetchGeneration + ++renderGeneration + ++transitionGeneration + loadedSlide = null + lastRenderedSlideId = null transitionOverlaySrc = null transitionOverlayStyle = '' + slideWrapperStyle = 'position: relative;' transitioning = false + animating = false + pendingAdvanceAfterImageLoad = false + animProgress = 0 + hasRenderedOnce = false + lastSlideTransition = undefined + + fabObjById.clear() + elementById.clear() + failedElementIds.clear() + + gifManager?.reset() + + if (presentationCanvas) { + presentationCanvas.remove(...presentationCanvas.getObjects()) + presentationCanvas.backgroundImage = undefined + presentationCanvas.backgroundColor = '#ffffff' + presentationCanvas.renderAll() + } return } @@ -222,6 +255,10 @@ }) onDestroy(() => { + // Dispose GIF manager BEFORE the canvas: dispose() cancels timers and + // schedulers that would otherwise touch a torn-down canvas. + gifManager?.dispose() + gifManager = undefined if (presentationCanvas) { presentationCanvas.dispose() presentationCanvas = undefined @@ -370,6 +407,9 @@ // Stamp the current generation so async image callbacks from a previous // render can detect that the slide has since changed and bail out. const generation = ++renderGeneration + // Reset GIF playback for the previous slide. Decode for the new slide's + // GIFs is scheduled below, after their Fabric images have been added. + gifManager?.reset() presentationCanvas.remove(...presentationCanvas.getObjects()) fabObjById.clear() @@ -521,6 +561,9 @@ fabObjById.set(element.id, img) applyAnimationStateToObject(slide, element, img) presentationCanvas.renderAll() + // Schedule GIF decode/playback. Fire-and-forget: never await — the + // static frame stays visible until decode completes. + if (isGifDataUrl(element.src)) gifManager?.register(element, img) continuePendingAdvance(slide, generation) }) .catch((err) => { @@ -537,14 +580,19 @@ await Promise.allSettled([backgroundLoad, ...imageLoads]) - if (!presentationCanvas || renderGeneration !== generation || imageLoads.length <= 1) return + if (!presentationCanvas || renderGeneration !== generation) return - const orderedObjects = (presentationCanvas.getObjects() as PresentationFabricObject[]) - .slice() - .sort((a, b) => (zIndexById.get(a.id ?? '') ?? 0) - (zIndexById.get(b.id ?? '') ?? 0)) + if (imageLoads.length > 1) { + const orderedObjects = (presentationCanvas.getObjects() as PresentationFabricObject[]) + .slice() + .sort((a, b) => (zIndexById.get(a.id ?? '') ?? 0) - (zIndexById.get(b.id ?? '') ?? 0)) - orderedObjects.forEach((obj, targetIndex) => presentationCanvas!.moveObjectTo(obj, targetIndex)) + orderedObjects.forEach((obj, targetIndex) => + presentationCanvas!.moveObjectTo(obj, targetIndex) + ) + } presentationCanvas.renderAll() + gifManager?.start() } function applyAnimationStateToObject(slide: Slide, el: TwigElement, fabObj: FabricObject): void { diff --git a/src/renderer/src/components/PropertiesPanel.svelte b/src/renderer/src/components/PropertiesPanel.svelte index 86c8783..32475a7 100644 --- a/src/renderer/src/components/PropertiesPanel.svelte +++ b/src/renderer/src/components/PropertiesPanel.svelte @@ -23,6 +23,7 @@ import { DEFAULT_ARROW_SHAPE } from '../lib/types' import { _ } from 'svelte-i18n' import { isSvgDataUrl, normalizeSvgDataUrl } from '../lib/svg' + import { isGifDataUrl, getGifFirstFrameDataUrl } from '../lib/gif' type RichText = { isBold: boolean @@ -262,6 +263,37 @@ const currentBg = $derived(appState.currentSlide?.background) const bgType = $derived(currentBg?.type ?? 'solid') + // Native tags animate GIFs. Decode just the first frame so the + // properties-panel preview matches the editor canvas (which stays static). + let bgPreviewSrc = $state(null) + let bgPreviewKey = '' + $effect(() => { + const bg = currentBg + const src = bg?.type === 'image' ? bg.src : null + if (src === bgPreviewKey) return undefined + bgPreviewKey = src ?? '' + if (!src) { + bgPreviewSrc = null + return undefined + } + if (!isGifDataUrl(src)) { + bgPreviewSrc = src + return undefined + } + // Show the animated GIF first so the preview is never blank, then swap + // in the decoded first-frame PNG once available. + bgPreviewSrc = src + let cancelled = false + getGifFirstFrameDataUrl(src) + .then((staticUrl) => { + if (!cancelled && bgPreviewKey === src) bgPreviewSrc = staticUrl + }) + .catch(() => {}) + return () => { + cancelled = true + } + }) + let activeTab = $derived.by<'solid' | 'gradient' | 'image'>( () => bgType as 'solid' | 'gradient' | 'image' ) @@ -1035,7 +1067,11 @@
{#if currentBg?.type === 'image'}
- slide background + slide background

{currentBg.filename ?? 'image'}

diff --git a/src/renderer/src/lib/gif.ts b/src/renderer/src/lib/gif.ts new file mode 100644 index 0000000..6b11ce2 --- /dev/null +++ b/src/renderer/src/lib/gif.ts @@ -0,0 +1,891 @@ +/** + * Animated GIF playback for fabric.js images in the presentation window. + * + * Constraints: + * - Editor canvas GIFs stay static — this module is only wired into + * Presentation.svelte. + * - Storage, DB schema, and IPC are unchanged; we read element.src as-is. + * - register() is fire-and-forget. renderSlide() must never await it. + * + * Future video support should follow the same per-canvas singleton shape: + * one manager next to the canvas, reset() per slide, dispose() on teardown. + * But it must be a separate manager — do not overload this one. + */ + +import { + parseGIF as defaultParseGIF, + decompressFrames as defaultDecompressFrames, + type ParsedGif, + type ParsedFrame +} from 'gifuct-js' +import type { Canvas as FabricCanvas, FabricImage } from 'fabric' +import type { TwigElement } from './types' + +// ============================================================================ +// Public API +// ============================================================================ + +const GIF_DATA_URL_PATTERN = /^data:image\/gif(?:;[^,]*)?;base64,/i + +/** Recognizes base64 GIF data URLs, case- and parameter-tolerant. */ +export function isGifDataUrl(value: string | undefined | null): boolean { + if (typeof value !== 'string') return false + return GIF_DATA_URL_PATTERN.test(value) +} + +/** + * Decodes the first frame of a GIF data URL into a PNG data URL so the result + * can be used as a static `` in editor UI (e.g. background preview). + * + * The plan keeps GIFs static in the editor; native `` tags animate them + * automatically, so anywhere we want a still preview we need to render frame + * zero explicitly. Falls back to returning the original URL on any failure + * (better an animated preview than a broken one). + */ +const firstFrameCache = new Map() + +export async function getGifFirstFrameDataUrl(gifDataUrl: string): Promise { + const cached = firstFrameCache.get(gifDataUrl) + if (cached) return cached + try { + const commaIdx = gifDataUrl.indexOf(',') + if (commaIdx < 0) return gifDataUrl + const base64 = gifDataUrl.slice(commaIdx + 1) + const binary = atob(base64) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i) + const parsed = defaultParseGIF( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer + ) + const frames = defaultDecompressFrames(parsed, true) + if (frames.length === 0) return gifDataUrl + const frame = frames[0] + const W = parsed.lsd.width + const H = parsed.lsd.height + if (!Number.isFinite(W) || !Number.isFinite(H) || W <= 0 || H <= 0) return gifDataUrl + const composite = document.createElement('canvas') + composite.width = W + composite.height = H + const ctx = composite.getContext('2d') + if (!ctx) return gifDataUrl + const scratch = document.createElement('canvas') + scratch.width = frame.dims.width + scratch.height = frame.dims.height + const sctx = scratch.getContext('2d') + if (!sctx) return gifDataUrl + sctx.putImageData( + new ImageData( + frame.patch as unknown as Uint8ClampedArray, + frame.dims.width, + frame.dims.height + ), + 0, + 0 + ) + ctx.drawImage( + scratch, + 0, + 0, + frame.dims.width, + frame.dims.height, + frame.dims.left, + frame.dims.top, + frame.dims.width, + frame.dims.height + ) + const pngUrl = composite.toDataURL('image/png') + firstFrameCache.set(gifDataUrl, pngUrl) + return pngUrl + } catch { + return gifDataUrl + } +} + +type AfterPaintCancel = () => void +type TimeoutHandle = unknown + +/** + * Internal/test injection hook. Production usage is `new GifAnimationManager(canvas)`. + * Tests can override the decoder, schedulers, document, and canvas factory so + * we don't need a real DOM canvas under Node. + */ +export interface GifAnimationManagerOptions { + parseGIF?: typeof defaultParseGIF + decompressFrames?: typeof defaultDecompressFrames + scheduleAfterPaint?: (cb: () => void) => AfterPaintCancel + scheduleTimeout?: (cb: () => void, delayMs: number) => TimeoutHandle + clearScheduledTimeout?: (handle: TimeoutHandle) => void + documentRef?: GifDocument | null + createCanvas?: (width: number, height: number) => HTMLCanvasElement + createImageData?: (data: Uint8ClampedArray, width: number, height: number) => ImageData + isLive?: (manager: GifAnimationManager, fabricImage: FabricImage) => boolean + now?: () => number + /** Override encoded-size limit (bytes). Defaults to 25 MB. Test hook. */ + maxEncodedSizeBytes?: number +} + +/** Subset of `document` we depend on, for injection. */ +export interface GifDocument { + hidden: boolean + addEventListener: (type: 'visibilitychange', listener: () => void) => void + removeEventListener: (type: 'visibilitychange', listener: () => void) => void +} + +// ============================================================================ +// Constants +// ============================================================================ + +const MAX_ENCODED_SIZE_BYTES = 25 * 1024 * 1024 +const MAX_LOGICAL_DIMENSION = 4096 +const MAX_FRAME_COUNT = 300 +const MAX_DECODE_WALLCLOCK_MS = 2000 +const MEMORY_BUDGET_BYTES = 64 * 1024 * 1024 +const MAX_DECODE_CONCURRENCY = 4 +const MIN_DELAY_MS = 20 +const DEFAULT_DELAY_MS = 100 + +// ============================================================================ +// Internal state types +// ============================================================================ + +interface ReservationToken { + bytes: number + /** + * 'reserved' — bytes counted in manager.reservedBytes + * 'committed' — bytes counted in manager.committedBytes + * 'released' — bytes refunded to nothing; further releases are no-ops + */ + state: 'reserved' | 'committed' | 'released' +} + +interface NormalizedFrame { + dims: { left: number; top: number; width: number; height: number } + delayMs: number + disposalType: number + patch: Uint8ClampedArray +} + +interface GifState { + elementId: string + element: TwigElement + fabricImage: FabricImage + generation: number + frames: NormalizedFrame[] + composite: HTMLCanvasElement + compositeCtx: CanvasRenderingContext2D + scratch: HTMLCanvasElement + scratchCtx: CanvasRenderingContext2D + logicalWidth: number + logicalHeight: number + /** Index of the most-recently-drawn frame on the composite. */ + drawnIndex: number + /** Disposal type of the frame currently sitting on the composite. */ + pendingDisposal: number + timer: TimeoutHandle | null + reservation: ReservationToken +} + +interface QueuedTask { + element: TwigElement + fabricImage: FabricImage + generation: number +} + +type WarningKey = + | 'lowDelay' + | 'encodedSize' + | 'badMetadata' + | 'frameCount' + | 'decodeWallclock' + | 'memoryBudget' + | 'decodeFailure' + +// ============================================================================ +// Default schedulers +// ============================================================================ + +function defaultScheduleAfterPaint(cb: () => void): AfterPaintCancel { + let cancelled = false + let timeoutId: ReturnType | null = null + // Decode after the next paint so the static Fabric image renders first. + // RAF alone runs *before* the next paint; pairing with setTimeout(0) defers + // heavy work until after the browser has actually painted. + const rafId = requestAnimationFrame(() => { + if (cancelled) return + timeoutId = setTimeout(() => { + if (cancelled) return + cb() + }, 0) + }) + return () => { + cancelled = true + cancelAnimationFrame(rafId) + if (timeoutId != null) clearTimeout(timeoutId) + } +} + +function defaultCreateCanvas(width: number, height: number): HTMLCanvasElement { + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + return canvas +} + +function defaultCreateImageData(data: Uint8ClampedArray, width: number, height: number): ImageData { + // gifuct-js patches are backed by a regular ArrayBuffer, but TS's lib types + // type Uint8ClampedArray as Uint8ClampedArray, which the + // ImageData ctor rejects (it wants ). Cast through unknown. + return new ImageData(data as unknown as Uint8ClampedArray, width, height) +} + +function defaultIsLive(manager: GifAnimationManager, fabricImage: FabricImage): boolean { + if (fabricImage.canvas !== manager.canvas) return false + return manager.canvas.getObjects().includes(fabricImage) +} + +// ============================================================================ +// GifAnimationManager +// ============================================================================ + +export class GifAnimationManager { + readonly canvas: FabricCanvas + private readonly opts: Required> & { + documentRef: GifDocument | null + } + + private generation = 0 + private running = false + private disposed = false + private readonly states = new Map() + private readonly queue: QueuedTask[] = [] + private readonly afterPaintCancels = new Set() + private inFlight = 0 + private reservedBytes = 0 + private committedBytes = 0 + private readonly warned = new Set() + private readonly visibilityListener: () => void + private visibilityRegistered = false + + constructor(canvas: FabricCanvas, options: GifAnimationManagerOptions = {}) { + this.canvas = canvas + const docRef = + options.documentRef === undefined + ? typeof document !== 'undefined' + ? document + : null + : options.documentRef + this.opts = { + parseGIF: options.parseGIF ?? defaultParseGIF, + decompressFrames: options.decompressFrames ?? defaultDecompressFrames, + scheduleAfterPaint: options.scheduleAfterPaint ?? defaultScheduleAfterPaint, + scheduleTimeout: + options.scheduleTimeout ?? + ((cb, delay) => setTimeout(cb, delay) as unknown as TimeoutHandle), + clearScheduledTimeout: + options.clearScheduledTimeout ?? + ((handle) => clearTimeout(handle as ReturnType)), + documentRef: docRef, + createCanvas: options.createCanvas ?? defaultCreateCanvas, + createImageData: options.createImageData ?? defaultCreateImageData, + isLive: options.isLive ?? defaultIsLive, + now: + options.now ?? + (typeof performance !== 'undefined' && typeof performance.now === 'function' + ? () => performance.now() + : () => Date.now()), + maxEncodedSizeBytes: options.maxEncodedSizeBytes ?? MAX_ENCODED_SIZE_BYTES + } + + this.visibilityListener = () => this.handleVisibilityChange() + if (this.opts.documentRef) { + this.opts.documentRef.addEventListener('visibilitychange', this.visibilityListener) + this.visibilityRegistered = true + } + } + + // -------------------------------------------------------------------------- + // Lifecycle + // -------------------------------------------------------------------------- + + /** Clears all pending and active GIFs but keeps the manager reusable. */ + reset(): void { + this.generation++ + this.running = false + this.cancelAllAfterPaint() + this.queue.length = 0 + this.clearAllTimers() + this.releaseAllStates() + } + + /** Permanently shuts down the manager. */ + dispose(): void { + this.generation++ + this.disposed = true + this.running = false + this.cancelAllAfterPaint() + this.queue.length = 0 + this.clearAllTimers() + this.releaseAllStates() + if (this.visibilityRegistered && this.opts.documentRef) { + this.opts.documentRef.removeEventListener('visibilitychange', this.visibilityListener) + this.visibilityRegistered = false + } + } + + /** Schedules decode of the given GIF element. Fire-and-forget. */ + register(element: TwigElement, fabricImage: FabricImage): void { + if (this.disposed) return + if (!isGifDataUrl(element.src)) return + this.queue.push({ element, fabricImage, generation: this.generation }) + this.pumpQueue() + } + + /** + * Starts playback of all decoded GIFs. Idempotent: extra calls do not + * duplicate timers. No timers scheduled while the document is hidden. + */ + start(): void { + if (this.disposed) return + this.running = true + if (this.opts.documentRef?.hidden) return + for (const state of this.states.values()) { + if (state.timer == null) this.scheduleNextTick(state) + } + } + + // -------------------------------------------------------------------------- + // Queue / decode pipeline + // -------------------------------------------------------------------------- + + private pumpQueue(): void { + while (!this.disposed && this.inFlight < MAX_DECODE_CONCURRENCY && this.queue.length > 0) { + const task = this.queue.shift()! + if (task.generation !== this.generation) continue + this.inFlight++ + const cancel = this.opts.scheduleAfterPaint(() => { + this.afterPaintCancels.delete(cancel) + try { + this.runDecode(task) + } finally { + this.inFlight-- + this.pumpQueue() + } + }) + this.afterPaintCancels.add(cancel) + } + } + + private runDecode(task: QueuedTask): void { + if (this.disposed) return + if (task.generation !== this.generation) return + if (!this.opts.isLive(this, task.fabricImage)) return + + const src = task.element.src + if (!isGifDataUrl(src)) return + + let reservation: ReservationToken | null = null + + try { + const encoded = base64FromDataUrl(src!) + if (encoded == null) return + + const approxEncodedBytes = approxBase64DecodedSize(encoded.length) + if (approxEncodedBytes > this.opts.maxEncodedSizeBytes) { + this.warnOnce( + 'encodedSize', + `GIF rejected: encoded size > ${this.opts.maxEncodedSizeBytes} bytes` + ) + return + } + + const bytes = decodeBase64ToBytes(encoded) + if (!bytes) return + if (bytes.byteLength > this.opts.maxEncodedSizeBytes) { + this.warnOnce( + 'encodedSize', + `GIF rejected: decoded size > ${this.opts.maxEncodedSizeBytes} bytes` + ) + return + } + + const t0 = this.opts.now() + let parsed: ParsedGif + try { + parsed = this.opts.parseGIF( + bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer + ) + } catch (err) { + this.warnOnce('decodeFailure', `GIF parseGIF failed: ${describeError(err)}`) + return + } + + const logicalWidth = parsed.lsd?.width ?? 0 + const logicalHeight = parsed.lsd?.height ?? 0 + if (!isValidLogicalDimension(logicalWidth) || !isValidLogicalDimension(logicalHeight)) { + this.warnOnce( + 'badMetadata', + `GIF rejected: invalid logical dimensions ${logicalWidth}x${logicalHeight}` + ) + return + } + + const frameCount = parsed.frames.filter((f) => 'image' in f).length + if (frameCount <= 0 || frameCount > MAX_FRAME_COUNT) { + this.warnOnce('frameCount', `GIF rejected: frame count ${frameCount} out of range`) + return + } + + // Preflight estimate. Patch rects are unknown until decompress, so fail + // closed with worst-case bytes per frame. + const compositeBytes = logicalWidth * logicalHeight * 4 + const worstScratchBytes = compositeBytes + const estimatedPatchBytes = compositeBytes * frameCount + const estimatedTotal = estimatedPatchBytes + compositeBytes + worstScratchBytes + if (!Number.isFinite(estimatedTotal) || estimatedTotal <= 0) { + this.warnOnce('badMetadata', `GIF rejected: estimated bytes not finite`) + return + } + reservation = this.tryReserve(estimatedTotal) + if (!reservation) { + this.warnOnce('memoryBudget', `GIF rejected: memory budget exceeded (preflight)`) + return + } + + let frames: ParsedFrame[] + try { + frames = this.opts.decompressFrames(parsed, true) + } catch (err) { + this.warnOnce('decodeFailure', `GIF decompressFrames failed: ${describeError(err)}`) + return + } + + const elapsed = this.opts.now() - t0 + if (elapsed > MAX_DECODE_WALLCLOCK_MS) { + this.warnOnce( + 'decodeWallclock', + `GIF rejected: decode wallclock ${elapsed}ms > ${MAX_DECODE_WALLCLOCK_MS}ms` + ) + return + } + + if (frames.length === 0 || frames.length > MAX_FRAME_COUNT) { + this.warnOnce( + 'frameCount', + `GIF rejected: decompressed frame count ${frames.length} out of range` + ) + return + } + + let maxPatchW = 0 + let maxPatchH = 0 + let actualPatchBytes = 0 + const normalized: NormalizedFrame[] = [] + for (const frame of frames) { + const dims = frame.dims + if ( + !isFiniteDim(dims.width) || + !isFiniteDim(dims.height) || + dims.width <= 0 || + dims.height <= 0 + ) { + this.warnOnce( + 'badMetadata', + `GIF rejected: invalid patch dimensions ${dims.width}x${dims.height}` + ) + return + } + if (!Number.isFinite(dims.left) || !Number.isFinite(dims.top)) { + this.warnOnce('badMetadata', `GIF rejected: non-finite patch offset`) + return + } + if ( + dims.left < 0 || + dims.top < 0 || + dims.left + dims.width > logicalWidth || + dims.top + dims.height > logicalHeight + ) { + this.warnOnce('badMetadata', `GIF rejected: patch rect outside logical canvas bounds`) + return + } + if (!frame.patch || frame.patch.byteLength === 0) { + this.warnOnce('badMetadata', `GIF rejected: missing patch data`) + return + } + + const rawDelay = typeof frame.delay === 'number' ? frame.delay : 0 + let delayMs = rawDelay + if (!Number.isFinite(delayMs) || delayMs <= 0) delayMs = DEFAULT_DELAY_MS + if (delayMs < MIN_DELAY_MS) { + this.warnOnce('lowDelay', `GIF delay ${delayMs}ms clamped to ${MIN_DELAY_MS}ms`) + delayMs = MIN_DELAY_MS + } + + normalized.push({ + dims: { left: dims.left, top: dims.top, width: dims.width, height: dims.height }, + delayMs, + disposalType: frame.disposalType ?? 0, + patch: frame.patch + }) + + actualPatchBytes += frame.patch.byteLength + if (dims.width > maxPatchW) maxPatchW = dims.width + if (dims.height > maxPatchH) maxPatchH = dims.height + } + + const scratchBytes = maxPatchW * maxPatchH * 4 + const actualTotal = actualPatchBytes + compositeBytes + scratchBytes + if (!Number.isFinite(actualTotal) || actualTotal <= 0) { + this.warnOnce('badMetadata', `GIF rejected: actual bytes not finite`) + return + } + + // Reconcile reservation against actual. + if (actualTotal > reservation.bytes) { + const extra = actualTotal - reservation.bytes + if (!this.tryTopUpReservation(reservation, extra)) { + this.warnOnce('memoryBudget', `GIF rejected: memory budget exceeded (actual)`) + return + } + } else if (actualTotal < reservation.bytes) { + const surplus = reservation.bytes - actualTotal + this.releaseReservation(reservation, surplus) + } + + // After top-up `reservation.bytes === actualTotal` (or it was already + // equal). Re-verify liveness and generation before allocating. + if (this.disposed) return + if (task.generation !== this.generation) return + if (!this.opts.isLive(this, task.fabricImage)) return + + const composite = this.opts.createCanvas(logicalWidth, logicalHeight) + const compositeCtx = composite.getContext('2d') + const scratch = this.opts.createCanvas(maxPatchW, maxPatchH) + const scratchCtx = scratch.getContext('2d') + if (!compositeCtx || !scratchCtx) { + this.warnOnce('decodeFailure', `GIF rejected: 2D context unavailable`) + return + } + + const state: GifState = { + elementId: task.element.id, + element: task.element, + fabricImage: task.fabricImage, + generation: task.generation, + frames: normalized, + composite, + compositeCtx, + scratch, + scratchCtx, + logicalWidth, + logicalHeight, + drawnIndex: -1, + pendingDisposal: 0, + timer: null, + reservation + } + + this.drawFrame(state, 0) + state.pendingDisposal = normalized[0].disposalType + state.drawnIndex = 0 + + this.installCompositeOnFabricImage(state) + + // Promote reservation → committed and hand ownership to the state. + // Setting reservation = null prevents the finally from refunding bytes + // we've now committed into the state. + this.commitReservation(reservation) + reservation = null + + this.states.set(task.element.id, state) + + if (this.running && !this.opts.documentRef?.hidden) { + this.scheduleNextTick(state) + } + } catch (err) { + this.warnOnce('decodeFailure', `GIF unexpected error: ${describeError(err)}`) + } finally { + if (reservation && reservation.state === 'reserved') { + this.releaseReservation(reservation, reservation.bytes) + } + } + } + + // -------------------------------------------------------------------------- + // Drawing + // -------------------------------------------------------------------------- + + private drawFrame(state: GifState, frameIndex: number): void { + const frame = state.frames[frameIndex] + const { compositeCtx, scratchCtx, scratch } = state + + // Apply pending disposal from previously drawn frame. + if (state.drawnIndex >= 0 && state.drawnIndex !== frameIndex) { + const prev = state.frames[state.drawnIndex] + this.applyDisposal(state, prev, state.pendingDisposal) + } + + // Compose the patch via a scratch canvas so transparent GIF pixels do not + // erase what's already on the composite (drawImage uses source-over by + // default, which is what we want for cumulative GIF rendering). + scratchCtx.clearRect(0, 0, scratch.width, scratch.height) + const imageData = this.opts.createImageData(frame.patch, frame.dims.width, frame.dims.height) + scratchCtx.putImageData(imageData, 0, 0) + compositeCtx.drawImage( + scratch, + 0, + 0, + frame.dims.width, + frame.dims.height, + frame.dims.left, + frame.dims.top, + frame.dims.width, + frame.dims.height + ) + + state.drawnIndex = frameIndex + state.pendingDisposal = frame.disposalType + } + + private applyDisposal(state: GifState, drawnFrame: NormalizedFrame, disposalType: number): void { + // 0 / 1: leave in place. + // 2: clear the previous frame's rect before drawing next. + // 3: "restore to previous" — treated as clear-rect (disposal 2) here. + // A full implementation would snapshot pre-frame pixels; we omit that + // to keep memory bounded. + if (disposalType === 2 || disposalType === 3) { + state.compositeCtx.clearRect( + drawnFrame.dims.left, + drawnFrame.dims.top, + drawnFrame.dims.width, + drawnFrame.dims.height + ) + } + } + + private installCompositeOnFabricImage(state: GifState): void { + const img = state.fabricImage + const targetW = state.element.width + const targetH = state.element.height + img.setElement(state.composite) + img.scaleX = targetW / state.composite.width + img.scaleY = targetH / state.composite.height + img.set({ dirty: true }) + this.canvas.requestRenderAll() + } + + // -------------------------------------------------------------------------- + // Tick scheduling + // -------------------------------------------------------------------------- + + private scheduleNextTick(state: GifState): void { + if (this.disposed) return + if (!this.running) return + if (this.opts.documentRef?.hidden) return + if (state.timer != null) { + this.opts.clearScheduledTimeout(state.timer) + state.timer = null + } + const frame = state.frames[state.drawnIndex >= 0 ? state.drawnIndex : 0] + const delay = frame.delayMs + state.timer = this.opts.scheduleTimeout(() => this.tick(state), delay) + } + + private tick(state: GifState): void { + state.timer = null + if (this.disposed) return + if (state.generation !== this.generation) { + this.releaseState(state.elementId) + return + } + if (!this.opts.isLive(this, state.fabricImage)) { + this.releaseState(state.elementId) + return + } + + const nextIndex = (state.drawnIndex + 1) % state.frames.length + this.drawFrame(state, nextIndex) + state.fabricImage.set({ dirty: true }) + this.canvas.requestRenderAll() + + if (this.running && !this.opts.documentRef?.hidden) { + this.scheduleNextTick(state) + } + } + + // -------------------------------------------------------------------------- + // Memory reservations + // -------------------------------------------------------------------------- + + private tryReserve(bytes: number): ReservationToken | null { + if (this.committedBytes + this.reservedBytes + bytes > MEMORY_BUDGET_BYTES) return null + this.reservedBytes += bytes + return { bytes, state: 'reserved' } + } + + private tryTopUpReservation(token: ReservationToken, extra: number): boolean { + if (token.state !== 'reserved') return false + if (this.committedBytes + this.reservedBytes + extra > MEMORY_BUDGET_BYTES) return false + this.reservedBytes += extra + token.bytes += extra + return true + } + + /** + * Releases `amount` bytes from the token. Idempotent — repeated calls past + * full release are no-ops. Only valid while the token is in 'reserved'. + */ + private releaseReservation(token: ReservationToken, amount: number): void { + if (token.state !== 'reserved') return + const released = Math.min(amount, token.bytes) + this.reservedBytes -= released + token.bytes -= released + if (token.bytes <= 0) { + token.bytes = 0 + token.state = 'released' + } + } + + /** Move the token's bytes from reserved → committed accounting. */ + private commitReservation(token: ReservationToken): void { + if (token.state !== 'reserved') return + this.committedBytes += token.bytes + this.reservedBytes -= token.bytes + token.state = 'committed' + } + + private releaseAllStates(): void { + for (const id of Array.from(this.states.keys())) { + this.releaseState(id) + } + // Any non-zero reservedBytes here would be accounting from tasks whose + // finally block hasn't run yet (synchronous decode pipeline means this is + // unreachable in practice). Defensive clear: + this.reservedBytes = 0 + this.committedBytes = 0 + } + + private releaseState(elementId: string): void { + const state = this.states.get(elementId) + if (!state) return + if (state.timer != null) { + this.opts.clearScheduledTimeout(state.timer) + state.timer = null + } + if (state.reservation.state === 'committed') { + this.committedBytes -= state.reservation.bytes + } else if (state.reservation.state === 'reserved') { + this.reservedBytes -= state.reservation.bytes + } + state.reservation.bytes = 0 + state.reservation.state = 'released' + this.states.delete(elementId) + } + + private clearAllTimers(): void { + for (const state of this.states.values()) { + if (state.timer != null) { + this.opts.clearScheduledTimeout(state.timer) + state.timer = null + } + } + } + + private cancelAllAfterPaint(): void { + for (const cancel of this.afterPaintCancels) { + try { + cancel() + } catch { + /* ignore */ + } + } + this.afterPaintCancels.clear() + // Cancelled callbacks never run their try/finally, so reset the in-flight + // counter manually. Otherwise subsequent register() calls would be capped + // below MAX_DECODE_CONCURRENCY forever. + this.inFlight = 0 + } + + // -------------------------------------------------------------------------- + // Visibility + // -------------------------------------------------------------------------- + + private handleVisibilityChange(): void { + if (this.disposed) return + const hidden = this.opts.documentRef?.hidden ?? false + if (hidden) { + // Cancel pending timers but preserve drawnIndex/pendingDisposal so we + // can resume seamlessly. + for (const state of this.states.values()) { + if (state.timer != null) { + this.opts.clearScheduledTimeout(state.timer) + state.timer = null + } + } + } else if (this.running) { + for (const state of this.states.values()) { + if (state.timer == null) this.scheduleNextTick(state) + } + } + } + + // -------------------------------------------------------------------------- + // Warning policy + // -------------------------------------------------------------------------- + + private warnOnce(key: WarningKey, message: string): void { + if (this.warned.has(key)) return + this.warned.add(key) + console.warn(`[gif] ${message}`) + } +} + +// ============================================================================ +// Helpers +// ============================================================================ + +function base64FromDataUrl(dataUrl: string): string | null { + const commaIdx = dataUrl.indexOf(',') + if (commaIdx < 0) return null + return dataUrl.slice(commaIdx + 1) +} + +function approxBase64DecodedSize(base64Length: number): number { + // Ignores padding refinement; an upper bound is fine for the cheap preflight. + return Math.floor((base64Length * 3) / 4) +} + +function decodeBase64ToBytes(base64: string): Uint8Array | null { + try { + if (typeof atob === 'function') { + const binary = atob(base64) + const out = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i) + return out + } + // Fallback path for Node (tests use injected decoder, but be safe). + if (typeof Buffer !== 'undefined') { + const buf = Buffer.from(base64, 'base64') + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength) + } + } catch { + return null + } + return null +} + +function isValidLogicalDimension(value: number): boolean { + return Number.isFinite(value) && value > 0 && value <= MAX_LOGICAL_DIMENSION +} + +function isFiniteDim(value: number): boolean { + return Number.isFinite(value) +} + +function describeError(err: unknown): string { + if (err instanceof Error) return err.message + try { + return String(err) + } catch { + return '' + } +} diff --git a/tests/renderer/lib/gif.test.ts b/tests/renderer/lib/gif.test.ts new file mode 100644 index 0000000..fca6755 --- /dev/null +++ b/tests/renderer/lib/gif.test.ts @@ -0,0 +1,810 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + GifAnimationManager, + isGifDataUrl, + type GifAnimationManagerOptions, + type GifDocument +} from '@renderer/lib/gif' +import type { ParsedFrame, ParsedGif } from 'gifuct-js' +import type { Canvas as FabricCanvas, FabricImage } from 'fabric' +import type { TwigElement } from '@renderer/lib/types' + +// ============================================================================ +// Test scaffolding: controllable schedulers, mock canvas/context, fake decoder +// ============================================================================ + +type Recorded2DCall = + | { op: 'clearRect'; args: [number, number, number, number] } + | { op: 'putImageData'; args: [unknown, number, number] } + | { op: 'drawImage'; args: unknown[] } + +interface MockCanvas { + width: number + height: number + ctx: MockContext + getContext(): MockContext +} + +interface MockContext { + calls: Recorded2DCall[] + clearRect: (x: number, y: number, w: number, h: number) => void + putImageData: (data: unknown, dx: number, dy: number) => void + drawImage: (...args: unknown[]) => void +} + +function makeMockCanvas(width: number, height: number): MockCanvas { + const ctx: MockContext = { + calls: [], + clearRect: (x, y, w, h) => ctx.calls.push({ op: 'clearRect', args: [x, y, w, h] }), + putImageData: (data, dx, dy) => ctx.calls.push({ op: 'putImageData', args: [data, dx, dy] }), + drawImage: (...args) => ctx.calls.push({ op: 'drawImage', args }) + } + return { + width, + height, + ctx, + getContext: () => ctx + } +} + +interface AfterPaintScheduler { + schedule: (cb: () => void) => () => void + flush: () => void + pending: () => number +} + +function makeAfterPaintScheduler(): AfterPaintScheduler { + const queue: Array<{ cb: () => void; cancelled: boolean }> = [] + return { + schedule: (cb: () => void): (() => void) => { + const task = { cb, cancelled: false } + queue.push(task) + return (): void => { + task.cancelled = true + } + }, + flush(): void { + const tasks = queue.splice(0) + for (const t of tasks) if (!t.cancelled) t.cb() + }, + pending(): number { + return queue.filter((t) => !t.cancelled).length + } + } +} + +interface TimeoutScheduler { + schedule: (cb: () => void, delay: number) => unknown + clear: (handle: unknown) => void + fireAll: () => void + fireOne: (id?: number) => void + delays: () => number[] + size: () => number +} + +function makeTimeoutScheduler(): TimeoutScheduler { + let nextId = 1 + const timers = new Map void; delay: number }>() + return { + schedule(cb: () => void, delay: number): unknown { + const id = nextId++ + timers.set(id, { cb, delay }) + return id + }, + clear(handle: unknown): void { + timers.delete(handle as number) + }, + fireAll(): void { + const entries = Array.from(timers.entries()) + timers.clear() + for (const [, t] of entries) t.cb() + }, + fireOne(id?: number): void { + const useId = id ?? Array.from(timers.keys())[0] + const t = timers.get(useId) + if (t) { + timers.delete(useId) + t.cb() + } + }, + delays(): number[] { + return Array.from(timers.values()).map((t) => t.delay) + }, + size(): number { + return timers.size + } + } +} + +function makeMockDocument(initialHidden = false): GifDocument & { setHidden(v: boolean): void } { + const listeners = new Set<() => void>() + let hidden = initialHidden + return { + get hidden() { + return hidden + }, + addEventListener: (_type: 'visibilitychange', listener: () => void) => { + listeners.add(listener) + }, + removeEventListener: (_type: 'visibilitychange', listener: () => void) => { + listeners.delete(listener) + }, + setHidden(v: boolean) { + hidden = v + for (const l of Array.from(listeners)) l() + } + } +} + +interface MockFabricImage { + canvas: unknown + setElement: ReturnType + set: ReturnType + scaleX: number + scaleY: number +} + +interface MockFabricCanvas { + objects: MockFabricImage[] + requestRenderAll: ReturnType + getObjects(): MockFabricImage[] +} + +function makeMockFabricCanvas(): MockFabricCanvas { + const canvas: MockFabricCanvas = { + objects: [], + requestRenderAll: vi.fn(), + getObjects: () => canvas.objects + } + return canvas +} + +function makeMockFabricImage(canvas: MockFabricCanvas): MockFabricImage { + const img: MockFabricImage = { + canvas, + setElement: vi.fn(), + set: vi.fn(), + scaleX: 1, + scaleY: 1 + } + canvas.objects.push(img) + return img +} + +function makeFakeFrame(opts: { + width: number + height: number + left?: number + top?: number + delay?: number + disposalType?: number + // patch bytes; if omitted, fills with 0xff + patchBytes?: Uint8ClampedArray +}): ParsedFrame { + const { width, height, left = 0, top = 0, delay = 100, disposalType = 0 } = opts + const patch = opts.patchBytes ?? new Uint8ClampedArray(width * height * 4).fill(0xff) + return { + dims: { width, height, left, top }, + delay, + disposalType, + patch, + colorTable: [], + pixels: [], + transparentIndex: -1 + } +} + +function makeFakeParsedGif(width: number, height: number, frameCount: number): ParsedGif { + return { + frames: Array.from({ length: frameCount }, () => ({ + gce: { + byteSize: 0, + codes: [], + delay: 10, + terminator: 0, + transparentColorIndex: 0, + extras: { + userInput: false, + transparentColorGiven: false, + future: 0, + disposal: 0 + } + }, + image: { + code: 0, + data: { minCodeSize: 0, blocks: [] }, + descriptor: { + top: 0, + left: 0, + width, + height, + lct: { exists: false, future: 0, interlaced: false, size: 0, sort: false } + } + } + })), + gct: [], + header: { signature: 'GIF', version: '89a' }, + lsd: { + backgroundColorIndex: 0, + gct: { exists: false, resolution: 0, size: 0, sort: false }, + width, + height, + pixelAspectRatio: 0 + } + } +} + +function makeGifDataUrl(size = 8): string { + const bytes = new Uint8Array(size) + return `data:image/gif;base64,${Buffer.from(bytes).toString('base64')}` +} + +function makeGifElement(id: string, width = 64, height = 32): TwigElement { + return { + id, + type: 'image', + x: 0, + y: 0, + width, + height, + angle: 0, + zIndex: 0, + src: makeGifDataUrl() + } +} + +interface Harness { + manager: GifAnimationManager + fabricCanvas: MockFabricCanvas + afterPaint: ReturnType + timeouts: ReturnType + doc: ReturnType + parseGIF: ReturnType + decompressFrames: ReturnType + createdCanvases: MockCanvas[] + imageDatas: Array<{ data: Uint8ClampedArray; width: number; height: number }> +} + +function makeHarness( + framesProvider: (gif: ParsedGif) => ParsedFrame[], + parsedProvider?: (bytes: ArrayBuffer) => ParsedGif, + overrides: Partial = {} +): Harness { + const fabricCanvas = makeMockFabricCanvas() + const afterPaint = makeAfterPaintScheduler() + const timeouts = makeTimeoutScheduler() + const doc = makeMockDocument(false) + const createdCanvases: MockCanvas[] = [] + const imageDatas: Array<{ data: Uint8ClampedArray; width: number; height: number }> = [] + + const parseGIF = vi.fn(parsedProvider ?? ((): ParsedGif => makeFakeParsedGif(8, 8, 2))) + const decompressFrames = vi.fn((gif: ParsedGif) => framesProvider(gif)) + + const manager = new GifAnimationManager(fabricCanvas as unknown as FabricCanvas, { + parseGIF: parseGIF as unknown as GifAnimationManagerOptions['parseGIF'], + decompressFrames: decompressFrames as unknown as GifAnimationManagerOptions['decompressFrames'], + scheduleAfterPaint: afterPaint.schedule, + scheduleTimeout: timeouts.schedule, + clearScheduledTimeout: timeouts.clear, + documentRef: doc, + createCanvas: (w, h) => { + const c = makeMockCanvas(w, h) + createdCanvases.push(c) + return c as unknown as HTMLCanvasElement + }, + createImageData: (data, width, height) => { + const entry = { data, width, height } + imageDatas.push(entry) + return entry as unknown as ImageData + }, + isLive: (mgr, img) => + img.canvas === mgr.canvas && + (mgr.canvas.getObjects() as unknown as MockFabricImage[]).includes( + img as unknown as MockFabricImage + ), + now: () => 0, + ...overrides + }) + + return { + manager, + fabricCanvas, + afterPaint, + timeouts, + doc, + parseGIF, + decompressFrames, + createdCanvases, + imageDatas + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +describe('isGifDataUrl', () => { + it('accepts canonical base64 GIF data URLs', () => { + expect(isGifDataUrl('data:image/gif;base64,AAAA')).toBe(true) + }) + + it('accepts case-mixed prefixes', () => { + expect(isGifDataUrl('DATA:IMAGE/GIF;BASE64,AAAA')).toBe(true) + }) + + it('accepts extra parameters before base64', () => { + expect(isGifDataUrl('data:image/gif;charset=utf-8;base64,AAAA')).toBe(true) + }) + + it('rejects non-base64 GIF data URLs', () => { + expect(isGifDataUrl('data:image/gif,raw')).toBe(false) + }) + + it('rejects other MIME types', () => { + expect(isGifDataUrl('data:image/png;base64,AAAA')).toBe(false) + expect(isGifDataUrl('data:image/svg+xml;base64,AAAA')).toBe(false) + }) + + it('handles null/undefined/empty', () => { + expect(isGifDataUrl(null)).toBe(false) + expect(isGifDataUrl(undefined)).toBe(false) + expect(isGifDataUrl('')).toBe(false) + }) +}) + +describe('GifAnimationManager scheduling', () => { + let warnSpy: ReturnType + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + warnSpy.mockRestore() + }) + + it('does not run heavy decode work synchronously in register()', () => { + const h = makeHarness(() => [ + makeFakeFrame({ width: 8, height: 8 }), + makeFakeFrame({ width: 8, height: 8 }) + ]) + const img = makeMockFabricImage(h.fabricCanvas) + const el = makeGifElement('a') + + h.manager.register(el, img as unknown as FabricImage) + + expect(h.parseGIF).not.toHaveBeenCalled() + expect(h.decompressFrames).not.toHaveBeenCalled() + expect(img.setElement).not.toHaveBeenCalled() + expect(h.afterPaint.pending()).toBe(1) + + h.afterPaint.flush() + + expect(h.parseGIF).toHaveBeenCalledTimes(1) + expect(h.decompressFrames).toHaveBeenCalledTimes(1) + expect(img.setElement).toHaveBeenCalledTimes(1) + }) + + it('returns immediately from register() (fire-and-forget)', () => { + const h = makeHarness(() => [makeFakeFrame({ width: 8, height: 8 })]) + const img = makeMockFabricImage(h.fabricCanvas) + const result = h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + expect(result).toBeUndefined() + }) + + it('clamps low frame delays to MIN_DELAY_MS', () => { + const h = makeHarness(() => [ + makeFakeFrame({ width: 8, height: 8, delay: 5 }), + makeFakeFrame({ width: 8, height: 8, delay: 0 }) + ]) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + h.manager.start() + expect(h.timeouts.delays()[0]).toBe(20) + }) + + it('uses default delay when frame delay is missing or non-finite', () => { + const h = makeHarness(() => [ + makeFakeFrame({ width: 8, height: 8, delay: NaN }), + makeFakeFrame({ width: 8, height: 8, delay: 100 }) + ]) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + h.manager.start() + expect(h.timeouts.delays()[0]).toBe(100) + }) +}) + +describe('GifAnimationManager validation', () => { + let warnSpy: ReturnType + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + warnSpy.mockRestore() + }) + + it('rejects to static when logical dimensions are zero', () => { + const h = makeHarness( + () => [makeFakeFrame({ width: 0, height: 8 })], + () => makeFakeParsedGif(0, 8, 1) + ) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + expect(img.setElement).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalled() + }) + + it('rejects to static when logical dimensions exceed 4096', () => { + const h = makeHarness( + () => [makeFakeFrame({ width: 4097, height: 8 })], + () => makeFakeParsedGif(4097, 8, 1) + ) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + expect(img.setElement).not.toHaveBeenCalled() + }) + + it('rejects to static when frame count exceeds 300', () => { + const big = 400 + const h = makeHarness( + () => Array.from({ length: big }, () => makeFakeFrame({ width: 8, height: 8 })), + () => makeFakeParsedGif(8, 8, big) + ) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + expect(img.setElement).not.toHaveBeenCalled() + expect(h.decompressFrames).not.toHaveBeenCalled() + }) + + it('rejects to static when patch rect escapes logical bounds', () => { + const h = makeHarness( + () => [makeFakeFrame({ width: 8, height: 8, left: 5, top: 5 })], + () => makeFakeParsedGif(8, 8, 1) + ) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + expect(img.setElement).not.toHaveBeenCalled() + }) + + it('rejects oversize encoded data URLs', () => { + // Injects a low ceiling so the test doesn't have to allocate a multi-MB + // base64 string just to trigger the production guardrail. + const h = makeHarness( + () => [makeFakeFrame({ width: 8, height: 8 })], + () => makeFakeParsedGif(8, 8, 1), + { maxEncodedSizeBytes: 4 } + ) + const img = makeMockFabricImage(h.fabricCanvas) + const el: TwigElement = { + ...makeGifElement('huge'), + // base64 length 12 → approx 9 decoded bytes > 4 byte cap. + src: `data:image/gif;base64,AAAAAAAAAAAA` + } + h.manager.register(el, img as unknown as FabricImage) + h.afterPaint.flush() + expect(h.parseGIF).not.toHaveBeenCalled() + expect(img.setElement).not.toHaveBeenCalled() + }) + + it('rejects when decode wallclock exceeds budget', () => { + let nowVal = 0 + const h = makeHarness( + () => [makeFakeFrame({ width: 8, height: 8 })], + () => makeFakeParsedGif(8, 8, 1), + { now: () => nowVal } + ) + // Simulate elapsed > 2s by advancing the clock between parse and after-decode check. + h.decompressFrames.mockImplementation(() => { + nowVal = 3000 + return [makeFakeFrame({ width: 8, height: 8 })] + }) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + expect(img.setElement).not.toHaveBeenCalled() + }) +}) + +describe('GifAnimationManager rendering and disposal', () => { + let warnSpy: ReturnType + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + warnSpy.mockRestore() + }) + + it('initializes frame 0 on the composite and recomputes scale', () => { + const h = makeHarness(() => [ + makeFakeFrame({ width: 8, height: 8 }), + makeFakeFrame({ width: 8, height: 8 }) + ]) + const img = makeMockFabricImage(h.fabricCanvas) + const el = makeGifElement('a', 64, 32) + h.manager.register(el, img as unknown as FabricImage) + h.afterPaint.flush() + + // Composite + scratch canvases allocated. + expect(h.createdCanvases.length).toBe(2) + const [composite, scratch] = h.createdCanvases + expect(composite.width).toBe(8) + expect(composite.height).toBe(8) + expect(scratch.width).toBeGreaterThan(0) + + // Fabric image swapped to composite + scale recomputed. + expect(img.setElement).toHaveBeenCalledWith(composite) + expect(img.scaleX).toBe(64 / 8) + expect(img.scaleY).toBe(32 / 8) + expect(h.fabricCanvas.requestRenderAll).toHaveBeenCalled() + }) + + it('composites patches via scratch canvas (transparency-safe)', () => { + const h = makeHarness(() => [ + makeFakeFrame({ width: 4, height: 4 }), + makeFakeFrame({ width: 4, height: 4 }) + ]) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + + const [composite, scratch] = h.createdCanvases + // Composite ctx must have had drawImage called, not putImageData. + const compositeOps = composite.ctx.calls.map((c) => c.op) + expect(compositeOps).toContain('drawImage') + expect(compositeOps).not.toContain('putImageData') + // Scratch ctx must have had clearRect followed by putImageData. + const scratchOps = scratch.ctx.calls.map((c) => c.op) + expect(scratchOps[0]).toBe('clearRect') + expect(scratchOps[1]).toBe('putImageData') + }) + + it('clears prior frame rect on disposal type 2', () => { + const h = makeHarness(() => [ + makeFakeFrame({ width: 4, height: 4, left: 2, top: 2, disposalType: 2 }), + makeFakeFrame({ width: 4, height: 4, left: 0, top: 0, disposalType: 0 }) + ]) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + h.manager.start() + // Capture composite call list before tick. + const composite = h.createdCanvases[0] + const callsBefore = composite.ctx.calls.length + h.timeouts.fireAll() + const newCalls = composite.ctx.calls.slice(callsBefore) + // The disposal of frame 0 (rect 2,2 4x4) should run before drawImage for frame 1. + const firstNew = newCalls[0] + expect(firstNew.op).toBe('clearRect') + expect(firstNew.args).toEqual([2, 2, 4, 4]) + }) + + it('leaves prior pixels in place on disposal type 0/1', () => { + const h = makeHarness(() => [ + makeFakeFrame({ width: 4, height: 4, disposalType: 1 }), + makeFakeFrame({ width: 4, height: 4, disposalType: 0 }) + ]) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + h.manager.start() + const composite = h.createdCanvases[0] + const before = composite.ctx.calls.length + h.timeouts.fireAll() + const newCalls = composite.ctx.calls.slice(before) + // No clearRect should run before frame 1's drawImage. + expect(newCalls[0].op).toBe('drawImage') + }) +}) + +describe('GifAnimationManager generation guard', () => { + let warnSpy: ReturnType + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + warnSpy.mockRestore() + }) + + it('aborts decode if reset() is called before scheduler fires', () => { + const h = makeHarness(() => [makeFakeFrame({ width: 8, height: 8 })]) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.manager.reset() + h.afterPaint.flush() + // Cancelled callbacks should not invoke the decoder, but even if they did, + // the generation guard would skip them. + expect(img.setElement).not.toHaveBeenCalled() + }) + + it('aborts ticks once the state generation no longer matches', () => { + const h = makeHarness(() => [ + makeFakeFrame({ width: 8, height: 8 }), + makeFakeFrame({ width: 8, height: 8 }) + ]) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + h.manager.start() + const composite = h.createdCanvases[0] + const callsBefore = composite.ctx.calls.length + // Advance generation, then fire the timer. + h.manager.reset() + h.timeouts.fireAll() + expect(composite.ctx.calls.length).toBe(callsBefore) + }) +}) + +describe('GifAnimationManager lifecycle', () => { + let warnSpy: ReturnType + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + warnSpy.mockRestore() + }) + + it('reset() cancels timers and clears states', () => { + const h = makeHarness(() => [ + makeFakeFrame({ width: 8, height: 8 }), + makeFakeFrame({ width: 8, height: 8 }) + ]) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + h.manager.start() + expect(h.timeouts.size()).toBe(1) + h.manager.reset() + expect(h.timeouts.size()).toBe(0) + }) + + it('dispose() removes the visibility listener', () => { + const h = makeHarness(() => [makeFakeFrame({ width: 8, height: 8 })]) + h.manager.dispose() + h.doc.setHidden(true) + // No throw + no state — manager is unusable but does not crash. + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + expect(img.setElement).not.toHaveBeenCalled() + }) + + it('start() does not schedule when document is hidden', () => { + const h = makeHarness(() => [ + makeFakeFrame({ width: 8, height: 8 }), + makeFakeFrame({ width: 8, height: 8 }) + ]) + h.doc.setHidden(true) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + h.manager.start() + expect(h.timeouts.size()).toBe(0) + }) + + it('resumes scheduling on visibility return', () => { + const h = makeHarness(() => [ + makeFakeFrame({ width: 8, height: 8 }), + makeFakeFrame({ width: 8, height: 8 }) + ]) + h.doc.setHidden(true) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + h.manager.start() + expect(h.timeouts.size()).toBe(0) + h.doc.setHidden(false) + expect(h.timeouts.size()).toBe(1) + }) + + it('repeated start() does not duplicate timers', () => { + const h = makeHarness(() => [ + makeFakeFrame({ width: 8, height: 8 }), + makeFakeFrame({ width: 8, height: 8 }) + ]) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + h.manager.start() + h.manager.start() + h.manager.start() + expect(h.timeouts.size()).toBe(1) + }) + + it('stops playback when the Fabric image is removed from the canvas', () => { + const h = makeHarness(() => [ + makeFakeFrame({ width: 8, height: 8 }), + makeFakeFrame({ width: 8, height: 8 }) + ]) + const img = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img as unknown as FabricImage) + h.afterPaint.flush() + h.manager.start() + expect(h.timeouts.size()).toBe(1) + // Remove image from the canvas. The next tick should release the state. + h.fabricCanvas.objects.length = 0 + h.timeouts.fireAll() + expect(h.timeouts.size()).toBe(0) + }) +}) + +describe('GifAnimationManager memory budget', () => { + let warnSpy: ReturnType + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + }) + afterEach(() => { + warnSpy.mockRestore() + }) + + it('rejects GIFs that exceed the 64 MB budget at the preflight estimate', () => { + // LSD 3500x3500x2 frames ≈ 98 MB worst-case preflight estimate, above the + // 64 MB cap. Reject before decompressFrames is even called, so no big + // allocations are needed in the mock. + const W = 3500 + const H = 3500 + const h = makeHarness( + () => { + throw new Error('decompressFrames should not be called past preflight') + }, + () => makeFakeParsedGif(W, H, 2) + ) + const img1 = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a', W, H), img1 as unknown as FabricImage) + h.afterPaint.flush() + expect(h.decompressFrames).not.toHaveBeenCalled() + expect(img1.setElement).not.toHaveBeenCalled() + }) + + it('cannot oversubscribe under concurrent registrations', () => { + // 2100×2100 single-frame: composite ≈ 17.64 MB. Preflight = + // 3×composite ≈ 53 MB, which fits. After reconcile, ~17.64 MB stays + // committed. The second registration's preflight (~53 MB) plus the + // first's committed (~17.64 MB) crosses the 64 MB budget. + const W = 2100 + const H = 2100 + const smallPatch = new Uint8ClampedArray(4) // 1×1 patch placed inside logical bounds + let call = 0 + const h = makeHarness( + () => { + call++ + // Patch dims 1×1 at (0,0): well inside 2000×2000 logical bounds. The + // preflight reservation uses worst-case bytes; the actual reservation + // shrinks during reconciliation but committed bytes still cover the + // composite, which alone is 16 MB. + return [makeFakeFrame({ width: 1, height: 1, left: 0, top: 0, patchBytes: smallPatch })] + }, + () => makeFakeParsedGif(W, H, 1) + ) + const img1 = makeMockFabricImage(h.fabricCanvas) + const img2 = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a', W, H), img1 as unknown as FabricImage) + h.manager.register(makeGifElement('b', W, H), img2 as unknown as FabricImage) + h.afterPaint.flush() + // First registration's preflight (48 MB) fits, decodes, commits its + // (smaller, post-reconcile) actual. Second registration's preflight then + // would push past 64 MB combined with the first's committed bytes. + expect(img1.setElement).toHaveBeenCalled() + expect(img2.setElement).not.toHaveBeenCalled() + expect(call).toBe(1) + }) + + it('reset() frees memory so subsequent registrations succeed', () => { + // Small GIF — fits within budget easily. Verify that after reset, a fresh + // registration also succeeds (no leaked accounting). + const h = makeHarness(() => [makeFakeFrame({ width: 8, height: 8 })]) + const img1 = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('a'), img1 as unknown as FabricImage) + h.afterPaint.flush() + expect(img1.setElement).toHaveBeenCalled() + h.manager.reset() + const img2 = makeMockFabricImage(h.fabricCanvas) + h.manager.register(makeGifElement('b'), img2 as unknown as FabricImage) + h.afterPaint.flush() + expect(img2.setElement).toHaveBeenCalled() + }) +})