From 3730429153096843eb2128bb5d219e2405de2a44 Mon Sep 17 00:00:00 2001 From: Reece Date: Fri, 27 Jun 2025 18:11:23 +0100 Subject: [PATCH] Optimising pageeditor --- frontend/public/thumbnailWorker.js | 136 +++++++ .../components/pageEditor/DragDropGrid.tsx | 8 +- .../pageEditor/PageEditor.module.css | 8 +- .../src/components/pageEditor/PageEditor.tsx | 114 +++--- .../components/pageEditor/PageThumbnail.tsx | 46 ++- .../services/thumbnailGenerationService.ts | 337 ++++++++++++++++++ 6 files changed, 580 insertions(+), 69 deletions(-) create mode 100644 frontend/public/thumbnailWorker.js create mode 100644 frontend/src/services/thumbnailGenerationService.ts diff --git a/frontend/public/thumbnailWorker.js b/frontend/public/thumbnailWorker.js new file mode 100644 index 000000000..4909a4927 --- /dev/null +++ b/frontend/public/thumbnailWorker.js @@ -0,0 +1,136 @@ +// Web Worker for parallel thumbnail generation +console.log('🔧 Thumbnail worker starting up...'); + +let pdfJsLoaded = false; + +// Import PDF.js properly for worker context +try { + console.log('📦 Attempting to load PDF.js from CDN...'); + importScripts('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js'); + + if (self.pdfjsLib) { + // Set up PDF.js worker + self.pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; + pdfJsLoaded = true; + console.log('✓ PDF.js loaded successfully from CDN'); + } else { + throw new Error('pdfjsLib not available after import'); + } +} catch (error) { + console.warn('⚠️ Failed to load PDF.js from CDN:', error); + console.error('✗ PDF.js CDN loading failed - worker will not be available'); + pdfJsLoaded = false; + + // Note: Local PDF.js fallback removed as pdf.js file is not available + // The main thread fallback will handle thumbnail generation instead +} + +// Log the final status +if (pdfJsLoaded) { + console.log('✅ Thumbnail worker ready for PDF processing'); +} else { + console.log('❌ Thumbnail worker failed to initialize - PDF.js not available'); +} + +self.onmessage = async function(e) { + const { type, data, jobId } = e.data; + + try { + // Handle PING for worker health check + if (type === 'PING') { + console.log('🏓 Worker received PING, sending PONG...'); + + // Check if PDF.js is loaded before responding + if (pdfJsLoaded && self.pdfjsLib) { + self.postMessage({ type: 'PONG', jobId }); + console.log('✓ PONG sent - worker is ready for thumbnail generation'); + } else { + console.error('✗ PDF.js not loaded - worker not ready'); + self.postMessage({ + type: 'ERROR', + jobId, + data: { error: 'PDF.js not loaded in worker' } + }); + } + return; + } + + if (type === 'GENERATE_THUMBNAILS') { + console.log(`🖼️ Worker starting thumbnail generation for ${data.pageNumbers?.length || 0} pages`); + + if (!pdfJsLoaded || !self.pdfjsLib) { + throw new Error('PDF.js not available in worker'); + } + const { pdfArrayBuffer, pageNumbers, scale = 0.2, quality = 0.8 } = data; + + // Load PDF in worker using imported PDF.js + const pdf = await self.pdfjsLib.getDocument({ data: pdfArrayBuffer }).promise; + + const thumbnails = []; + + // Process pages in smaller batches for smoother UI + const batchSize = 3; // Process 3 pages at once for smoother UI + for (let i = 0; i < pageNumbers.length; i += batchSize) { + const batch = pageNumbers.slice(i, i + batchSize); + + const batchPromises = batch.map(async (pageNumber) => { + try { + const page = await pdf.getPage(pageNumber); + const viewport = page.getViewport({ scale }); + + // Create OffscreenCanvas for better performance + const canvas = new OffscreenCanvas(viewport.width, viewport.height); + const context = canvas.getContext('2d'); + + await page.render({ canvasContext: context, viewport }).promise; + + // Convert to blob then to base64 (more efficient than toDataURL) + const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality }); + const arrayBuffer = await blob.arrayBuffer(); + const base64 = btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))); + const thumbnail = `data:image/jpeg;base64,${base64}`; + + return { pageNumber, thumbnail, success: true }; + } catch (error) { + return { pageNumber, error: error.message, success: false }; + } + }); + + const batchResults = await Promise.all(batchPromises); + thumbnails.push(...batchResults); + + // Send progress update + self.postMessage({ + type: 'PROGRESS', + jobId, + data: { + completed: thumbnails.length, + total: pageNumbers.length, + thumbnails: batchResults.filter(r => r.success) + } + }); + + // Small delay between batches to keep UI smooth + if (i + batchSize < pageNumbers.length) { + await new Promise(resolve => setTimeout(resolve, 100)); // Increased to 100ms pause between batches for smoother scrolling + } + } + + // Clean up + pdf.destroy(); + + self.postMessage({ + type: 'COMPLETE', + jobId, + data: { thumbnails: thumbnails.filter(r => r.success) } + }); + + } + } catch (error) { + self.postMessage({ + type: 'ERROR', + jobId, + data: { error: error.message } + }); + } +}; \ No newline at end of file diff --git a/frontend/src/components/pageEditor/DragDropGrid.tsx b/frontend/src/components/pageEditor/DragDropGrid.tsx index 18ccda8f9..466f14a3b 100644 --- a/frontend/src/components/pageEditor/DragDropGrid.tsx +++ b/frontend/src/components/pageEditor/DragDropGrid.tsx @@ -77,7 +77,13 @@ const DragDropGrid = ({ flexWrap: 'wrap', gap: '1.5rem', justifyContent: 'flex-start', - paddingBottom: '100px' + paddingBottom: '100px', + // Performance optimizations for smooth scrolling + willChange: 'scroll-position', + transform: 'translateZ(0)', // Force hardware acceleration + backfaceVisibility: 'hidden', + // Use containment for better rendering performance + contain: 'layout style paint', }} > {items.map((item, index) => ( diff --git a/frontend/src/components/pageEditor/PageEditor.module.css b/frontend/src/components/pageEditor/PageEditor.module.css index 5901e80e6..8b1c84638 100644 --- a/frontend/src/components/pageEditor/PageEditor.module.css +++ b/frontend/src/components/pageEditor/PageEditor.module.css @@ -1,10 +1,14 @@ -/* Page container hover effects */ +/* Page container hover effects - optimized for smooth scrolling */ .pageContainer { transition: transform 0.2s ease-in-out; + /* Enable hardware acceleration for smoother scrolling */ + will-change: transform; + transform: translateZ(0); + backface-visibility: hidden; } .pageContainer:hover { - transform: scale(1.02); + transform: scale(1.02) translateZ(0); } .pageContainer:hover .pageNumber { diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 29ff62bdf..914ee5384 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -17,6 +17,7 @@ import { ToggleSplitCommand } from "../../commands/pageCommands"; import { pdfExportService } from "../../services/pdfExportService"; +import { thumbnailGenerationService } from "../../services/thumbnailGenerationService"; import './pageEditor.module.css'; import PageThumbnail from './PageThumbnail'; import BulkSelectionPanel from './BulkSelectionPanel'; @@ -283,62 +284,78 @@ const PageEditor = ({ }, []); // Start thumbnail generation process (separate from document loading) - const startThumbnailGeneration = useCallback(async () => { + const startThumbnailGeneration = useCallback(() => { if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted) return; const file = activeFiles[0]; const totalPages = mergedPdfDocument.totalPages; - console.log(`Starting thumbnail generation for ${totalPages} pages`); + console.log(`Starting Web Worker thumbnail generation for ${totalPages} pages`); setThumbnailGenerationStarted(true); - try { - // Load PDF ONCE for thumbnail generation (separate from document structure loading) - const arrayBuffer = await file.arrayBuffer(); - const { getDocument } = await import('pdfjs-dist'); - const pdf = await getDocument({ data: arrayBuffer }).promise; - setSharedPdfInstance(pdf); - - console.log('Shared PDF loaded, starting progressive thumbnail generation'); - - // Process pages in batches - let currentPage = 1; - const batchSize = totalPages > 500 ? 1 : 2; // Slower for massive files - const batchDelay = totalPages > 500 ? 300 : 200; // More delay for massive files - - const processBatch = async () => { - const endPage = Math.min(currentPage + batchSize - 1, totalPages); - console.log(`Generating thumbnails for pages ${currentPage}-${endPage}`); + // Run everything asynchronously to avoid blocking the main thread + setTimeout(async () => { + try { + console.log('📖 Loading PDF array buffer...'); - for (let i = currentPage; i <= endPage; i++) { - // Send the shared PDF instance and cache functions to components - window.dispatchEvent(new CustomEvent('generateThumbnail', { - detail: { - pageNumber: i, - sharedPdf: pdf, - getThumbnailFromCache, - addThumbnailToCache - } - })); - } + // Load PDF array buffer for Web Workers + const arrayBuffer = await file.arrayBuffer(); - currentPage += batchSize; + console.log('✅ PDF array buffer loaded, starting Web Workers...'); - if (currentPage <= totalPages) { - setTimeout(processBatch, batchDelay); - } else { - console.log('Progressive thumbnail generation completed'); - } - }; - - // Start generating thumbnails immediately - processBatch(); - - } catch (error) { - console.error('Failed to start thumbnail generation:', error); - setThumbnailGenerationStarted(false); - } - }, [mergedPdfDocument, activeFiles, thumbnailGenerationStarted]); + // Generate all page numbers + const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1); + + // Start parallel thumbnail generation WITHOUT blocking the main thread + thumbnailGenerationService.generateThumbnails( + arrayBuffer, + pageNumbers, + { + scale: 0.2, // Low quality for page editor + quality: 0.8, + batchSize: 15, // Smaller batches per worker for smoother UI + parallelBatches: 3 // Use 3 Web Workers in parallel + }, + // Progress callback (throttled for better performance) + (progress) => { + // Reduce console spam - only log every 10 completions + if (progress.completed % 10 === 0) { + console.log(`Thumbnail progress: ${progress.completed}/${progress.total} completed`); + } + + // Batch process thumbnails to reduce main thread work + requestAnimationFrame(() => { + progress.thumbnails.forEach(({ pageNumber, thumbnail }) => { + // Check cache first, then send thumbnail + const pageId = `${file.name}-page-${pageNumber}`; + const cached = getThumbnailFromCache(pageId); + + if (!cached) { + // Cache and send to component + addThumbnailToCache(pageId, thumbnail); + + window.dispatchEvent(new CustomEvent('thumbnailReady', { + detail: { pageNumber, thumbnail, pageId } + })); + } + }); + }); + } + ).then(thumbnails => { + console.log(`🎉 Web Worker thumbnail generation completed: ${thumbnails.length} thumbnails generated`); + }).catch(error => { + console.error('❌ Web Worker thumbnail generation failed:', error); + setThumbnailGenerationStarted(false); + }); + + } catch (error) { + console.error('Failed to start Web Worker thumbnail generation:', error); + setThumbnailGenerationStarted(false); + } + }, 0); // setTimeout with 0ms to defer to next tick + + console.log('🚀 Thumbnail generation queued - UI remains responsive'); + }, [mergedPdfDocument, activeFiles, thumbnailGenerationStarted, getThumbnailFromCache, addThumbnailToCache]); // Start thumbnail generation after document loads and UI settles useEffect(() => { @@ -349,7 +366,7 @@ const PageEditor = ({ } }, [mergedPdfDocument, startThumbnailGeneration, thumbnailGenerationStarted]); - // Cleanup shared PDF instance and cache when component unmounts or files change + // Cleanup shared PDF instance, workers, and cache when component unmounts or files change useEffect(() => { return () => { if (sharedPdfInstance) { @@ -358,6 +375,9 @@ const PageEditor = ({ } setThumbnailGenerationStarted(false); clearThumbnailCache(); // Clear cache when leaving/changing documents + + // Cancel any ongoing Web Worker operations + thumbnailGenerationService.destroy(); }; }, [activeFiles, clearThumbnailCache]); diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index fa1f9d5cf..d229cf68c 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -46,7 +46,7 @@ interface PageThumbnailProps { setPdfDocument: any; } -const PageThumbnail = ({ +const PageThumbnail = React.memo(({ page, index, totalPages, @@ -78,28 +78,22 @@ const PageThumbnail = ({ const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false); - // Listen for progressive thumbnail generation events + // Listen for ready thumbnails from Web Workers (optimized) useEffect(() => { - const handleThumbnailGeneration = (event: CustomEvent) => { - const { pageNumber, sharedPdf, getThumbnailFromCache, addThumbnailToCache } = event.detail; - if (pageNumber === page.pageNumber && !thumbnailUrl && !isLoadingThumbnail) { - - // Check cache first - const cachedThumbnail = getThumbnailFromCache(page.id); - if (cachedThumbnail) { - console.log(`Using cached thumbnail for page ${page.pageNumber}`); - setThumbnailUrl(cachedThumbnail); - return; + const handleThumbnailReady = (event: CustomEvent) => { + const { pageNumber, thumbnail, pageId } = event.detail; + if (pageNumber === page.pageNumber && pageId === page.id && !thumbnailUrl) { + // Reduce console spam during scrolling + if (pageNumber % 20 === 0) { + console.log(`Received Web Worker thumbnail for page ${page.pageNumber}`); } - - // Generate new thumbnail and cache it - loadThumbnailFromSharedPdf(sharedPdf, addThumbnailToCache); + setThumbnailUrl(thumbnail); } }; - window.addEventListener('generateThumbnail', handleThumbnailGeneration as EventListener); - return () => window.removeEventListener('generateThumbnail', handleThumbnailGeneration as EventListener); - }, [page.pageNumber, page.id, thumbnailUrl, isLoadingThumbnail]); + window.addEventListener('thumbnailReady', handleThumbnailReady as EventListener); + return () => window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener); + }, [page.pageNumber, page.id, thumbnailUrl]); const loadThumbnailFromSharedPdf = async (sharedPdf: any, addThumbnailToCache?: (pageId: string, thumbnail: string) => void) => { if (isLoadingThumbnail || thumbnailUrl) return; @@ -441,6 +435,20 @@ const PageThumbnail = ({ ); -}; +}, (prevProps, nextProps) => { + // Only re-render if essential props change + return ( + prevProps.page.id === nextProps.page.id && + prevProps.page.pageNumber === nextProps.page.pageNumber && + prevProps.page.rotation === nextProps.page.rotation && + prevProps.page.thumbnail === nextProps.page.thumbnail && + prevProps.selectedPages.includes(prevProps.page.id) === nextProps.selectedPages.includes(nextProps.page.id) && + prevProps.selectionMode === nextProps.selectionMode && + prevProps.draggedPage === nextProps.draggedPage && + prevProps.dropTarget === nextProps.dropTarget && + prevProps.movingPage === nextProps.movingPage && + prevProps.isAnimating === nextProps.isAnimating + ); +}); export default PageThumbnail; diff --git a/frontend/src/services/thumbnailGenerationService.ts b/frontend/src/services/thumbnailGenerationService.ts new file mode 100644 index 000000000..42cfd13c5 --- /dev/null +++ b/frontend/src/services/thumbnailGenerationService.ts @@ -0,0 +1,337 @@ +/** + * High-performance thumbnail generation service using Web Workers + */ + +interface ThumbnailResult { + pageNumber: number; + thumbnail: string; + success: boolean; + error?: string; +} + +interface ThumbnailGenerationOptions { + scale?: number; + quality?: number; + batchSize?: number; + parallelBatches?: number; +} + +export class ThumbnailGenerationService { + private workers: Worker[] = []; + private activeJobs = new Map(); + private jobCounter = 0; + private isGenerating = false; + + constructor(private maxWorkers: number = 3) { + this.initializeWorkers(); + } + + private initializeWorkers(): void { + const workerPromises: Promise[] = []; + + for (let i = 0; i < this.maxWorkers; i++) { + const workerPromise = new Promise((resolve) => { + try { + console.log(`Attempting to create worker ${i}...`); + const worker = new Worker('/thumbnailWorker.js'); + let workerReady = false; + let pingTimeout: NodeJS.Timeout; + + worker.onmessage = (e) => { + const { type, data, jobId } = e.data; + + // Handle PONG response to confirm worker is ready + if (type === 'PONG') { + workerReady = true; + clearTimeout(pingTimeout); + console.log(`✓ Worker ${i} is ready and responsive`); + resolve(worker); + return; + } + + const job = this.activeJobs.get(jobId); + if (!job) return; + + switch (type) { + case 'PROGRESS': + if (job.onProgress) { + job.onProgress(data); + } + break; + + case 'COMPLETE': + job.resolve(data.thumbnails); + this.activeJobs.delete(jobId); + break; + + case 'ERROR': + job.reject(new Error(data.error)); + this.activeJobs.delete(jobId); + break; + } + }; + + worker.onerror = (error) => { + console.error(`✗ Worker ${i} failed with error:`, error); + clearTimeout(pingTimeout); + worker.terminate(); + resolve(null); + }; + + // Test worker with timeout + pingTimeout = setTimeout(() => { + if (!workerReady) { + console.warn(`✗ Worker ${i} timed out (no PONG response)`); + worker.terminate(); + resolve(null); + } + }, 3000); // Reduced timeout for faster feedback + + // Send PING to test worker + try { + worker.postMessage({ type: 'PING' }); + } catch (pingError) { + console.error(`✗ Failed to send PING to worker ${i}:`, pingError); + clearTimeout(pingTimeout); + worker.terminate(); + resolve(null); + } + + } catch (error) { + console.error(`✗ Failed to create worker ${i}:`, error); + resolve(null); + } + }); + + workerPromises.push(workerPromise); + } + + // Wait for all workers to initialize or fail + Promise.all(workerPromises).then((workers) => { + this.workers = workers.filter((w): w is Worker => w !== null); + const successCount = this.workers.length; + const failCount = this.maxWorkers - successCount; + + console.log(`🔧 Worker initialization complete: ${successCount}/${this.maxWorkers} workers ready`); + + if (failCount > 0) { + console.warn(`⚠️ ${failCount} workers failed to initialize - will use main thread fallback`); + } + + if (successCount === 0) { + console.warn('🚨 No Web Workers available - all thumbnail generation will use main thread'); + } + }); + } + + /** + * Generate thumbnails for multiple pages using Web Workers + */ + async generateThumbnails( + pdfArrayBuffer: ArrayBuffer, + pageNumbers: number[], + options: ThumbnailGenerationOptions = {}, + onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void + ): Promise { + if (this.isGenerating) { + throw new Error('Thumbnail generation already in progress'); + } + + this.isGenerating = true; + + const { + scale = 0.2, + quality = 0.8, + batchSize = 20, // Pages per worker + parallelBatches = this.maxWorkers + } = options; + + try { + // Check if workers are available, fallback to main thread if not + if (this.workers.length === 0) { + console.warn('No Web Workers available, falling back to main thread processing'); + return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress); + } + + // Split pages across workers + const workerBatches = this.distributeWork(pageNumbers, this.workers.length); + const jobPromises: Promise[] = []; + + for (let i = 0; i < workerBatches.length; i++) { + const batch = workerBatches[i]; + if (batch.length === 0) continue; + + const worker = this.workers[i % this.workers.length]; + const jobId = `job-${++this.jobCounter}`; + + const promise = new Promise((resolve, reject) => { + this.activeJobs.set(jobId, { resolve, reject, onProgress }); + + // Add timeout for worker jobs + const timeout = setTimeout(() => { + this.activeJobs.delete(jobId); + reject(new Error(`Worker job ${jobId} timed out`)); + }, 60000); // 1 minute timeout + + // Clear timeout when job completes + const originalResolve = resolve; + const originalReject = reject; + this.activeJobs.set(jobId, { + resolve: (result: any) => { clearTimeout(timeout); originalResolve(result); }, + reject: (error: any) => { clearTimeout(timeout); originalReject(error); }, + onProgress + }); + + worker.postMessage({ + type: 'GENERATE_THUMBNAILS', + jobId, + data: { + pdfArrayBuffer, + pageNumbers: batch, + scale, + quality + } + }); + }); + + jobPromises.push(promise); + } + + // Wait for all workers to complete + const results = await Promise.all(jobPromises); + + // Flatten and sort results by page number + const allThumbnails = results.flat().sort((a, b) => a.pageNumber - b.pageNumber); + + return allThumbnails; + + } catch (error) { + console.error('Web Worker thumbnail generation failed, falling back to main thread:', error); + return await this.generateThumbnailsMainThread(pdfArrayBuffer, pageNumbers, scale, quality, onProgress); + } finally { + this.isGenerating = false; + } + } + + /** + * Fallback thumbnail generation on main thread + */ + private async generateThumbnailsMainThread( + pdfArrayBuffer: ArrayBuffer, + pageNumbers: number[], + scale: number, + quality: number, + onProgress?: (progress: { completed: number; total: number; thumbnails: ThumbnailResult[] }) => void + ): Promise { + // Import PDF.js dynamically for main thread + const { getDocument } = await import('pdfjs-dist'); + + // Load PDF once + const pdf = await getDocument({ data: pdfArrayBuffer }).promise; + + const allResults: ThumbnailResult[] = []; + let completed = 0; + const batchSize = 5; // Small batches for UI responsiveness + + // Process pages in small batches + for (let i = 0; i < pageNumbers.length; i += batchSize) { + const batch = pageNumbers.slice(i, i + batchSize); + + // Process batch sequentially (to avoid canvas conflicts) + for (const pageNumber of batch) { + try { + const page = await pdf.getPage(pageNumber); + const viewport = page.getViewport({ scale }); + + const canvas = document.createElement('canvas'); + canvas.width = viewport.width; + canvas.height = viewport.height; + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Could not get canvas context'); + } + + await page.render({ canvasContext: context, viewport }).promise; + const thumbnail = canvas.toDataURL('image/jpeg', quality); + + allResults.push({ pageNumber, thumbnail, success: true }); + + } catch (error) { + console.error(`Failed to generate thumbnail for page ${pageNumber}:`, error); + allResults.push({ + pageNumber, + thumbnail: '', + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + }); + } + } + + completed += batch.length; + + // Report progress + if (onProgress) { + onProgress({ + completed, + total: pageNumbers.length, + thumbnails: allResults.slice(-batch.length).filter(r => r.success) + }); + } + + // Small delay to keep UI responsive + if (i + batchSize < pageNumbers.length) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + } + + // Clean up + pdf.destroy(); + + return allResults.filter(r => r.success); + } + + /** + * Distribute work evenly across workers + */ + private distributeWork(pageNumbers: number[], numWorkers: number): number[][] { + const batches: number[][] = Array(numWorkers).fill(null).map(() => []); + + pageNumbers.forEach((pageNum, index) => { + const workerIndex = index % numWorkers; + batches[workerIndex].push(pageNum); + }); + + return batches; + } + + /** + * Generate a single thumbnail (fallback for individual pages) + */ + async generateSingleThumbnail( + pdfArrayBuffer: ArrayBuffer, + pageNumber: number, + options: ThumbnailGenerationOptions = {} + ): Promise { + const results = await this.generateThumbnails(pdfArrayBuffer, [pageNumber], options); + + if (results.length === 0 || !results[0].success) { + throw new Error(`Failed to generate thumbnail for page ${pageNumber}`); + } + + return results[0].thumbnail; + } + + /** + * Terminate all workers and stop generation + */ + destroy(): void { + this.workers.forEach(worker => worker.terminate()); + this.workers = []; + this.activeJobs.clear(); + this.isGenerating = false; + } +} + +// Export singleton instance +export const thumbnailGenerationService = new ThumbnailGenerationService(); \ No newline at end of file