@syngrisi/syngrisi
Version:
Syngrisi - Visual Testing Tool
328 lines (277 loc) • 9.42 kB
text/typescript
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 };