mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-09-08 17:51:20 +02:00
Optimising pageeditor
This commit is contained in:
parent
42abe83385
commit
3730429153
136
frontend/public/thumbnailWorker.js
Normal file
136
frontend/public/thumbnailWorker.js
Normal file
@ -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 }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
@ -77,7 +77,13 @@ const DragDropGrid = <T extends DragDropItem>({
|
|||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
gap: '1.5rem',
|
gap: '1.5rem',
|
||||||
justifyContent: 'flex-start',
|
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) => (
|
{items.map((item, index) => (
|
||||||
|
@ -1,10 +1,14 @@
|
|||||||
/* Page container hover effects */
|
/* Page container hover effects - optimized for smooth scrolling */
|
||||||
.pageContainer {
|
.pageContainer {
|
||||||
transition: transform 0.2s ease-in-out;
|
transition: transform 0.2s ease-in-out;
|
||||||
|
/* Enable hardware acceleration for smoother scrolling */
|
||||||
|
will-change: transform;
|
||||||
|
transform: translateZ(0);
|
||||||
|
backface-visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageContainer:hover {
|
.pageContainer:hover {
|
||||||
transform: scale(1.02);
|
transform: scale(1.02) translateZ(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pageContainer:hover .pageNumber {
|
.pageContainer:hover .pageNumber {
|
||||||
|
@ -17,6 +17,7 @@ import {
|
|||||||
ToggleSplitCommand
|
ToggleSplitCommand
|
||||||
} from "../../commands/pageCommands";
|
} from "../../commands/pageCommands";
|
||||||
import { pdfExportService } from "../../services/pdfExportService";
|
import { pdfExportService } from "../../services/pdfExportService";
|
||||||
|
import { thumbnailGenerationService } from "../../services/thumbnailGenerationService";
|
||||||
import './pageEditor.module.css';
|
import './pageEditor.module.css';
|
||||||
import PageThumbnail from './PageThumbnail';
|
import PageThumbnail from './PageThumbnail';
|
||||||
import BulkSelectionPanel from './BulkSelectionPanel';
|
import BulkSelectionPanel from './BulkSelectionPanel';
|
||||||
@ -283,62 +284,78 @@ const PageEditor = ({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Start thumbnail generation process (separate from document loading)
|
// Start thumbnail generation process (separate from document loading)
|
||||||
const startThumbnailGeneration = useCallback(async () => {
|
const startThumbnailGeneration = useCallback(() => {
|
||||||
if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted) return;
|
if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted) return;
|
||||||
|
|
||||||
const file = activeFiles[0];
|
const file = activeFiles[0];
|
||||||
const totalPages = mergedPdfDocument.totalPages;
|
const totalPages = mergedPdfDocument.totalPages;
|
||||||
|
|
||||||
console.log(`Starting thumbnail generation for ${totalPages} pages`);
|
console.log(`Starting Web Worker thumbnail generation for ${totalPages} pages`);
|
||||||
setThumbnailGenerationStarted(true);
|
setThumbnailGenerationStarted(true);
|
||||||
|
|
||||||
|
// Run everything asynchronously to avoid blocking the main thread
|
||||||
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
// Load PDF ONCE for thumbnail generation (separate from document structure loading)
|
console.log('📖 Loading PDF array buffer...');
|
||||||
|
|
||||||
|
// Load PDF array buffer for Web Workers
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
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');
|
console.log('✅ PDF array buffer loaded, starting Web Workers...');
|
||||||
|
|
||||||
// Process pages in batches
|
// Generate all page numbers
|
||||||
let currentPage = 1;
|
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 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 () => {
|
// Start parallel thumbnail generation WITHOUT blocking the main thread
|
||||||
const endPage = Math.min(currentPage + batchSize - 1, totalPages);
|
thumbnailGenerationService.generateThumbnails(
|
||||||
console.log(`Generating thumbnails for pages ${currentPage}-${endPage}`);
|
arrayBuffer,
|
||||||
|
pageNumbers,
|
||||||
for (let i = currentPage; i <= endPage; i++) {
|
{
|
||||||
// Send the shared PDF instance and cache functions to components
|
scale: 0.2, // Low quality for page editor
|
||||||
window.dispatchEvent(new CustomEvent('generateThumbnail', {
|
quality: 0.8,
|
||||||
detail: {
|
batchSize: 15, // Smaller batches per worker for smoother UI
|
||||||
pageNumber: i,
|
parallelBatches: 3 // Use 3 Web Workers in parallel
|
||||||
sharedPdf: pdf,
|
},
|
||||||
getThumbnailFromCache,
|
// Progress callback (throttled for better performance)
|
||||||
addThumbnailToCache
|
(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 }
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
});
|
||||||
currentPage += batchSize;
|
});
|
||||||
|
|
||||||
if (currentPage <= totalPages) {
|
|
||||||
setTimeout(processBatch, batchDelay);
|
|
||||||
} else {
|
|
||||||
console.log('Progressive thumbnail generation completed');
|
|
||||||
}
|
}
|
||||||
};
|
).then(thumbnails => {
|
||||||
|
console.log(`🎉 Web Worker thumbnail generation completed: ${thumbnails.length} thumbnails generated`);
|
||||||
// Start generating thumbnails immediately
|
}).catch(error => {
|
||||||
processBatch();
|
console.error('❌ Web Worker thumbnail generation failed:', error);
|
||||||
|
setThumbnailGenerationStarted(false);
|
||||||
|
});
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to start thumbnail generation:', error);
|
console.error('Failed to start Web Worker thumbnail generation:', error);
|
||||||
setThumbnailGenerationStarted(false);
|
setThumbnailGenerationStarted(false);
|
||||||
}
|
}
|
||||||
}, [mergedPdfDocument, activeFiles, thumbnailGenerationStarted]);
|
}, 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
|
// Start thumbnail generation after document loads and UI settles
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -349,7 +366,7 @@ const PageEditor = ({
|
|||||||
}
|
}
|
||||||
}, [mergedPdfDocument, startThumbnailGeneration, thumbnailGenerationStarted]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (sharedPdfInstance) {
|
if (sharedPdfInstance) {
|
||||||
@ -358,6 +375,9 @@ const PageEditor = ({
|
|||||||
}
|
}
|
||||||
setThumbnailGenerationStarted(false);
|
setThumbnailGenerationStarted(false);
|
||||||
clearThumbnailCache(); // Clear cache when leaving/changing documents
|
clearThumbnailCache(); // Clear cache when leaving/changing documents
|
||||||
|
|
||||||
|
// Cancel any ongoing Web Worker operations
|
||||||
|
thumbnailGenerationService.destroy();
|
||||||
};
|
};
|
||||||
}, [activeFiles, clearThumbnailCache]);
|
}, [activeFiles, clearThumbnailCache]);
|
||||||
|
|
||||||
|
@ -46,7 +46,7 @@ interface PageThumbnailProps {
|
|||||||
setPdfDocument: any;
|
setPdfDocument: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const PageThumbnail = ({
|
const PageThumbnail = React.memo(({
|
||||||
page,
|
page,
|
||||||
index,
|
index,
|
||||||
totalPages,
|
totalPages,
|
||||||
@ -78,28 +78,22 @@ const PageThumbnail = ({
|
|||||||
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
|
||||||
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false);
|
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false);
|
||||||
|
|
||||||
// Listen for progressive thumbnail generation events
|
// Listen for ready thumbnails from Web Workers (optimized)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleThumbnailGeneration = (event: CustomEvent) => {
|
const handleThumbnailReady = (event: CustomEvent) => {
|
||||||
const { pageNumber, sharedPdf, getThumbnailFromCache, addThumbnailToCache } = event.detail;
|
const { pageNumber, thumbnail, pageId } = event.detail;
|
||||||
if (pageNumber === page.pageNumber && !thumbnailUrl && !isLoadingThumbnail) {
|
if (pageNumber === page.pageNumber && pageId === page.id && !thumbnailUrl) {
|
||||||
|
// Reduce console spam during scrolling
|
||||||
// Check cache first
|
if (pageNumber % 20 === 0) {
|
||||||
const cachedThumbnail = getThumbnailFromCache(page.id);
|
console.log(`Received Web Worker thumbnail for page ${page.pageNumber}`);
|
||||||
if (cachedThumbnail) {
|
|
||||||
console.log(`Using cached thumbnail for page ${page.pageNumber}`);
|
|
||||||
setThumbnailUrl(cachedThumbnail);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
setThumbnailUrl(thumbnail);
|
||||||
// Generate new thumbnail and cache it
|
|
||||||
loadThumbnailFromSharedPdf(sharedPdf, addThumbnailToCache);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('generateThumbnail', handleThumbnailGeneration as EventListener);
|
window.addEventListener('thumbnailReady', handleThumbnailReady as EventListener);
|
||||||
return () => window.removeEventListener('generateThumbnail', handleThumbnailGeneration as EventListener);
|
return () => window.removeEventListener('thumbnailReady', handleThumbnailReady as EventListener);
|
||||||
}, [page.pageNumber, page.id, thumbnailUrl, isLoadingThumbnail]);
|
}, [page.pageNumber, page.id, thumbnailUrl]);
|
||||||
|
|
||||||
const loadThumbnailFromSharedPdf = async (sharedPdf: any, addThumbnailToCache?: (pageId: string, thumbnail: string) => void) => {
|
const loadThumbnailFromSharedPdf = async (sharedPdf: any, addThumbnailToCache?: (pageId: string, thumbnail: string) => void) => {
|
||||||
if (isLoadingThumbnail || thumbnailUrl) return;
|
if (isLoadingThumbnail || thumbnailUrl) return;
|
||||||
@ -441,6 +435,20 @@ const PageThumbnail = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}, (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;
|
export default PageThumbnail;
|
||||||
|
337
frontend/src/services/thumbnailGenerationService.ts
Normal file
337
frontend/src/services/thumbnailGenerationService.ts
Normal file
@ -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<string, { resolve: Function; reject: Function; onProgress?: Function }>();
|
||||||
|
private jobCounter = 0;
|
||||||
|
private isGenerating = false;
|
||||||
|
|
||||||
|
constructor(private maxWorkers: number = 3) {
|
||||||
|
this.initializeWorkers();
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeWorkers(): void {
|
||||||
|
const workerPromises: Promise<Worker | null>[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < this.maxWorkers; i++) {
|
||||||
|
const workerPromise = new Promise<Worker | null>((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<ThumbnailResult[]> {
|
||||||
|
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<ThumbnailResult[]>[] = [];
|
||||||
|
|
||||||
|
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<ThumbnailResult[]>((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<ThumbnailResult[]> {
|
||||||
|
// 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<string> {
|
||||||
|
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();
|
Loading…
Reference in New Issue
Block a user