diff --git a/js/CanvasLayers.js b/js/CanvasLayers.js index 323b9d7..3cb89a3 100644 --- a/js/CanvasLayers.js +++ b/js/CanvasLayers.js @@ -310,6 +310,7 @@ export class CanvasLayers { this.canvas.canvasSelection.selectedLayers.forEach((layer) => { layer.width *= scale; layer.height *= scale; + this.invalidateBlendCache(layer); }); this.canvas.render(); this.canvas.requestSaveState(); @@ -319,6 +320,7 @@ export class CanvasLayers { return; this.canvas.canvasSelection.selectedLayers.forEach((layer) => { layer.rotation += angle; + this.invalidateBlendCache(layer); }); this.canvas.render(); this.canvas.requestSaveState(); @@ -368,64 +370,27 @@ export class CanvasLayers { const blendArea = layer.blendArea ?? 0; const needsBlendAreaEffect = blendArea > 0; if (needsBlendAreaEffect) { - log.debug(`Applying blend area effect for layer ${layer.id}, blendArea: ${blendArea}%`); - // --- BLEND AREA MASK: Use cropped region if cropBounds is set --- - let maskCanvas = null; - let maskWidth = layer.width; - let maskHeight = layer.height; - if (layer.cropBounds && layer.originalWidth && layer.originalHeight) { - // Create a cropped canvas - const s = layer.cropBounds; - const { canvas: cropCanvas, ctx: cropCtx } = createCanvas(s.width, s.height); - if (cropCtx) { - cropCtx.drawImage(layer.image, s.x, s.y, s.width, s.height, 0, 0, s.width, s.height); - // Generate distance field mask for the cropped region - maskCanvas = this.getDistanceFieldMaskSync(cropCanvas, blendArea); - maskWidth = s.width; - maskHeight = s.height; - } + // Check if we have a valid cached blended image + if (layer.blendedImageCache && !layer.blendedImageDirty) { + // Use cached blended image for optimal performance + ctx.globalCompositeOperation = layer.blendMode || 'normal'; + ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; + ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height); } else { - // No crop, use full image - maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea); - maskWidth = layer.originalWidth || layer.width; - maskHeight = layer.originalHeight || layer.height; - } - if (maskCanvas) { - // Create a temporary canvas for the masked layer - const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height); - if (tempCtx) { - const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight }; - if (!layer.originalWidth || !layer.originalHeight) { - tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height); - } - else { - const layerScaleX = layer.width / layer.originalWidth; - const layerScaleY = layer.height / layer.originalHeight; - const dWidth = s.width * layerScaleX; - const dHeight = s.height * layerScaleY; - const dX = s.x * layerScaleX; - const dY = s.y * layerScaleY; - tempCtx.drawImage(layer.image, s.x, s.y, s.width, s.height, dX, dY, dWidth, dHeight); - // --- Apply the distance field mask only to the visible (cropped) area --- - tempCtx.globalCompositeOperation = 'destination-in'; - // Scale the mask to match the drawn area - tempCtx.drawImage(maskCanvas, 0, 0, maskWidth, maskHeight, dX, dY, dWidth, dHeight); - } - // Draw the result + // Cache is invalid or doesn't exist, update it + this.updateLayerBlendEffect(layer); + // Use the newly created cache if available, otherwise fallback + if (layer.blendedImageCache) { ctx.globalCompositeOperation = layer.blendMode || 'normal'; ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; - ctx.drawImage(tempCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height); } else { // Fallback to normal drawing this._drawLayerImage(ctx, layer); } } - else { - // Fallback to normal drawing - this._drawLayerImage(ctx, layer); - } } else { // Normal drawing without blend area effect @@ -457,6 +422,92 @@ export class CanvasLayers { dX, dY, dWidth, dHeight // destination rect (scaled and positioned within the transform frame) ); } + /** + * Invalidates the blended image cache for a layer + */ + invalidateBlendCache(layer) { + layer.blendedImageDirty = true; + layer.blendedImageCache = undefined; + } + /** + * Updates the blended image cache for a layer with blendArea effect + */ + updateLayerBlendEffect(layer) { + const blendArea = layer.blendArea ?? 0; + if (blendArea <= 0) { + // No blend effect needed, clear cache + layer.blendedImageCache = undefined; + layer.blendedImageDirty = false; + return; + } + try { + log.debug(`Updating blend effect cache for layer ${layer.id}, blendArea: ${blendArea}%`); + // Create the blended image using the same logic as _drawLayer + let maskCanvas = null; + let maskWidth = layer.width; + let maskHeight = layer.height; + if (layer.cropBounds && layer.originalWidth && layer.originalHeight) { + // Create a cropped canvas + const s = layer.cropBounds; + const { canvas: cropCanvas, ctx: cropCtx } = createCanvas(s.width, s.height); + if (cropCtx) { + cropCtx.drawImage(layer.image, s.x, s.y, s.width, s.height, 0, 0, s.width, s.height); + // Generate distance field mask for the cropped region + maskCanvas = this.getDistanceFieldMaskSync(cropCanvas, blendArea); + maskWidth = s.width; + maskHeight = s.height; + } + } + else { + // No crop, use full image + maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea); + maskWidth = layer.originalWidth || layer.width; + maskHeight = layer.originalHeight || layer.height; + } + if (maskCanvas) { + // Create the final blended canvas + const { canvas: blendedCanvas, ctx: blendedCtx } = createCanvas(layer.width, layer.height); + if (blendedCtx) { + const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight }; + if (!layer.originalWidth || !layer.originalHeight) { + blendedCtx.drawImage(layer.image, 0, 0, layer.width, layer.height); + } + else { + const layerScaleX = layer.width / layer.originalWidth; + const layerScaleY = layer.height / layer.originalHeight; + const dWidth = s.width * layerScaleX; + const dHeight = s.height * layerScaleY; + const dX = s.x * layerScaleX; + const dY = s.y * layerScaleY; + blendedCtx.drawImage(layer.image, s.x, s.y, s.width, s.height, dX, dY, dWidth, dHeight); + // Apply the distance field mask only to the visible (cropped) area + blendedCtx.globalCompositeOperation = 'destination-in'; + // Scale the mask to match the drawn area + blendedCtx.drawImage(maskCanvas, 0, 0, maskWidth, maskHeight, dX, dY, dWidth, dHeight); + } + // Store the blended result in cache + layer.blendedImageCache = blendedCanvas; + layer.blendedImageDirty = false; + log.debug(`Blend effect cache updated for layer ${layer.id}`); + } + else { + log.warn(`Failed to create blended canvas context for layer ${layer.id}`); + layer.blendedImageCache = undefined; + layer.blendedImageDirty = false; + } + } + else { + log.warn(`Failed to create distance field mask for layer ${layer.id}`); + layer.blendedImageCache = undefined; + layer.blendedImageDirty = false; + } + } + catch (error) { + log.error(`Error updating blend effect for layer ${layer.id}:`, error); + layer.blendedImageCache = undefined; + layer.blendedImageDirty = false; + } + } getDistanceFieldMaskSync(imageOrCanvas, blendArea) { // Use a WeakMap for images, and a Map for canvases (since canvases are not always stable references) let cacheKey = imageOrCanvas; @@ -527,6 +578,7 @@ export class CanvasLayers { return; this.canvas.canvasSelection.selectedLayers.forEach((layer) => { layer.flipH = !layer.flipH; + this.invalidateBlendCache(layer); }); this.canvas.render(); this.canvas.requestSaveState(); @@ -536,6 +588,7 @@ export class CanvasLayers { return; this.canvas.canvasSelection.selectedLayers.forEach((layer) => { layer.flipV = !layer.flipV; + this.invalidateBlendCache(layer); }); this.canvas.render(); this.canvas.requestSaveState(); @@ -815,10 +868,16 @@ export class CanvasLayers { if (selectedLayer) { const newValue = parseInt(blendAreaSlider.value, 10); selectedLayer.blendArea = newValue; + // Invalidate cache when blend area changes + this.invalidateBlendCache(selectedLayer); this.canvas.render(); } }; blendAreaSlider.addEventListener('change', () => { + if (selectedLayer) { + // Update the blend effect cache when the slider value is finalized + this.updateLayerBlendEffect(selectedLayer); + } this.canvas.saveState(); }); blendAreaContainer.appendChild(blendAreaLabel); diff --git a/js/CanvasState.js b/js/CanvasState.js index 0b7745b..dd907ef 100644 --- a/js/CanvasState.js +++ b/js/CanvasState.js @@ -286,6 +286,9 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer, index) => { const newLayer = { ...layer, imageId: layer.imageId || '' }; delete newLayer.image; + // Remove cache properties that cannot be serialized for the worker + delete newLayer.blendedImageCache; + delete newLayer.blendedImageDirty; if (layer.image instanceof HTMLImageElement) { if (layer.imageId) { newLayer.imageId = layer.imageId; diff --git a/src/CanvasLayers.ts b/src/CanvasLayers.ts index bcec70f..761f2ef 100644 --- a/src/CanvasLayers.ts +++ b/src/CanvasLayers.ts @@ -355,6 +355,7 @@ export class CanvasLayers { this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { layer.width *= scale; layer.height *= scale; + this.invalidateBlendCache(layer); }); this.canvas.render(); this.canvas.requestSaveState(); @@ -365,6 +366,7 @@ export class CanvasLayers { this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { layer.rotation += angle; + this.invalidateBlendCache(layer); }); this.canvas.render(); this.canvas.requestSaveState(); @@ -428,80 +430,25 @@ export class CanvasLayers { const needsBlendAreaEffect = blendArea > 0; if (needsBlendAreaEffect) { - log.debug(`Applying blend area effect for layer ${layer.id}, blendArea: ${blendArea}%`); - - // --- BLEND AREA MASK: Use cropped region if cropBounds is set --- - let maskCanvas: HTMLCanvasElement | null = null; - let maskWidth = layer.width; - let maskHeight = layer.height; - - if (layer.cropBounds && layer.originalWidth && layer.originalHeight) { - // Create a cropped canvas - const s = layer.cropBounds; - const { canvas: cropCanvas, ctx: cropCtx } = createCanvas(s.width, s.height); - if (cropCtx) { - cropCtx.drawImage( - layer.image, - s.x, s.y, s.width, s.height, - 0, 0, s.width, s.height - ); - // Generate distance field mask for the cropped region - maskCanvas = this.getDistanceFieldMaskSync(cropCanvas, blendArea); - maskWidth = s.width; - maskHeight = s.height; - } + // Check if we have a valid cached blended image + if (layer.blendedImageCache && !layer.blendedImageDirty) { + // Use cached blended image for optimal performance + ctx.globalCompositeOperation = layer.blendMode as any || 'normal'; + ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; + ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height); } else { - // No crop, use full image - maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea); - maskWidth = layer.originalWidth || layer.width; - maskHeight = layer.originalHeight || layer.height; - } - - if (maskCanvas) { - // Create a temporary canvas for the masked layer - const { canvas: tempCanvas, ctx: tempCtx } = createCanvas(layer.width, layer.height); + // Cache is invalid or doesn't exist, update it + this.updateLayerBlendEffect(layer); - if (tempCtx) { - const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight }; - - if (!layer.originalWidth || !layer.originalHeight) { - tempCtx.drawImage(layer.image, 0, 0, layer.width, layer.height); - } else { - const layerScaleX = layer.width / layer.originalWidth; - const layerScaleY = layer.height / layer.originalHeight; - - const dWidth = s.width * layerScaleX; - const dHeight = s.height * layerScaleY; - const dX = s.x * layerScaleX; - const dY = s.y * layerScaleY; - - tempCtx.drawImage( - layer.image, - s.x, s.y, s.width, s.height, - dX, dY, dWidth, dHeight - ); - - // --- Apply the distance field mask only to the visible (cropped) area --- - tempCtx.globalCompositeOperation = 'destination-in'; - // Scale the mask to match the drawn area - tempCtx.drawImage( - maskCanvas, - 0, 0, maskWidth, maskHeight, - dX, dY, dWidth, dHeight - ); - } - - // Draw the result + // Use the newly created cache if available, otherwise fallback + if (layer.blendedImageCache) { ctx.globalCompositeOperation = layer.blendMode as any || 'normal'; ctx.globalAlpha = layer.opacity !== undefined ? layer.opacity : 1; - ctx.drawImage(tempCanvas, -layer.width / 2, -layer.height / 2, layer.width, layer.height); + ctx.drawImage(layer.blendedImageCache, -layer.width / 2, -layer.height / 2, layer.width, layer.height); } else { // Fallback to normal drawing this._drawLayerImage(ctx, layer); } - } else { - // Fallback to normal drawing - this._drawLayerImage(ctx, layer); } } else { // Normal drawing without blend area effect @@ -544,6 +491,113 @@ export class CanvasLayers { ); } + /** + * Invalidates the blended image cache for a layer + */ + public invalidateBlendCache(layer: Layer): void { + layer.blendedImageDirty = true; + layer.blendedImageCache = undefined; + } + + /** + * Updates the blended image cache for a layer with blendArea effect + */ + public updateLayerBlendEffect(layer: Layer): void { + const blendArea = layer.blendArea ?? 0; + + if (blendArea <= 0) { + // No blend effect needed, clear cache + layer.blendedImageCache = undefined; + layer.blendedImageDirty = false; + return; + } + + try { + log.debug(`Updating blend effect cache for layer ${layer.id}, blendArea: ${blendArea}%`); + + // Create the blended image using the same logic as _drawLayer + let maskCanvas: HTMLCanvasElement | null = null; + let maskWidth = layer.width; + let maskHeight = layer.height; + + if (layer.cropBounds && layer.originalWidth && layer.originalHeight) { + // Create a cropped canvas + const s = layer.cropBounds; + const { canvas: cropCanvas, ctx: cropCtx } = createCanvas(s.width, s.height); + if (cropCtx) { + cropCtx.drawImage( + layer.image, + s.x, s.y, s.width, s.height, + 0, 0, s.width, s.height + ); + // Generate distance field mask for the cropped region + maskCanvas = this.getDistanceFieldMaskSync(cropCanvas, blendArea); + maskWidth = s.width; + maskHeight = s.height; + } + } else { + // No crop, use full image + maskCanvas = this.getDistanceFieldMaskSync(layer.image, blendArea); + maskWidth = layer.originalWidth || layer.width; + maskHeight = layer.originalHeight || layer.height; + } + + if (maskCanvas) { + // Create the final blended canvas + const { canvas: blendedCanvas, ctx: blendedCtx } = createCanvas(layer.width, layer.height); + + if (blendedCtx) { + const s = layer.cropBounds || { x: 0, y: 0, width: layer.originalWidth, height: layer.originalHeight }; + + if (!layer.originalWidth || !layer.originalHeight) { + blendedCtx.drawImage(layer.image, 0, 0, layer.width, layer.height); + } else { + const layerScaleX = layer.width / layer.originalWidth; + const layerScaleY = layer.height / layer.originalHeight; + + const dWidth = s.width * layerScaleX; + const dHeight = s.height * layerScaleY; + const dX = s.x * layerScaleX; + const dY = s.y * layerScaleY; + + blendedCtx.drawImage( + layer.image, + s.x, s.y, s.width, s.height, + dX, dY, dWidth, dHeight + ); + + // Apply the distance field mask only to the visible (cropped) area + blendedCtx.globalCompositeOperation = 'destination-in'; + // Scale the mask to match the drawn area + blendedCtx.drawImage( + maskCanvas, + 0, 0, maskWidth, maskHeight, + dX, dY, dWidth, dHeight + ); + } + + // Store the blended result in cache + layer.blendedImageCache = blendedCanvas; + layer.blendedImageDirty = false; + + log.debug(`Blend effect cache updated for layer ${layer.id}`); + } else { + log.warn(`Failed to create blended canvas context for layer ${layer.id}`); + layer.blendedImageCache = undefined; + layer.blendedImageDirty = false; + } + } else { + log.warn(`Failed to create distance field mask for layer ${layer.id}`); + layer.blendedImageCache = undefined; + layer.blendedImageDirty = false; + } + } catch (error) { + log.error(`Error updating blend effect for layer ${layer.id}:`, error); + layer.blendedImageCache = undefined; + layer.blendedImageDirty = false; + } + } + private getDistanceFieldMaskSync(imageOrCanvas: HTMLImageElement | HTMLCanvasElement, blendArea: number): HTMLCanvasElement | null { // Use a WeakMap for images, and a Map for canvases (since canvases are not always stable references) let cacheKey: any = imageOrCanvas; @@ -611,6 +665,7 @@ export class CanvasLayers { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { layer.flipH = !layer.flipH; + this.invalidateBlendCache(layer); }); this.canvas.render(); this.canvas.requestSaveState(); @@ -620,6 +675,7 @@ export class CanvasLayers { if (this.canvas.canvasSelection.selectedLayers.length === 0) return; this.canvas.canvasSelection.selectedLayers.forEach((layer: Layer) => { layer.flipV = !layer.flipV; + this.invalidateBlendCache(layer); }); this.canvas.render(); this.canvas.requestSaveState(); @@ -957,11 +1013,17 @@ export class CanvasLayers { if (selectedLayer) { const newValue = parseInt(blendAreaSlider.value, 10); selectedLayer.blendArea = newValue; + // Invalidate cache when blend area changes + this.invalidateBlendCache(selectedLayer); this.canvas.render(); } }; blendAreaSlider.addEventListener('change', () => { + if (selectedLayer) { + // Update the blend effect cache when the slider value is finalized + this.updateLayerBlendEffect(selectedLayer); + } this.canvas.saveState(); }); diff --git a/src/CanvasState.ts b/src/CanvasState.ts index 0aa89e0..3b40824 100644 --- a/src/CanvasState.ts +++ b/src/CanvasState.ts @@ -326,6 +326,9 @@ If you see dark images or masks in the output, make sure node_id is set to ${cor const preparedLayers = await Promise.all(this.canvas.layers.map(async (layer: Layer, index: number) => { const newLayer: Omit & { imageId: string } = { ...layer, imageId: layer.imageId || '' }; delete (newLayer as any).image; + // Remove cache properties that cannot be serialized for the worker + delete (newLayer as any).blendedImageCache; + delete (newLayer as any).blendedImageDirty; if (layer.image instanceof HTMLImageElement) { if (layer.imageId) { diff --git a/src/types.ts b/src/types.ts index b74d366..b86eb6b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -28,6 +28,8 @@ export interface Layer { width: number; // szerokość widocznego obszaru height: number; // wysokość widocznego obszaru }; + blendedImageCache?: HTMLCanvasElement; // Cache for the pre-rendered blendArea effect + blendedImageDirty?: boolean; // Flag to invalidate the cache } export interface ComfyNode {