From 923300e49b1e12978561a3be744ffc1181980286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roman=20Kr=C4=8Dek?= Date: Wed, 30 Jul 2025 16:36:06 +0200 Subject: [PATCH] Memory leak fixes --- .github/copilot-instructions.md | 3 +- package-lock.json | 7 + package.json | 1 + src/lib/components/wizard/StepGallery.svelte | 172 +++++++++++++----- src/lib/components/wizard/StepGenerate.svelte | 89 +++++++-- .../components/wizard/StepRowFilter.svelte | 21 +-- src/lib/pdfLayout.ts | 6 + 7 files changed, 213 insertions(+), 86 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 05149cb..e6b7d26 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -13,4 +13,5 @@ - Generate ".github/done.md" file to see what is done and what is not. Check it when you start and finish a task. - Remain consistent in styling and code structure. - Avoid unncessary iterations. If problems is mostly solved, stop. -- Split big components into subcomponents. Always create smaller subcomponents for better context management later. \ No newline at end of file +- Split big components into subcomponents. Always create smaller subcomponents for better context management later. +- Do not do what you're not being asked. Stick to scope of my request. \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d439cf2..9086028 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "fontkit": "^2.0.4", "heic-convert": "^2.1.0", "idb": "^8.0.3", + "idb-keyval": "^6.2.2", "p-queue": "^8.1.0", "pdf-lib": "^1.17.1", "uuid": "^11.1.0" @@ -1194,6 +1195,12 @@ "version": "8.0.3", "license": "ISC" }, + "node_modules/idb-keyval": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz", + "integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==", + "license": "Apache-2.0" + }, "node_modules/is-core-module": { "version": "2.16.1", "dev": true, diff --git a/package.json b/package.json index 2b264ab..79932b1 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "fontkit": "^2.0.4", "heic-convert": "^2.1.0", "idb": "^8.0.3", + "idb-keyval": "^6.2.2", "p-queue": "^8.1.0", "pdf-lib": "^1.17.1", "uuid": "^11.1.0" diff --git a/src/lib/components/wizard/StepGallery.svelte b/src/lib/components/wizard/StepGallery.svelte index 628dfc0..1ab57d5 100644 --- a/src/lib/components/wizard/StepGallery.svelte +++ b/src/lib/components/wizard/StepGallery.svelte @@ -7,6 +7,8 @@ import PhotoCard from './subcomponents/PhotoCard.svelte'; import * as tf from '@tensorflow/tfjs'; import * as blazeface from '@tensorflow-models/blazeface'; + import PQueue from 'p-queue'; + import { set, clear } from 'idb-keyval'; let photos = $state([]); let isProcessing = $state(false); @@ -14,6 +16,8 @@ let totalCount = $state(0); let detector: blazeface.BlazeFaceModel | undefined; let detectorPromise: Promise | undefined; + let downloadQueue: PQueue; + let faceDetectionQueue: PQueue; interface PhotoInfo { name: string; @@ -38,13 +42,59 @@ return detectorPromise; } + // Force memory cleanup + async function forceMemoryCleanup() { + await tf.nextFrame(); // Wait for any pending GPU operations + + // Log memory state without aggressive cleanup + const memInfo = tf.memory(); + console.log('Memory status:', { + tensors: memInfo.numTensors, + dataBuffers: memInfo.numDataBuffers, + bytes: memInfo.numBytes + }); + + // Only run garbage collection if available, don't dispose variables + if (typeof window !== 'undefined' && 'gc' in window) { + (window as any).gc(); + } + } + async function processPhotosInParallel() { if (isProcessing) return; - console.log('Starting processPhotos in parallel...'); + console.log('Starting processPhotos with queues...'); isProcessing = true; processedCount = 0; + try { + // Clear previous session's images from IndexedDB + await clear(); + console.log('Cleared IndexedDB.'); + } catch (e) { + console.error('Could not clear IndexedDB:', e); + } + + // Initialize queues with more conservative concurrency + downloadQueue = new PQueue({ concurrency: 3 }); // Reduced from 5 + faceDetectionQueue = new PQueue({ concurrency: 1 }); // Keep at 1 for memory safety + + // When both queues are idle, we're done + downloadQueue.on('idle', async () => { + if (faceDetectionQueue.size === 0 && faceDetectionQueue.pending === 0) { + await forceMemoryCleanup(); // Clean up memory when processing is complete + isProcessing = false; + console.log('All queues are idle. Processing complete.'); + } + }); + faceDetectionQueue.on('idle', async () => { + if (downloadQueue.size === 0 && downloadQueue.pending === 0) { + await forceMemoryCleanup(); // Clean up memory when processing is complete + isProcessing = false; + console.log('All queues are idle. Processing complete.'); + } + }); + const validRows = $filteredSheetData.filter((row) => row._isValid); const photoUrls = new Set(); const photoMap = new Map(); @@ -62,7 +112,7 @@ }); totalCount = photoUrls.size; - console.log(`Found ${totalCount} unique photo URLs`); + console.log(`Found ${totalCount} unique photo URLs to process.`); photos = Array.from(photoUrls).map((url) => ({ name: photoMap.get(url)![0].name + ' ' + photoMap.get(url)![0].surname, @@ -72,26 +122,10 @@ faceDetectionStatus: 'pending' as const })); - const concurrencyLimit = 5; - const promises = []; - + // Add all photos to the download queue for (let i = 0; i < photos.length; i++) { - const promise = (async () => { - await loadPhoto(i); - processedCount++; - })(); - promises.push(promise); - - if (promises.length >= concurrencyLimit) { - await Promise.all(promises); - promises.length = 0; - } + downloadQueue.add(() => loadPhoto(i)); } - - await Promise.all(promises); - - isProcessing = false; - console.log('All photos processed.'); } // Initialize detector and process photos @@ -148,6 +182,8 @@ } catch (error) { console.error(`Failed to load photo for ${photo.name}:`, error); photo.status = 'error'; + // Since this step failed, we still count it as "processed" to not stall the progress bar + processedCount++; } } @@ -175,6 +211,8 @@ } catch (e) { console.error(`Failed to convert HEIC image for ${photo.name}:`, e); photo.status = 'error'; + // Since this step failed, we still count it as "processed" to not stall the progress bar + processedCount++; } } @@ -198,12 +236,14 @@ photo.status = 'success'; console.log(`Photo loaded successfully: ${photo.name}`); - // Save to pictures store + // Save blob to IndexedDB instead of the store + await set(photo.url, blob); + + // Save to pictures store, but without the blob to save memory pictures.update((pics) => ({ ...pics, [photo.url]: { id: photo.url, - blob: blob, url: objectUrl, downloaded: true, faceDetected: false, @@ -211,32 +251,47 @@ } })); - // Automatically run face detection to generate crop - await detectFaceForPhoto(index); + // Add face detection to its queue + faceDetectionQueue.add(() => detectFaceForPhoto(index)); } catch (error) { console.error(`Failed to process blob for ${photo.name}:`, error); photo.status = 'error'; + // Since this step failed, we still count it as "processed" to not stall the progress bar + processedCount++; } } async function detectFaceForPhoto(index: number) { + const photo = photos[index]; + let imageTensor; try { await initializeDetector(); // Ensure detector is loaded if (!detector) { - photos[index].faceDetectionStatus = 'failed'; + photo.faceDetectionStatus = 'failed'; console.error('Face detector not available.'); return; } - photos[index].faceDetectionStatus = 'processing'; + photo.faceDetectionStatus = 'processing'; const img = new Image(); img.crossOrigin = 'anonymous'; - img.src = photos[index].objectUrl!; + img.src = photo.objectUrl!; await new Promise((r, e) => { img.onload = r; img.onerror = e; }); - const predictions = await detector.estimateFaces(img, false); + + // Create tensor and manually dispose it after use + imageTensor = tf.browser.fromPixels(img); + const predictions = await detector.estimateFaces(imageTensor, false); + + // Log memory usage for debugging + const memInfo = tf.memory(); + console.log(`TensorFlow.js memory after face detection for ${photo.name}:`, { + numTensors: memInfo.numTensors, + numDataBuffers: memInfo.numDataBuffers, + numBytes: memInfo.numBytes + }); if (predictions.length > 0) { const getProbability = (p: number | tf.Tensor) => @@ -245,26 +300,27 @@ const face = predictions.sort( (a, b) => getProbability(b.probability!) - getProbability(a.probability!) )[0]; - // Coordinates in displayed image space - let [x1, y1] = face.topLeft as [number, number]; - let [x2, y2] = face.bottomRight as [number, number]; - // Scale to natural image size + + const topLeft = face.topLeft as [number, number]; + const bottomRight = face.bottomRight as [number, number]; + + let [x1, y1] = topLeft; + let [x2, y2] = bottomRight; const scaleX = img.naturalWidth / img.width; const scaleY = img.naturalHeight / img.height; const faceWidth = (x2 - x1) * scaleX; const faceHeight = (y2 - y1) * scaleY; const faceCenterX = (x1 + (x2 - x1) / 2) * scaleX; const faceCenterY = (y1 + (y2 - y1) / 2) * scaleY; - // Load crop config from env + const cropRatio = parseFloat(env.PUBLIC_CROP_RATIO || '1.0'); const offsetX = parseFloat(env.PUBLIC_FACE_OFFSET_X || '0.0'); const offsetY = parseFloat(env.PUBLIC_FACE_OFFSET_Y || '0.0'); const cropScale = parseFloat(env.PUBLIC_CROP_SCALE || '2.5'); - // Compute crop size and center + let cropWidth = faceWidth * cropScale; let cropHeight = cropWidth / cropRatio; - // If crop is larger than image, scale it down while maintaining aspect ratio if (cropWidth > img.naturalWidth || cropHeight > img.naturalHeight) { const widthRatio = img.naturalWidth / cropWidth; const heightRatio = img.naturalHeight / cropHeight; @@ -276,9 +332,11 @@ let centerX = faceCenterX + cropWidth * offsetX; let centerY = faceCenterY + cropHeight * offsetY; - // Clamp center to ensure crop fits centerX = Math.max(cropWidth / 2, Math.min(centerX, img.naturalWidth - cropWidth / 2)); - centerY = Math.max(cropHeight / 2, Math.min(centerY, img.naturalHeight - cropHeight / 2)); + centerY = Math.max( + cropHeight / 2, + Math.min(centerY, img.naturalHeight - cropHeight / 2) + ); const cropX = centerX - cropWidth / 2; const cropY = centerY - cropHeight / 2; @@ -289,32 +347,40 @@ width: Math.round(cropWidth), height: Math.round(cropHeight) }; - photos[index].cropData = crop; - photos[index].faceDetectionStatus = 'completed'; + photo.cropData = crop; + photo.faceDetectionStatus = 'completed'; - // Save crop data to store cropRects.update((crops) => ({ ...crops, - [photos[index].url]: crop + [photo.url]: crop })); - // Update pictures store with face detection info pictures.update((pics) => ({ ...pics, - [photos[index].url]: { - ...pics[photos[index].url], + [photo.url]: { + ...pics[photo.url], faceDetected: true, faceCount: predictions.length } })); } else { - photos[index].faceDetectionStatus = 'failed'; + photo.faceDetectionStatus = 'failed'; } } catch (error) { - console.error(`Face detection failed for ${photos[index].name}:`, error); - photos[index].faceDetectionStatus = 'failed'; + console.error(`Face detection failed for ${photo.name}:`, error); + photo.faceDetectionStatus = 'failed'; + } finally { + // Manually dispose of the input tensor to prevent memory leaks + if (imageTensor) { + imageTensor.dispose(); + } + + // Add a small delay to allow GPU memory to be freed before next operation + await new Promise(resolve => setTimeout(resolve, 100)); + + // This is the final step for a photo, so we increment the processed count here. + processedCount++; } - // No need to reassign photos array with $state reactivity } async function retryPhoto(index: number) { @@ -325,7 +391,8 @@ } photo.retryCount++; - await loadPhoto(index, true); + // Add the retry attempt back to the download queue + downloadQueue.add(() => loadPhoto(index, true)); } function handleCropUpdate( @@ -364,6 +431,13 @@ // Cleanup on unmount using $effect $effect(() => { return () => { + // Clear queues on component unmount to stop any ongoing processing + if (downloadQueue) { + downloadQueue.clear(); + } + if (faceDetectionQueue) { + faceDetectionQueue.clear(); + } cleanupObjectUrls(); }; }); diff --git a/src/lib/components/wizard/StepGenerate.svelte b/src/lib/components/wizard/StepGenerate.svelte index 82ddfdf..c189080 100644 --- a/src/lib/components/wizard/StepGenerate.svelte +++ b/src/lib/components/wizard/StepGenerate.svelte @@ -3,6 +3,7 @@ import { filteredSheetData, currentStep, pictures, cropRects } from '$lib/stores'; import { PDFDocument, StandardFonts, rgb } from 'pdf-lib'; import * as fontkit from 'fontkit'; + import { clear } from 'idb-keyval'; import { BORDER_CONFIG, TEXT_CONFIG, @@ -10,6 +11,7 @@ calculateGrid, getAbsolutePositionPt, getAbsolutePhotoDimensionsPt, + getImageBlob, MM_TO_PT } from '$lib/pdfLayout'; import { @@ -52,10 +54,34 @@ let files = $state(JSON.parse(JSON.stringify(initialFiles))); + // Cleanup function to clear IndexedDB and sensitive data + async function clearSensitiveData() { + try { + await clear(); // Clear all data from IndexedDB + console.log('IndexedDB cleared for security'); + } catch (error) { + console.error('Failed to clear IndexedDB:', error); + } + } + + // Handle tab close or page unload + function handleBeforeUnload() { + clearSensitiveData(); + } + onMount(() => { + // Add event listener for page unload + window.addEventListener('beforeunload', handleBeforeUnload); + // Start generation automatically when the component mounts handleGenerate('people_data.pdf'); handleGenerate('people_photos.pdf'); + + // Cleanup function when component unmounts + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + clearSensitiveData(); + }; }); // Load Roboto font @@ -150,6 +176,13 @@ fileToUpdate.url = URL.createObjectURL(blob); fileToUpdate.size = pdfBytes.length; fileToUpdate.state = 'done'; + + // Check if both PDFs are done, then clear sensitive data + const allDone = files.every((f) => f.state === 'done' || f.state === 'error'); + if (allDone) { + console.log('All PDFs generated, clearing sensitive data...'); + await clearSensitiveData(); + } } catch (error: any) { console.error(`PDF generation failed for ${fileName}:`, error); fileToUpdate.state = 'error'; @@ -310,30 +343,42 @@ if (pictureInfo && cropData) { try { - const croppedImageBytes = await cropImage(pictureInfo.blob, cropData); - const embeddedImage = await pdfDoc.embedJpg(croppedImageBytes); + // Get blob from IndexedDB instead of the store + const imageBlob = await getImageBlob(pictureUrl); + if (imageBlob) { + const croppedImageBytes = await cropImage(imageBlob, cropData); + const embeddedImage = await pdfDoc.embedJpg(croppedImageBytes); - const imageAspectRatio = embeddedImage.width / embeddedImage.height; - const photoBoxAspectRatio = photoDimsPt.width / photoDimsPt.height; + const imageAspectRatio = embeddedImage.width / embeddedImage.height; + const photoBoxAspectRatio = photoDimsPt.width / photoDimsPt.height; - let imageWidth, imageHeight; - if (imageAspectRatio > photoBoxAspectRatio) { - imageWidth = photoDimsPt.width; - imageHeight = photoDimsPt.width / imageAspectRatio; + let imageWidth, imageHeight; + if (imageAspectRatio > photoBoxAspectRatio) { + imageWidth = photoDimsPt.width; + imageHeight = photoDimsPt.width / imageAspectRatio; + } else { + imageHeight = photoDimsPt.height; + imageWidth = photoDimsPt.height * imageAspectRatio; + } + + const imageX = photoDimsPt.x + (photoDimsPt.width - imageWidth) / 2; + const imageY = photoDimsPt.y + (photoDimsPt.height - imageHeight) / 2; + + page.drawImage(embeddedImage, { + x: imageX, + y: imageY, + width: imageWidth, + height: imageHeight + }); } else { - imageHeight = photoDimsPt.height; - imageWidth = photoDimsPt.height * imageAspectRatio; + console.warn(`Image blob not found in IndexedDB for ${pictureUrl}`); + // Draw placeholder when blob not found + page.drawRectangle({ + ...photoDimsPt, + borderColor: rgb(BORDER_CONFIG.color.r, BORDER_CONFIG.color.g, BORDER_CONFIG.color.b), + borderWidth: BORDER_CONFIG.width + }); } - - const imageX = photoDimsPt.x + (photoDimsPt.width - imageWidth) / 2; - const imageY = photoDimsPt.y + (photoDimsPt.height - imageHeight) / 2; - - page.drawImage(embeddedImage, { - x: imageX, - y: imageY, - width: imageWidth, - height: imageHeight - }); } catch (error: any) { console.error(`Failed to embed photo for ${row.name}:`, error); // Draw placeholder on error @@ -399,6 +444,10 @@ } }); files = JSON.parse(JSON.stringify(initialFiles)); + + // Clear sensitive data when starting over + clearSensitiveData(); + currentStep.set(0); } diff --git a/src/lib/components/wizard/StepRowFilter.svelte b/src/lib/components/wizard/StepRowFilter.svelte index 9285992..1009418 100644 --- a/src/lib/components/wizard/StepRowFilter.svelte +++ b/src/lib/components/wizard/StepRowFilter.svelte @@ -107,7 +107,7 @@ return processedRow; }); - // Initially select rows based on validity and "Already Printed" status, up to 200 + // Initially select rows based on validity and "Already Printed" status const rowsToConsider = processedData.filter((row) => { if (!row._isValid) return false; const alreadyPrinted = row.alreadyPrinted; @@ -118,7 +118,7 @@ return true; }); - const initialSelection = rowsToConsider.slice(0, 200).map((row) => row._rowIndex); + const initialSelection = rowsToConsider.map((row) => row._rowIndex); selectedRows = new Set(initialSelection); updateSelectAllState(); @@ -131,10 +131,6 @@ if (selectedRows.has(rowIndex)) { selectedRows.delete(rowIndex); } else { - if (selectedRows.size >= 200) { - alert('You can only select a maximum of 200 rows at a time.'); - return; - } selectedRows.add(rowIndex); } selectedRows = new Set(selectedRows); // Trigger reactivity @@ -150,20 +146,13 @@ } }); } else { - // Select all visible valid rows that aren't already printed, up to the limit + // Select all visible valid rows that aren't already printed const rowsToSelect = filteredData.filter( (row) => row._isValid && !isRowAlreadyPrinted(row) && !selectedRows.has(row._rowIndex) ); - const availableSlots = 200 - selectedRows.size; - if (rowsToSelect.length > availableSlots) { - alert( - `You can only select up to 200 rows. Only the first ${availableSlots} available rows will be selected.` - ); - } - - for (let i = 0; i < Math.min(rowsToSelect.length, availableSlots); i++) { - selectedRows.add(rowsToSelect[i]._rowIndex); + for (const row of rowsToSelect) { + selectedRows.add(row._rowIndex); } } selectedRows = new Set(selectedRows); diff --git a/src/lib/pdfLayout.ts b/src/lib/pdfLayout.ts index 453d396..f89c185 100644 --- a/src/lib/pdfLayout.ts +++ b/src/lib/pdfLayout.ts @@ -6,6 +6,7 @@ import { TEXT_FIELD_LAYOUT, PHOTO_FIELD_LAYOUT } from './pdfSettings'; +import { get } from 'idb-keyval'; // Conversion factor from millimeters to points (1 inch = 72 points, 1 inch = 25.4 mm) export const MM_TO_PT = 72 / 25.4; @@ -17,6 +18,11 @@ export interface GridLayout { cellHeight: number; // mm } +// Function to retrieve a blob from IndexedDB +export async function getImageBlob(url: string): Promise { + return await get(url); +} + // Calculate how many cards can fit on a page. export function calculateGrid( pageWidth: number,