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();