From 7d7076cc45ba1f96fd7553414f274c001b9f851a Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Thu, 26 Jun 2025 18:28:50 +0200 Subject: [PATCH] Add image garbage collection to canvas Introduced ImageReferenceManager to track and clean up unused images from the database and cache. Added manual garbage collection controls to the UI and exposed related stats and cleanup methods in Canvas. Updated db.js with a method to retrieve all image IDs for cleanup purposes. --- js/Canvas.js | 31 ++++ js/CanvasView.js | 26 ++++ js/ImageReferenceManager.js | 275 ++++++++++++++++++++++++++++++++++++ js/db.js | 22 +++ 4 files changed, 354 insertions(+) create mode 100644 js/ImageReferenceManager.js diff --git a/js/Canvas.js b/js/Canvas.js index b866bea..20ff6fb 100644 --- a/js/Canvas.js +++ b/js/Canvas.js @@ -5,6 +5,7 @@ import {CanvasInteractions} from "./CanvasInteractions.js"; import {CanvasLayers} from "./CanvasLayers.js"; import {CanvasRenderer} from "./CanvasRenderer.js"; import {CanvasIO} from "./CanvasIO.js"; +import {ImageReferenceManager} from "./ImageReferenceManager.js"; import {createModuleLogger} from "./utils/LoggerUtils.js"; const log = createModuleLogger('Canvas'); @@ -42,6 +43,7 @@ export class Canvas { this.canvasLayers = new CanvasLayers(this); this.canvasRenderer = new CanvasRenderer(this); this.canvasIO = new CanvasIO(this); + this.imageReferenceManager = new ImageReferenceManager(this); this.interaction = this.canvasInteractions.interaction; this.setupEventListeners(); @@ -372,4 +374,33 @@ export class Canvas { showOpacitySlider(mode) { return this.canvasLayers.showOpacitySlider(mode); } + + /** + * Ręczne uruchomienie garbage collection + */ + async runGarbageCollection() { + if (this.imageReferenceManager) { + await this.imageReferenceManager.manualGarbageCollection(); + } + } + + /** + * Zwraca statystyki garbage collection + */ + getGarbageCollectionStats() { + if (this.imageReferenceManager) { + return this.imageReferenceManager.getStats(); + } + return null; + } + + /** + * Czyści zasoby canvas (wywoływane przy usuwaniu) + */ + destroy() { + if (this.imageReferenceManager) { + this.imageReferenceManager.destroy(); + } + log.info("Canvas destroyed"); + } } diff --git a/js/CanvasView.js b/js/CanvasView.js index 1db8f2f..f494d9c 100644 --- a/js/CanvasView.js +++ b/js/CanvasView.js @@ -667,6 +667,27 @@ async function createCanvasWidget(node, widget, app) { $el("div.painter-separator"), $el("div.painter-button-group", {}, [ + $el("button.painter-button", { + textContent: "Run GC", + title: "Run Garbage Collection to clean unused images", + style: {backgroundColor: "#4a7c59", borderColor: "#3a6c49"}, + onclick: async () => { + try { + const stats = canvas.getGarbageCollectionStats(); + log.info("GC Stats before cleanup:", stats); + + await canvas.runGarbageCollection(); + + const newStats = canvas.getGarbageCollectionStats(); + log.info("GC Stats after cleanup:", newStats); + + alert(`Garbage collection completed!\nTracked images: ${newStats.trackedImages}\nTotal references: ${newStats.totalReferences}`); + } catch (e) { + log.error("Failed to run garbage collection:", e); + alert("Error running garbage collection. Check the console for details."); + } + } + }), $el("button.painter-button", { textContent: "Clear Cache", style: {backgroundColor: "#c54747", borderColor: "#a53737"}, @@ -1002,6 +1023,11 @@ app.registerExtension({ document.body.removeChild(backdrop); } + // Cleanup canvas resources including garbage collection + if (this.canvasWidget && this.canvasWidget.destroy) { + this.canvasWidget.destroy(); + } + return onRemoved?.apply(this, arguments); }; diff --git a/js/ImageReferenceManager.js b/js/ImageReferenceManager.js new file mode 100644 index 0000000..9e2f78d --- /dev/null +++ b/js/ImageReferenceManager.js @@ -0,0 +1,275 @@ +import {removeImage, getAllImageIds} from "./db.js"; +import {createModuleLogger} from "./utils/LoggerUtils.js"; + +const log = createModuleLogger('ImageReferenceManager'); + +export class ImageReferenceManager { + constructor(canvas) { + this.canvas = canvas; + this.imageReferences = new Map(); // imageId -> count + this.imageLastUsed = new Map(); // imageId -> timestamp + this.gcInterval = 5 * 60 * 1000; // 5 minut (nieużywane) + this.maxAge = 30 * 60 * 1000; // 30 minut bez użycia + this.gcTimer = null; + this.isGcRunning = false; + + // Nie uruchamiamy automatycznego GC + // this.startGarbageCollection(); + } + + /** + * Uruchamia automatyczne garbage collection + */ + startGarbageCollection() { + if (this.gcTimer) { + clearInterval(this.gcTimer); + } + + this.gcTimer = setInterval(() => { + this.performGarbageCollection(); + }, this.gcInterval); + + log.info("Garbage collection started with interval:", this.gcInterval / 1000, "seconds"); + } + + /** + * Zatrzymuje automatyczne garbage collection + */ + stopGarbageCollection() { + if (this.gcTimer) { + clearInterval(this.gcTimer); + this.gcTimer = null; + } + log.info("Garbage collection stopped"); + } + + /** + * Dodaje referencję do obrazu + * @param {string} imageId - ID obrazu + */ + addReference(imageId) { + if (!imageId) return; + + const currentCount = this.imageReferences.get(imageId) || 0; + this.imageReferences.set(imageId, currentCount + 1); + this.imageLastUsed.set(imageId, Date.now()); + + log.debug(`Added reference to image ${imageId}, count: ${currentCount + 1}`); + } + + /** + * Usuwa referencję do obrazu + * @param {string} imageId - ID obrazu + */ + removeReference(imageId) { + if (!imageId) return; + + const currentCount = this.imageReferences.get(imageId) || 0; + if (currentCount <= 1) { + this.imageReferences.delete(imageId); + log.debug(`Removed last reference to image ${imageId}`); + } else { + this.imageReferences.set(imageId, currentCount - 1); + log.debug(`Removed reference to image ${imageId}, count: ${currentCount - 1}`); + } + } + + /** + * Aktualizuje referencje na podstawie aktualnego stanu canvas + */ + updateReferences() { + log.debug("Updating image references..."); + + // Wyczyść stare referencje + this.imageReferences.clear(); + + // Zbierz wszystkie używane imageId + const usedImageIds = this.collectAllUsedImageIds(); + + // Dodaj referencje dla wszystkich używanych obrazów + usedImageIds.forEach(imageId => { + this.addReference(imageId); + }); + + log.info(`Updated references for ${usedImageIds.size} unique images`); + } + + /** + * Zbiera wszystkie używane imageId z różnych źródeł + * @returns {Set} Zbiór używanych imageId + */ + collectAllUsedImageIds() { + const usedImageIds = new Set(); + + // 1. Aktualne warstwy + this.canvas.layers.forEach(layer => { + if (layer.imageId) { + usedImageIds.add(layer.imageId); + } + }); + + // 2. Historia undo + if (this.canvas.canvasState && this.canvas.canvasState.layersUndoStack) { + this.canvas.canvasState.layersUndoStack.forEach(layersState => { + layersState.forEach(layer => { + if (layer.imageId) { + usedImageIds.add(layer.imageId); + } + }); + }); + } + + // 3. Historia redo + if (this.canvas.canvasState && this.canvas.canvasState.layersRedoStack) { + this.canvas.canvasState.layersRedoStack.forEach(layersState => { + layersState.forEach(layer => { + if (layer.imageId) { + usedImageIds.add(layer.imageId); + } + }); + }); + } + + log.debug(`Collected ${usedImageIds.size} used image IDs`); + return usedImageIds; + } + + /** + * Znajduje nieużywane obrazy + * @param {Set} usedImageIds - Zbiór używanych imageId + * @returns {Array} Lista nieużywanych imageId + */ + async findUnusedImages(usedImageIds) { + try { + // Pobierz wszystkie imageId z bazy danych + const allImageIds = await getAllImageIds(); + const unusedImages = []; + const now = Date.now(); + + for (const imageId of allImageIds) { + // Sprawdź czy obraz nie jest używany + if (!usedImageIds.has(imageId)) { + const lastUsed = this.imageLastUsed.get(imageId) || 0; + const age = now - lastUsed; + + // Usuń tylko stare obrazy (grace period) + if (age > this.maxAge) { + unusedImages.push(imageId); + } else { + log.debug(`Image ${imageId} is unused but too young (age: ${Math.round(age/1000)}s)`); + } + } + } + + log.debug(`Found ${unusedImages.length} unused images ready for cleanup`); + return unusedImages; + } catch (error) { + log.error("Error finding unused images:", error); + return []; + } + } + + /** + * Czyści nieużywane obrazy + * @param {Array} unusedImages - Lista nieużywanych imageId + */ + async cleanupUnusedImages(unusedImages) { + if (unusedImages.length === 0) { + log.debug("No unused images to cleanup"); + return; + } + + log.info(`Starting cleanup of ${unusedImages.length} unused images`); + let cleanedCount = 0; + let errorCount = 0; + + for (const imageId of unusedImages) { + try { + // Usuń z bazy danych + await removeImage(imageId); + + // Usuń z cache + if (this.canvas.imageCache && this.canvas.imageCache.has(imageId)) { + this.canvas.imageCache.delete(imageId); + } + + // Usuń z tracking + this.imageReferences.delete(imageId); + this.imageLastUsed.delete(imageId); + + cleanedCount++; + log.debug(`Cleaned up image: ${imageId}`); + + } catch (error) { + errorCount++; + log.error(`Error cleaning up image ${imageId}:`, error); + } + } + + log.info(`Garbage collection completed: ${cleanedCount} images cleaned, ${errorCount} errors`); + } + + /** + * Wykonuje pełne garbage collection + */ + async performGarbageCollection() { + if (this.isGcRunning) { + log.debug("Garbage collection already running, skipping"); + return; + } + + this.isGcRunning = true; + log.info("Starting garbage collection..."); + + try { + // 1. Aktualizuj referencje + this.updateReferences(); + + // 2. Zbierz wszystkie używane imageId + const usedImageIds = this.collectAllUsedImageIds(); + + // 3. Znajdź nieużywane obrazy + const unusedImages = await this.findUnusedImages(usedImageIds); + + // 4. Wyczyść nieużywane obrazy + await this.cleanupUnusedImages(unusedImages); + + } catch (error) { + log.error("Error during garbage collection:", error); + } finally { + this.isGcRunning = false; + } + } + + /** + * Ręczne uruchomienie garbage collection + */ + async manualGarbageCollection() { + log.info("Manual garbage collection triggered"); + await this.performGarbageCollection(); + } + + /** + * Zwraca statystyki garbage collection + * @returns {Object} Statystyki + */ + getStats() { + return { + trackedImages: this.imageReferences.size, + totalReferences: Array.from(this.imageReferences.values()).reduce((sum, count) => sum + count, 0), + isRunning: this.isGcRunning, + gcInterval: this.gcInterval, + maxAge: this.maxAge + }; + } + + /** + * Czyści wszystkie dane (przy usuwaniu canvas) + */ + destroy() { + this.stopGarbageCollection(); + this.imageReferences.clear(); + this.imageLastUsed.clear(); + log.info("ImageReferenceManager destroyed"); + } +} diff --git a/js/db.js b/js/db.js index 3140f6a..04fade8 100644 --- a/js/db.js +++ b/js/db.js @@ -146,6 +146,28 @@ export async function removeImage(imageId) { log.debug(`Remove image success for id: ${imageId}`); } +export async function getAllImageIds() { + log.info("Getting all image IDs..."); + const db = await openDB(); + const transaction = db.transaction([IMAGE_STORE_NAME], 'readonly'); + const store = transaction.objectStore(IMAGE_STORE_NAME); + + return new Promise((resolve, reject) => { + const request = store.getAllKeys(); + + request.onerror = (event) => { + log.error("Error getting all image IDs:", event.target.error); + reject("Error getting all image IDs"); + }; + + request.onsuccess = (event) => { + const imageIds = event.target.result; + log.debug(`Found ${imageIds.length} image IDs in database`); + resolve(imageIds); + }; + }); +} + export async function clearAllCanvasStates() { log.info("Clearing all canvas states..."); const db = await openDB();