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.
This commit is contained in:
Dariusz L
2025-08-03 02:43:30 +02:00
parent 3d6e3901d0
commit 503ec126a5
5 changed files with 243 additions and 114 deletions

View File

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

View File

@@ -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;

View File

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

View File

@@ -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<Layer, 'image'> & { 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) {

View File

@@ -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 {