diff --git a/invokeai/frontend/web/src/features/controlLayers/components/MaskOpacity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/MaskOpacity.tsx
index 58c659c65b..d3372f4fae 100644
--- a/invokeai/frontend/web/src/features/controlLayers/components/MaskOpacity.tsx
+++ b/invokeai/frontend/web/src/features/controlLayers/components/MaskOpacity.tsx
@@ -13,7 +13,7 @@ export const MaskOpacity = memo(() => {
const opacity = useAppSelector((s) => Math.round(s.canvasV2.settings.maskOpacity * 100));
const onChange = useCallback(
(v: number) => {
- dispatch(maskOpacityChanged(v / 100));
+ dispatch(maskOpacityChanged(Math.max(v / 100, 0.25)));
},
[dispatch]
);
@@ -22,7 +22,7 @@ export const MaskOpacity = memo(() => {
{t('controlLayers.globalMaskOpacity')}
{
minW={48}
/>
{
@@ -67,14 +69,18 @@ export class CanvasInpaintMask {
update = async (arg?: { state: CanvasInpaintMaskState; toolState: CanvasV2State['tool']; isSelected: boolean }) => {
const state = get(arg, 'state', this.state);
+ const maskOpacity = this.manager.stateApi.getMaskOpacity();
- if (!this.isFirstRender && state === this.state) {
+ if (
+ !this.isFirstRender &&
+ state === this.state &&
+ state.fill === this.state.fill &&
+ maskOpacity === this.maskOpacity
+ ) {
this.log.trace('State unchanged, skipping update');
return;
}
- // const maskOpacity = this.manager.stateApi.getMaskOpacity()
-
this.log.debug('Updating');
const { position, objects, isEnabled } = state;
@@ -82,18 +88,23 @@ export class CanvasInpaintMask {
await this.updateObjects({ objects });
}
if (this.isFirstRender || position !== this.state.position) {
- await this.transformer.updatePosition({ position });
+ this.transformer.updatePosition({ position });
}
// if (this.isFirstRender || opacity !== this.state.opacity) {
// await this.updateOpacity({ opacity });
// }
if (this.isFirstRender || isEnabled !== this.state.isEnabled) {
- await this.updateVisibility({ isEnabled });
+ this.updateVisibility({ isEnabled });
+ }
+
+ if (this.isFirstRender || state.fill !== this.state.fill || maskOpacity !== this.maskOpacity) {
+ this.renderer.updateCompositingRect(state.fill, maskOpacity);
+ this.maskOpacity = maskOpacity;
}
// this.transformer.syncInteractionState();
if (this.isFirstRender) {
- await this.transformer.updateBbox();
+ this.transformer.updateBbox();
}
this.state = state;
@@ -110,8 +121,6 @@ export class CanvasInpaintMask {
if (didUpdate) {
this.transformer.requestRectCalculation();
}
-
- this.isFirstRender = false;
};
// updateOpacity = (arg?: { opacity: number }) => {
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts
index 6d80a45540..571eac133b 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasManager.ts
@@ -366,6 +366,7 @@ export class CanvasManager {
let currentFill: RgbaColor = state.tool.fill;
const selectedEntity = this.getSelectedEntity();
if (selectedEntity) {
+ // These two entity types use a compositing rect for opacity. Their alpha is always 1.
if (selectedEntity.state.type === 'regional_guidance') {
currentFill = { ...selectedEntity.state.fill, a: 1 };
} else if (selectedEntity.state.type === 'inpaint_mask') {
@@ -470,6 +471,7 @@ export class CanvasManager {
if (
this._isFirstRender ||
+ state.inpaintMask !== this._prevState.inpaintMask ||
state.settings.maskOpacity !== this._prevState.settings.maskOpacity ||
state.tool.selected !== this._prevState.tool.selected ||
state.selectedEntityIdentifier?.id !== this._prevState.selectedEntityIdentifier?.id
diff --git a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts
index 35ae6209d5..c62d2a3d68 100644
--- a/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts
+++ b/invokeai/frontend/web/src/features/controlLayers/konva/CanvasObjectRenderer.ts
@@ -14,6 +14,7 @@ import type {
CanvasEraserLineState,
CanvasImageState,
CanvasRectState,
+ RgbColor,
} from 'features/controlLayers/store/types';
import Konva from 'konva';
import type { Logger } from 'roarr';
@@ -58,11 +59,26 @@ export class CanvasObjectRenderer {
*/
renderers: Map = new Map();
+ /**
+ * A object containing singleton Konva nodes.
+ */
konva: {
+ /**
+ * The compositing rect is used to draw the inpaint mask as a single shape with a given opacity.
+ *
+ * When drawing multiple transparent shapes on a canvas, overlapping regions will be more opaque. This doesn't
+ * match the expectation for a mask, where all shapes should have the same opacity, even if they overlap.
+ *
+ * To prevent this, we use a trick. Instead of drawing all shapes at the desired opacity, we draw them at opacity of 1.
+ * Then we draw a single rect that covers the entire canvas at the desired opacity, with a globalCompositeOperation
+ * of 'source-in'. The shapes effectively become a mask for the "compositing rect".
+ *
+ * This node is only added when the parent of the renderer is an inpaint mask or region, which require this behavior.
+ */
compositingRect: Konva.Rect | null;
};
- constructor(parent: CanvasLayer | CanvasInpaintMask, withCompositingRect: boolean = false) {
+ constructor(parent: CanvasLayer | CanvasInpaintMask) {
this.id = getPrefixedId(CanvasObjectRenderer.TYPE);
this.parent = parent;
this.manager = parent.manager;
@@ -74,10 +90,11 @@ export class CanvasObjectRenderer {
compositingRect: null,
};
- if (withCompositingRect) {
+ if (this.parent.type === 'inpaint_mask') {
this.konva.compositingRect = new Konva.Rect({
name: CanvasObjectRenderer.KONVA_COMPOSITING_RECT_NAME,
listening: false,
+ globalCompositeOperation: 'source-in',
});
this.parent.konva.objectGroup.add(this.konva.compositingRect);
}
@@ -131,27 +148,20 @@ export class CanvasObjectRenderer {
didRender = (await this.renderObject(this.buffer)) || didRender;
}
- if (didRender && this.parent.type === 'inpaint_mask') {
- assert(this.konva.compositingRect, 'Compositing rect must exist for inpaint mask');
-
- // Convert the color to a string, stripping the alpha - the object group will handle opacity.
- const rgbColor = rgbColorToString(this.parent.state.fill);
- const maskOpacity = this.manager.stateApi.getMaskOpacity();
-
- this.konva.compositingRect.setAttrs({
- fill: rgbColor,
- opacity: maskOpacity,
- // Draw this rect only where there are non-transparent pixels under it (e.g. the mask shapes)
- globalCompositeOperation: 'source-in',
- visible: true,
- // This rect must always be on top of all other shapes
- // zIndex: this.renderers.size + 1,
- });
- }
-
return didRender;
};
+ updateCompositingRect = (fill: RgbColor, opacity: number) => {
+ this.log.trace('Updating compositing rect');
+ assert(this.konva.compositingRect, 'Missing compositing rect');
+
+ const rgbColor = rgbColorToString(fill);
+ this.konva.compositingRect.setAttrs({
+ fill: rgbColor,
+ opacity,
+ });
+ };
+
/**
* Renders the given object. If the object renderer does not exist, it will be created and its Konva group added to the
* parent entity's object group.
@@ -230,12 +240,6 @@ export class CanvasObjectRenderer {
this.buffer = objectState;
return await this.renderObject(this.buffer, true);
-
- // const didDraw = await this.renderObject(this.buffer, true);
- // if (didDraw && this.konva.compositingRect) {
- // this.konva.compositingRect.zIndex(this.renderers.size + 1);
- // }
- // return didDraw;
};
/**