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',
|
||||
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) => (
|
||||
|
@ -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 {
|
||||
|
@ -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]);
|
||||
|
||||
|
@ -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<string | null>(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 = ({
|
||||
</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;
|
||||
|
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