/** * render-system.js — Advanced rendering system with performance optimization * * Manages the complete rendering pipeline with: * - Double buffering for flicker-free rendering * - Adaptive quality based on performance * - Intelligent page caching * - Smooth zoom and scroll handling */ import { PerformanceManager } from './performance-manager.js'; class RenderSystem { constructor(pdfDoc, container, pageManager) { this.pdfDoc = pdfDoc; this.container = container; this.pageManager = pageManager; try { this.performanceManager = new PerformanceManager(); } catch (error) { console.error("Failed to create PerformanceManager:", error); // Fallback: create a minimal performance manager this.performanceManager = { getPerformanceStats: () => ({ fps: 60, renderQuality: 1.0, adaptiveMode: false }), isPerformanceCritical: () => false, cleanup: () => {} }; } // Render state this.currentScale = 1.0; this.targetScale = 1.0; this.renderQuality = 1.0; this.visiblePages = new Set(); this.bufferPages = new Set(); this.renderQueue = []; this.activeRenders = new Map(); this.pageCache = new Map(); // { "page_scale": {bitmap, timestamp, quality} } this.maxCacheSize = 20; // Max pages in cache this.cacheSize = 0; // Performance metrics this.lastRenderTime = 0; this.renderCount = 0; // Setup event listeners this.setupEventListeners(); } setupEventListeners() { // Performance-based quality adjustments setInterval(() => { this.adjustQualityBasedOnPerformance(); }, 1000); } setVisibility(visiblePages, bufferPages) { this.visiblePages = new Set(visiblePages); this.bufferPages = new Set(bufferPages); this.scheduleRenders(); } setScale(scale) { this.targetScale = scale; // Smooth transition for zoom if (Math.abs(this.targetScale - this.currentScale) > 0.01) { this.startZoomTransition(); } else { this.currentScale = this.targetScale; this.scheduleRenders(); } } startZoomTransition() { const startScale = this.currentScale; const endScale = this.targetScale; const startTime = performance.now(); const duration = 150; // 150ms transition const animate = (currentTime) => { const elapsed = currentTime - startTime; const progress = Math.min(elapsed / duration, 1); // Ease-in-out animation const easedProgress = this.easeInOutCubic(progress); this.currentScale = startScale + (endScale - startScale) * easedProgress; // Use lower quality during transition const transitionQuality = this.renderQuality * 0.8; this.scheduleRenders(transitionQuality); if (progress < 1) { requestAnimationFrame(animate); } else { this.currentScale = endScale; // Final high-quality render after transition this.scheduleRenders(this.renderQuality); } }; requestAnimationFrame(animate); } easeInOutCubic(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; } scheduleRenders(overrideQuality = null) { const quality = overrideQuality !== null ? overrideQuality : this.renderQuality; const scale = this.currentScale; // Cancel renders for pages that are no longer needed this.cancelStaleRenders(); // Prioritize visible pages, then buffer pages const allPages = [...this.visiblePages, ...this.bufferPages]; allPages.forEach(pageNum => { const cacheKey = this.getCacheKey(pageNum, scale); // Check if we need to render this page if (this.shouldRenderPage(pageNum, scale, quality)) { this.queueRender(pageNum, scale, quality); } }); // Process the render queue this.processRenderQueue(); } shouldRenderPage(pageNum, scale, quality) { const cacheKey = this.getCacheKey(pageNum, scale); // Check if already rendering if (this.activeRenders.has(pageNum)) { return false; } // Check cache with acceptable quality const cached = this.pageCache.get(cacheKey); if (cached) { // If cached quality is acceptable, no need to re-render if (cached.quality >= quality * 0.9) { this.useCachedPage(pageNum, scale, cached.bitmap); return false; } } return true; } queueRender(pageNum, scale, quality) { // Avoid duplicate queue entries if (this.renderQueue.some(item => item.pageNum === pageNum && Math.abs(item.scale - scale) < 0.01)) { return; } // Prioritize visible pages const priority = this.visiblePages.has(pageNum) ? 1 : 2; this.renderQueue.push({ pageNum, scale, quality, priority }); // Sort by priority (visible pages first) this.renderQueue.sort((a, b) => a.priority - b.priority); } processRenderQueue() { if (this.renderQueue.length === 0) return; // Limit concurrent renders based on performance const maxConcurrent = this.performanceManager.isPerformanceCritical() ? 2 : 4; while (this.activeRenders.size < maxConcurrent && this.renderQueue.length > 0) { const renderTask = this.renderQueue.shift(); this.startRender(renderTask.pageNum, renderTask.scale, renderTask.quality); } } async startRender(pageNum, scale, vpWidth, vpHeight, quality) { this.activeRenders.set(pageNum, true); let page = null; try { page = await this.pdfDoc.getPage(pageNum); // Apply quality-based scaling const effectiveScale = scale * quality; const viewport = page.getViewport({ scale: effectiveScale }); // Validate canvas dimensions const width = Math.round(viewport.width); const height = Math.round(viewport.height); if (isNaN(width) || isNaN(height) || width <= 0 || height <= 0) { throw new Error(`Invalid canvas dimensions: ${width}x${height}`); } // Create offscreen canvas const offscreen = new OffscreenCanvas(width, height); const context = offscreen.getContext('2d'); // Render with quality settings await page.render({ canvasContext: context, viewport: viewport, // Use lower quality rendering when performance is critical renderQuality: this.performanceManager.isPerformanceCritical() ? 'low' : 'high' }).promise; // Transfer bitmap const bitmap = offscreen.transferToImageBitmap(); // Cache the result const cacheKey = this.getCacheKey(pageNum, scale); this.cachePage(cacheKey, bitmap, quality); // Update display this.updatePageDisplay(pageNum, bitmap); } catch (error) { if (error?.name !== "RenderingCancelledException") { console.warn(`[RenderSystem] Render failed for page ${pageNum}:`, error); } } finally { this.activeRenders.delete(pageNum); page?.cleanup(); // Process next in queue this.processRenderQueue(); } } cachePage(cacheKey, bitmap, quality) { // Evict if cache is full if (this.cacheSize >= this.maxCacheSize) { this.evictOldestCacheEntry(); } this.pageCache.set(cacheKey, { bitmap, quality, timestamp: Date.now(), accessTime: Date.now() }); this.cacheSize++; // Update access time for LRU this.updateCacheAccess(cacheKey); } evictOldestCacheEntry() { if (this.pageCache.size === 0) return; // Find least recently used let oldestKey = null; let oldestTime = Infinity; this.pageCache.forEach((entry, key) => { if (entry.accessTime < oldestTime) { oldestTime = entry.accessTime; oldestKey = key; } }); if (oldestKey) { const entry = this.pageCache.get(oldestKey); entry.bitmap.close(); // Free memory this.pageCache.delete(oldestKey); this.cacheSize--; } } updateCacheAccess(cacheKey) { const entry = this.pageCache.get(cacheKey); if (entry) { entry.accessTime = Date.now(); } } useCachedPage(pageNum, scale, bitmap) { // Create a copy of the bitmap for display const canvas = new OffscreenCanvas(bitmap.width, bitmap.height); const context = canvas.getContext('2d'); context.drawImage(bitmap, 0, 0); const displayBitmap = canvas.transferToImageBitmap(); this.updatePageDisplay(pageNum, displayBitmap); } updatePageDisplay(pageNum, bitmap) { // Use the EnhancedPageManager's onRendered method to handle display // This ensures proper integration with the page manager's state and canvas management const gen = this.pageManager.renderGen; const quality = this.renderQuality; // Use current render quality this.pageManager.onRendered(pageNum, gen, bitmap, quality); } cancelStaleRenders() { // Cancel renders for pages that are no longer visible or buffered const neededPages = new Set([...this.visiblePages, ...this.bufferPages]); this.activeRenders.forEach((_, pageNum) => { if (!neededPages.has(pageNum)) { // This render is no longer needed this.activeRenders.delete(pageNum); } }); // Also clean up render queue this.renderQueue = this.renderQueue.filter(task => neededPages.has(task.pageNum) ); } adjustQualityBasedOnPerformance() { const stats = this.performanceManager.getPerformanceStats(); // If performance is critical, we may have already adjusted quality // Here we can make additional adjustments based on render load if (this.activeRenders.size > 4 && stats.fps < 50) { // Too many active renders and low FPS - reduce quality more const newQuality = Math.max(0.5, this.renderQuality - 0.1); if (newQuality < this.renderQuality) { this.setRenderQuality(newQuality); } } } setRenderQuality(quality) { this.renderQuality = Math.max(0.3, Math.min(1.0, quality)); // Re-render visible pages with new quality if (this.visiblePages.size > 0) { this.scheduleRenders(this.renderQuality); } } cleanupMemory() { // Aggressive cleanup - keep only visible pages const visibleCacheKeys = new Set(); this.visiblePages.forEach(pageNum => { const cacheKey = this.getCacheKey(pageNum, this.currentScale); visibleCacheKeys.add(cacheKey); }); // Remove all non-visible pages from cache this.pageCache.forEach((entry, cacheKey) => { if (!visibleCacheKeys.has(cacheKey)) { entry.bitmap.close(); this.pageCache.delete(cacheKey); this.cacheSize--; } }); // Force garbage collection if available if (window.gc) { window.gc(); } } getCacheKey(pageNum, scale) { // Round scale to 2 decimal places for caching const roundedScale = Math.round(scale * 100) / 100; return `${pageNum}_${roundedScale}`; } async handleRenderRequest(pageNum, scale, vpWidth, vpHeight, quality) { // Handle render request from PageManager // This integrates with the PageManager's rendering pipeline await this.startRender(pageNum, scale, vpWidth, vpHeight, quality); } cleanup() { // Clean up all resources this.pageCache.forEach(entry => { entry.bitmap.close(); }); this.frontBuffer.forEach(entry => { entry.bitmap.close(); }); this.backBuffer.forEach(entry => { entry.bitmap.close(); }); this.pageCache.clear(); this.frontBuffer.clear(); this.backBuffer.clear(); this.activeRenders.clear(); this.renderQueue = []; this.pendingSwaps.clear(); this.performanceManager.cleanup(); } getPerformanceStats() { return { ...this.performanceManager.getPerformanceStats(), activeRenders: this.activeRenders.size, cacheSize: this.cacheSize, renderQueueLength: this.renderQueue.length }; } } // Export for ES6 modules export { RenderSystem };