339 lines
13 KiB
JavaScript
339 lines
13 KiB
JavaScript
/**
|
|
* render-system.ts — 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();
|
|
if (renderTask) {
|
|
this.startRender(renderTask.pageNum, renderTask.scale, renderTask.quality);
|
|
}
|
|
}
|
|
}
|
|
async startRender(pageNum, scale, 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');
|
|
if (!context) {
|
|
throw new Error('Failed to get 2D context for offscreen canvas');
|
|
}
|
|
// 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 && typeof error === 'object' && 'name' in error && 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);
|
|
if (entry) {
|
|
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');
|
|
if (context) {
|
|
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, quality);
|
|
}
|
|
cleanup() {
|
|
// Clean up all resources
|
|
this.pageCache.forEach(entry => {
|
|
entry.bitmap.close();
|
|
});
|
|
this.activeRenders.clear();
|
|
this.renderQueue = [];
|
|
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 };
|
|
//# sourceMappingURL=render-system.js.map
|