UNPKG

@syngrisi/syngrisi

Version:
328 lines (277 loc) 9.42 kB
import config from '@config'; import { log } from '@shared/utils/Logger'; interface PreloadedImage { image: HTMLImageElement; src: string; loadedAt: number; } interface PreloadRequest { src: string; priority: 'high' | 'medium' | 'low'; checkId: string; resolve: (img: HTMLImageElement) => void; reject: (error: Error) => void; } export interface ICheck { _id: string; baselineId?: { filename?: string }; actualSnapshotId?: { filename?: string }; diffId?: { filename?: string }; } interface ImagePreloadConfig { maxConcurrentPreloads: number; maxCacheSize: number; // in items maxCacheAge: number; // in ms } const DEFAULT_CONFIG: ImagePreloadConfig = { maxConcurrentPreloads: 6, maxCacheSize: 100, // 30 checks * 3 images = 90, plus buffer maxCacheAge: 5 * 60 * 1000, // 5 minutes }; class ImagePreloadService { private preloadedImages: Map<string, PreloadedImage> = new Map(); private preloadQueue: PreloadRequest[] = []; private activePreloads: number = 0; private config: ImagePreloadConfig; private loadingPromises: Map<string, Promise<HTMLImageElement>> = new Map(); constructor(cfg?: Partial<ImagePreloadConfig>) { this.config = { ...DEFAULT_CONFIG, ...cfg }; } /** * Build image URL from filename */ private buildImageUrl(filename: string | undefined): string | null { if (!filename) return null; return `${config.baseUri}/snapshoots/${filename}`; } /** * Get all image URLs for a check */ getCheckImageUrls(check: ICheck): string[] { const urls: string[] = []; const baselineUrl = this.buildImageUrl(check.baselineId?.filename); const actualUrl = this.buildImageUrl(check.actualSnapshotId?.filename); const diffUrl = this.buildImageUrl(check.diffId?.filename); if (baselineUrl) urls.push(baselineUrl); if (actualUrl) urls.push(actualUrl); if (diffUrl) urls.push(diffUrl); return urls; } /** * Check if image is already preloaded */ isPreloaded(src: string): boolean { const cached = this.preloadedImages.get(src); if (!cached) { log.debug(`[ImagePreload] Cache MISS for: ${src}`); return false; } // Check if cache is still valid if (Date.now() - cached.loadedAt > this.config.maxCacheAge) { this.preloadedImages.delete(src); log.debug(`[ImagePreload] Cache EXPIRED for: ${src}`); return false; } log.debug(`[ImagePreload] Cache HIT for: ${src}`); return true; } /** * Get preloaded image from cache */ getPreloadedImage(src: string): HTMLImageElement | null { if (!this.isPreloaded(src)) { log.debug(`[ImagePreload] getPreloadedImage returning null. Cache size: ${this.preloadedImages.size}`); return null; } return this.preloadedImages.get(src)?.image || null; } /** * Store an already-loaded image in the cache * Useful for images loaded outside the preload service */ storeImage(src: string, img: HTMLImageElement): void { // Manage cache size this.ensureCacheSize(); // Store in cache this.preloadedImages.set(src, { image: img, src, loadedAt: Date.now(), }); log.debug(`[ImagePreload] Stored image in cache: ${src}`); } /** * Preload a single image */ preloadImage(src: string, priority: 'high' | 'medium' | 'low' = 'medium', checkId: string = ''): Promise<HTMLImageElement> { // Already cached const cached = this.getPreloadedImage(src); if (cached) { return Promise.resolve(cached); } // Already loading const existingPromise = this.loadingPromises.get(src); if (existingPromise) { return existingPromise; } // Create new loading promise const promise = new Promise<HTMLImageElement>((resolve, reject) => { const request: PreloadRequest = { src, priority, checkId, resolve, reject, }; // Add to queue based on priority if (priority === 'high') { this.preloadQueue.unshift(request); } else if (priority === 'medium') { // Insert after high priority items const insertIndex = this.preloadQueue.findIndex((r) => r.priority === 'low'); if (insertIndex === -1) { this.preloadQueue.push(request); } else { this.preloadQueue.splice(insertIndex, 0, request); } } else { this.preloadQueue.push(request); } this.processQueue(); }); this.loadingPromises.set(src, promise); // Clean up loading promise when done promise.finally(() => { this.loadingPromises.delete(src); }); return promise; } /** * Process the preload queue */ private processQueue(): void { while (this.activePreloads < this.config.maxConcurrentPreloads && this.preloadQueue.length > 0) { const request = this.preloadQueue.shift(); if (request) { this.loadImage(request); } } } /** * Load a single image */ private loadImage(request: PreloadRequest): void { this.activePreloads++; const img = new Image(); const cleanup = () => { this.activePreloads--; this.processQueue(); }; img.onload = () => { // Manage cache size this.ensureCacheSize(); // Store in cache this.preloadedImages.set(request.src, { image: img, src: request.src, loadedAt: Date.now(), }); log.debug(`[ImagePreload] Loaded: ${request.src}`); request.resolve(img); cleanup(); }; img.onerror = (e) => { log.warn(`[ImagePreload] Failed to load: ${request.src}`, e); request.reject(new Error(`Failed to load image: ${request.src}`)); cleanup(); }; img.src = request.src; } /** * Ensure cache doesn't exceed max size (LRU eviction) */ private ensureCacheSize(): void { if (this.preloadedImages.size >= this.config.maxCacheSize) { // Remove oldest entry const oldestKey = this.preloadedImages.keys().next().value; if (oldestKey) { this.preloadedImages.delete(oldestKey); log.debug(`[ImagePreload] Evicted from cache: ${oldestKey}`); } } } /** * Preload all images for a single check */ async preloadCheckImages(check: ICheck, priority: 'high' | 'medium' | 'low' = 'medium'): Promise<void> { const urls = this.getCheckImageUrls(check); await Promise.all(urls.map((url) => this.preloadImage(url, priority, check._id))); } /** * Preload images for multiple checks */ preloadChecksImages( checks: ICheck[], options?: { startIndex?: number; count?: number; priority?: 'high' | 'medium' | 'low'; }, ): void { const { startIndex = 0, count = checks.length, priority = 'medium', } = options || {}; const checksToPreload = checks.slice(startIndex, startIndex + count); checksToPreload.forEach((check) => { this.preloadCheckImages(check, priority).catch((e) => { log.warn(`[ImagePreload] Failed to preload check ${check._id}:`, e); }); }); } /** * Cancel pending preload for a specific URL */ cancelPreload(src: string): void { const index = this.preloadQueue.findIndex((r) => r.src === src); if (index !== -1) { const request = this.preloadQueue.splice(index, 1)[0]; request.reject(new Error('Preload cancelled')); } } /** * Cancel all pending preloads for a check */ cancelCheckPreloads(checkId: string): void { const toRemove = this.preloadQueue.filter((r) => r.checkId === checkId); toRemove.forEach((r) => this.cancelPreload(r.src)); } /** * Clear all cached images */ clearCache(): void { this.preloadedImages.clear(); this.preloadQueue.forEach((r) => r.reject(new Error('Cache cleared'))); this.preloadQueue = []; log.debug('[ImagePreload] Cache cleared'); } /** * Get cache statistics */ getStats(): { cachedCount: number; pendingCount: number; activePreloads: number; } { return { cachedCount: this.preloadedImages.size, pendingCount: this.preloadQueue.length, activePreloads: this.activePreloads, }; } } // Export singleton instance export const imagePreloadService = new ImagePreloadService(); // Export class for testing export { ImagePreloadService };