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; }; /**