From 00ff9000ba1b0a32e4f245678c403850aec8f42f Mon Sep 17 00:00:00 2001 From: boomzero Date: Fri, 15 May 2026 18:40:34 +0800 Subject: [PATCH 1/2] Animate GIFs in the presentation window Adds GifAnimationManager (src/renderer/src/lib/gif.ts), a per-canvas manager that decodes GIF data URLs via gifuct-js after the first paint and ticks frames through a scratch-canvas compositor so transparent patches do not erase prior pixels. The editor canvas, GIF backgrounds, and storage stay unchanged. Guardrails: 25 MB encoded size cap, 4096 px logical-dimension cap, 300-frame cap, 2 s post-hoc decode wallclock cap, 64 MB per-canvas memory budget with token-based reservation and reconcile-on-actual. Decode is gated by document.visibilityState; reset()/dispose() cancel schedulers, timers, and refund accounting. Presentation.svelte instantiates one manager next to presentationCanvas, calls reset() at the top of renderSlide(), registers each GIF image after its static frame loads, and start()s playback after image loads settle (including for one-GIF slides). Disposal runs before canvas disposal. The no-slide branch now also clears canvas state and resets the manager so stale visuals/timers do not survive a wipe. Co-Authored-By: Claude Opus 4.7 --- package-lock.json | 16 + package.json | 5 +- src/renderer/src/Presentation.svelte | 58 +- src/renderer/src/lib/gif.ts | 823 +++++++++++++++++++++++++++ tests/renderer/lib/gif.test.ts | 810 ++++++++++++++++++++++++++ 5 files changed, 1705 insertions(+), 7 deletions(-) create mode 100644 src/renderer/src/lib/gif.ts create mode 100644 tests/renderer/lib/gif.test.ts 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/lib/gif.ts b/src/renderer/src/lib/gif.ts new file mode 100644 index 0000000..db0a67e --- /dev/null +++ b/src/renderer/src/lib/gif.ts @@ -0,0 +1,823 @@ +/** + * 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) +} + +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() + }) +}) From 39b71f09cfd0cf7793e64a860035b110388efc70 Mon Sep 17 00:00:00 2001 From: boomzero Date: Fri, 15 May 2026 20:02:40 +0800 Subject: [PATCH 2/2] Decode GIF first frame for static editor previews MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes two issues surfaced during QA: 1. The properties panel background preview uses a native tag, so browsers animated the GIF in the editor sidebar even though the canvas GIF stays static. PropertiesPanel.svelte now detects GIF backgrounds and shows the decoded first frame instead, matching the editor canvas. 2. Adds getGifFirstFrameDataUrl(src) in lib/gif.ts — a small async helper that parses the GIF, renders frame 0 through a scratch canvas onto a composite, and returns a PNG data URL. Results are cached by source so repeated previews don't re-decode. Falls back to the original URL on any error so the preview never goes blank. This is a small editor-side affordance; presentation playback is unchanged. (The plan kept App.svelte and presentation behavior fixed; this only touches PropertiesPanel preview rendering and the gif module.) Co-Authored-By: Claude Opus 4.7 --- .../src/components/PropertiesPanel.svelte | 38 ++++++++++- src/renderer/src/lib/gif.ts | 68 +++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) 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 index db0a67e..6b11ce2 100644 --- a/src/renderer/src/lib/gif.ts +++ b/src/renderer/src/lib/gif.ts @@ -33,6 +33,74 @@ export function isGifDataUrl(value: string | undefined | null): boolean { 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