diff --git a/src/components/ImageModal.tsx b/src/components/ImageModal.tsx index 5e31670..07b617a 100644 --- a/src/components/ImageModal.tsx +++ b/src/components/ImageModal.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState, useCallback } from 'react' +import { useEffect, useState, useCallback, useMemo } from 'react' +import { getCachedBlobUrl } from '../lib/image-utils' interface ImageModalData { url: string @@ -55,24 +56,14 @@ export function ImageModal() { } }, [isOpen, handleKeyDown]) - if (!isOpen || !imageData) return null + // Convert base64 to blob URL for better performance with large images + // useMemo ensures we only convert when the URL changes + const imageSrc = useMemo(() => { + if (!imageData?.url) return '' + return getCachedBlobUrl(imageData.url) + }, [imageData?.url]) - // Build full image URL - let imageSrc = imageData.url - if (imageSrc && !imageSrc.startsWith('http') && !imageSrc.startsWith('data:') && !imageSrc.startsWith('blob:')) { - // Assume base64 - detect format - if (imageSrc.startsWith('/9j/')) { - imageSrc = `data:image/jpeg;base64,${imageSrc}` - } else if (imageSrc.startsWith('iVBORw')) { - imageSrc = `data:image/png;base64,${imageSrc}` - } else if (imageSrc.startsWith('R0lGOD')) { - imageSrc = `data:image/gif;base64,${imageSrc}` - } else if (imageSrc.startsWith('UklGR')) { - imageSrc = `data:image/webp;base64,${imageSrc}` - } else { - imageSrc = `data:image/png;base64,${imageSrc}` - } - } + if (!isOpen || !imageData) return null // Format metadata for display const formatMetadata = (metadata: Record) => { diff --git a/src/lib/image-utils.ts b/src/lib/image-utils.ts new file mode 100644 index 0000000..dd4eb8e --- /dev/null +++ b/src/lib/image-utils.ts @@ -0,0 +1,142 @@ +/** + * Image utility functions for performance optimization. + */ + +/** + * Converts a base64 string to a Blob URL. + * Blob URLs are much more memory-efficient than data URLs for large images. + */ +export function base64ToBlobUrl(base64: string, mimeType?: string): string { + // Detect mime type from base64 header if not provided + if (!mimeType) { + if (base64.startsWith('/9j/')) { + mimeType = 'image/jpeg' + } else if (base64.startsWith('iVBORw')) { + mimeType = 'image/png' + } else if (base64.startsWith('R0lGOD')) { + mimeType = 'image/gif' + } else if (base64.startsWith('UklGR')) { + mimeType = 'image/webp' + } else { + mimeType = 'image/png' // Default fallback + } + } + + // Decode base64 to binary + const binaryString = atob(base64) + const bytes = new Uint8Array(binaryString.length) + for (let i = 0; i < binaryString.length; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + + // Create blob and URL + const blob = new Blob([bytes], { type: mimeType }) + return URL.createObjectURL(blob) +} + +/** + * Checks if a string is a base64 image (not a URL or blob URL). + */ +export function isBase64Image(str: string): boolean { + if (!str) return false + return !str.startsWith('http') && !str.startsWith('data:') && !str.startsWith('blob:') +} + +/** + * Converts an image source to an optimized blob URL if it's base64. + * Returns the original URL if it's already a URL or blob. + */ +export function toOptimizedImageUrl(src: string): string { + if (!src) return src + + // Already a URL or blob + if (src.startsWith('http') || src.startsWith('blob:')) { + return src + } + + // Data URL - extract base64 and convert + if (src.startsWith('data:')) { + const base64Match = src.match(/^data:([^;]+);base64,(.+)$/) + if (base64Match) { + const mimeType = base64Match[1] + const base64Data = base64Match[2] + if (base64Data) { + return base64ToBlobUrl(base64Data, mimeType) + } + } + return src + } + + // Raw base64 - convert to blob URL + return base64ToBlobUrl(src) +} + +/** + * Cache for blob URLs to avoid recreating them. + */ +const blobUrlCache = new Map() +const MAX_CACHE_SIZE = 50 + +/** + * Gets or creates an optimized blob URL with caching. + * Uses a hash of the first 100 chars + length as cache key for efficiency. + */ +export function getCachedBlobUrl(src: string): string { + if (!src) return src + + // Already a URL or blob - return as-is + if (src.startsWith('http') || src.startsWith('blob:')) { + return src + } + + // Create cache key from first/last chars and length + const cacheKey = `${src.slice(0, 50)}_${src.slice(-50)}_${src.length}` + + // Check cache + const cached = blobUrlCache.get(cacheKey) + if (cached) { + return cached + } + + // Convert and cache + const blobUrl = toOptimizedImageUrl(src) + + // Evict oldest entries if cache is full + if (blobUrlCache.size >= MAX_CACHE_SIZE) { + const firstKey = blobUrlCache.keys().next().value + if (firstKey) { + const oldUrl = blobUrlCache.get(firstKey) + if (oldUrl?.startsWith('blob:')) { + URL.revokeObjectURL(oldUrl) + } + blobUrlCache.delete(firstKey) + } + } + + blobUrlCache.set(cacheKey, blobUrl) + return blobUrl +} + +/** + * Clears a specific blob URL from cache and revokes it. + */ +export function revokeCachedBlobUrl(src: string): void { + const cacheKey = `${src.slice(0, 50)}_${src.slice(-50)}_${src.length}` + const cached = blobUrlCache.get(cacheKey) + if (cached?.startsWith('blob:')) { + URL.revokeObjectURL(cached) + blobUrlCache.delete(cacheKey) + } +} + +/** + * Clears all cached blob URLs. + */ +export function clearBlobUrlCache(): void { + for (const url of blobUrlCache.values()) { + if (url.startsWith('blob:')) { + URL.revokeObjectURL(url) + } + } + blobUrlCache.clear() +} diff --git a/src/nodes/base/BaseNode.ts b/src/nodes/base/BaseNode.ts index f7993a7..6524c0d 100644 --- a/src/nodes/base/BaseNode.ts +++ b/src/nodes/base/BaseNode.ts @@ -1,5 +1,6 @@ import type { LGraphNode, LGraphCanvas } from 'litegraph.js' import type { NodeCategory, ExecutionStatus } from '../../types/nodes' +import { getCachedBlobUrl } from '../../lib/image-utils' /** * Node class with metadata exposed as static properties. @@ -119,27 +120,14 @@ const loadingImages = new Set() /** * Load an image and cache it. * Supports URLs, data URLs, and raw base64 strings. + * Uses blob URLs for base64 images for better performance with large images. */ function loadImage(url: string): HTMLImageElement | null { if (!url) return null - // Convert raw base64 to data URL if needed - let src = url - if (!url.startsWith('http') && !url.startsWith('data:') && !url.startsWith('blob:')) { - // Assume it's raw base64 data - detect format from magic bytes - if (url.startsWith('/9j/')) { - src = `data:image/jpeg;base64,${url}` - } else if (url.startsWith('iVBORw')) { - src = `data:image/png;base64,${url}` - } else if (url.startsWith('R0lGOD')) { - src = `data:image/gif;base64,${url}` - } else if (url.startsWith('UklGR')) { - src = `data:image/webp;base64,${url}` - } else { - // Default to PNG - src = `data:image/png;base64,${url}` - } - } + // Convert base64 to blob URL for better performance with large images + // Blob URLs are much more memory-efficient than data URLs + const src = getCachedBlobUrl(url) // Return cached image if available const cached = imageCache.get(src)