From 503ec126a54f793b98d42798e8c31f499836b08b Mon Sep 17 00:00:00 2001 From: Dariusz L Date: Sun, 3 Aug 2025 02:43:30 +0200 Subject: [PATCH] Fix DataCloneError by excluding non-serializable cache from state Excluded blendedImageCache and blendedImageDirty properties from layer serialization in CanvasState.ts to prevent DataCloneError when saving state. This ensures that only serializable data is sent to Web Workers, while runtime caches are regenerated as needed. Blend area performance optimization remains functional without serialization issues. --- js/CanvasLayers.js | 155 ++++++++++++++++++++++++----------- js/CanvasState.js | 3 + src/CanvasLayers.ts | 194 +++++++++++++++++++++++++++++--------------- src/CanvasState.ts | 3 + src/types.ts | 2 + 5 files changed, 243 insertions(+), 114 deletions(-) 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 {